第 14、15 章的 BFS 有个躲不掉的毛病:它必须把所有到过的状态记在一张表里。
八数码只有 36 万个状态,撑得住。可一旦状态空间上亿(十五数码、魔方、 或者任何一道「状态是一串数字」的题),那张表就装不下了。
这一章的两个工具分别对付两个问题:
| 工具 | 解决什么 | 代价 |
|---|---|---|
| 迭代加深(IDDFS) | 内存 —— 只要一个递归栈 | 会重复搜前面的层 |
| 双向 BFS | 时间 —— 把搜索深度砍一半 | 必须知道终点是什么 |
题目继续用第 15 章那个八数码,这样能直接对比。
1 回顾:单向 BFS 的代价
点「运行 ▶」看结果
它没错也不慢,问题在那张 unordered_map:状态越多,内存越大。
后面会看到具体数字。
2 ★ 迭代加深:限定深度的 DFS,一层层放开
做法:只往下搜到第 limit 层,到了就掉头。
limit 从 0 开始一层层往上加,第一次搜到目标时,limit 就是最短步数。
for (limit = 0; limit <= 上限; limit++)
if (dfs(0)) { 答案就是 limit; break; }
bool dfs(int depth) {
if (到目标) return true;
if (depth >= limit) return false; // ← 就这一句,DFS 变成迭代加深
for (每个选择) { 进入; if (dfs(depth+1)) return true; 撤销; }
return false;
}它同时拿到了两边的好处:
- 像 DFS 一样省内存 —— 只有一个递归栈,几十个字节,没有任何判重表
- 像 BFS 一样保证最短 —— 因为深度是一层层放开的,浅的解一定先被找到
每加一层,前面所有层都要重搜 —— 听着浪费得离谱。算一笔账:
设每个状态平均能扩展出 b 个新状态(八数码大约 2~3)。
深度 d 那一层: b^d 个节点
前面所有层加起来: 1 + b + b² + … + b^(d-1) ≈ b^d / (b-1)b = 3 时,前面全部重搜的总量还不到最后一层的一半。
指数增长下,最后一层就占了绝大多数 —— 前面重来几遍根本无所谓。 这条性质对所有指数级搜索都成立,值得记死。
点「运行 ▶」看结果
if (prevMove >= 0 && (t ^ 1) == prevMove) continue;刚把空格往右挪,下一步又往左挪回来 —— 等于原地打转,白白浪费一层深度。
(0/1 是上/下,2/3 是左/右,所以异或 1 正好是反方向。)
迭代加深没有判重表,所以这种「一步就绕回来」的浪费必须手动挡掉。 这也是它和 BFS 最大的取舍:省了内存,就得自己小心重复。
3 ★ 再进一步:IDA* —— 给迭代加深装一个估价函数
迭代加深还有个明显的浪费:明明离目标还差十万八千里,它还在傻乎乎往下搜, 非要撞上深度上限才掉头。
如果能估计「从现在这个局面出发,至少还要走多少步」(记作 h),就能提前掐掉:
if (g + h() > limit) return false; // 已经走了 g 步,至少还要 h 步 —— 本轮不可能走通这就是第 16 章第 9 步提到的估价函数,也是 A* 家族的核心。
这道题用的估价:曼哈顿距离。 每个数字块「离它该在的位置」还差几格(横向差 + 纵向差),全部加起来。
为什么它是「至少还要走的步数」?因为每走一步只有一个块动一格, 它的曼哈顿距离最多减 1。要把总距离降到 0,至少需要那么多步。
专业说法叫「可接纳」(admissible)。
一旦估多了,就可能把真正的最优解剪掉 —— 那不是剪枝,是剪错了。 (第 16 章那句话:剪枝不该改变答案。)
所以估价函数宁可保守。曼哈顿距离是安全的:它连「其他块挡路」都没算进去, 实际步数只会更多,不会更少。
点「运行 ▶」看结果
4 ★ 双向 BFS:两头一起搜
单向 BFS 要铺开一棵深度 d 的树,节点数约 b^d。
如果从起点和终点同时往中间搜,两边各铺 d/2 层就会撞上:
单向: b^d
双向: 2 · b^(d/2)b = 3、d = 20 时:
单向:3²⁰ ≈ 35 亿
双向:2 × 3¹⁰ ≈ 12 万**快了三万倍。**这不是常数优化 —— 它把指数砍了一半。
三个实现要点:
- 每次扩展节点少的那一边,让两棵树长得均衡。
- 相遇判定:新扩展出的状态如果在对面那张表里,答案 = 这边步数 + 对面步数。
- 必须整层整层地扩,否则可能先撞上一条不是最短的路径。
「求最少步数到某个确定状态」→ 可以用。
「求最少步数到任意一个满足某条件的状态」→ 不行,你没法从终点倒着搜。
这个限制很实在。拿到题先确认「终点是不是唯一且已知」,再决定用不用它。
点「运行 ▶」看结果
5 ★ 四种方法,同一个局面,工作量并排数出来
点「运行 ▶」看结果
本机实测(局面 845201376,最优 18 步):
| 方法 | 答案 | 展开的节点数 | 记住的状态数 | 耗时 |
|---|---|---|---|---|
| 单向 BFS | 18 | 19 437 | 29 446 | 14.53 毫秒 |
| 双向 BFS | 18 | 557 | 901 | 0.38 毫秒 |
| 迭代加深 | 18 | 190 706 | 0 | 3.48 毫秒 |
| IDA*(曼哈顿) | 18 | 149 | 0 | 0.01 毫秒 |
1. 「记住的状态数」那一列就是内存。 迭代加深和 IDA* 是 0 —— 它们只有一个递归栈。 这是它们存在的全部理由:能解决那些 BFS 内存爆掉的题。
2. 迭代加深展开了 19 万个节点,却比只展开 1.9 万个的 BFS 还快。 因为它的每个节点极其便宜(就是几次交换), 而 BFS 每个节点都要往哈希表里插一个字符串。 节点数不等于时间 —— 还要看每个节点有多贵。
3. IDA* 只用 149 个节点。 一个好的估价函数,比任何常数优化都值钱。
因为八数码规模太小 —— 四种方法都在几十毫秒内跑完, 进程启动的时间比算法本身还长,测出来的数字全是噪音。
所以这一章改用 count.cpp 在程序内部计时和计数。
这本身也是个值得学的做法:当被测对象比测量误差还小的时候,
就要把测量搬到程序内部去。
6 单步看「一个大圆 vs 两个小圆」
八数码画不出来(状态是九个数字的排列),所以这个动画换成迷宫 —— 道理一模一样。
换一张迷宫(1 = 能走,0 = 墙)
- 单向:从起点铺开一个圆,一直铺到终点。访问的格子 ≈ 半径
d的圆面积。 - 双向:起点和终点各铺一个小圆,在中间撞上。访问的格子 ≈ 两个半径
d/2的圆。
上面那行状态栏实时显示两种模式各自访问了多少格。 迷宫小的时候差别不大,但格子数是按「半径的平方」涨的 —— 图一大,这个差距就出来了。(在状态图上是按「指数」涨,差距更夸张。)
把迷宫里的墙拆掉几堵(改成全 1),再对比一次 —— 空旷的图上双向的优势最明显。
7 ★ 对拍:四种方法互相验证
把「IDA*」那一栏换成你自己写的(迭代加深或双向 BFS 都行),再点开始。
标准答案用的是第 15 章那份单向 BFS —— 和你要验的东西机制完全不同, 这才是有意义的交叉验证。
值得故意写错的:
- 估价函数把空格也算进曼哈顿距离 → 高估了,会剪掉最优解,答案偏大
if (g + h() > limit)写成>=→ 把恰好等于上限的解也剪了,答案偏大- 迭代加深忘了「不走回头路」 → 答案还是对的,但慢好几倍
- 双向 BFS 不是整层扩,而是一次弹一个节点 → 可能得到非最短的答案
- 双向 BFS 相遇时只算一边的步数 → 答案差一半
8 阶段 3 小结:搜索的工具箱
| 情况 | 用什么 |
|---|---|
| 求「能不能到 / 有多少种方案 / 所有方案」 | DFS(第 13 章) |
| 求「最少多少步」,状态数不大 | BFS(第 14、15 章) |
| 求「最少多少步」,状态数大到内存装不下 | 迭代加深(本章) |
| 同上,而且能想出一个不高估的估价函数 | IDA*(本章) |
| 求「最少多少步」,起点终点都明确、深度大 | 双向 BFS(本章) |
| 状态会重复出现,且只关心结果不关心路径 | 记忆化搜索(第 17 章) |
| 搜索树太大 | 剪枝(第 16 章)—— 这条和上面所有情况都能叠加 |
这七行就是阶段 3 的全部。 剩下的都是往里面填「什么是状态」「什么是一步」。
9 自测
- 洛谷 P1379 八数码难题 —— 本章原题。用双向 BFS 或 IDA* 各交一遍,对比一下耗时
- 洛谷 P2324 骑士精神 —— SCOI2005。IDA* 的经典题,估价函数是「有几个棋子不在位」
- 洛谷 P1032 字串变换 —— NOIP2002。双向 BFS 的经典题,起点终点都明确 —— 正好符合前提
- 洛谷 P1516 青蛙的约会 —— 换换脑子:这题看着像搜索,其实是数学(扩展欧几里得)。练「先判断该不该搜」
六章走完,你手上有:DFS、BFS、多源与状态图、剪枝、记忆化、迭代加深与双向搜索。
这是信息学竞赛里最能靠「想清楚」拿分的一块。 遇到不会做的题,写个搜索加几个剪枝,往往就能拿到一半以上的分。
接下来是阶段 4(贪心)—— 那一块最难的从来不是写代码,是证明它为什么对。 而第 9 章的二分答案里,你其实已经证过一次贪心了(「多装绝不吃亏」)。