
第二十七课:图的遍历------BFS广度优先搜索
------像"水波纹"一样,一层一层地搜索
上一课我们学习了:
DFS:深度优先搜索
我们把它想象成走迷宫:
一条路走到底,走不通了再回来。
今天学习一个和 DFS 完全不同、但同样重要的算法:
BFS:广度优先搜索
Breadth First Search
如果说 DFS 是:
"我先钻进去看看!"
那么 BFS 就是:
"别急,我们先把这一圈的人都认识,再认识下一圈。"
这也是图的两种基本遍历方法之一。
我们对 BFS 的定义非常明确:从起始点出发,先访问它的邻接点,再依次访问这些邻接点中尚未访问的邻接点,直到所有顶点都被访问。其基本思想是仿树的层次遍历过程。
一、先回忆:DFS是怎么走的?
看这张图:
0
/ \
1 2
/ \
3 4
如果从0开始,按照编号从小到大访问:
DFS会:
0
↓
1
↓
3
↑
1
↓
4
↑
1
↑
0
↓
2
访问顺序:
0,1,3,4,2
它的特点是:
先深入
二、BFS不一样
如果还是这张图:
0
/ \
1 2
/ \
3 4
BFS从0开始:
第一层:
0
第二层:
1 2
第三层:
3 4
所以:
0,1,2,3,4
这就是:
一层一层地访问
三、BFS到底是什么意思?
把一个点看成水面上的一块石头。
石头扔进水里:
·
~~~~~
~~~~~~~~
~~~~~~~~~~~
水波纹:
第0层:石头自己
第1层:第一圈
第2层:第二圈
第3层:第三圈
......
BFS就是这样:
先访问距离起点近的点,再访问距离起点远的点。
所以:
BFS = 一层一层向外扩散
这也是理解 BFS 最好的方法。
四、为什么BFS像"树的层序遍历"?
我们之前学过二叉树:
1
/ \
2 3
/ \ / \
4 5 6 7
层序遍历:
第一层:1
第二层:2 3
第三层:4 5 6 7
结果:
1,2,3,4,5,6,7
这就是:
从上到下,一层一层访问。
BFS的思想完全类似。
所以讲义把 BFS 概括为:
仿树的层次遍历过程。
五、但是图不是树
这里一定要注意。
树:
A
/ \
B C
/ \
D E
通常不会出现环。
图却可能:
A ------ B
| |
D ------ C
如果BFS不做任何处理:
A → B
B → A
A → B
B → A
...
又死循环了。
所以:
BFS同样需要visited数组!
六、DFS和BFS的共同点
这一点非常重要。
虽然:
DFS
和:
BFS
走法不同,但是它们有共同的基本框架:
图
↓
从一个点出发
↓
寻找邻居
↓
只访问没访问过的点
↓
visited标记
所以:
bool visited[N];
DFS需要。
BFS也需要。
七、DFS和BFS最大的区别是什么?
先看一句非常重要的话:
DFS:先深后广
BFS:先广后深
例如:
A
/ \
B C
/ \ \
D E F
DFS
可能是:
A → B → D
↓
回退
↓
B → E
↓
回退
↓
A → C → F
BFS
则是:
第一层:
A
第二层:
B C
第三层:
D E F
所以:
DFS:钻进去
BFS:铺开来
这是今天第一个必须记住的知识点。
八、BFS最重要的工具:队列
这一课最核心的知识不是代码,而是:
为什么BFS必须使用队列?
答案:
因为BFS要求"先发现的点先处理"。
而队列的特点是什么?
上一阶段我们学习过:
FIFO------先进先出
比如排队买票:
小明 → 小红 → 小刚 → 小丽
谁先来?
小明
谁先走?
小明
所以:
先进入队列的点,先被处理。
这恰好就是BFS需要的。
讲义的栈、队列部分明确说明队列是 FIFO(先进先出),而讲义的相关选择题也直接考过"广度优先搜索时,需要用到的数据结构是队列"。
所以:
BFS → 队列
一定要记住!
九、一个非常好玩的故事
假设学校组织春游。
老师说:
"从小明开始,把所有同学都认识一遍。"
小明认识:
小红
小刚
于是:
小明
↓
小红、小刚
接下来我们先处理:
小红
小红又认识:
小丽
小强
于是把:
小丽
小强
排到队伍后面。
此时队伍:
小刚 → 小丽 → 小强
然后处理:
小刚
小刚认识:
小美
加入队伍:
小丽 → 小强 → 小美
然后:
小丽
小强
小美
一个一个处理。
这就是:
BFS
十、BFS的核心过程
可以浓缩成:
起点入队
↓
队列不为空?
↓
取出队首
↓
访问它
↓
寻找所有邻居
↓
没访问过的邻居入队
↓
继续
所以:
BFS = 队列 + visited
十一、BFS第一步:起点入队
假设从:
0
开始。
首先:
queue<int> q;
q.push(0);
此时:
队列:
┌───┐
│ 0 │
└───┘
十二、第二步:标记0已经访问
这里有一个非常重要的细节。
我们可以在:
入队的时候
就标记:
visited[0] = true;
为什么?
因为:
0已经决定要进入搜索队伍了。
这样可以防止其他点又把0重复加入队列。
所以:
visited[0] = true;
q.push(0);
十三、第三步:取队首
int v = q.front();
q.pop();
例如:
队列:
0 → 1 → 2
执行:
v = q.front();
得到:
v = 0
然后:
q.pop();
队列变成:
1 → 2
这就是:
先进先出
十四、第四步:寻找v的所有邻居
如果:
vector<int> g[N];
那么:
for (int u : g[v])
就是:
把v的所有邻居找出来。
如果:
v = 0
而:
g[0] = {1,2}
那么:
1
2
就是0的邻居。
十五、第五步:没有访问过就入队
判断:
if (!visited[u])
如果u没有访问过:
visited[u] = true;
q.push(u);
注意顺序:
先标记
再入队
例如:
0的邻居:
1、2
于是:
队列:
1 → 2
十六、完整BFS模板
这是今天最重要的代码:
void bfs(int start)
{
queue<int> q;
visited[start] = true;
q.push(start);
while (!q.empty())
{
int v = q.front();
q.pop();
cout << v << " ";
for (int u : graph[v])
{
if (!visited[u])
{
visited[u] = true;
q.push(u);
}
}
}
}
建议初学者不要背,而是要理解:
创建队列
↓
起点入队
↓
起点标记
↓
队列不空
↓
取队首
↓
处理它
↓
所有没访问过的邻居入队
↓
继续
十七、逐行解析
queue<int> q;
意思:
创建一个队列。
visited[start] = true;
意思:
起点已经访问。
q.push(start);
意思:
起点进入等待处理的队伍。
while (!q.empty())
意思:
只要还有城市等待处理,就继续。
int v = q.front();
意思:
看队伍最前面的城市。
q.pop();
意思:
这个城市从等待队伍中离开。
for (int u : graph[v])
意思:
查看v所有邻居。
if (!visited[u])
意思:
这个邻居以前没来过吗?
visited[u] = true;
q.push(u);
意思:
标记它,然后让它排队等待。
十八、完整模拟一次BFS
还是这张图:
0
/ \
1 2
/ \
3 4
从0开始。
第一步
visited[0] = true
队列:
0
第二步
取出0:
0
访问0。
发现:
1、2
于是:
队列:
1 → 2
第三步
取出1:
1
访问1。
发现:
3、4
加入队列:
2 → 3 → 4
第四步
取出2:
2
2没有新的点。
队列:
3 → 4
第五步
取出3:
3
没有新点。
第六步
取出4:
4
没有新点。
队列空了。
结束。
最终:
0,1,2,3,4
十九、为什么一定是一层一层?
这其实就是队列造成的。
0发现:
1、2
它们进入队列:
1 → 2
1又发现:
3、4
但是3、4只能排在:
2
后面:
2 → 3 → 4
所以必须:
先处理2,再处理3、4。
这就是:
第一层没有处理完,第二层不能插队。
而这正是 BFS 的"广度优先"。
二十、BFS和DFS为什么会使用不同的数据结构?
这是非常值得让大家理解的一点。
DFS
想:
"我要一直往里面走。"
所以需要:
后进去的先回来
这是:
栈
递归本身就利用了函数调用栈。
BFS
想:
"我先把这一层的人处理完。"
所以需要:
先来的先处理
这是:
队列
因此:
DFS → 栈
BFS → 队列
可以记成:
深度用栈,广度用队
二十一、一个特别容易考的对应关系
| 算法 | 思想 | 典型数据结构 |
|---|---|---|
| DFS | 深入到底再回退 | 栈 |
| BFS | 一层一层向外扩展 | 队列 |
考试如果看到:
"广度优先搜索需要什么数据结构?"
答案:
队列
这一考点,经常考到。
二十二、BFS的一个巨大优势:找最短路
现在出现一个非常重要的问题。
假设:
A ------ B ------ D
| /
C ------ E ------
我们想问:
A到D最少经过几条边?
如果使用BFS:
第0层:
A
第1层:
B、C
第2层:
D、E
D第一次被发现的时候:
距离 = 2
所以:
A→B→D
是长度2的一条最短路径。
为什么第一次到达D就是最短?
因为BFS是:
按照距离起点从近到远,一层一层搜索。
这是BFS最重要的应用之一。
二十三、用"地铁换乘"理解最短路
假设:
A
是你家附近地铁站。
你想去:
F
每坐一站算1步。
BFS会:
0站:
A
1站:
A能直接到达的站
2站:
再坐一站能够到达的站
3站:
再坐一站能够到达的站
第一次找到F时:
已经保证使用的边数最少。
所以:
无权图最短路 → BFS
这是后面图论非常重要的一条知识。
二十四、"无权图"非常重要
如果边上有不同费用:
A --100-- B
A --2---- C
C --2---- B
那么:
A → B
虽然只有1条边,但费用是100。
而:
A → C → B
有2条边,但费用只有4。
所以:
"边数最少"不等于"权值总和最小"。
因此普通BFS最适合解决:
每条边代价相同的最短路
例如:
迷宫
网格移动
每一步代价都相同
以后学习:
Dijkstra
再解决:
边权不同的最短路。
二十五、BFS在迷宫中的应用
迷宫:
S . . #
# . . #
# . . T
假设:
S = 起点
T = 终点
从S开始:
第0层:S
第1层:S能走到的位置
第2层:第二步能到的位置
第3层:第三步能到的位置
像水一样:
S
↓↓↓
第一圈
↓↓↓
第二圈
↓↓↓
第三圈
第一次到T:
就找到了最少步数。
二十六、这和我们之前学的Flood Fill联系起来了
之前我们学习过:
Flood Fill(泛洪算法)
比如:
□□□□□□
□■■□□□
□■■■□□
□□■□□□
□□□□□□
从某一个格子开始:
找上下左右相邻的同类格子。
不断扩散。
这个过程可以使用:
DFS
也可以使用:
BFS
所以:
Flood Fill = DFS/BFS在网格上的应用
今天学完BFS后,孩子应该能看到一个更大的知识网络:
图
│
├── DFS
│ └── 递归 / 栈
│
└── BFS
└── 队列
而:
网格
↓
每个格子看成一个顶点
↓
相邻格子之间看成边
↓
DFS / BFS
↓
Flood Fill / 迷宫
二十七、BFS也需要邻接矩阵或邻接表
上一课我们学习:
邻接矩阵
和:
邻接表
它们只是:
保存图的方法。
DFS、BFS则是:
访问图的方法。
所以:
邻接矩阵 / 邻接表
↓
保存图
↓
┌────┴────┐
↓ ↓
DFS BFS
↓ ↓
栈 队列
这是非常重要的概念区分。
二十八、邻接表版BFS
我们以后做算法题,非常常见的是:
vector<int> graph[N];
BFS:
void bfs(int start)
{
queue<int> q;
visited[start] = true;
q.push(start);
while (!q.empty())
{
int v = q.front();
q.pop();
cout << v << " ";
for (int u : graph[v])
{
if (!visited[u])
{
visited[u] = true;
q.push(u);
}
}
}
}
这里:
queue<int> q;
是BFS的核心。
二十九、邻接矩阵版BFS
如果使用:
int graph[N][N];
那么:
void bfs(int start)
{
queue<int> q;
visited[start] = true;
q.push(start);
while (!q.empty())
{
int v = q.front();
q.pop();
cout << v << " ";
for (int u = 0; u < n; u++)
{
if (graph[v][u] == 1 && !visited[u])
{
visited[u] = true;
q.push(u);
}
}
}
}
区别仅仅是:
"如何找到邻居"不同。
BFS的核心思想完全没有变化。
三十、一个非常重要的细节:什么时候标记visited?
推荐写法:
if (!visited[u])
{
visited[u] = true;
q.push(u);
}
也就是:
入队时标记
为什么?
看这个图:
A
/ \
B C
\ /
D
B和C都能发现D。
如果:
B → D
D进入队列。
此时应该立刻:
visited[D] = true;
这样C再看到D:
if (!visited[D])
条件已经不成立。
D不会重复入队。
三十一、如果出队时才标记会怎样?
例如错误思路:
q.push(u);
但不立刻:
visited[u] = true;
那么:
B发现D
C也发现D
可能出现:
队列:
D
D
D被重复加入。
所以标准写法是:
发现新点 → 立即标记 → 入队
三十二、BFS遍历一个不连通的图
和DFS一样,如果图不是连通的:
A ------ B ------ C
D ------ E
F
从A开始:
bfs(A);
只能访问:
A B C
不会自动访问:
D E F
如果题目要求遍历整个图,也可以:
for (int i = 0; i < n; i++)
{
if (!visited[i])
{
bfs(i);
}
}
于是:
第一次BFS → 第一块
第二次BFS → 第二块
第三次BFS → 第三块
这和上一课的DFS完全对应。
三十三、BFS可以统计连通块
例如:
A ------ B ------ C
D ------ E
F ------ G
执行:
int count = 0;
for (int i = 0; i < n; i++)
{
if (!visited[i])
{
bfs(i);
count++;
}
}
那么:
第一次:
A B C
第二次:
D E
第三次:
F G
所以:
count=3
这就是:
连通分量数量
三十四、DFS和BFS的终极对比
| DFS | BFS | |
|---|---|---|
| 中文 | 深度优先搜索 | 广度优先搜索 |
| 英文 | Depth First Search | Breadth First Search |
| 思想 | 一路深入 | 一层一层扩散 |
| 类比 | 树的先序遍历 | 树的层序遍历 |
| 典型工具 | 栈 | 队列 |
| 常见实现 | 递归 | queue |
| 是否需要visited | 是 | 是 |
| 是否适合无权图最短路 | 一般不保证 | 是 |
| 典型应用 | 搜索、连通块、回溯 | 最短步数、层次搜索 |
三十五、用一句话区分DFS和BFS
DFS
"一条路走到底!"
BFS
"一圈一圈往外扩!"
再记:
DFS
栈
BFS
队列
基本就建立起来了。
三十六、CSP-J初赛重点考法
考法1:问BFS使用什么数据结构
答案:
队列
考法2:判断遍历顺序
看到:
一层一层
逐层
层次遍历
由近及远
想到:
BFS
看到:
深入
回退
递归
一路走到底
想到:
DFS
三十七、考法3:判断最短路
看到:
"每一步代价相同,求最少走几步。"
马上想到:
BFS
例如:
迷宫最少走多少步?
棋盘最少移动多少次?
从A到B至少经过多少条边?
第一反应:
BFS
三十八、考法4:程序阅读
看到:
queue<int> q;
然后:
q.push(...)
和:
q.front();
q.pop();
同时配合:
visited[]
大概率就是:
BFS
而如果看到:
dfs(...)
或者:
stack<int>
则大概率是:
DFS
三十九、一道典型程序阅读题
queue<int> q;
visited[0] = true;
q.push(0);
while (!q.empty())
{
int v = q.front();
q.pop();
cout << v << " ";
for (int u : g[v])
{
if (!visited[u])
{
visited[u] = true;
q.push(u);
}
}
}
问:
该程序实现什么算法?
看到:
queue
再看到:
front
pop
push
visited
答案:
BFS
四十、再来一道"手算题"
图:
1
/ \
2 3
/ \ / \
4 5 6 7
从1开始,邻接点按编号从小到大访问。
BFS:
第一层:
1
第二层:
2 3
第三层:
4 5 6 7
所以:
1,2,3,4,5,6,7
这其实就是树的层序遍历。
四十一、再来一个容易错的图
0
/ \
1 2
\ /
3
从0开始。
BFS:
0
0发现:
1、2
队列:
1 → 2
处理1:
1
发现3:
2 → 3
处理2:
2
发现3,但:
visited[3] == true
所以不能重复加入。
最终:
0,1,2,3
这里最容易犯的错误是把3写两次。
四十二、为什么BFS特别适合"最少几步"?
可以把整个图按照距离分层:
距离0:
A
距离1:
B C
距离2:
D E F
距离3:
G
BFS的本质就是:
按照距离从小到大处理。
所以:
第1次找到目标
意味着:
已经没有更短的办法了。
这就是BFS解决无权最短路的核心原因。
四十三、今天和前面知识的完整连接
到本课,我们已经形成了一条非常漂亮的知识链:
图
│
┌──────┴──────┐
│ │
邻接矩阵 邻接表
│ │
└──────┬──────┘
│
图的遍历
┌──────┴──────┐
│ │
DFS BFS
│ │
深度优先 广度优先
│ │
栈 队列
│ │
递归 FIFO
│ │
一路深入 一层扩散
再往外延伸:
DFS
├── 连通块
├── Flood Fill
├── 回溯
└── 搜索
BFS
├── 连通块
├── Flood Fill
├── 迷宫
└── 无权最短路
四十四、本课和上一课放在一起比较
大家完成这张"超级知识卡"。
| 问题 | DFS | BFS |
|---|---|---|
| 怎么走? | 先深入 | 先扩散 |
| 像什么? | 先序遍历 | 层序遍历 |
| 用什么? | 栈 | 队列 |
| 递归常见吗? | 很常见 | 不常用 |
| 需要visited吗? | 需要 | 需要 |
| 无权最短路 | 不保证 | 非常适合 |
| 口诀 | 一条路走到底 | 一层一层向外扩 |
四十五、本课"必背知识卡"
① BFS是什么?
广度优先搜索(Breadth First Search)
② BFS的核心思想
一层一层向外扩展。
③ BFS像什么?
树的层序遍历。
④ BFS使用什么数据结构?
队列 Queue
⑤ 队列是什么特点?
FIFO------先进先出。
⑥ BFS需要什么数组?
bool visited[N];
用于:
防止重复访问。
⑦ BFS最重要的代码结构
queue<int> q;
visited[start] = true;
q.push(start);
while (!q.empty())
{
int v = q.front();
q.pop();
for (int u : graph[v])
{
if (!visited[u])
{
visited[u] = true;
q.push(u);
}
}
}
⑧ BFS的经典应用
无权图最短路
例如:
迷宫最少几步
棋盘最少走几次
两个点最少经过多少条边
四十六、最后给大家一个"终极记忆法"
把DFS和BFS想成两种探险队。
DFS探险队
队长说:
"这条路不错,我们一直走进去!"
A
↓
B
↓
D
↓
继续......
走不动:
↑
回退
所以:
DFS = 深入 + 回退 + 栈
BFS探险队
队长说:
"大家不要乱跑!先把第一圈探索完,再探索第二圈!"
第二圈
○ ○ ○ ○ ○
第一圈
○ ○ ○
起点
●
所以:
BFS = 分层 + 扩散 + 队列
学生们要记住:
DFS问的是:"我能不能沿着这条路继续深入?"
BFS问的是:"离我最近的地方,我是不是已经全部探索完了?"
下一课 生成树与最小生成树(MST) ,我们提出一个非常有趣的问题:
如果一个网络有很多条道路,但我们不需要保留所有道路,只想让所有城市保持连通,而且总建设费用最低,该怎么办?
这就从"如何遍历图 "进入"如何建设图 ",并自然引出生成树、最小生成树、Prim、Kruskal。