阶段 3 · 搜索 · 第 14 章

BFS 广度优先搜索:迷宫最短路

第一次到达,就是最短到达 —— 这就是 BFS 全部的秘密。

例题:迷宫从左上走到右下的最少步数 建议用时:100 分钟

1 一句话问题

同样的网格,1 能走,0 是墙。从左上角 (0,0) 走到右下角 (n-1,m-1), 每步只能上下左右移动一格,问最少要几步。走不到就输出 -1

输入      3 3
          110
          011
          011
输出      4        (0,0)→(0,1)→(1,1)→(1,2)→(2,2)

2 先用纸笔手算一遍

这次换一种手算方式,请一定照做 —— 它就是 BFS 本身。

在起点写 0。然后:把所有「挨着 0 且能走」的格子写上 1; 再把所有「挨着 1 且还是空白」的格子写上 2;再写 3……

起点 0        第一圈 1        第二圈 2        第三圈 3
0 . .         0 1 .           0 1 2           0 1 2
. . .         . . .           . 2 .           3 2 3
. . .         . . .           . . .           . 3 .

写到终点被填上数字为止,那个数字就是答案。

注意你没有去枚举任何一条路径 —— 你只是一圈一圈往外涂。这就是 BFS。

3 暴力:DFS 枚举每一条路,取最短的

如果不知道上面那个涂色法,最直接的想法是:既然要「最短」,那把所有能走的路线都走一遍, 记下其中最短的。用第 13 章的 DFS 枚举,走过的格子标记上不再重复走(否则会绕圈子), 从一条分支回来时再把标记撤销 —— 也就是第 3 章的回溯。

brute.cpp暴力
输入(stdin)
输出
点「运行 ▶」看结果

4 实测:看它怎么爆炸

下面这个对比是这一章最重要的一个实验,请亲手做一遍

先用默认的 6(一个 6×6 的空网格),点右上角的 「开始对比 ▶」 跑一次,记住暴力用了多久。 然后把「网格边长」框里的 6 改成 7,再点一次「开始对比 ▶」。

同题对比:DFS 枚举所有路径 vs BFS
生成一个完全空旷的正方形网格 —— 没有墙,分叉最多,是暴力的最坏情况。先跑 6,再改成 7。
DFS 枚举所有路径
BFS

空网格上,从左上走到右下的路径条数是:

网格路径条数暴力实测
4×4184瞬间
5×58 512瞬间
6×61 262 816约 0.15 秒
7×7575 780 564十几秒都跑不完
8×8789 360 053 252别试了

**边长只加了 1,工作量乘了 450 倍。**而 BFS 这边,6×6 是 36 个格子,7×7 是 49 个 —— 它压根没感觉到区别。

⚠ 这就是「指数爆炸」的真实手感

很多人背过「指数级复杂度」这个词,但没有真正被它吓到过。 现在你亲眼看到了:一个只有 49 个格子的问题,暴力就已经算不完了。

这类问题不是「电脑再快一点就行」—— 就算计算机快一万倍,你也只是从能算 7×7 变成能算 9×9。 必须换算法。

5 慢在哪

暴力的毛病不在「用了 DFS」,而在于它必须把每一条路都走到底才敢下结论

可是那五亿条路里,绝大多数一眼就知道不可能是最短的 —— 绕远了、兜圈了。 暴力却老老实实一条条走完。

更根本地说:暴力是在「所有路径」这个集合里找最小值,而这个集合是指数大的。 但我们真正想要的只是一个数字(最短步数),根本不需要把路径都列出来。

6 ★ 关键的一步

★ 关键的一步

回头看第 2 步你手算时干的事:你不是在枚举路径,你是在按距离一圈一圈往外涂色

先站在起点(距离 0); 把所有距离 1 的格子找出来; 再把所有距离 2 的格子找出来……

这样一来,终点第一次被涂上颜色时,那个圈号就是最短距离 —— 不需要再看任何别的路,因为更外面的圈只会更远。

第一次到达 = 最短到达。

怎么实现「一圈一圈」?用一个队列:先进队列的先处理。 而先进队列的一定是距离更近的 —— 队列天然帮你把格子按距离排好了序, 你什么都不用额外做。

O(所有路径) → O(n × m),因为每个格子只进队一次。

dist[i][j] 的含义:从起点走到 (i,j) 的最短步数,-1 表示还没到过。 -1 同时充当了「未访问」标记 —— 一个数组干两件事。

7 BFS 写法

fast.cpp正解
输入(stdin)
输出
点「运行 ▶」看结果

BFS 的骨架永远是这四行,背下来:

dist[起点] = 0;  q.push(起点);
while (!q.empty()) {
    取出队首 u;
    for (u 的每个邻居 v)
        if (v 能走 && dist[v] == -1) { dist[v] = dist[u] + 1; q.push(v); }
}
⚠ BFS 的头号错误:标记的时机

