阶段 3 · 搜索 · 第 14 章普及组 J

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

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

需要先学:第 13 章 DFS 深度优先搜索:网格连通块例题:迷宫从左上走到右下的最少步数建议用时:100 分钟

1一句话问题

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

输入

3 3
110
011
011

输出

4

第一行是行数和列数,之后是迷宫。1 能走、0 是墙。

最短的走法是 (0,0) → (0,1) → (1,1) → (1,2) → (2,2),走了 4 步。 ⚠ 程序只输出步数,不输出路径。

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暴力
// 迷宫最短路 —— 暴力:DFS 枚举每一条路,取最短的那条
//
// 输入:第一行 n m,接下来 n 行每行 m 个字符,'1' 能走,'0' 是墙
// 输出:从左上角 (0,0) 走到右下角 (n-1,m-1) 的最少步数;走不到输出 -1
// 每步只能上下左右走一格。
//
// 这是最诚实的暴力:既然要「最短」,那我把所有能走的路线全都走一遍,
// 记录下其中最短的那条就行了。用 DFS 枚举,走过的格子标记上不再重复走
// (否则会绕圈子绕到天荒地老),从一条分支回来时再把标记撤销。
//
// 它慢在哪?慢在它枚举的是「所有路径」,而路径的条数是指数级的。
// 一个 5×5 的空网格,从左上到右下有 8512 条路;
// 6×6 有 1262816 条;7×7 有 575780564 条 —— 五亿七千万条。
// 边长每加一,工作量乘以几百倍。这就是「指数爆炸」,
// 不是「电脑再快一点就行」能救的。
//
// 更要命的是:这五亿条路里,绝大多数一看就不可能是最短的 ——
// 暴力却老老实实每一条都走到底。
#include <bits/stdc++.h>
using namespace std;
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = {0, 0, -1, 1};
int n, m;
vector<string> g;
vector<vector<char>> vis;
int best;
void dfs(int i, int j, int steps) {
if (i == n - 1 && j == m - 1) { // 走到终点了
best = min(best, steps);
return;
}
for (int d = 0; d < 4; d++) {
int x = i + dx[d], y = j + dy[d];
if (x < 0 || x >= n || y < 0 || y >= m) continue;
if (g[x][y] != '1') continue; // 是墙
if (vis[x][y]) continue; // 这条路上已经踩过了,不能绕圈
vis[x][y] = 1; // 踩上去
dfs(x, y, steps + 1);
vis[x][y] = 0; // 退回来,把脚印擦掉(回溯)
}
}
int main() {
if (!(cin >> n >> m)) return 0;
g.resize(n);
for (int i = 0; i < n; i++) cin >> g[i];
if (g[0][0] != '1' || g[n - 1][m - 1] != '1') { cout << -1 << "\n"; return 0; }
vis.assign(n, vector<char>(m, 0));
best = INT_MAX;
vis[0][0] = 1;
dfs(0, 0, 0);
cout << (best == INT_MAX ? -1 : best) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

4实测:看它怎么爆炸

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

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

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

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

网格 路径条数 暴力实测
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(nm),因为每个格子只进队一次。

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

7BFS 写法

fast.cpp正解
// 迷宫最短路 —— BFS
//
// 和 brute.cpp 解同一道题,答案必须一样,但快得没法比。
//
// 关键的那一步想法:
// 暴力的错误不在于「用了 DFS」,而在于它一头扎到底、把每条路都走完才敢下结论。
// 可是「最短」这件事有个特别好的性质 —— 我们可以按距离一圈一圈地往外找:
//
// 先站在起点(距离 0);
// 把所有距离 1 的格子全找出来;
// 再把所有距离 2 的格子全找出来;
// ……
//
// 这样一来,只要终点第一次被找到,它所在的那一圈的编号就是最短距离。
// 不需要再看任何别的路 —— 因为更外面的圈只会更远。
//
// ★ 第一次到达 = 最短到达。这就是 BFS 全部的秘密。
//
// 怎么实现「一圈一圈」?用一个队列:
// 先进队列的格子先被处理,而先进队列的一定是距离更近的,
// 所以队列天然帮你把格子按距离排好了序。
//
// 注意 vis 标记要在「入队时」打上,不是在「出队时」——
// 否则同一个格子会被不同的邻居重复塞进队列,退化成暴力。这是 BFS 的头号错误。
//
// 复杂度:每个格子最多进队一次,O(nm)。
#include <bits/stdc++.h>
using namespace std;
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = {0, 0, -1, 1};
int main() {
int n, m;
if (!(cin >> n >> m)) return 0;
vector<string> g(n);
for (int i = 0; i < n; i++) cin >> g[i];
if (g[0][0] != '1' || g[n - 1][m - 1] != '1') { cout << -1 << "\n"; return 0; }
vector<vector<int>> dist(n, vector<int>(m, -1)); // -1 = 还没到过
queue<pair<int, int>> q;
dist[0][0] = 0;
q.push({0, 0});
while (!q.empty()) {
auto [i, j] = q.front();
q.pop();
if (i == n - 1 && j == m - 1) break; // 第一次碰到终点,就已经是最短了
for (int d = 0; d < 4; d++) {
int x = i + dx[d], y = j + dy[d];
if (x < 0 || x >= n || y < 0 || y >= m) continue;
if (g[x][y] != '1') continue;
if (dist[x][y] != -1) continue; // 来过了,而且那次一定不比这次远
dist[x][y] = dist[i][j] + 1; // ← 入队时就定下距离并标记
q.push({x, y});
}
}
cout << dist[n - 1][m - 1] << "\n";
return 0;
}
点一下即可编辑
输入(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); }
}
⚠ 上面那行 `auto [i, j] = q.front();` 是 C++17 的写法

