阶段 3 · 搜索 · 第 18 章

迭代加深与双向 BFS

BFS 快但费内存,DFS 省内存但不保证最短。这一章的两个工具,各拿走一半的好处。

例题:八数码(继续) 建议用时:110 分钟
阶段 3 的收官章

第 14、15 章的 BFS 有个躲不掉的毛病:它必须把所有到过的状态记在一张表里。

八数码只有 36 万个状态,撑得住。可一旦状态空间上亿(十五数码、魔方、 或者任何一道「状态是一串数字」的题),那张表就装不下了。

这一章的两个工具分别对付两个问题:

工具解决什么代价
迭代加深(IDDFS)内存 —— 只要一个递归栈会重复搜前面的层
双向 BFS时间 —— 把搜索深度砍一半必须知道终点是什么

题目继续用第 15 章那个八数码,这样能直接对比。

1 回顾:单向 BFS 的代价

bfs.cpp单向 BFS(对照组)
这是第 15 章那份,搬过来当基准。这个局面要 18 步。
输入(stdin)
输出
点「运行 ▶」看结果

它没错也不慢,问题在那张 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 时,前面全部重搜的总量还不到最后一层的一半

指数增长下,最后一层就占了绝大多数 —— 前面重来几遍根本无所谓。 这条性质对所有指数级搜索都成立,值得记死。

iddfs.cpp迭代加深
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 一个必须写的小细节:不走回头路
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 章那句话:剪枝不该改变答案。)

所以估价函数宁可保守。曼哈顿距离是安全的:它连「其他块挡路」都没算进去, 实际步数只会更多,不会更少。

ida.cppIDA*
和 iddfs.cpp 比,只多了一个 h() 函数和一句判断。效果见下一步那张表。
输入(stdin)
输出
点「运行 ▶」看结果

4 ★ 双向 BFS:两头一起搜

★ 关键的一步

单向 BFS 要铺开一棵深度 d 的树,节点数约 b^d

如果从起点和终点同时往中间搜,两边各铺 d/2 层就会撞上:

单向:  b^d
双向:  2 · b^(d/2)

b = 3d = 20 时:

单向:3²⁰ ≈ 35 亿
双向:2 × 3¹⁰ ≈ 12 万

**快了三万倍。**这不是常数优化 —— 它把指数砍了一半。

三个实现要点:

  1. 每次扩展节点少的那一边,让两棵树长得均衡。
  2. 相遇判定:新扩展出的状态如果在对面那张表里,答案 = 这边步数 + 对面步数。
  3. 必须整层整层地扩,否则可能先撞上一条不是最短的路径。
⚠ 双向 BFS 的前提:终点必须是明确知道的

「求最少步数到某个确定状态」→ 可以用。

「求最少步数到任意一个满足某条件的状态」→ 不行,你没法从终点倒着搜。

这个限制很实在。拿到题先确认「终点是不是唯一且已知」,再决定用不用它。

bibfs.cpp双向 BFS
输入(stdin)
输出
点「运行 ▶」看结果

5 ★ 四种方法,同一个局面,工作量并排数出来

count.cpp四种方法对比
换几个不同深度的局面跑跑看,四种方法的强弱会随深度变化。
输入(stdin)
输出
点「运行 ▶」看结果

本机实测(局面 845201376,最优 18 步):

方法答案展开的节点数记住的状态数耗时
单向 BFS1819 43729 44614.53 毫秒
双向 BFS185579010.38 毫秒
迭代加深18190 70603.48 毫秒
IDA*(曼哈顿)1814900.01 毫秒
★ 这张表要读出三件事

1. 「记住的状态数」那一列就是内存。 迭代加深和 IDA* 是 0 —— 它们只有一个递归栈。 这是它们存在的全部理由:能解决那些 BFS 内存爆掉的题。

2. 迭代加深展开了 19 万个节点,却比只展开 1.9 万个的 BFS 还快。 因为它的每个节点极其便宜(就是几次交换), 而 BFS 每个节点都要往哈希表里插一个字符串。 节点数不等于时间 —— 还要看每个节点有多贵。

3. IDA* 只用 149 个节点。 一个好的估价函数,比任何常数优化都值钱。