dist[v] = dist[u] + 1 这一步必须在 入队的时候 做,不能等到出队再做。

如果等出队才标记,同一个格子会被它的好几个邻居重复塞进队列。 队列会指数级膨胀,BFS 退化成暴力,然后超时。

记住这句话:入队即标记。 你可以在第 10 步的对拍里亲手验证 —— 把判重那行删掉,它会立刻报超时。

8 按圈打印扩散过程

trace.cpp过程演示
每处理完一圈就打印一次距离图。你会看到一圈波纹从起点荡开。
输入(stdin)
输出
点「运行 ▶」看结果

对比第 13 章 trace.cpp 的输出:那边是一条越缩越深的线,这边是一层层齐头并进。 同样是搜索,一个用递归(栈),一个用队列 —— 差别全在这里

9 单步看波纹扩散

BFS 扩散:一圈一圈往外推共 27 步
第 1 / 27 步
0
灰色方块是墙 / 水,不能走。
格子里的数字 = 到起点的距离,也就是它在第几圈。颜色相同 = 同一圈。 黑框 = 正在处理,白框 = 这一步刚入队。
换一张地图试试(1 = 能走,0 = 墙)
队列(左边是队首,先出)
0,0
队列长度
1
起点 (0,0) 距离 0,放进队列。队列是 BFS 的全部机关 —— 先进先出,保证近的先被处理。

**这是第 13 章那张一模一样的 8×8 地图。**建议开两个标签页,把两个动画放在一起播。

盯住这几件事:

  • 颜色相同 = 距离相同 = 同一圈。你会看到清清楚楚的等距波纹。
  • 右边的队列:队首出去,新格子从队尾进来。队列里的元素距离最多只差 1 —— 这就是 BFS 能保证按距离顺序处理的原因。
  • 终点被涂上颜色的那一刻,动画立刻停止 —— 后面的格子根本不用算了。
  • 和 DFS 对比:DFS 的访问顺序是一根线,BFS 是一圈圈的环。

10 ★ 对拍验证

★ 正确的用法

把「BFS 版」那一栏换成你自己默写的,再点开始。

对拍器
生成器造 6×6 以内、墙占 40%~60% 的迷宫。墙多是故意的 —— 逼着最短路必须绕路,才能查出「漏方向」这类错误。

必试的几个错误:

  • 删掉 if (dist[x][y] != -1) continue; → 报超时。这就是上面说的「入队即标记」。
  • 漏掉一个方向(比如把 d = 0 改成 d = 1,丢掉「向上」)→ 会被抓,但可能要几十轮
  • 在出队时才判断终点,改成入队时就 break → 想想这样对不对,用对拍验证你的判断
  • 起点终点是墙的情况 → 生成器强制了它们可走,试试手动改代码去掉那个特判
⚠ 我在准备这一章时踩的坑(请认真读)

最早我的生成器造的是 5×5、墙只占 35% 的迷宫。 拿一份漏掉了「向上」方向的错误 BFS 去对拍,跑了 500 轮一次都没抓出来

原因很简单:空旷的小网格里,从左上到右下顺着往右下走就到了,压根不需要往回绕。 那份错代码就一直蒙对。

把墙加密到 40% 以上、网格放到 6×6,第 55 轮就抓到了

结论:对拍全过,只说明「在你造得出的数据里没问题」。 数据太温柔的时候,「全部通过」什么也不能证明。 造数据的功夫,和写算法的功夫一样重要。

11 什么时候用 DFS,什么时候用 BFS

这是本章最该带走的判断力:

问题用哪个为什么
有几个连通块 / 这一块有多大DFS只要走遍,顺序无所谓,递归写起来最短
最短步数(每步代价相同)BFS第一次到达即最短,DFS 得枚举所有路径
判断连通性(能不能到)都行谁顺手用谁
求所有方案 / 需要回溯记录路径DFS天然带回溯
网格特别大(几十万格以上)BFSDFS 递归深度会爆栈
每步代价不同(比如有的路要 3 秒)都不行得用 Dijkstra,见第 32 章
✓ 一句话记住

要「最短」就用 BFS,要「所有」就用 DFS。

BFS 之所以能保证最短,是因为它按距离从小到大处理格子; 一旦每步代价不再相同,这个顺序就被破坏了,BFS 也就不成立了 —— 那时候需要 Dijkstra。

12 自测

自测清单0 / 7
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 15 章会把 BFS 从网格搬到抽象的状态图上(八数码问题): 「格子」变成「棋盘的一种摆法」,「相邻」变成「移动一步能变成的摆法」。 一旦接受了这个抽象,BFS 的适用范围会一下子宽出去很多。