队列里存的是 pair<int,int>,这一行等价于 C++14 的两句:

int i = q.front().first;
int j = q.front().second;

这叫结构化绑定(structured binding),C++17 才有。全书有 43 份代码用到它, 第一次就是这里 —— 后面不再逐处说明。

⚠ 和评测环境有关的一句实测(g++ 15.2.0,2026-08-25): 用 -std=c++14 -O2 -Wall 编译这一份,只是一条 warning,编译照样通过; 但加上 -pedantic-errors,或者用很老的 Dev-C++(GCC 4.9.2)就是错误。 ⇒ 自己写的时候拿不准,就写成上面那两行 —— 一个字符都不会更慢,也不挑编译器。 (这本书选 C++17 的写法是为了短,不是因为它更快。)

⚠ BFS 的头号错误:标记的时机

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

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

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

8按圈打印扩散过程

trace.cpp过程演示
每处理完一圈就打印一次距离图。你会看到一圈波纹从起点荡开。
// 迷宫最短路 —— 带打印的 BFS,用来「看见」一圈一圈扩散
//
// 它按层输出:第 0 层只有起点,第 1 层是所有距离 1 的格子……
// 每层结束后打印一次当前的距离图。跑一遍你会看到一圈波纹从起点荡开。
//
// 对比第 13 章 DFS 的 trace:
// DFS 是「一条道走到黑再退回来」,BFS 是「所有方向齐头并进」。
// 同样是搜索,一个用递归(栈),一个用队列 —— 差别全在这里。
#include <bits/stdc++.h>
using namespace std;
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = {0, 0, -1, 1};
int main() {
int n, m;
if (!(cin >> n >> m)) return 0;
vector<string> g(n);
for (int i = 0; i < n; i++) cin >> g[i];
if (g[0][0] != '1' || g[n - 1][m - 1] != '1') {
cout << "起点或终点是墙,直接无解\n-1\n";
return 0;
}
vector<vector<int>> dist(n, vector<int>(m, -1));
queue<pair<int, int>> q;
dist[0][0] = 0;
q.push({0, 0});
int layer = 0;
while (!q.empty()) {
int sz = (int)q.size(); // 当前这一圈有多少个格子
cout << "===== 第 " << layer << " 圈(距离 " << layer << "),共 " << sz << " 个格子 =====\n";
cout << " 这一圈的格子:";
vector<pair<int, int>> cur;
for (int k = 0; k < sz; k++) { cur.push_back(q.front()); q.pop(); }
for (auto& [i, j] : cur) cout << "(" << i << "," << j << ") ";
cout << "\n";
for (auto& [i, j] : cur) {
for (int d = 0; d < 4; d++) {
int x = i + dx[d], y = j + dy[d];
if (x < 0 || x >= n || y < 0 || y >= m) continue;
if (g[x][y] != '1' || dist[x][y] != -1) continue;
dist[x][y] = layer + 1;
q.push({x, y});
}
}
cout << " 扩散后的距离图(# 是墙,. 是还没到过):\n";
for (int i = 0; i < n; i++) {
cout << " ";
for (int j = 0; j < m; j++) {
if (g[i][j] != '1') cout << " #";
else if (dist[i][j] < 0) cout << " .";
else cout << " " << (char)('0' + dist[i][j] % 10);
}
cout << "\n";
}
if (dist[n - 1][m - 1] != -1) {
cout << "\n终点第一次被碰到,距离 = " << dist[n - 1][m - 1] << ",可以收工了。\n";
break;
}
layer++;
}
cout << "\n答案 = " << dist[n - 1][m - 1] << "\n";
return 0;
}
点一下即可编辑
输入(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% 的迷宫。墙多是故意的 —— 逼着最短路必须绕路,才能查出「漏方向」这类错误。
// 迷宫最短路 —— BFS
//
// 和 brute.cpp 解同一道题,答案必须一样,但快得没法比。
//
// 关键的那一步想法:
// 暴力的错误不在于「用了 DFS」,而在于它一头扎到底、把每条路都走完才敢下结论。
// 可是「最短」这件事有个特别好的性质 —— 我们可以按距离一圈一圈地往外找:
//
// 先站在起点(距离 0);
// 把所有距离 1 的格子全找出来;
// 再把所有距离 2 的格子全找出来;
// ……
//
// 这样一来,只要终点第一次被找到,它所在的那一圈的编号就是最短距离。
// 不需要再看任何别的路 —— 因为更外面的圈只会更远。
//
// ★ 第一次到达 = 最短到达。这就是 BFS 全部的秘密。
//
// 怎么实现「一圈一圈」?用一个队列:
// 先进队列的格子先被处理,而先进队列的一定是距离更近的,
// 所以队列天然帮你把格子按距离排好了序。
//
// 注意 vis 标记要在「入队时」打上,不是在「出队时」——
// 否则同一个格子会被不同的邻居重复塞进队列,退化成暴力。这是 BFS 的头号错误。
//
// 复杂度:每个格子最多进队一次,O(nm)。
#include <bits/stdc++.h>
using namespace std;
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = {0, 0, -1, 1};
int main() {
int n, m;
if (!(cin >> n >> m)) return 0;
vector<string> g(n);
for (int i = 0; i < n; i++) cin >> g[i];
if (g[0][0] != '1' || g[n - 1][m - 1] != '1') { cout << -1 << "\n"; return 0; }
vector<vector<int>> dist(n, vector<int>(m, -1)); // -1 = 还没到过
queue<pair<int, int>> q;
dist[0][0] = 0;
q.push({0, 0});
while (!q.empty()) {
auto [i, j] = q.front();
q.pop();
if (i == n - 1 && j == m - 1) break; // 第一次碰到终点,就已经是最短了
for (int d = 0; d < 4; d++) {
int x = i + dx[d], y = j + dy[d];
if (x < 0 || x >= n || y < 0 || y >= m) continue;
if (g[x][y] != '1') continue;
if (dist[x][y] != -1) continue; // 来过了,而且那次一定不比这次远
dist[x][y] = dist[i][j] + 1; // ← 入队时就定下距离并标记
q.push({x, y});
}
}
cout << dist[n - 1][m - 1] << "\n";
return 0;
}
点一下即可编辑

必试的几个错误:

  • 删掉 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自测

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

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