阶段 3 · 搜索 · 第 15 章

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

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

例题:多源扩散 · 八数码 建议用时:110 分钟
这一章不教新算法,教两种「换个眼光看题」

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

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

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

第 2 项是这一章真正值钱的东西 —— 图不一定要画出来。 想清楚「什么是一个状态」,BFS 就能上。

前半场 · 多源 BFS

1 一句话问题

一张地图,. 是空地、# 是墙、F 是火源(可能有很多个)。 火每秒向上下左右蔓延一格,穿不过墙。问每个格子在第几秒被烧到。

输入 3 4
     F..#
     ....
     ..#F
输出 0 1 2 -1        墙输出 -1
     1 2 2 1
     2 3 -1 0

2 暴力:每个火源跑一次 BFS

最直接的想法:有 k 个火源,就跑 k 次单源 BFS,每次更新全图的最小值。

brute.cpp暴力
输入(stdin)
输出
点「运行 ▶」看结果

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

3 实测

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

fast.cpp正解
输入(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),墙的密度也随机 —— 会造出大量「被墙围死、永远烧不到」的空地。这两种都是最容易漏的边界。

值得故意写错的:

  • 只把第一个火源入队 → 退化成单源,其他区域的答案偏大
  • 入队时不设 d = 0,等出队再设 → 同一个火源可能被重复入队(第 14 章讲过)
  • 忘了处理「没有火源」 → 队列一开始就空,要保证输出全是 -1
  • 墙的位置输出成 0 而不是 -1 → 边界处理

后半场 · 状态图搜索:八数码

7 一句话问题

3×3 的格子里放着 1~8 八个数字和一个空格,每次可以把和空格相邻的一个数字挪进空格。 问最少几步能拼成目标状态。

输入 283104765          2 8 3          目标: 1 2 3
                        1 . 4                 8 . 4
                        7 6 5                 7 6 5
输出 4

0 表示空格。九个数字按从上到下、从左到右写成一行。)

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)。
输入(stdin)
输出
点「运行 ▶」看结果
✓ 状态总数:为什么不会爆

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

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

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

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

eightTrace.cpp过程演示
输出每一层新增了多少状态。换个更乱的局面(比如 087654321 —— 但它可能无解)再试试。
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

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

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

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

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

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

eightIddfs.cpp迭代加深(对拍用)
对拍器
生成器不是随机打乱九个数字 —— 那样有一半概率是无解局面,迭代加深会跑到天荒地老。它是「从目标状态随机走 0~10 步」,这样既保证有解,最优步数又不会太大。
✓ 注意这个生成器的造法:按照解法反过来造

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

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

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

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

值得故意写错的:

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

11 小结:BFS 的三种面孔

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

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

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

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

12 自测

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

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

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