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 章的回溯。
点「运行 ▶」看结果
4 实测:看它怎么爆炸
下面这个对比是这一章最重要的一个实验,请亲手做一遍。
先用默认的 6(一个 6×6 的空网格),点右上角的 「开始对比 ▶」 跑一次,记住暴力用了多久。 然后把「网格边长」框里的 6 改成 7,再点一次「开始对比 ▶」。
空网格上,从左上走到右下的路径条数是:
| 网格 | 路径条数 | 暴力实测 |
|---|---|---|
| 4×4 | 184 | 瞬间 |
| 5×5 | 8 512 | 瞬间 |
| 6×6 | 1 262 816 | 约 0.15 秒 |
| 7×7 | 575 780 564 | 十几秒都跑不完 |
| 8×8 | 789 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 写法
点「运行 ▶」看结果
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); }
}
dist[v] = dist[u] + 1 这一步必须在 入队的时候 做,不能等到出队再做。
如果等出队才标记,同一个格子会被它的好几个邻居重复塞进队列。 队列会指数级膨胀,BFS 退化成暴力,然后超时。
记住这句话:入队即标记。 你可以在第 10 步的对拍里亲手验证 —— 把判重那行删掉,它会立刻报超时。
8 按圈打印扩散过程
点「运行 ▶」看结果
对比第 13 章 trace.cpp 的输出:那边是一条越缩越深的线,这边是一层层齐头并进。
同样是搜索,一个用递归(栈),一个用队列 —— 差别全在这里。
9 单步看波纹扩散
换一张地图试试(1 = 能走,0 = 墙)
**这是第 13 章那张一模一样的 8×8 地图。**建议开两个标签页,把两个动画放在一起播。
盯住这几件事:
- 颜色相同 = 距离相同 = 同一圈。你会看到清清楚楚的等距波纹。
- 右边的队列:队首出去,新格子从队尾进来。队列里的元素距离最多只差 1 —— 这就是 BFS 能保证按距离顺序处理的原因。
- 终点被涂上颜色的那一刻,动画立刻停止 —— 后面的格子根本不用算了。
- 和 DFS 对比:DFS 的访问顺序是一根线,BFS 是一圈圈的环。
10 ★ 对拍验证
把「BFS 版」那一栏换成你自己默写的,再点开始。
必试的几个错误:
- 删掉
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 | 天然带回溯 |
| 网格特别大(几十万格以上) | BFS | DFS 递归深度会爆栈 |
| 每步代价不同(比如有的路要 3 秒) | 都不行 | 得用 Dijkstra,见第 32 章 |
要「最短」就用 BFS,要「所有」就用 DFS。
BFS 之所以能保证最短,是因为它按距离从小到大处理格子; 一旦每步代价不再相同,这个顺序就被破坏了,BFS 也就不成立了 —— 那时候需要 Dijkstra。
12 自测
- 洛谷 P1443 马的遍历 —— BFS 模板题,只是方向从 4 个变成 8 个(马走日)
- 洛谷 P1746 离开中山路 —— 和本章例题几乎一样,起终点由输入给定
- 洛谷 P1747 好奇怪的游戏 —— 两个起点各跑一次 BFS
- 洛谷 P1332 血色先锋队 —— 多源 BFS —— 把所有起点一开始就全塞进队列,想想为什么这样是对的
第 15 章会把 BFS 从网格搬到抽象的状态图上(八数码问题): 「格子」变成「棋盘的一种摆法」,「相邻」变成「移动一步能变成的摆法」。 一旦接受了这个抽象,BFS 的适用范围会一下子宽出去很多。