为什么这一章没有「同题对比」组件

因为八数码规模太小 —— 四种方法都在几十毫秒内跑完, 进程启动的时间比算法本身还长,测出来的数字全是噪音。

所以这一章改用 count.cpp 在程序内部计时和计数。 这本身也是个值得学的做法:当被测对象比测量误差还小的时候, 就要把测量搬到程序内部去。

6 单步看「一个大圆 vs 两个小圆」

八数码画不出来(状态是九个数字的排列),所以这个动画换成迷宫 —— 道理一模一样。

单向 vs 双向:一个大圆,还是两个小圆
单向访问 62 格 双向访问 47 格
第 1 / 20 步
0
0
蓝 = 从起点铺开的,橙 = 从终点铺开的,绿 = 两边撞上的那一格。灰色是墙。
换一张迷宫(1 = 能走,0 = 墙)
已访问格子数
2
扩了几圈
0
这张图上:单向要访问 62 格,双向只要 47 格。 迷宫小的时候差别不大,但格子数是按「半径的平方」涨的 —— 图一大,这个差距就是指数级的。
双向 BFS:起点和终点同时开始铺,谁的队列小就先扩谁。
  • 单向:从起点铺开一个圆,一直铺到终点。访问的格子 ≈ 半径 d 的圆面积。
  • 双向:起点和终点各铺一个小圆,在中间撞上。访问的格子 ≈ 两个半径 d/2 的圆。

上面那行状态栏实时显示两种模式各自访问了多少格。 迷宫小的时候差别不大,但格子数是按「半径的平方」涨的 —— 图一大,这个差距就出来了。(在状态图上是按「指数」涨,差距更夸张。)

把迷宫里的墙拆掉几堵(改成全 1),再对比一次 —— 空旷的图上双向的优势最明显。

7 ★ 对拍:四种方法互相验证

★ 正确的用法

把「IDA*」那一栏换成你自己写的(迭代加深或双向 BFS 都行),再点开始。

标准答案用的是第 15 章那份单向 BFS —— 和你要验的东西机制完全不同, 这才是有意义的交叉验证。

对拍器
生成器从目标状态倒着随机走 0~12 步(第 15 章那个套路),保证有解且不会太深。想验更深的局面,可以把生成器里的 12 改大 —— 但别超过 20,否则单向 BFS 那边会变慢。

值得故意写错的:

  • 估价函数把空格也算进曼哈顿距离 → 高估了,会剪掉最优解,答案偏大
  • if (g + h() > limit) 写成 >= → 把恰好等于上限的解也剪了,答案偏大
  • 迭代加深忘了「不走回头路」 → 答案还是对的,但慢好几倍
  • 双向 BFS 不是整层扩,而是一次弹一个节点 → 可能得到非最短的答案
  • 双向 BFS 相遇时只算一边的步数 → 答案差一半

8 阶段 3 小结:搜索的工具箱

★ 拿到一道搜索题,怎么选
情况用什么
求「能不能到 / 有多少种方案 / 所有方案」DFS(第 13 章)
求「最少多少步」,状态数不大BFS(第 14、15 章)
求「最少多少步」,状态数大到内存装不下迭代加深(本章)
同上,而且能想出一个不高估的估价函数IDA*(本章)
求「最少多少步」,起点终点都明确、深度大双向 BFS(本章)
状态会重复出现,且只关心结果不关心路径记忆化搜索(第 17 章)
搜索树太大剪枝(第 16 章)—— 这条和上面所有情况都能叠加

这七行就是阶段 3 的全部。 剩下的都是往里面填「什么是状态」「什么是一步」。

9 自测

自测清单0 / 10
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
阶段 3 到此结束

六章走完,你手上有:DFS、BFS、多源与状态图、剪枝、记忆化、迭代加深与双向搜索。

这是信息学竞赛里最能靠「想清楚」拿分的一块。 遇到不会做的题,写个搜索加几个剪枝,往往就能拿到一半以上的分。

接下来是阶段 4(贪心)—— 那一块最难的从来不是写代码,是证明它为什么对。 而第 9 章的二分答案里,你其实已经证过一次贪心了(「多装绝不吃亏」)。