CSP-J 初赛(以满分为目标):第二十七课 《图的遍历——BFS广度优先搜索——像“水波纹”一样,一层一层地搜索》


第二十七课:图的遍历------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。


相关推荐
倒头就睡的小比特3 天前
算法竞赛C++常用的STL
c++·算法
weilx12343 天前
C++笔记-文件IO-<fcntl.h>
c++
Smileyqp沛沛3 天前
前端?C++ ?较大差异基础罗列
c++·基础·前端转c++
C语言小火车3 天前
C/C++ 为什么需要编译器?
开发语言·c++
旖旎夜光3 天前
力控面试题 01.01: 判定字符是否唯一(位运算) —— 题解
c++·学习·算法·leetcode·力控
吞下星星的少年·-·3 天前
C++ 萌新语法入门篇
c++·算法比赛
霍霍的袁3 天前
【C++】map 和 set 的使用 | 从用法到底层
开发语言·c++·学习·visual studio
another heaven3 天前
【算法/C++ MD5算法能否逆解码?原理、C++实现与同类哈希算法对比】
c++·算法·哈希算法
程序猿编码3 天前
告别改源码适配模型:纯 C++ 可配置 LLM 推理引擎,全格式全结构兼容
c++·大模型·llm·推理引擎
此生决int3 天前
深入理解C++系列(20)——C++11(下)
开发语言·c++