- bitworld 的博客
【CSP-J初赛】BFS
- 2025-7-28 16:11:10 @
广度优先搜索
一、图论基础:一切的起点
在深入广度优先搜索(BFS)之前,我们必须先理解它所作用的对象——图(Graph)。你可以将图想象成一个由点和线组成的网络。
1.1 什么是图?
一个图 由两个集合组成:一个顶点(Vertex)集合 和一个边(Edge)集合 。每条边连接着图中的一对顶点。
- 顶点(Vertex):可以看作是图中的节点或对象。例如,在地图上,城市可以被看作顶点。我们通常用 来表示所有顶点的集合。
- 边(Edge):表示顶点之间的关系或连接。例如,连接两个城市的公路可以被看作是一条边。我们通常用 来表示所有边的集合。
一条边 连接了顶点 和顶点 。
1.2 图的种类
-
无向图(Undirected Graph):边没有方向。如果顶点 和 之间有一条边,那么你可以从 到 ,也可以从 到 。这就像一条双向车道。边 和 是同一条边。
-
有向图(Directed Graph):边有方向。如果有一条从 指向 的边,记作 ,那么你只能从 到 ,不能反向。这就像一条单行道。
-
带权图(Weighted Graph):每条边都有一个相关的数值,称为“权重”(Weight)。这个权重可以代表距离、时间、成本等。例如,地图上两个城市之间的公路长度。
-
无权图(Unweighted Graph):所有边的权重都相同,通常我们认为权重为 。
BFS 主要应用于无权图,或者说,它在处理问题时会“无视”边的权重,认为每条边的“长度”都是一样的。
1.3 如何表示图?
在计算机中,我们需要一种方式来存储图的结构。最常见的两种方法是邻接矩阵和邻接表。
-
邻接矩阵(Adjacency Matrix) 我们使用一个二维数组
g[N][N]
来表示图,其中N
是顶点的最大数量。- 对于无权图,如果顶点 和 之间有边,则
g[i][j] = 1
,否则为0
。对于无向图,g[i][j]
和g[j][i]
是对称的。 - 优点:查询两个点之间是否有边非常快( 时间)。
- 缺点:当顶点很多但边很少时(称为稀疏图),会浪费大量存储空间。空间复杂度为 。
- 对于无权图,如果顶点 和 之间有边,则
-
邻接表(Adjacency List) 我们为每个顶点 维护一个列表,这个列表包含了所有与 直接相连的顶点。在C++中,这通常用一个
vector
数组vector<int> g[N]
来实现。g[u]
存储了所有 可以直接到达的顶点。- 优点:只存储存在的边,空间效率高。空间复杂度为 ,其中 是点数, 是边数。
- 缺点:查询两个点之间是否有边需要遍历其中一个点的邻接表,时间复杂度最坏为 。
在算法竞赛中,由于题目给定的图通常是稀疏图,邻接表是更常用和更高效的选择。
二、广度优先搜索的核心思想
2.1 思想起源:水波涟漪
想象一下,你向平静的湖面中心投下一颗石子。会发生什么?
- 石子落水点是第一个被扰动的地方。
- 接着,一圈水波会从中心向外扩散,均匀地到达所有与中心距离相同的点。
- 然后,这一圈水波又会成为新的波源,激起下一圈更大的水波,继续向外扩散。
- 这个过程持续进行,直到水波传遍整个湖面。
广度优先搜索(BFS) 的工作方式与此完全相同。它从一个指定的起始顶点(源点)开始,探索所有与之直接相连的邻居顶点。然后,对于那些邻居顶点,再去探索它们尚未被访问过的邻居顶点。这个过程一层一层地向外展开,就像水波的扩散一样。
核心特征:BFS 是一种逐层的搜索策略。它首先访问所有距离源点为 的顶点,然后是所有距离为 的顶点,以此类推。这保证了当 BFS 第一次找到目标顶点时,它所经过的路径是最短的(在无权图中,路径长度等于边的数量)。
2.2 与深度优先搜索(DFS)的对比
为了更好地理解 BFS,我们可以将它与另一种常见的搜索算法——深度优先搜索(DFS) 进行对比。
- BFS(广度/宽度优先):像水波一样,全面铺开,一层一层地探索。它追求的是“广度”,在深入下一层之前,必须完全探索当前层。
- DFS(深度优先):像探险家走迷宫,遇到岔路口就选一条路一直走下去,直到走到死胡同才返回上一个路口,选择另一条路继续探索。它追求的是“深度”,会沿着一条路径尽可能深地探索。
简单来说:
- BFS:广而浅。
- DFS:深而窄。
这个根本性的差异决定了它们各自的应用场景。BFS 天然地适用于寻找无权图中的最短路径问题。
三、BFS 的算法实现:队列是关键
如何用代码实现“逐层探索”这一过程呢?答案是使用一种名为队列(Queue) 的数据结构。
3.1 队列(Queue)
队列是一种先进先出(First-In, First-Out, FIFO) 的数据结构。你可以把它想象成一个排队买票的队伍。
- 入队(Enqueue):新来的人总是排在队尾。
- 出队(Dequeue):排在队首的人最先买到票并离开。
队列的这种 FIFO 特性完美地契合了 BFS 逐层搜索的需求。
- 我们将起始顶点放入队列中。
- 只要队列不为空,我们就从队首取出一个顶点(称之为当前顶点)。
- 我们访问这个当前顶点,并找到它所有尚未被访问过的邻居。
- 将这些新的邻居顶点全部放入队尾。
- 重复步骤 2-4。
通过这种方式,我们确保了先被发现的顶点(离源点更近的层)会先被处理,它们的邻居(更远的层)也就会被相应地、有次序地加入队列。这就实现了逐层扩展。
3.2 算法步骤详解
假设我们有一个图 和一个起始顶点 。
-
初始化:
- 创建一个队列 。
- 创建一个数组
dis[N]
来存储每个顶点到源点 的距离,并将所有值初始化为一个特殊值(例如 或无穷大),表示尚未访问。 - 将源点 的距离
dis[s]
设为 。 - 将源点 入队 。
-
循环搜索:
- 当队列 不为空时,执行以下操作:
a. 从队首取出一个顶点 :,。
b. 遍历 的所有邻居 :
i. 如果 尚未被访问(即
dis[v] == -1
): * 标记 为已访问。 * 更新 到源点的距离:dis[v] = dis[u] + 1
。 * 将 入队:。
- 当队列 不为空时,执行以下操作:
a. 从队首取出一个顶点 :,。
b. 遍历 的所有邻居 :
i. 如果 尚未被访问(即
-
结束:
- 当队列为空时,搜索结束。此时,
dis
数组中就包含了从源点 到所有可达顶点的最短距离。如果某个顶点的dis
值仍为初始的特殊值,则表示从 无法到达该顶点。
- 当队列为空时,搜索结束。此时,
3.3 伪代码
BFS(G, s):
// G: 图, s: 起始顶点
let Q be a queue
let dis be an array to store distances, initialized to infinity
dis[s] = 0
Q.push(s)
while Q is not empty:
u = Q.front()
Q.pop()
for each neighbor v of u in G:
if dis[v] is infinity: // 如果 v 未被访问
dis[v] = dis[u] + 1
Q.push(v)
四、BFS 示例:一步步走过迷宫
让我们通过一个具体的例子来模拟 BFS 的过程。
问题:给定一个 的迷宫,其中 .
代表通路,#
代表墙壁。给定一个起点 S
和一个终点 T
,求从 S
到 T
的最短步数。每一步可以向上下左右四个方向移动。
这是一个典型的 BFS 应用场景,因为“最短步数”正对应了无权图中的最短路径。我们可以把整个迷宫看作一个图:
- 顶点:每个
.
、S
、T
方格都是一个顶点。 - 边:如果两个通路方格相邻(上下左右),则它们之间有一条无向边。
迷宫示例 (5x5):
S . . # .
. # . . .
. # . # .
. . . # T
# # . . .
BFS 过程:
-
初始化:
- 创建一个队列
Q
。 - 创建一个二维数组
dis[5][5]
存储步数,全部初始化为 。 - 起点
S
坐标为 。设置dis[0][0] = 0
。 - 将
S
(即坐标 ) 加入队列Q
。 Q
当前为:[(0,0)]
- 创建一个队列
-
第 1 轮 (处理距离为 0 的层):
- 队首元素
(0,0)
出队。 - 寻找
(0,0)
的邻居:(0,1)
和(1,0)
。它们都未被访问过 (dis
值为 )。 - 更新它们的距离:
dis[0][1] = dis[0][0] + 1 = 1
,dis[1][0] = dis[0][0] + 1 = 1
。 - 将
(0,1)
和(1,0)
入队。 Q
当前为:[(0,1), (1,0)]
- 队首元素
-
第 2 轮 (处理距离为 1 的层):
- 队首元素
(0,1)
出队。 - 寻找
(0,1)
的邻居:(0,0)
(已访问),(0,2)
(未访问)。 - 更新
dis[0][2] = dis[0][1] + 1 = 2
。将(0,2)
入队。 Q
当前为:[(1,0), (0,2)]
- 队首元素
(1,0)
出队。 - 寻找
(1,0)
的邻居:(0,0)
(已访问),(2,0)
(未访问)。 - 更新
dis[2][0] = dis[1][0] + 1 = 2
。将(2,0)
入队。 Q
当前为:[(0,2), (2,0)]
- 队首元素
-
第 3 轮 (处理距离为 2 的层):
- 队首元素
(0,2)
出队。 - 邻居:
(0,1)
(已访问),(1,2)
(未访问)。 - 更新
dis[1][2] = dis[0][2] + 1 = 3
。将(1,2)
入队。 Q
当前为:[(2,0), (1,2)]
- 队首元素
(2,0)
出队。 - 邻居:
(1,0)
(已访问),(3,0)
(未访问)。 - 更新
dis[3][0] = dis[2][0] + 1 = 3
。将(3,0)
入队。 Q
当前为:[(1,2), (3,0)]
- 队首元素
... 这个过程会一直持续下去。每一轮循环,都会处理完同一距离(同一层)的所有节点,然后再将下一层的节点入队。
距离矩阵 dis
的变化过程:
初始:
0 -1 -1 -1 -1
-1 -1 -1 -1 -1
-1 -1 -1 -1 -1
-1 -1 -1 -1 -1
-1 -1 -1 -1 -1
处理完 S
后 (距离 1):
0 1 -1 -1 -1
1 -1 -1 -1 -1
-1 -1 -1 -1 -1
-1 -1 -1 -1 -1
-1 -1 -1 -1 -1
处理完距离 1 的节点后 (距离 2):
0 1 2 -1 -1
1 -1 -1 -1 -1
2 -1 -1 -1 -1
-1 -1 -1 -1 -1
-1 -1 -1 -1 -1
最终,当 T
(坐标 (3,4)
) 被访问时,dis[3][4]
的值就是从 S
到 T
的最短步数。如果 BFS 结束后 dis[3][4]
仍然是 ,则说明 S
无法到达 T
。
五、C++ 代码实现
下面我们用 C++ 来实现一个经典的 BFS 问题:在图中找源点到其他所有点的最短路。
5.1 题意概括
给定一个 个点 条边的无向无权图,以及一个起始点 。求 到图中所有点的最短距离。如果无法到达,则距离为 。
5.2 核心代码
#include<bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1e5 + 5; // 顶点数量上限
vector<int> g[N]; // 邻接表存图
int d[N]; // 存储距离,-1表示未访问
int n, m, s; // 点数,边数,起点
void bfs() {
memset(d, -1, sizeof(d)); // 初始化所有距离为-1
queue<int> q; // 创建队列
q.push(s); // 起点入队
d[s] = 0; // 起点到自身的距离为0
while (!q.empty()) {
int u = q.front(); // 取出队头元素
q.pop();
// 遍历 u 的所有邻居
for (int v : g[u]) {
if (d[v] == -1) { // 如果邻居 v 未被访问
d[v] = d[u] + 1; // 更新距离
q.push(v); // 将 v 入队
}
}
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
cin >> n >> m >> s; // 读入点数、边数、起点
for (int i = 0; i < m; ++i) {
int u, v;
cin >> u >> v;
g[u].push_back(v); // 无向图,加两条边
g[v].push_back(u);
}
bfs(); // 执行广度优先搜索
for (int i = 1; i <= n; ++i) {
cout << d[i] << (i == n ? "" : " "); // 输出结果
}
cout << endl;
return 0;
}
5.3 代码解释
g[N]
: 一个vector
数组,g[u]
存储了所有与顶点u
直接相连的顶点。这是邻接表的标准实现。d[N]
:distance
的缩写,用于存储起点s
到每个顶点的最短距离。同时,它也兼具visited
数组的功能:如果d[i] == -1
,说明顶点i
尚未被访问。bfs()
函数:memset(d, -1, sizeof(d))
: 这是一个高效的初始化技巧。memset
按字节填充,-1
的二进制表示所有位都是1
,所以int
类型的d[i]
会被正确地设置为-1
。queue<int> q
: C++ STL 提供的队列容器,完美支持push
(入队)、pop
(出队)、front
(取队头元素)和empty
(判空)操作。while (!q.empty())
: 循环的核心条件。只要队列中还有待处理的节点,搜索就继续。for (int v : g[u])
: C++11 的范围for
循环,方便地遍历u
的所有邻居v
。if (d[v] == -1)
: 这是 BFS 的关键。只有当一个邻居从未被访问过时,我们才更新它的距离并将其入队。这保证了每个顶点只入队一次,避免了死循环和重复计算。
5.4 复杂度分析
-
时间复杂度:
- 是顶点数, 是边数。
- 每个顶点最多入队一次、出队一次。这部分操作的总时间是 。
- 在整个 BFS 过程中,当我们处理顶点 时,我们会遍历它的所有邻居。对于整个图来说,每条边 都只会被访问两次(一次在处理 的邻居时,一次在处理 的邻居时,这是针对无向图;有向图则是一次)。因此,遍历所有邻接表的总时间是 。
- 综上,总时间复杂度为 。
-
空间复杂度:
- 邻接表
g
需要 的空间。但在复杂度分析中,我们通常认为图的存储是输入的一部分,不计入额外空间。 - 距离数组
d
需要 的空间。 - 队列
q
在最坏的情况下(例如一个星形图,一个中心点连接所有其他点),可能需要存储 个顶点,因此空间复杂度是 。 - 综上,额外的空间复杂度为 。
- 邻接表
六、BFS 的典型应用
除了最基础的求最短路径,BFS 还有许多变体和应用。
6.1 走迷宫问题(二维/三维网格)
这是最经典的应用。如前所述,任何网格上的移动问题,只要每一步的“代价”相同,都可以转化为 BFS 问题。
- 顶点:网格中的每个合法位置。
- 边:从一个位置可以一步到达的另一个位置。
变化:
- 带传送门的迷宫:如果从点 A 可以瞬间传送到点 B,这相当于在图中 A 和 B 之间增加了一条边。
- 有多种移动方式的棋盘:例如,一个棋子可以走“日”字(像象棋的马),也可以直走。那么从一个点出发,它所有可能的下一步落点都是它的邻居。
6.2 连通块计数
在一个图中,如果从顶点 可以到达顶点 ,则称 和 是连通的。一个连通块是图的一个子图,其中任意两个顶点都相互连通。
问题:计算一个图中有多少个独立的连通块。
思路:
- 遍历所有顶点,从 到 。
- 如果当前顶点 还没有被访问过,说明我们发现了一个新的连通块。
- 以 为起点,执行一次 BFS。这次 BFS 会访问到所有与 在同一个连通块内的顶点。
- 连通块计数器加一。
- 继续遍历,寻找下一个未被访问的顶点。
#include<bits/stdc++.h>
using namespace std;
// ... (图的存储和基本变量)
bool vis[N]; // 访问标记数组
void bfs(int s) { // 标准的BFS,但只访问连通的部分
queue<int> q;
q.push(s);
vis[s] = true;
while(!q.empty()){
int u = q.front();
q.pop();
for(int v : g[u]){
if(!vis[v]){
vis[v] = true;
q.push(v);
}
}
}
}
int main() {
// ... (IO优化和图的输入)
int cnt = 0; // 连通块计数器
for (int i = 1; i <= n; ++i) {
if (!vis[i]) { // 如果发现一个未被访问过的顶点
bfs(i); // 从它开始BFS,标记整个连通块
cnt++; // 计数器加一
}
}
cout << cnt << endl;
return 0;
}
6.3 树的层次遍历
树是一种特殊的图(无环连通图)。对树进行层次遍历(Level Order Traversal),即从上到下、从左到右逐层访问节点,其实现方法与 BFS 完全一致。
- 从根节点开始 BFS。
dis
数组在这里可以用来记录每个节点的深度。dis[root] = 0
。
6.4 拓扑排序(Kahn算法)
对于一个有向无环图(DAG),拓扑排序是其顶点的线性排序,使得对于每一条有向边 ,顶点 都排在顶点 的前面。
Kahn 算法是实现拓扑排序的一种方法,它基于 BFS 的思想。
思路:
- 计算所有顶点的入度(指向该顶点的边的数量)。
- 将所有入度为 的顶点放入一个队列。这些是图的起点,可以作为排序的最前部分。
- 当队列不为空时: a. 取出一个顶点 。将 加入拓扑序列的结果中。 b. 遍历 的所有邻居 。 c. 将边 “删除”,即把 的入度减 。 d. 如果 的入度变为 ,则将 入队。
- 如果最终拓扑序列中的顶点数量等于图的顶点总数,则排序成功。否则,说明图中存在环。
这个过程与 BFS 非常相似,都是从一个初始集合(入度为0的节点)开始,逐层处理,并将满足新条件(入度变为0)的节点加入队列。
广度优先搜索是图论算法的基石之一。它的核心在于队列数据结构和逐层扩展的思想。
- 适用场景:主要用于无权图,解决最短路径问题。
- 核心数据结构:队列(Queue),保证先进先出(FIFO)。
- 实现关键:一个记录距离/访问状态的数组(如
dis
或vis
),确保每个节点只被访问和入队一次。- 复杂度:时间 ,空间 。
- 思想:从源点出发,像水波一样,一层一层地向外探索。