第 14 章的 BFS 你已经会了:一个起点,一圈圈往外扩,第一次到达即最短。
这一章的两个变形,BFS 的代码几乎一个字都不用改:
- 多源 BFS:起点不止一个。改动只有「初始化时把所有起点一起入队」这一句。
- 状态图搜索:根本没有网格。改动只有「用什么当作图上的点」。
第 2 项是这一章真正值钱的东西 —— 图不一定要画出来。 想清楚「什么是一个状态」,BFS 就能上。
前半场 · 多源 BFS
1一句话问题
一张地图,. 是空地、# 是墙、F 是火源(可能有很多个)。
火每秒向上下左右蔓延一格,穿不过墙。问每个格子在第几秒被烧到。
输入
3 4 F..# .... ..#F
输出
0 1 2 -1 1 2 2 1 2 3 -1 0
第一行是行数和列数。输出和地图一样大,每格一个数:墙输出 -1,
火源本身是 0,其余是被烧到的秒数。
2暴力:每个火源跑一次 BFS
最直接的想法:有 k 个火源,就跑 k 次单源 BFS,每次更新全图的最小值。
// 多源 BFS —— 暴力:每个火源单独跑一次 BFS,再取最小值//// 输入:第一行 n m// 接下来 n 行,每行 m 个字符:// '.' 空地 '#' 墙 'F' 火源(火源本身也算空地)// 输出:n 行,每行 m 个整数 —— 每个格子到**最近火源**的最短步数// 墙输出 -1,走不到的空地也输出 -1,火源本身是 0//// 例:// F . . .// . . . .// . . . F// 每个格子到最近那个 F 的距离(只能上下左右走,不能穿墙)。//// 暴力思路:有 k 个火源,就跑 k 次单源 BFS,每次更新一遍全图的最小值。// 复杂度 O(knm)。火源一多就废了 —— 极端情况下 k = n·m,直接变成 O((nm)²)。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
vector<string> g(n); for (int i = 0; i < n; i++) cin >> g[i];
const int INF = INT_MAX; vector<vector<int>> best(n, vector<int>(m, INF)); const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1};
for (int si = 0; si < n; si++) for (int sj = 0; sj < m; sj++) { if (g[si][sj] != 'F') continue;
// 从这一个火源出发做一次普通 BFS vector<vector<int>> d(n, vector<int>(m, -1)); queue<pair<int, int>> q; d[si][sj] = 0; q.push({si, sj});
while (!q.empty()) { auto [i, j] = q.front(); q.pop(); for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= n || y < 0 || y >= m) continue; if (g[x][y] == '#' || d[x][y] != -1) continue; d[x][y] = d[i][j] + 1; q.push({x, y}); } }
for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) if (d[i][j] != -1) best[i][j] = min(best[i][j], d[i][j]); }
for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { int v = (g[i][j] == '#' || best[i][j] == INF) ? -1 : best[i][j]; cout << v << " \n"[j == m - 1]; } } return 0;}点「运行 ▶」看结果
O(knm)。完全正确,但火源一多就废了 ——
极端情况下 k = n·m,直接变成 O((nm)²)。
3实测
| 地图 | 火源数 | 每个火源跑一次 | 多源一次跑完 |
|---|---|---|---|
| 100×100 | 约 200 | 0.03 秒 | 0.004 秒 |
| 200×200 | 约 800 | 0.52 秒 | 0.006 秒 |
| 300×300 | 约 1 800 | 2.58 秒 | 0.01 秒 |
注意暴力那一列的增长速度:地图边长翻倍,它慢了 8 倍(面积 ×4,火源数 ×4)。
4★ 关键的一步
把所有火源一次性全塞进队列,距离都设成 0,然后照常 BFS。
for (每个格子)
if (是火源) { d[i][j] = 0; q.push({i, j}); } // ← 唯一的改动
// 后面的 BFS 一个字都不用改为什么这样是对的?
回想 BFS 的本质(第 14 章):队列里的元素按距离单调不减排列, 所以第一次到达某个格子时,距离一定是最小的。
一开始队列里就是所有距离为 0 的格子(那 k 个火源)—— 这个性质仍然成立。 之后弹出它们、扩展出距离 1 的一圈,再扩展出距离 2 的一圈…… 每个格子第一次被访问时,它到「离它最近的那个火源」的距离就定下来了。
还有个更好懂的说法:想象在地图外面加一个超级源点, 它到每个火源的距离是 0。那么「到最近火源的距离」就等于「到超级源点的距离」—— 多源问题被翻译成了单源问题。
复杂度:O(nm),和只有一个火源时一模一样。
// 多源 BFS —— 所有火源一起入队,只跑一次//// 输入输出和 brute.cpp 完全一样,但只扫一遍全图:O(nm)。//// ============ 关键的一步 ============//// 第 14 章的 BFS 是从**一个**起点开始的:起点入队、距离 0,然后一圈圈往外扩。//// 现在有 k 个火源。很多人的第一反应是「跑 k 次 BFS」——// 但其实**只要把 k 个火源一次性全部塞进队列,距离都设成 0,然后照常 BFS 就行**。//// 为什么这样是对的?回想 BFS 的本质:// 队列里的元素按距离**单调不减**排列,所以第一次到达某个格子时,距离一定最小。//// 一开始队列里就是所有距离为 0 的格子(那 k 个火源)—— 这个性质仍然成立。// 之后弹出它们、扩展出距离 1 的一圈,再扩展出距离 2 的一圈……// **每个格子第一次被访问时,它到「离它最近的那个火源」的距离就定下来了。**//// 换个说法更好理解:想象在图外面加一个「超级源点」,// 它到每个火源的距离是 0。那么「到最近火源的距离」= 「到超级源点的距离」,// 于是多源问题就变成了单源问题。//// 代码和第 14 章的单源 BFS **只差初始化那几行**。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
vector<string> g(n); for (int i = 0; i < n; i++) cin >> g[i];
vector<vector<int>> d(n, vector<int>(m, -1)); queue<pair<int, int>> q;
// ★ 唯一的区别:所有火源一起进队,距离都是 0 for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) if (g[i][j] == 'F') { d[i][j] = 0; q.push({i, j}); }
const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1}; while (!q.empty()) { auto [i, j] = q.front(); q.pop(); for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= n || y < 0 || y >= m) continue; if (g[x][y] == '#' || d[x][y] != -1) continue; d[x][y] = d[i][j] + 1; // 入队时就定下距离(第 14 章讲过为什么不能等出队) q.push({x, y}); } }
for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) cout << (g[i][j] == '#' ? -1 : d[i][j]) << " \n"[j == m - 1];
return 0;}点「运行 ▶」看结果
如果不同格子的通过代价不一样(比如走草地 1 秒、走沼泽 3 秒), BFS 的「单调不减」就不成立了,多源这一招也跟着失效。
那时候要用的是多源 Dijkstra(第 32 章)—— 思路一样(所有源点距离设 0 一起入堆), 但队列要换成优先队列。这个「多源」技巧是通用的,换的只是容器。
5单步看几处火一起烧
换一张地图(. 空地,# 墙,F 火源)
这个动画一层一帧(而不是一格一帧),因为 BFS 的本质就是按层扩展:
- 第 0 帧:所有火源同时点亮 —— 这就是和单源 BFS 唯一的区别。
- 之后每一帧,所有距离相同的格子同时被烧到。
- 看两团火的波纹撞在一起:撞上的地方就停住了,因为那些格子已经被更近的火源占了。
试着在地图里多加几个 F,或者把中间的墙拆掉,看波纹形状怎么变。
6★ 对拍验证
// 多源 BFS —— 所有火源一起入队,只跑一次//// 输入输出和 brute.cpp 完全一样,但只扫一遍全图:O(nm)。//// ============ 关键的一步 ============//// 第 14 章的 BFS 是从**一个**起点开始的:起点入队、距离 0,然后一圈圈往外扩。//// 现在有 k 个火源。很多人的第一反应是「跑 k 次 BFS」——// 但其实**只要把 k 个火源一次性全部塞进队列,距离都设成 0,然后照常 BFS 就行**。//// 为什么这样是对的?回想 BFS 的本质:// 队列里的元素按距离**单调不减**排列,所以第一次到达某个格子时,距离一定最小。//// 一开始队列里就是所有距离为 0 的格子(那 k 个火源)—— 这个性质仍然成立。// 之后弹出它们、扩展出距离 1 的一圈,再扩展出距离 2 的一圈……// **每个格子第一次被访问时,它到「离它最近的那个火源」的距离就定下来了。**//// 换个说法更好理解:想象在图外面加一个「超级源点」,// 它到每个火源的距离是 0。那么「到最近火源的距离」= 「到超级源点的距离」,// 于是多源问题就变成了单源问题。//// 代码和第 14 章的单源 BFS **只差初始化那几行**。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
vector<string> g(n); for (int i = 0; i < n; i++) cin >> g[i];
vector<vector<int>> d(n, vector<int>(m, -1)); queue<pair<int, int>> q;
// ★ 唯一的区别:所有火源一起进队,距离都是 0 for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) if (g[i][j] == 'F') { d[i][j] = 0; q.push({i, j}); }
const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1}; while (!q.empty()) { auto [i, j] = q.front(); q.pop(); for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= n || y < 0 || y >= m) continue; if (g[x][y] == '#' || d[x][y] != -1) continue; d[x][y] = d[i][j] + 1; // 入队时就定下距离(第 14 章讲过为什么不能等出队) q.push({x, y}); } }
for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) cout << (g[i][j] == '#' ? -1 : d[i][j]) << " \n"[j == m - 1];
return 0;}值得故意写错的:
- 只把第一个火源入队 → 退化成单源,其他区域的答案偏大
- 入队时不设
d = 0,等出队再设 → 同一个火源可能被重复入队(第 14 章讲过) - 忘了处理「没有火源」 → 队列一开始就空,要保证输出全是 -1
- 墙的位置输出成 0 而不是 -1 → 边界处理
后半场 · 状态图搜索:八数码
7一句话问题
3×3 的格子里放着 1~8 八个数字和一个空格,每次可以把和空格相邻的一个数字挪进空格。 问最少几步能拼成目标状态。
输入
283104765
输出
4
九个数字按从上到下、从左到右写成一行,0 表示空格。这一组是:
初始: 2 8 3 目标: 1 2 3
1 . 4 8 . 4
7 6 5 7 6 5挪 4 步就能拼好。
8★ 关键的一步:什么是「图上的一个点」
第 14 章的 BFS 走在网格上:
- 点 = 一个格子
- 边 = 上下左右挪一步
这道题看着完全不同 —— 没有网格,是一个拼图。但换个眼光:
- 点 = 整个棋盘的一种摆法(一个 9 位字符串,比如
"283104765") - 边 = 把空格和相邻某一块交换一次(每个状态最多有 4 条边)
于是「最少几步拼好」就是「从起点状态走到目标状态的最短路」—— 一张不折不扣的图,虽然它长得不像图。
BFS 原封不动就能用。
唯一要改的是「访问标记」:网格用 vis[i][j] 数组,
而状态是字符串,所以改用 unordered_map<string, int> 记距离。
图不一定要画出来。
只要你能说清楚两件事:
- 什么是一个状态?(棋盘摆法 / 水壶里的水量 / 人在哪+钥匙拿了哪些)
- 从一个状态,一步能变到哪些状态?
那么 BFS(求最少步数)和 DFS(求方案)就都能上。
八数码、倒水问题、跳马、推箱子、魔方…… 全都是这一个套路。 考场上遇到「最少操作多少次」的题,先问自己这两句 —— 很多看起来无从下手的题,一下子就变成模板题了。
// 八数码 —— 在「状态图」上做 BFS//// 输入:一行 9 个数字(0 表示空格),按从上到下、从左到右读// 例如 283104765 表示// 2 8 3// 1 0 4// 7 6 5// 输出:变成目标状态最少需要多少步;变不成就输出 -1//// 目标状态固定为 123804765,也就是// 1 2 3// 8 0 4// 7 6 5//// ============ 关键的一步:把「状态」当成图上的点 ============//// 第 14 章的 BFS 走在网格上:点 = 格子,边 = 上下左右挪一步。// 这道题看着完全不同 —— 没有网格,是一个拼图。//// 但换个眼光:// **一个点 = 整个棋盘的一种摆法(一个 9 位字符串)**// **一条边 = 把空格和相邻某一块交换一次**//// 于是「最少几步拼好」= 「从起点状态走到目标状态的最短路」。// 这就是一张图,虽然它不长得像图 —— BFS 原封不动就能用。//// 唯一的区别是「访问标记」:网格用 vis[i][j] 数组,// 而状态是字符串,所以改用 unordered_map<string,int> 记距离。//// ★ 这个视角是整个搜索里最值钱的东西之一:// **图不一定要画出来。只要你能说清楚「什么是一个状态」和// 「从一个状态能一步变到哪些状态」,BFS/DFS 就能上。**// 八数码、倒水问题、魔方、推箱子……全都是这个套路。//// 状态总数:9! = 362880(其中一半是无解的)。所以 BFS 最多访问 18 万个状态,很快。
#include <bits/stdc++.h>using namespace std;
const string GOAL = "123804765";
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string start; if (!(cin >> start)) return 0; if (start.size() != 9) { cout << -1 << "\n"; return 0; }
if (start == GOAL) { cout << 0 << "\n"; return 0; }
unordered_map<string, int> dist; dist.reserve(1 << 16); queue<string> q; dist[start] = 0; q.push(start);
const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1};
while (!q.empty()) { string cur = q.front(); q.pop(); int d = dist[cur];
int p = (int)cur.find('0'); int i = p / 3, j = p % 3;
for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue;
string nxt = cur; swap(nxt[p], nxt[x * 3 + y]); // 空格和那一块交换 —— 这就是一条边
if (dist.count(nxt)) continue; // 已经到过,跳过 dist[nxt] = d + 1; if (nxt == GOAL) { cout << d + 1 << "\n"; return 0; } q.push(nxt); } }
cout << -1 << "\n"; // 队列空了还没到,说明无解 return 0;}点「运行 ▶」看结果
九个数字的排列一共 9! = 362 880 种。听着不少,但对计算机来说是小数目 —— BFS 最多访问这么多状态,每个状态处理 4 条边,转眼就跑完。
而且实际上远远用不了这么多:从目标出发几步之内的局面,几百个状态就找到了。
(顺带一提:这 36 万个局面里,恰好一半是无解的 —— 它们和目标之间存在一个叫「逆序对奇偶性」的不变量,永远跨不过去。 想深究可以查「八数码可解性判定」,本章不展开。)
9看它是怎么一层层铺开的
// 八数码 —— 把「按层扩展」打印出来//// 输入:一行 9 个数字// 输出:BFS 每一层新访问了多少个状态,以及最先找到目标的那一层//// 跑一遍,看那一列「这一层有多少个新状态」。// 以 283104765 为例:1, 4, 8, 8, 16 —— 层数一深,状态数就成倍地涨。// 这就是搜索题的常态,也是为什么剪枝(第 16 章)那么值钱。//// 另外注意「已访问状态总数」这个数字:它远小于 9! = 362880,// 因为一旦碰到目标就停了,根本不用把整张状态图跑完。
#include <bits/stdc++.h>using namespace std;
const string GOAL = "123804765";
void show(const string& s) { for (int i = 0; i < 3; i++) { cout << " "; for (int j = 0; j < 3; j++) { char c = s[i * 3 + j]; cout << (c == '0' ? '.' : c) << ' '; } cout << "\n"; }}
int main() { string start; if (!(cin >> start)) return 0; if (start.size() != 9) { cout << "请输入 9 个数字(0 表示空格)\n"; return 0; }
cout << "起始局面:\n"; show(start); cout << "目标局面:\n"; show(GOAL); cout << "\n";
unordered_map<string, int> dist; queue<string> q; dist[start] = 0; q.push(start);
const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1}; int curLayer = 0, layerCount = 1, total = 1, answer = -1;
cout << " 层 这一层的新状态数 累计访问\n"; cout << "---- ---------------- --------\n";
while (!q.empty() && answer < 0) { int sz = (int)q.size(); layerCount = sz; cout << setw(4) << curLayer << setw(18) << layerCount << setw(10) << total << "\n";
for (int c = 0; c < sz && answer < 0; c++) { string s = q.front(); q.pop(); if (s == GOAL) { answer = dist[s]; break; }
int p = (int)s.find('0'); int i = p / 3, j = p % 3; for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue; string nxt = s; swap(nxt[p], nxt[x * 3 + y]); if (dist.count(nxt)) continue; dist[nxt] = dist[s] + 1; q.push(nxt); total++; } } curLayer++; }
cout << "\n"; if (answer >= 0) { cout << "最少 " << answer << " 步。一共访问了 " << total << " 个状态" << "(全部可能的局面有 9! = 362880 个,其中一半根本无解)。\n"; } else { cout << "这个局面无解 —— 整张状态图都搜完了也到不了目标。\n"; cout << "一共访问了 " << total << " 个状态。\n"; } return 0;}点「运行 ▶」看结果
以 283104765 为例,每层的新状态数是 1, 4, 8, 8, 16 —— 按层成倍地涨。
这就是搜索题的常态:层数每深一层,工作量翻好几倍。 所以第 16 章的剪枝、第 18 章的双向 BFS 才那么值钱 —— 它们要么砍掉一整片分支,要么把「一棵深树」变成「两棵浅树」。
10★ 对拍:用完全不同的搜索方式
拿什么给 BFS 对拍?再写一份 BFS 是没用的 —— 错了会一起错。
这里用迭代加深 DFS:限定深度做 DFS,找不到就把深度上限 +1 重来。 第一次搜到目标时,深度上限就是最短步数。
它和 BFS 的机制完全不同(几乎不用内存,但会重复搜), 却必须给出同一个答案 —— 这才是有意义的交叉验证。
(迭代加深是第 18 章的主角,这里先借来当标准答案用。)
// 八数码 —— 在「状态图」上做 BFS//// 输入:一行 9 个数字(0 表示空格),按从上到下、从左到右读// 例如 283104765 表示// 2 8 3// 1 0 4// 7 6 5// 输出:变成目标状态最少需要多少步;变不成就输出 -1//// 目标状态固定为 123804765,也就是// 1 2 3// 8 0 4// 7 6 5//// ============ 关键的一步:把「状态」当成图上的点 ============//// 第 14 章的 BFS 走在网格上:点 = 格子,边 = 上下左右挪一步。// 这道题看着完全不同 —— 没有网格,是一个拼图。//// 但换个眼光:// **一个点 = 整个棋盘的一种摆法(一个 9 位字符串)**// **一条边 = 把空格和相邻某一块交换一次**//// 于是「最少几步拼好」= 「从起点状态走到目标状态的最短路」。// 这就是一张图,虽然它不长得像图 —— BFS 原封不动就能用。//// 唯一的区别是「访问标记」:网格用 vis[i][j] 数组,// 而状态是字符串,所以改用 unordered_map<string,int> 记距离。//// ★ 这个视角是整个搜索里最值钱的东西之一:// **图不一定要画出来。只要你能说清楚「什么是一个状态」和// 「从一个状态能一步变到哪些状态」,BFS/DFS 就能上。**// 八数码、倒水问题、魔方、推箱子……全都是这个套路。//// 状态总数:9! = 362880(其中一半是无解的)。所以 BFS 最多访问 18 万个状态,很快。
#include <bits/stdc++.h>using namespace std;
const string GOAL = "123804765";
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string start; if (!(cin >> start)) return 0; if (start.size() != 9) { cout << -1 << "\n"; return 0; }
if (start == GOAL) { cout << 0 << "\n"; return 0; }
unordered_map<string, int> dist; dist.reserve(1 << 16); queue<string> q; dist[start] = 0; q.push(start);
const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1};
while (!q.empty()) { string cur = q.front(); q.pop(); int d = dist[cur];
int p = (int)cur.find('0'); int i = p / 3, j = p % 3;
for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue;
string nxt = cur; swap(nxt[p], nxt[x * 3 + y]); // 空格和那一块交换 —— 这就是一条边
if (dist.count(nxt)) continue; // 已经到过,跳过 dist[nxt] = d + 1; if (nxt == GOAL) { cout << d + 1 << "\n"; return 0; } q.push(nxt); } }
cout << -1 << "\n"; // 队列空了还没到,说明无解 return 0;}随机打乱九个数字,有一半的概率造出无解局面。
所以这里改成:从目标状态出发,随机走若干步。这样:
- 一定有解(原路走回去就行)
- 最优步数不超过走的步数,对拍跑得快
「按照解法反过来造数据」是个通用技巧, 特别适合那些「随机造出来大概率不合法」的题目(迷宫、拼图、合法括号串……)。
值得故意写错的:
dist判重写在出队时而不是入队时 → 同一个状态被重复入队,慢且可能超时- 忘了判重 → 状态无限循环,直接爆内存
- 找到目标时不立刻返回 → 答案还是对的,但会把整张状态图跑完
- 空格移动的越界判断写错(比如允许从第 0 列往左) → 字符串下标错位,答案乱掉
11小结:BFS 的三种面孔
| 点是什么 | 边是什么 | 判重用什么 | |
|---|---|---|---|
| 网格最短路(第 14 章) | 一个格子 | 上下左右一步 | vis[i][j] 数组 |
| 多源扩散(本章前半) | 一个格子 | 上下左右一步 | 同上,只是初始入队多个 |
| 状态图(本章后半) | 一个「局面」 | 一次合法操作 | map / unordered_map |
BFS 的代码骨架从头到尾没变过:
起点入队并标记 → 反复取队首 → 扩展所有邻居 → 没访问过的标记 + 入队变的只是「点是什么」「邻居是什么」「拿什么记访问过」。 把这三个问题想清楚,任何 BFS 题都是模板题。
12自测
- 洛谷 P1332 血色先锋队解析 → —— 多源 BFS 模板题,本章前半场的原题
- 洛谷 P1443 马的遍历解析 → —— 单源 BFS,但邻居有 8 个方向。先把「邻居是什么」想清楚
- 洛谷 P1379 八数码难题解析 → —— 本章后半场原题。BFS 能过,学完第 18 章可以回来用双向 BFS 再写一遍
- 洛谷 P1135 奇怪的电梯解析 → —— NOIP2007。状态是「在第几层」,边是「上 K[i] 层或下 K[i] 层」—— 典型的状态图 BFS
第 16 章 DFS 剪枝:可行性剪枝、最优性剪枝、搜索顺序。
第 4 章 N 皇后已经用过最基础的剪枝了。第 16 章会把它系统化 —— 搜索题的分数几乎全部来自剪枝,而剪枝的三板斧是可以套路化的。