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 章的回溯。
// 迷宫最短路 —— 暴力: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;}点「运行 ▶」看结果
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(nm),因为每个格子只进队一次。
dist[i][j] 的含义:从起点走到 (i,j) 的最短步数,-1 表示还没到过。
-1 同时充当了「未访问」标记 —— 一个数组干两件事。
7BFS 写法
// 迷宫最短路 —— 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;}点「运行 ▶」看结果
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); }
}
队列里存的是 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 的写法是为了短,不是因为它更快。)
dist[v] = dist[u] + 1 这一步必须在 入队的时候 做,不能等到出队再做。
如果等出队才标记,同一个格子会被它的好几个邻居重复塞进队列。 队列会指数级膨胀,BFS 退化成暴力,然后超时。
记住这句话:入队即标记。 你可以在第 10 步的对拍里亲手验证 —— 把判重那行删掉,它会立刻报超时。
8按圈打印扩散过程
// 迷宫最短路 —— 带打印的 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;}点「运行 ▶」看结果
对比第 13 章 trace.cpp 的输出:那边是一条越缩越深的线,这边是一层层齐头并进。
同样是搜索,一个用递归(栈),一个用队列 —— 差别全在这里。
9单步看波纹扩散
换一张地图试试(1 = 能走,0 = 墙)
这是第 13 章那张一模一样的 8×8 地图。建议开两个标签页,把两个动画放在一起播。
盯住这几件事:
- 颜色相同 = 距离相同 = 同一圈。你会看到清清楚楚的等距波纹。
- 右边的队列:队首出去,新格子从队尾进来。队列里的元素距离最多只差 1 —— 这就是 BFS 能保证按距离顺序处理的原因。
- 终点被涂上颜色的那一刻,动画立刻停止 —— 后面的格子根本不用算了。
- 和 DFS 对比:DFS 的访问顺序是一根线,BFS 是一圈圈的环。
10★ 对拍验证
把「BFS 版」那一栏换成你自己默写的,再点开始。
// 迷宫最短路 —— 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自测
- 洛谷 P1443 马的遍历解析 → —— BFS 模板题,只是方向从 4 个变成 8 个(马走日)
- 洛谷 P1746 离开中山路解析 → —— 和本章例题几乎一样,起终点由输入给定
- 洛谷 P1747 好奇怪的游戏解析 → —— 两个起点各跑一次 BFS
- 洛谷 P1332 血色先锋队解析 → —— 多源 BFS —— 把所有起点一开始就全塞进队列,想想为什么这样是对的
第 15 章会把 BFS 从网格搬到抽象的状态图上(八数码问题): 「格子」变成「棋盘的一种摆法」,「相邻」变成「移动一步能变成的摆法」。 一旦接受了这个抽象,BFS 的适用范围会一下子宽出去很多。