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

BFS 变形:多源 BFS 与状态图搜索

两个变形,代码几乎不用改 —— 变的是「什么算一个起点」和「什么算一个点」。

需要先学:第 14 章 BFS 广度优先搜索:迷宫最短路例题:多源扩散 · 八数码建议用时:110 分钟
这一章不教新算法,教两种「换个眼光看题」

第 14 章的 BFS 你已经会了:一个起点,一圈圈往外扩,第一次到达即最短。

这一章的两个变形,BFS 的代码几乎一个字都不用改:

  1. 多源 BFS:起点不止一个。改动只有「初始化时把所有起点一起入队」这一句。
  2. 状态图搜索:根本没有网格。改动只有「用什么当作图上的点」。

第 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,每次更新全图的最小值。

brute.cpp暴力
// 多源 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

O(knm)。完全正确,但火源一多就废了 —— 极端情况下 k = n·m,直接变成 O((nm)²)。

3实测

同题对比:每个火源跑一次 vs 多源一次跑完
地图里约 2% 的格子是火源 —— n=200 就有约 800 个火源,暴力要跑 800 次全图 BFS。跑完改成 300、400。
每个火源跑一次
多源一次跑完
地图 火源数 每个火源跑一次 多源一次跑完
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),和只有一个火源时一模一样。

fast.cpp正解
// 多源 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 这一招只对「无权图 + 每步代价相同」有效

如果不同格子的通过代价不一样(比如走草地 1 秒、走沼泽 3 秒), BFS 的「单调不减」就不成立了,多源这一招也跟着失效。

那时候要用的是多源 Dijkstra(第 32 章)—— 思路一样(所有源点距离设 0 一起入堆), 但队列要换成优先队列。这个「多源」技巧是通用的,换的只是容器。

5单步看几处火一起烧

多源 BFS:几处火同时烧起来
共 14 层
第 1 / 14 步
火
火
火
灰色是墙。数字 = 到最近火源的步数。黑框 = 这一帧刚被烧到的格子。
换一张地图(. 空地,# 墙,F 火源)
当前层(距离)
0
这一层新烧到
3 个
累计已点燃
3 个
单源 BFS 是一个圆往外扩; 多源 BFS 是好几个圆同时扩,撞上了就停 —— 而代码只差「初始化时把所有源一起入队」这一句。
第 0 层:3 个火源「同时」进队,距离都是 0。这是和单源 BFS 唯一的区别。

这个动画一层一帧(而不是一格一帧),因为 BFS 的本质就是按层扩展:

  • 第 0 帧:所有火源同时点亮 —— 这就是和单源 BFS 唯一的区别。
  • 之后每一帧,所有距离相同的格子同时被烧到。
  • 看两团火的波纹撞在一起:撞上的地方就停住了,因为那些格子已经被更近的火源占了。

试着在地图里多加几个 F,或者把中间的墙拆掉,看波纹形状怎么变。

6★ 对拍验证

对拍器
生成器有 1/6 的概率造出「一个火源都没有」的地图(那时全图都是 -1),墙的密度也随机 —— 会造出大量「被墙围死、永远烧不到」的空地。这两种都是最容易漏的边界。
// 多源 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> 记距离。

★ 这是整个搜索里最值钱的一个视角

图不一定要画出来。

只要你能说清楚两件事:

  1. 什么是一个状态?(棋盘摆法 / 水壶里的水量 / 人在哪+钥匙拿了哪些)
  2. 从一个状态,一步能变到哪些状态?

那么 BFS(求最少步数)和 DFS(求方案)就都能上。

八数码、倒水问题、跳马、推箱子、魔方…… 全都是这一个套路。 考场上遇到「最少操作多少次」的题,先问自己这两句 —— 很多看起来无从下手的题,一下子就变成模板题了。

eightFast.cpp状态图 BFS
试试 123804765(已经是目标,0 步)、123456780(这个局面无解,输出 -1)。
// 八数码 —— 在「状态图」上做 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
✓ 状态总数:为什么不会爆

九个数字的排列一共 9! = 362 880 种。听着不少,但对计算机来说是小数目 —— BFS 最多访问这么多状态,每个状态处理 4 条边,转眼就跑完。

而且实际上远远用不了这么多:从目标出发几步之内的局面,几百个状态就找到了。

(顺带一提:这 36 万个局面里,恰好一半是无解的 —— 它们和目标之间存在一个叫「逆序对奇偶性」的不变量,永远跨不过去。 想深究可以查「八数码可解性判定」,本章不展开。)

9看它是怎么一层层铺开的

eightTrace.cpp过程演示
输出每一层新增了多少状态。换个更乱的局面(比如 087654321 —— 但它可能无解)再试试。
// 八数码 —— 把「按层扩展」打印出来
//
// 输入:一行 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

以 283104765 为例,每层的新状态数是 1, 4, 8, 8, 16 —— 按层成倍地涨。

这就是搜索题的常态:层数每深一层,工作量翻好几倍。 所以第 16 章的剪枝、第 18 章的双向 BFS 才那么值钱 —— 它们要么砍掉一整片分支,要么把「一棵深树」变成「两棵浅树」。

10★ 对拍:用完全不同的搜索方式

★ 标准答案要用不同的思路(第 9 章那条经验)

拿什么给 BFS 对拍?再写一份 BFS 是没用的 —— 错了会一起错。

这里用迭代加深 DFS:限定深度做 DFS,找不到就把深度上限 +1 重来。 第一次搜到目标时,深度上限就是最短步数。

它和 BFS 的机制完全不同(几乎不用内存,但会重复搜), 却必须给出同一个答案 —— 这才是有意义的交叉验证。

(迭代加深是第 18 章的主角,这里先借来当标准答案用。)

eightIddfs.cpp迭代加深(对拍用)
对拍器
生成器不是随机打乱九个数字 —— 那样有一半概率是无解局面,迭代加深会跑到天荒地老。它是「从目标状态随机走 0~10 步」,这样既保证有解,最优步数又不会太大。
// 八数码 —— 在「状态图」上做 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;
}
点一下即可编辑
✓ 注意这个生成器的造法:按照解法反过来造

随机打乱九个数字,有一半的概率造出无解局面。

所以这里改成:从目标状态出发,随机走若干步。这样:

  1. 一定有解(原路走回去就行)
  2. 最优步数不超过走的步数,对拍跑得快

「按照解法反过来造数据」是个通用技巧, 特别适合那些「随机造出来大概率不合法」的题目(迷宫、拼图、合法括号串……)。

值得故意写错的:

  • dist 判重写在出队时而不是入队时 → 同一个状态被重复入队,慢且可能超时
  • 忘了判重 → 状态无限循环,直接爆内存
  • 找到目标时不立刻返回 → 答案还是对的,但会把整张状态图跑完
  • 空格移动的越界判断写错(比如允许从第 0 列往左) → 字符串下标错位,答案乱掉

11小结:BFS 的三种面孔

★ 一张表
点是什么 边是什么 判重用什么
网格最短路(第 14 章) 一个格子 上下左右一步 vis[i][j] 数组
多源扩散(本章前半) 一个格子 上下左右一步 同上,只是初始入队多个
状态图(本章后半) 一个「局面」 一次合法操作 map / unordered_map

BFS 的代码骨架从头到尾没变过:

起点入队并标记 → 反复取队首 → 扩展所有邻居 → 没访问过的标记 + 入队

变的只是「点是什么」「邻居是什么」「拿什么记访问过」。 把这三个问题想清楚,任何 BFS 题都是模板题。

12自测

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

第 16 章 DFS 剪枝:可行性剪枝、最优性剪枝、搜索顺序。

第 4 章 N 皇后已经用过最基础的剪枝了。第 16 章会把它系统化 —— 搜索题的分数几乎全部来自剪枝,而剪枝的三板斧是可以套路化的。