CSP-S 提高级课程中心 / 算法进阶 / 第31讲
CSP-S 提高级 · 算法进阶 · s5-09

启发式搜索、双向 BFS 与迭代加深

当状态的指数爆炸让朴素 BFS 与 DFS 双双崩溃时:A* 用估价函数"抄近道",双向 BFS 把 b 的 d 次方砍成 2·b 的 d/2 次方,迭代加深用"逐层放宽水深"换回 O(d) 的空间。

3 个大纲知识点 A* f = g + h 指数减半 双向加速 IDA* 剪枝 + 加深 12 道洛谷原题
本讲 · 第 31/52 讲启发式搜索、双向BFS与迭代加深
模块 01

情景导航

先用一个"密室逃脱"的故事,把三种加速搜索的直觉装进脑子,再落回严谨的定义。

故事密室逃脱的三把钥匙

学校科技节办了一场密室逃脱:密室被划分成 9 个格子,其中有 8 个格子上摆着编号 1~8 的滑块,剩下 1 个是空格。队伍要做的,是把滑块推到"123 / 456 / 780"这个目标摆法,步数最少才能拿到奖励。

第一队玩得很"老实":从初始局面开始,一层一层地把"走 1 步能到的局面""走 2 步能到的局面"全部列出来,像水波一样向外扩散,遇到目标就停 —— 这就是 广度优先搜索(BFS)。可他们很快发现:每推一步,新出现的局面就多好几倍,走到第 15 层时,桌面上的卡片已经堆成小山(约 108 张)。

第二队换了个思路。他们拿了一份导航软件式的评分表:每个局面都算一个分数 f = 已经走的步数 g + 离目标还有多远的估计 h。每次只推进"分数最小"的那个局面。因为这个估计 h 总是往小了说、从不夸大,所以他们第一次摸到目标时,走的就是最短路线 —— 这就是 A* 启发式搜索。

第三队更狡猾。他们派出两个人:一个从起点往外推,一个从目标倒着往外推,谁的"波前"小就先扩谁。两边波前在中间一碰面,答案立刻合并出来。原本要 bd 次的搜索,变成了 2·bd/2 次 —— 这就是 双向广度优先搜索。

第四队带了钓鱼线:先只允许"最多走 1 步",找不到就放到"最多 2 步",再不行放到"最多 3 步"……每层都用 DFS 深度受限地搜。DFS 只占 O(d) 的栈空间,而重复搜的那些浅层又少得可怜(总共才多花一倍左右)。这就是 迭代加深搜索;如果每次加深时还顺手用 A* 的 f 值剪掉明显不可能的分支,那就是 IDA*。

三把钥匙,其实解决的是同一个问题:指数爆炸下如何把搜索规模压下来。

💡 故事背后的算法思想

我们面对的是典型的"隐式图最短路"问题:状态数量太大,图无法显式存储,只能"边搜边生成"。朴素的 BFS 花费 O(bd) 时间与空间,朴素的 DFS 空间只有 O(d) 但找到的解不一定最浅。

本讲的三种技术分别从三个维度削弱指数:A* 削减每层的分支(只扩展有希望的状态),双向 BFS 削减指数本身(把 d 变成 d/2),迭代加深削减空间(时间只多常数倍,空间从 bd 降到 d)。

一句话:能估就估、能对搜就对搜、能省内存就逐层放宽。

①生活类比
🧭

导航的"预计剩余时间"

导航不可能把所有路线都试一遍,它用"直线距离 ÷ 限速"估算还剩多久,优先走估计更近的路口。

→ A* 的启发函数 h
🕳️

两支工程队对向挖隧道

从山的两头同时挖,谁进度慢就支援谁,挖通即完成任务,比单头挖快得多。

→ 双向 BFS 两个波前
🎣

逐层放长的钓鱼线

先试 1 米深度,没鱼再放 2 米、3 米……每次重新下钩成本很小,却能保证第一尾鱼出现在最浅处。

→ 迭代加深搜索
💰

预算封顶的装修

报价一超过预算就立刻砍掉这家供应商 ——"已花的钱 + 预计还要花"超限就不做。

→ IDA* 的 f ≤ limit 剪枝
②核心定义

启发式搜索(Heuristic Search):在搜索中引入一个与具体问题有关的估计函数,用它指导扩展顺序,从而优先探索"更有希望"的状态。典型代表是 A*。

A* 算法:对每个状态 n 定义估价 f(n) = g(n) + h(n),其中 g(n) 是从起点到 n 已花费的实际代价,h(n) 是 n 到目标的代价估计。每次取出 f 最小的状态扩展。

双向广度优先搜索(Bidirectional BFS):同时从起点与终点开始 BFS,两棵搜索树(波前)在中间相遇时合并路径,得到最短路。

迭代加深搜索(IDDFS):从小到大枚举深度上限 limit,每次做一次"深度不超过 limit 的 DFS",一旦找到解立即返回,因此第一个解一定是最浅解。

IDA*:迭代加深与 A* 的结合体 —— 深度上限不是"深度"而是"f 值上限",每轮把超出上限的状态剪掉,下一轮把上限提高到"本轮剪掉的最小 f 值"。

📌 本讲核心知识地图

板块核心内容对应大纲
启发式搜索f = g + h;可采纳性与一致性;八数码曼哈顿距离【7】启发式搜索
双向广度优先搜索双波前相向扩展、相遇判定、扩展较小一侧【7】双向广度优先搜索
迭代加深搜索深度上限逐层放宽、空间 O(d)、时间仅多常数倍【7】迭代加深搜索
IDA*用 f 值上界代替深度上界,配合乐观估计剪枝【7】启发式搜索 + 迭代加深
③核心口诀
🔑 启发式搜索

已走代价加估计,f = g + h 排优先;
估计只能往小算,夸大一句解就错!

🔑 双向 BFS

起点终点同时推,波前小的一边扩;
相遇即刻合路径,指数 d 秒变 d/2!

🔑 迭代加深

水深一层层加,DFS 找解 BFS 的序;
时间多一倍,空间省到 O(d)!

模块 02

知识探究

从 A* 的估价函数讲到双向 BFS 的相遇判定,再讲迭代加深与 IDA*,最后用三个完整案例把它们全部落到代码上。

1启发式搜索 A* 核心

DFS 和 BFS 都是"盲目"的:DFS 认死一条路不回头,BFS 对远近一视同仁。如果我们能对"离目标还有多远"做个靠谱的估计,搜索就可以有方向感。

📊 f = g + h 的拆解
g(n)已走代价
+加上
h(n)剩余估计
=等于
f(n)总估价
优先队列按 f 升序取出 → 估计"总花费最小"的状态先被扩展
分量含义取值特点作用
g(n)起点 → n 的实际代价确定值,随搜索推进单调不减保证不会"忘记已付出的成本"
h(n)n → 目标的代价估计启发式信息,必须"不过高估计"提供方向感,决定搜索效率
f(n)经过 n 到目标的总代价估计f 不小于真实总代价优先队列的排序键

🔍 A* 的算法骨架(与最短路算法只差一个"排序键")

第1步:把起点压入优先队列(小根堆),键是 f(起点) = 0 + h(起点)。

第2步:每次弹出 f 最小的状态 u;若 u 等于目标,返回 g(u) —— 一定最优。

第3步:否则枚举 u 的所有后继 v:若 g(u) + w(u,v) 比已知的 g(v) 更小,就更新 g(v) 并把 v 入堆,键为 g(v) + h(v)。

第4步:重复第 2、3 步直到队列为空(此时无解)。

关键点:若把 h 恒取 0,A* 就退化成"按已走距离排序"的 Dijkstra;所以 A* 可以理解成"长了一只眼睛的最短路算法"。

✅ h 恒等于 0

退化为 Dijkstra / BFS:完全没有启发信息,一圈圈均匀扩散,保证最优但扩展状态最多。

❌ h 估计过大(h 超过真实值)

搜索会很"快",但第一个碰到的目标可能不是最优解 —— 结果错了,速度毫无意义。

✅ h 恰好等于真实值

只沿最优路径一路走到底,扩展状态数最少,这是理想极限。

C++A* 通用骨架(模板级)
struct Node {
    int f, g;   // f = g + h,h 由估价函数现算
    State s;     // 状态本身
    bool operator<(const Node& o) const {
        return f > o.f;   // 优先队列默认大根堆,所以反向定义
    }
};

int astar(State st) {
    priority_queue<Node> pq;
    best[st] = 0;
    pq.push({h(st), 0, st});
    while (!pq.empty()) {
        Node u = pq.top(); pq.pop();
        if (isGoal(u.s)) return u.g;      // 首次弹出即最优
        if (u.g > best[u.s]) continue;        // 过期状态,跳过
        for (每个后继 v) {
            int ng = u.g + w(u.s, v);
            if (ng < best[v] 或 v 未访问) {
                best[v] = ng;
                pq.push({ng + h(v), ng, v});
            }
        }
    }
    return -1;   // 无解
}
▶ A* 手推:3×3 网格 S(0,0)→G(2,2),h 取曼哈顿距离
1S 入堆:f = g + h = 0 + 4 = 4(h 为到 G 的曼哈顿距离)。
2弹出 f=4 的 S,生成后继:(0,1) 与 (1,0) 均为 g=1、h=3、f=4,双双入堆。
3弹出 (0,1)(f=4):后继 (0,2) g=2,h=2,f=4 与 (1,1) g=2,h=2,f=4 入堆。
4弹出 (1,0)(f=4):后继 (1,1) 已有更优 g=2,跳过;(2,0) g=2,h=2,f=4 入堆。
5弹出 (0,2)(f=4):后继 (1,2) g=3,h=1,f=4 入堆。
6弹出 (1,1)、(2,0)(f=4):新后继 (2,1) g=3,h=1,f=4 入堆;其余后继均已有更优 g。
7弹出 (1,2)(f=4):生成目标 (2,2):g=4、h=0、f=4,入堆。
8弹出 (2,2):它是目标,立即返回 g=4 —— h 从不高估,首次弹出即最短。
等权网格上所有 f 恒等于 4(恰好等于真实最短路长),扩展顺序退化为 BFS;在带障碍或带权重的图上,f 值的差异才会体现"挑最有希望的先走"。
2可采纳的启发函数 证明直觉

A* 保证最优解的前提只有一条:h 不能高估。

📐 两个关键性质

可采纳性(admissible):对任意状态 n,都有 h(n) ≤ h*(n)(h* 是 n 到目标的真实最小代价)。通俗说就是"只许往小了猜"。它保证:一旦某条路径的 f 超过了最优解的真实代价,A* 绝不会去扩展它。

一致性 / 单调性(consistent):对任意边 (n, n') 满足 h(n) ≤ w(n, n') + h(n'),即三角不等式。它保证每个状态只会被弹出一次就达到最优,所以配合"过期状态跳过"的写法天然无重复扩展。可以证明:一致性蕴含可采纳性。

为什么"不高估"就一定能拿到最优解?反证:设真实最优代价为 C*。由于 h 从不高估,最优路径上任何状态的 f 都不超过 C*。所以只要队列非空,A* 总会先扩展 f ≤ C* 的状态,绝不会"跳到"一条 f > C* 的支路上。于是第一个被弹出的目标状态,必然对应代价 C*。

问题一个可用的 h是否可采纳说明
八数码曼哈顿距离之和✅ 可采纳且一致每步最多让一个数字的曼哈顿距离减 1
八数码不在目标位置的数字个数✅ 可采纳比曼哈顿弱,每个错位数字至少要 1 步
网格最短路曼哈顿距离✅ 可采纳四连通网格每步最多缩短 1
n×n 棋盘骑士max(⌈Δx/2⌉, ⌈Δy/2⌉)✅ 可采纳马一步在单轴上最多走 2 格
任意图h 恒为 0✅ 可采纳但无用退化成 Dijkstra
任意图随手编的"剩余步数 × 10"❌ 高估可能返回非最优解

🔍 八数码的曼哈顿距离:为什么它恰好可采纳?

设目标局面中数字 d(d ≠ 0)所在的位置为 (rd, cd),它当前的位置为 (r'd, c'd)。定义

h = 对所有 d ≠ 0 求和 ( |rd − r'd| + |cd − c'd| )

理由:一次滑动只改变一个数字的位置,而且它只移动一格,曼哈顿距离最多减少 1。因此想把所有数字都送到目标位置,至少需要 h 步。所以 h 是真实步数的下界。

一致性验证:滑动一格时 h 至多变化 1,而边权也是 1,所以恒有 h(n) ≤ 1 + h(n'),一致性成立。

实测(目标 123456780):局面 123408765 的 h = 6,而它的真实最短步数也恰好是 6(h 紧贴真实值,搜索几乎不绕路);局面 123450678 的 h = 5,真实最短步数是 13(h 明显偏低,但仍是有用的下界)。

1
2
3
4
0
8
7
6
5

局面 123408765:数字 8、6、5 各偏离目标 2 格,h = 2+2+2 = 6,恰好等于真实最短步数

🔑 选择 h 的三条标准

① 可采纳(绝不高估)→ 保证答案正确;
② 一致性(满足三角不等式)→ 保证每个状态只扩展一次;
③ 越紧越好(在不高估的前提下尽量大)→ 扩展状态数越少。

3双向广度优先搜索 核心

BFS 的开销是 bd,其中 d 是答案层数。既然如此,为什么不从两头同时搜,让每一头只走 d/2 层?

🔍 核心思想

做法:维护两个队列与两张距离表 —— q1 / d1 从起点 S 出发,q2 / d2 从终点 T 出发。交替(或按需)扩展两边。

相遇判定:当从一边扩展出状态 v 时,如果 v 已经出现在另一边的距离表里,说明两波前"碰头"了,立即返回 d1[v] + d2[v]。由于两边都是 BFS,各自的路径都是最短路,拼接起来也是全局最短路。

复杂度:单向 BFS 访问约 bd 个状态;双向只需各走 d/2 层,访问约 2·bd/2 个状态。以 b = 2、d = 20 为例:220 ≈ 1.05×106,而 2·210 = 2048 —— 差了 500 倍。

优化:每次只扩展"当前队列较短"的那一侧(俗称"谁小扩谁")。因为 BFS 的代价正比于该侧访问量,平衡两侧规模能让总访问量最小。

适用前提:状态图必须可逆(从 v 能推回前驱,才好从终点倒着搜),且需要能高效判断"两边是否相遇"。这两个条件八数码都满足。

S 起点 T 终点 波前相遇 → 返回 d1 + d2 d1 层数(只走 d/2 层) d2 层数(只走 d/2 层)
图:两条波前相向推进,在中间"撞线"时路径立即合并
双向 BFS 的三个要点
  1. 两表两队列:d1/d2 都记录"从各自起点走多少步到该状态"。
  2. 扩小不扩大:每层开始前比较两侧队列长度,用 swap 让 q1 永远是较小的那个。
  3. 先判相遇再判重复:生成后继 v 时,先查是否在对方表中(相遇就返回),再查是否在自己表中(去重)。

⚠️ 注意:交换队列时一定要把两张距离表一起交换,否则步数会算错。

C++双向 BFS 通用骨架
int bidBFS(State S, State T) {
    if (S == T) return 0;
    queue<State> q1, q2;
    unordered_map<State, int> d1, d2;
    q1.push(S); d1[S] = 0;
    q2.push(T); d2[T] = 0;
    while (!q1.empty() && !q2.empty()) {
        // 关键优化:只扩展较小的一侧
        if (q1.size() > q2.size()) { swap(q1, q2); swap(d1, d2); }
        int sz = q1.size();
        while (sz--) {                        // 只扩"一整层",保证层数同步
            State u = q1.front(); q1.pop();
            for (State v : nxt(u)) {
                int step = d1[u] + 1;
                if (d2.count(v)) return step + d2[v];  // 相遇!
                if (d1.count(v)) continue;           // 自己这边已访问
                d1[v] = step; q1.push(v);
            }
        }
    }
    return -1;
}
▶ 双向 BFS 手推:000 变 111(每步翻转一位)
1初始化:q1 = {000}、d1[000]=0;q2 = {111}、d2[111]=0。两侧队列各有 1 个状态。
2扩展 q1 一层:000 翻转一位得 100 / 010 / 001(d1=1),都不在 d2 中。
3扩展 q2 一层:111 翻转一位得 011 / 101 / 110(d2=1),也不在 d1 中。
4两侧队列都是 3 个状态,扩 q1:弹出 100,翻转第 2 位得 110 —— 查表发现 110 已在 d2 中!
5相遇!返回 step + d2[110] = (1+1) + 1 = 3,正是 000→100→110→111 的最短变换步数。
注意判定顺序:先查对方表(相遇),再查自己表(去重);返回值是 d1[u]+1 加对方距离,不能把两个队头直接拼接。
4迭代加深搜索 核心

DFS 的空间只要 O(d),可惜"先撞到的不一定最浅";BFS 保证最浅,却要吃掉 O(bd) 的空间。迭代加深的本质是:用 DFS 的空间,得到 BFS 的层序。

🔍 算法流程

第1步:令 limit = 0。

第2步:调用一次 dfs(起点, 深度 0),其中 DFS 只在"当前深度不超过 limit"时继续递归;一旦到达目标立刻返回成功。

第3步:若失败,则 limit 加 1,回到第 2 步重来。

为什么第一个解一定最浅?因为 limit 是从小到大枚举的,答案深度为 d* 时,所有 limit 小于 d* 的轮次都必然失败,第一成功的轮次恰好是 limit = d*。

▶ 迭代加深:逐层放宽深度上限
1limit = 0:只允许深度 0(即起点本身)。若起点就是目标,答案是 0 步。
2limit = 1:DFS 最多往下走 1 层,把起点的所有邻居都试一遍。没找到就往上放宽。
3limit = 2:重新从起点 DFS,这次允许走 2 层。注意深度 1 的分支被重复走了一遍,但重复量很小。
4limit = 3:允许 3 层……如此往复,直到某一轮 dfs 返回成功,当前 limit 就是最短步数。
5重复代价分析:总访问量约为 b0+b1+…+bd = (bd+1−1)/(b−1),只比 BFS 多一个常数因子(b = 2 时正好 2 倍)。
6空间代价:递归栈深度不超过 limit,即 O(d)。这正是迭代加深相对 BFS 的最大优势。
点击"自动演示"逐条查看;核心结论是"时间常数倍、空间指数级节省"。
C++迭代加深搜索模板
int limit;

bool dfs(State u, int dep) {
    if (dep > limit) return false;        // 超出深度上限,剪掉
    if (isGoal(u)) return true;
    for (State v : nxt(u)) {
        if (dep + 1 > limit) continue;   // 也可用估价 h 进一步提前剪枝
        if (dfs(v, dep + 1)) return true;
    }
    return false;
}

int iddfs(State st, int maxDep) {
    for (limit = 0; limit <= maxDep; limit++)
        if (dfs(st, 0)) return limit;
    return -1;
}

把条件 dep 大于 limit 换成 dep + h(u) 大于 limit,迭代加深就升级成了 IDA*。

5IDA*:当"深度上限"换成"f 值上限" 进阶

纯迭代加深的剪枝只有"深度"一条信息,太弱了。把 A* 的估价函数搬进来,剪枝立刻变得锋利。

① 定上限limit = h(起点)
② 剪枝f 超限就砍
③ 提升limit 取最小超限值
④ 命中返回 g

🔍 与迭代加深、A* 的三角关系

对比迭代加深:迭代加深数的是"深度层数",IDA* 数的是"f 值预算"。用 f 作预算的好处是:那些 h 很大(明显没希望)的分支在第一轮就被砍掉,而迭代加深要傻等到 limit 涨上去才会碰它们。

对比 A*:IDA* 使用 O(d) 的线性空间,而 A* 需要把所有访问过的状态存进哈希表或优先队列(八数码里约 18 万个状态,15 数码则是天文数字)。所以问题规模一大,IDA* 常常是唯一能过的方法。

代价:IDA* 会重复扩展浅层节点(每轮重来)。这也是它只适合"分支多、但解很浅"问题的原因 —— 解一深,重复成本就压不住了。

✅ 适合 IDA* 的问题

状态空间巨大(无法哈希)、解答深度较小(一般不超过 15 层)、且能写出紧凑的乐观估价 h。

典型:15 数码(h = 曼哈顿)、骑士精神、小木棍、埃及分数。

❌ 不适合 IDA* 的问题

解答深度本身很大(比如网格图上几百步的最短路),或者几乎没有有效估价,重复搜索会把常数倍放大到无法接受。

典型:带权最短路应当直接用堆优化的 Dijkstra。

⚖️三种方法选型对比
方法时间复杂度空间复杂度解的最优性典型适用场景
BFS(对照)O(bd)O(bd)最优(等权)状态少、求最短路
A*远小于 bd(取决于 h)O(已访问状态数)最优(h 可采纳)有良好估价、状态可存
双向 BFS约 2·bd/2约 2·bd/2最优(等权)能倒推、状态可存、答案深度中等
迭代加深约 bd·b/(b−1)O(d)最优(最浅解)空间紧张、求最浅解
IDA*因剪枝常远小于 IDDFSO(d)最优(h 可采纳)状态爆炸 + 解浅 + 有 h
你的处境首选方法理由
能写出靠谱的 h,且状态可存进哈希表A*扩展状态最少,速度快
能写出 h,但状态多到哈希表装不下IDA*线性空间 + 强剪枝
没有 h,但状态可存且答案层数不太大双向 BFS指数直接减半
没有 h,状态多、内存紧张迭代加深用常数倍时间换指数级空间
状态很少(小于 106)朴素 BFS写起来最快,不容易出错
案例案例1:八数码难题 —— 双向 BFS 完整实现 P1379

题目:给出一个 3×3 局面的 9 位数字串(0 表示空格),一次操作可以把空格与上下左右的滑块交换。求把局面变成 123456780(即 1 2 3 / 4 5 6 / 7 8 0)所需的最少步数。

🔍 详细分析过程

第1步:识别模型 —— 每个局面是一个状态,每推一步是一条代价为 1 的边。求"最少步数"就是无权图最短路,属于 BFS 家族。

第2步:状态表示 —— 直接用 string(9 个字符,'0' 表示空格)。比手写康托展开更易读,配合 unordered_map 哈希足够通过八数码的规模。

第3步:为什么用双向 —— 八数码的可达状态共 181440 个(9! 的一半),从两端各搜 6~7 层就能相遇,工作量远小于单向的 13 层以上。

第4步:扩展哪一侧 —— 每层开始前比较 q1.size() 与 q2.size(),把较小的换到 q1,实现"谁小扩谁"。

第5步:相遇判定顺序 —— 生成后继 nxt 后先查对方表(能立即返回),再查自己表去重。顺序反了会漏掉最优相遇点。

第6步:无解处理 —— 若逆序数为奇数则局面不可达;本模板用"两队列皆空仍未见面"返回 −1 兜底。

C++八数码难题 · 双向 BFS 完整代码
#include <bits/stdc++.h>
using namespace std;

string S;                              // 初始局面
const string T = "123456780";        // 目标局面
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

int bfs() {
    if (S == T) return 0;

    queue<string> q1, q2;                // q1 从起点扩展,q2 从目标扩展
    unordered_map<string, int> d1, d2;  // 两侧各自的距离表
    q1.push(S); d1[S] = 0;
    q2.push(T); d2[T] = 0;

    while (!q1.empty() && !q2.empty()) {
        // 关键优化:总是扩展队列较小的一侧
        if (q1.size() > q2.size()) { swap(q1, q2); swap(d1, d2); }

        int sz = q1.size();              // 只扩展"一整层",保证两侧层数可比
        while (sz--) {
            string cur = q1.front(); q1.pop();

            int id = cur.find('0');        // 空格的一维下标
            int x = id / 3, y = id % 3;

            for (int k = 0; k < 4; k++) {
                int nx = x + dx[k], ny = y + dy[k];
                if (nx < 0 || nx > 2 || ny < 0 || ny > 2) continue;

                string nxt = cur;
                swap(nxt[id], nxt[nx * 3 + ny]);   // 交换空格与滑块

                int step = d1[cur] + 1;              // 走一步的代价
                if (d2.count(nxt)) return step + d2[nxt];  // ① 两侧相遇
                if (d1.count(nxt)) continue;             // ② 自己这边已访问过

                d1[nxt] = step;
                q1.push(nxt);
            }
        }
    }
    return -1;                          // 不可达(逆序数为奇数)
}

int main() {
    ios::sync_with_stdio(0);
    cin >> S;
    cout << bfs() << "\n";
    return 0;
}
运行结果
输入: 123408765
输出: 6

输入: 123450678
输出: 13

输入: 123456780
输出: 0

(局面 123408765 即 1 2 3 / 4 0 8 / 7 6 5,把 8、6、5 依次归位恰好 6 步)
🔍 代码逐段解析 · 八数码双向 BFS
1全局 S / T / dx / dy:S 是读入的初始局面,T 固定为 "123456780";dx、dy 是空格上下左右四个方向的偏移量,供后面枚举后继用。
2if (S == T) return 0;:边界特判。少了它,输入 123456780 时两个队列都装着同一状态,扩展逻辑混乱可能输出错误值。
3q1/d1 与 q2/d2 初始化:q1 从起点正向搜,q2 从目标逆向搜;d1、d2 分别记录"从各自起点到该状态的步数"。两套结构必须成对出现。
4if (q1.size() > q2.size()) swap(q1,q2); swap(d1,d2);:"谁小扩谁"优化。易错点:队列和距离表必须同时交换,只换队列不换表,步数立刻错乱。
5int sz = q1.size(); while (sz--):一次只扩"一整层"。BFS 的层内状态距离相同,整层扩展才能保证两侧层数同步、相遇时答案正确。
6cur.find('0') 与 x=id/3, y=id%3:一维下标转二维行列;nx<0||nx>2... 越界剪掉。注意用 nx*3+ny 转回一维再 swap。
7string nxt = cur; swap(nxt[id], nxt[nx*3+ny]);:复制一份再交换,生成后继局面。string 作哈希键简单可读,代价是常数偏大。
8if (d2.count(nxt)) return step + d2[nxt]; 在 if (d1.count(nxt)) continue; 之前:先判相遇再去重。顺序颠倒会漏掉最优相遇点,答案偏大。
9return -1;:两队列同时为空仍未相遇,说明不可达(逆序数为奇数的局面)。main 里读入后直接输出 bfs()。
两大易错:① swap 队列时忘 swap 距离表;② 先判 d1 后判 d2 漏掉相遇。

💡 想一想

单向 BFS 求解 123450678 时,需要把 13 层以内的局面全部展开约 17 万个状态;而双向 BFS 两边各走 6~7 层,访问量不到 2 万个 —— 快了一个数量级。

如果把 string 换成"康托展开压缩成 int",哈希开销再降一大截,15 数码也能扛(再配上 A* 更好)。

案例案例2:迭代加深求最浅解 —— 棋盘上的骑士 模板题

题目:在 n×n 的棋盘上,马(骑士)从 (sx, sy) 以"日"字形走法出发,求到达 (tx, ty) 的最少步数。坐标使用 0 开始的下标,n ≤ 8。这里我们故意用迭代加深 + 估价剪枝来解,以观察"逐层放宽限制"的过程。

🔍 详细分析过程

第1步:为什么要迭代加深 —— 棋盘上骑士走法环环相扣,纯 DFS 会一头扎进死胡同;但马的最少步数在 8×8 棋盘上最多只有 6 步,解答很浅,非常适合迭代加深。

第2步:估价函数 —— 马一步在单个坐标轴上最多推进 2 格,所以要把行差 a 走完至少需要 ⌈a/2⌉ 步,列差 b 同理。取 h = max(⌈a/2⌉, ⌈b/2⌉),这是可采纳的下界。

第3步:剪枝 —— dep + h 超过 limit 时直接返回 false,能砍掉大量"步子不够"的分支。

第4步:不回头 —— 记录上一步的位置,避免 A→B→A 这种白走两格的回头路,对最短路径的正确性无影响。

第5步:外层循环 —— limit 从 0 递增到 2n,第一轮成功的 limit 就是最短步数。

第6步:答案核对 —— 8×8 棋盘上 (0,0) 到 (7,7) 是 6 步、(0,0) 到 (4,4) 是 4 步、(0,0) 到 (1,1) 是 4 步,都可用 BFS 独立验证。

C++骑士最短路 · 迭代加深完整代码
#include <bits/stdc++.h>
using namespace std;

int n, sx, sy, tx, ty, limit;
int dx[8] = {1, 2, 2, 1, -1, -2, -2, -1};
int dy[8] = {2, 1, -1, -2, -2, -1, 1, 2};

// 可采纳估价:马一步单轴最多走 2 格
int h(int x, int y) {
    int a = abs(x - tx), b = abs(y - ty);
    return max((a + 1) / 2, (b + 1) / 2);
}

bool dfs(int x, int y, int dep, int px, int py) {
    if (dep + h(x, y) > limit) return false;   // 预算不够,剪枝
    if (x == tx && y == ty) return true;          // 命中目标

    for (int k = 0; k < 8; k++) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
        if (nx == px && ny == py) continue;          // 不走回头路
        if (dfs(nx, ny, dep + 1, x, y)) return true;
    }
    return false;
}

int main() {
    cin >> n >> sx >> sy >> tx >> ty;
    // 逐层放宽深度上限
    for (limit = 0; limit <= 2 * n; limit++) {
        if (dfs(sx, sy, 0, -1, -1)) {
            cout << limit << "\n";
            break;
        }
    }
    return 0;
}
运行结果
输入: 8 0 0 7 7
输出: 6

输入: 8 0 0 4 4
输出: 4

输入: 6 0 0 5 5
输出: 4

(limit = 0,1,2,3,4,5 均失败,limit = 6 首次成功 → 最短步数 6)
🔍 代码逐段解析 · 骑士迭代加深
1dx[8] / dy[8]:8 个"日"字马步向量,(±1,±2) 与 (±2,±1) 全覆盖,顺序不影响正确性。
2h(x, y):可采纳估价。行差 a 每步最多消 2,故至少 ⌈a/2⌉ 步,列差同理;(a + 1) / 2 就是向上取整。易错点:写成 a / 2 会低估(仍正确但剪枝变弱),写成 a 会高估(答案错)。
3if (dep + h(x, y) > limit) return false;:IDA* 式剪枝 —— 已走 dep 加"至少还要 h 步"超预算,整棵子树全砍。注意是严格大于,等于 limit 仍可搜。
4if (x == tx && y == ty) return true;:命中判定放在剪枝之后。若目标恰好满足 dep+h > limit,说明真正的解轮不到本轮 limit,剪掉是安全的。
5if (nx == px && ny == py) continue;:不走回头路(上一步来自 (px,py),跳回它必浪费 2 步)。这只影响效率,不影响正确性。
6for (limit = 0; limit <= 2 * n; limit++):外层逐层放宽。n×n 棋盘任意两点马步距离不超过 2n,所以 2n 是安全的上界;第一轮成功即最短步数。易错点:limit 起点必须是 0,否则起点即终点时会漏解。
核心结构 = "DFS + dep+h 剪枝 + 外层 limit 递增";三处缺一不可。
L=0 L=1 L=2 L=3 L=4 L=5 L=6 成功 每轮的访问量按几何级数增长,最后一轮占绝对大头
迭代加深的重复开销:前面所有轮次加起来也只有最后一轮的约 b/(b−1) 倍
为什么重复搜索不吃亏?

很多同学第一反应是"每轮都从头再搜一遍,也太浪费了"。但注意:越深的层,节点数越多。

  • 深度 0 层只有 1 个节点;
  • 深度 5 层有 b 的 5 次方个节点;
  • 所有浅层加起来约等于 bd 除以 (b−1)。

所以重复的总工作量只是最后一轮的一个常数因子,在 b = 2 时约为 2 倍 —— 用 2 倍时间换来"空间从 bd 降到 d",这笔买卖极其划算。

案例案例3:骑士精神 —— IDA* 的经典舞台 P2324

题目:在 5×5 棋盘上,有 12 个白骑士、12 个黑骑士和 1 个空格。空格可以与骑士按"日"字走法互换位置。给定一个局面,求至少多少步能变成下面这个目标局面(1 表示白骑士,0 表示黑骑士,* 表示空格),若 15 步内无解则输出 −1。

🎯 目标局面(P2324)
1
1
1
1
1
0
1
1
1
1
0
0
*
1
1
0
0
0
0
1
0
0
0
0
0
目标是一个"阶梯"形状:上半部分全为白骑士,下半部分全为黑骑士

🔍 思路详解(为什么必须上 IDA*)

第1步:状态规模 —— 25 个格子中选 1 个放空格、其余按骑士分布,状态数远超可哈希的范围。所以 A* + 哈希表的路线走不通,只能用线性空间的 IDA*。

第2步:可采纳的 h —— 最自然的估价是"当前局面与目标局面不一致的格子数"。因为一次操作只挪动一个骑士,最多让一个格子的内容变得正确,所以至少还需要这么多步,显然不高估。

第3步:更好的 h —— 上述 h 还可优化,不少题解用"错位数 − 1"(因为空格也必须移动,空格一动至少牵动一个骑士)。核心始终是保证不高估。

第4步:迭代框架 —— 从 limit = h(初始局面) 开始;DFS 中若 dep + h 超过 limit 就剪枝,并记录被剪掉的最小 f 值 minExceed,下一轮把 limit 设为它。这样比 limit 一次次加 1 收敛快得多。

第5步:方向剪枝 —— 记录空格的上一步来源,避免走回头路(A→B→A 一定浪费 2 步)。

第6步:判无解 —— limit 一旦超过 15 就输出 −1,这是题目给出的无解阈值。

C++骑士精神 · IDA* 核心框架
#include <bits/stdc++.h>
using namespace std;

const int KX[8] = {-2, -1, 1, 2, 2, 1, -1, -2};
const int KY[8] = {1, 2, 2, 1, -1, -2, -2, -1};

// 目标局面:与输入局面逐格比较,得出"错位数"
const char G[5][6] = {
    "11111",
    "01111",
    "00*11",
    "00001",
    "00000"
};

int LIMIT, ans;
string board;

int h() {                        // 乐观估价:错位格子数
    int c = 0;
    for (int i = 0; i < 25; i++)
        if (board[i] != '*' && board[i] != G[i / 5][i % 5]) c++;
    return c;
}

bool ida(int blank, int dep, int pre, int& nxtLimit) {
    int g = h();
    if (dep + g > LIMIT) { nxtLimit = min(nxtLimit, dep + g); return false; }
    if (g == 0) return true;            // 已与目标一致

    int x = blank / 5, y = blank % 5;
    for (int k = 0; k < 8; k++) {
        int nx = x + KX[k], ny = y + KY[k];
        if (nx < 0 || nx >= 5 || ny < 0 || ny >= 5) continue;
        int np = nx * 5 + ny;
        if (np == pre) continue;             // 不走回头路

        swap(board[blank], board[np]);
        bool ok = ida(np, dep + 1, blank, nxtLimit);
        swap(board[blank], board[np]);          // 回溯
        if (ok) return true;
    }
    return false;
}

int main() {
    ios::sync_with_stdio(0);
    int T; cin >> T;
    while (T--) {
        board.clear();
        for (int i = 0; i < 5; i++) { string row; cin >> row; board += row; }
        int blank = board.find('*');
        ans = -1;
        for (LIMIT = h(); LIMIT <= 15; ) {     // f 值上限递增
            int nxtLimit = INT_MAX;
            if (ida(blank, 0, -1, nxtLimit)) { ans = LIMIT; break; }
            if (nxtLimit == INT_MAX) break;      // 搜索树完整展开仍无解
            LIMIT = nxtLimit;                     // 直接跳到"最小超限 f 值",比加 1 快
        }
        cout << ans << "\n";
    }
    return 0;
}
运行结果(洛谷 P2324 官方样例)
输入: 2
      10110
      01*11
      10111
      01001
      00000
      01011
      110*1
      01110
      01010
      00100
输出: 7
      -1

说明:第一组初始 h = 5(5 个错位格子),LIMIT 依次取 5、6、7,
      前两轮失败、第三轮命中 → 答案 7;
      第二组的棋子分布无法通过骑士移动到达目标(黑白骑士数量分布不满足可达性),
      三轮搜尽仍无解 → 输出 -1。
▶ IDA* 三轮 limit 推进(对应上方第 1 组样例,初始 h = 5)
1第 1 轮 LIMIT = 5(= h(初始局面)):DFS 中所有 dep + h > 5 的分支被剪掉,整棵树搜完未命中,记录被剪掉的最小 f 值 = 6。
2第 2 轮 LIMIT = 6:直接跳到 6(不是盲目 +1 而是恰好差 1)。仍然失败,新的最小超限 f = 7。
3第 3 轮 LIMIT = 7:预算放宽到 7 后,某条 7 步路径命中目标,返回 7。
4三轮即出解:因为 h 可采纳,最优解必然 ≥ 5;若最优解真是 5 或 6,前两轮就该找到 —— 没找到恰好证明最优解是 7。
"limit 跳到最小超限 f 值"保证被跳过的每个 limit 都不可能成功,因此不会漏解。
🔍 代码逐段解析 · 骑士精神 IDA*
1KX / KY 与 G[5][6]:8 个马步向量;G 是题目给定的阶梯形目标局面,用字符串二维数组逐行书写,便于逐格比对。
2int h():乐观估价 = 与目标不一致且非空格的格子数。一次移动只挪动一个骑士,最多修正一个格子,所以 h 从不高估;board[i] != '*' 把空格排除是关键细节。
3bool ida(blank, dep, pre, int& nxtLimit):blank 是空格位置,pre 是空格上一步的来源;nxtLimit 用引用传参,让递归内层也能把"被剪掉的最小 f 值"汇总到 main。
4if (dep + g > LIMIT) { nxtLimit = min(...); return false; }:预算剪枝并记录最小超限值。这是 IDA* 的灵魂:下一轮 limit 直接取它,而不是 +1。
5if (g == 0) return true;:错位数归零即与目标一致,成功返回;不需要单独比较 board == 目标串。
6swap(board[blank], board[np]); ... swap(board[blank], board[np]);:移动与回溯成对出现,board 是全局变量,靠"进栈换出栈还原"保证各分支互不干扰。易错点:漏写第二个 swap 会导致兄弟分支拿着脏棋盘搜索。
7if (np == pre) continue;:空格不走回头路;nx/ny 越界 continue 处理棋盘边缘。
8for (LIMIT = h(); LIMIT <= 15; ):从初始估价起步(比从 0 起步省去若干必然失败的轮次);if (nxtLimit == INT_MAX) break; 表示整棵树在当前预算内被完整展开仍无解,直接输出 −1;LIMIT = nxtLimit; 跳到最小超限 f 值继续。
两大易错:① h 忘排除空格导致高估;② LIMIT 误写成 LIMIT+1 使轮次数暴涨。

💡 IDA* 的关键技巧:limit 不要只加 1

本代码里 LIMIT 取 nxtLimit(跳到本轮被剪掉的最小 f 值),而不是 LIMIT 加 1。这能让轮次数量从"最多 15 轮"压缩到"通常 2~4 轮",是 IDA* 提速的核心手法。

同理,每次进入 DFS 时先算一遍 h 存进局部变量,比在递归里反复算更省时;也可以在状态变化时增量维护 h,进一步提速。

模块 03

实践应用

三道完整题目:两道用双向 BFS 与迭代加深,一道用 A*。都给出输入输出格式、样例、数据范围、多方法对比与完整代码。

题1八数码最短步数 双向 BFS

题目描述

给出一个 3×3 的八数码局面(用 9 个字符表示,0 表示空格,按行读入),每次可以把空格与上下左右的数字交换。求把它变成 123456780 的最少步数;若不可达输出 -1。

输入格式:一行,长度为 9 的数字串,保证是 0~8 的一个排列。

输出格式:一行一个整数,表示最少步数(不可达输出 −1)。

输入样例:123450678

输出样例:13

数据范围:时间限制 1s,内存 256MB。局面保证是 0~8 的排列。

🔍 两种方法对比

方法一:单向 BFS内存吃紧 —— 从起点一层层扩散到 13 层,最多要展开约 17 万个状态,还要存下所有访问过的局面。1s 内能过,但内存与常数都很勉强。

方法二:双向 BFS推荐 —— 起点、终点各走 6~7 层,两侧合计不到 2 万个状态,速度提升一个数量级。

为什么双向的正确性成立?设相遇状态为 v,则存在长度 d1[v] 的从 S 到 v 的最短路,以及长度 d2[v] 的从 v 到 T 的最短路,拼接得到长度 d1[v] + d2[v] 的路径。又因为两边都是按层推进的 BFS,任何更短的路径在中间某处必然也会造成更早的相遇 —— 所以首次相遇给出的就是最短距离。

复杂度:状态访问量约 2·bd/2,其中 b 约为 2~3(空格平均有 2~3 个可走方向),d = 13。

C++题1 参考代码 · 双向 BFS
#include <bits/stdc++.h>
using namespace std;

string S;
const string T = "123456780";
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

int bfs() {
    if (S == T) return 0;
    queue<string> q1, q2;
    unordered_map<string, int> d1, d2;
    q1.push(S); d1[S] = 0;
    q2.push(T); d2[T] = 0;

    while (!q1.empty() && !q2.empty()) {
        if (q1.size() > q2.size()) { swap(q1, q2); swap(d1, d2); }
        int sz = q1.size();
        while (sz--) {
            string cur = q1.front(); q1.pop();
            int id = cur.find('0');
            int x = id / 3, y = id % 3;
            for (int k = 0; k < 4; k++) {
                int nx = x + dx[k], ny = y + dy[k];
                if (nx < 0 || nx > 2 || ny < 0 || ny > 2) continue;
                string nxt = cur;
                swap(nxt[id], nxt[nx * 3 + ny]);
                int step = d1[cur] + 1;
                if (d2.count(nxt)) return step + d2[nxt];
                if (d1.count(nxt)) continue;
                d1[nxt] = step;
                q1.push(nxt);
            }
        }
    }
    return -1;
}

int main() {
    ios::sync_with_stdio(0);
    cin >> S;
    cout << bfs() << "\n";
    return 0;
}
运行结果
输入: 123450678
输出: 13

输入: 123408765
输出: 6

输入: 102345678
输出: 21

💡 引入

如果数据规模再大一点(例如 15 数码),unordered_map<string,int> 的哈希开销就成了瓶颈。这时可以考虑:① 用康托展开把排列压缩成 0 到 9!−1 的整数,用数组代替哈希表;② 换成 A* 或 IDA* 减少访问量。三条路线可以叠加使用。

再想一想:为什么双向 BFS 不能直接用在带权图上?(提示:带权时"相遇"不再意味着两段最短路的简单拼接。)

题2骑士最少步数 迭代加深

题目描述

在 n×n 的棋盘上,马(骑士)从 (sx, sy) 按"日"字形走到 (tx, ty),求最少步数。坐标从 0 开始编号,左上角是 (0, 0)。

输入格式:一行五个整数 n, sx, sy, tx, ty。

输出格式:一行一个整数,表示最少步数。

输入样例:8 0 0 7 7

输出样例:6

数据范围:1s / 256MB,1 ≤ n ≤ 8,0 ≤ sx, sy, tx, ty 小于 n。

🔍 两种方法对比

方法一:BFS 逐层扩散稳 —— 8×8 只有 64 格,BFS 秒过。但 BFS 需要开 dist 数组与队列;一旦棋盘放大到 109×109,BFS 就彻底没戏了。

方法二:迭代加深 + 估价剪枝推荐 —— 空间只有递归栈 O(d),天然适应"坐标范围巨大但答案很浅"的情形。

估价函数的正确性:马一步在行方向最多变化 2、列方向最多变化 2。因此要把行差 a 消掉至少需要 ⌈a/2⌉ 步,列差 b 同理,取两者较大值即为步数下界 —— 可采纳。

复杂度:每轮限制 limit 层、每层最多 8 个分支(剪枝后远小于 8),总规模受 limit ≤ 6 限制,非常小。

C++题2 参考代码 · 迭代加深
#include <bits/stdc++.h>
using namespace std;

int n, sx, sy, tx, ty, limit;
int dx[8] = {1, 2, 2, 1, -1, -2, -2, -1};
int dy[8] = {2, 1, -1, -2, -2, -1, 1, 2};

int h(int x, int y) {
    int a = abs(x - tx), b = abs(y - ty);
    return max((a + 1) / 2, (b + 1) / 2);
}

bool dfs(int x, int y, int dep, int px, int py) {
    if (dep + h(x, y) > limit) return false;
    if (x == tx && y == ty) return true;
    for (int k = 0; k < 8; k++) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
        if (nx == px && ny == py) continue;
        if (dfs(nx, ny, dep + 1, x, y)) return true;
    }
    return false;
}

int main() {
    cin >> n >> sx >> sy >> tx >> ty;
    for (limit = 0; limit <= 2 * n; limit++)
        if (dfs(sx, sy, 0, -1, -1)) { cout << limit << "\n"; break; }
    return 0;
}
运行结果
输入: 8 0 0 7 7
输出: 6

输入: 8 0 0 4 4
输出: 4

输入: 8 0 0 1 1
输出: 4

🔍 代码逐段解析 · 题2 迭代加深

全局与 h 函数 —— dx/dy 存 8 个马步;h(x,y) = max((a+1)/2, (b+1)/2) 用整数技巧实现向上取整,是可采纳的下界估价。

dfs 三个判定 —— ① dep + h(x,y) > limit 剪枝(严格大于);② 命中目标返回 true;③ 枚举 8 方向递归,px,py 记录来时位置以跳过回头路。

main 主循环 —— for (limit = 0; ...; limit++) 从 0 起步逐层放宽,命中即输出并 break;2n 是马步距离的安全上界。

易错点 1 —— 递归调用 dfs(nx, ny, dep+1, x, y) 的第 4、5 个参数是当前坐标 (x,y)(即下一格的"来时位置"),传成 px/py 会破坏回头路剪枝。

易错点 2 —— 若起点等于终点,必须靠 limit=0 那轮返回 true 输出 0;把 limit 从 1 开始或把剪枝写成 >= 都会错。

💡 引入

如果把问题改成"求所有最短路径的条数",迭代加深就不再合适(它只回答"存在性")。此时应改用 BFS + 计数数组,或在 A* 中记录前驱计数。

体会一下:迭代加深擅长回答"最少步数是多少",不擅长回答"有多少种最优方案"。这是检索型搜索与计数型搜索的典型分野。

题3八数码最短步数 · A* 版 启发式

题目描述

同题1,但改用 A* 算法(启发函数取曼哈顿距离之和)求解,输出最少步数。

输入格式:一行,长度为 9 的数字串(0~8 的排列)。

输出格式:一行一个整数,表示最少步数。

输入样例:123450678

输出样例:13

数据范围:1s / 256MB。局面保证合法。

🔍 从分析到实现

第1步:状态压缩 —— 把 9 位数字串当成一个十进制整数(如 123450678),用 int 就能存,哈希表不必对字符串做哈希,常数明显更小。

第2步:估价函数 —— h 取各数字曼哈顿距离之和。这个 h 既可采纳(每步最多让一个数字靠近 1 格)又一致(滑动一格 h 变化不超过 1)。

第3步:优先队列 —— 小根堆按 f 排序。priority_queue 默认大根堆,所以在结构体里把比较写成 f 大于 o.f。

第4步:过期状态 —— 弹出时若 g 大于 best[s] 直接跳过(同一状态可能被多次入堆)。

第5步:正确性 —— h 一致意味着每个状态首次被弹出即最优;第一个弹出的目标状态必然是最优解。

复杂度:八数码 181440 个可达状态,A* 平均只展开其中一小部分。本样例初始 h = 5,f(起点) = 5,而真实答案是 13,可见 h 明显偏低但方向正确。

C++题3 参考代码 · A* + 曼哈顿距离
#include <bits/stdc++.h>
using namespace std;

const int GOAL = 123456780;
int gx[10], gy[10];          // 数字 d 的目标行列
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

int hval(int s) {                 // 曼哈顿距离之和
    int sum = 0;
    for (int i = 8; i >= 0; i--) {
        int d = s % 10; s /= 10;
        if (d == 0) continue;             // 空格不计入
        sum += abs(i / 3 - gx[d]) + abs(i % 3 - gy[d]);
    }
    return sum;
}

struct Node {
    int f, g, s;
    bool operator<(const Node& o) const { return f > o.f; } // 小根堆
};

int astar(int start) {
    priority_queue<Node> pq;
    unordered_map<int, int> best;
    best[start] = 0;
    pq.push({hval(start), 0, start});

    while (!pq.empty()) {
        Node u = pq.top(); pq.pop();
        if (u.s == GOAL) return u.g;          // 首次弹出即最优
        if (u.g > best[u.s]) continue;          // 过期状态,跳过

        int a[9], pos = 0, t = u.s;
        for (int i = 8; i >= 0; i--) { a[i] = t % 10; t /= 10; if (a[i] == 0) pos = i; }
        int x = pos / 3, y = pos % 3;

        for (int k = 0; k < 4; k++) {
            int nx = x + dx[k], ny = y + dy[k];
            if (nx < 0 || nx > 2 || ny < 0 || ny > 2) continue;
            int np = nx * 3 + ny;
            swap(a[pos], a[np]);
            int ns = 0;
            for (int i = 0; i < 9; i++) ns = ns * 10 + a[i];
            swap(a[pos], a[np]);

            int ng = u.g + 1;
            if (!best.count(ns) || best[ns] > ng) {
                best[ns] = ng;
                pq.push({ng + hval(ns), ng, ns});
            }
        }
    }
    return -1;
}

int main() {
    ios::sync_with_stdio(0);
    // 预处理每个数字在目标局面中的行列
    int t = GOAL;
    for (int i = 8; i >= 0; i--) { int d = t % 10; t /= 10; gx[d] = i / 3; gy[d] = i % 3; }

    string s; cin >> s;
    int st = 0;
    for (char c : s) st = st * 10 + (c - '0');
    cout << astar(st) << "\n";
    return 0;
}
运行结果
输入: 123450678
输出: 13

输入: 123408765
输出: 6

(局面 123408765 的 h = 6 恰等于真实步数,A* 几乎不绕路;
  局面 123450678 的 h = 5,真实步数 13,扩展量略多但仍远小于单向 BFS)
🔍 代码逐段解析 · A* + 曼哈顿距离
1gx[10] / gy[10]:预处理每个数字在目标局面 123456780 中的行列。注意循环从 i=8 往下取个位,正好让 i 与"从左数第 i 格"对齐。
2hval(int s):把整数状态逐位拆开,对每个非 0 数字累加 |当前行 − 目标行| + |当前列 − 目标列|;if (d == 0) continue; 排除空格,否则 h 被高估、答案可能错。
3struct Node 的 operator<:写成 return f > o.f; 让默认大根堆变成按 f 的小根堆。易错点:方向写反会变成"最差优先",又慢又错。
4astar 开头:best[start]=0 并把 {h(start), 0, start} 入堆 —— 起点的 f 就是纯估价。
5if (u.s == GOAL) return u.g;:首次弹出目标即最优(h 可采纳保证);if (u.g > best[u.s]) continue; 跳过过期堆项 —— 同一状态可能以不同 g 被多次入堆,只有 g 等于当前最优的那次值得扩展。
6数组 a[9] 与 pos:把整数状态反序列化成数位数组并找到空格下标;swap(a[pos], a[np]) → 拼回整数 ns → 再 swap 回来还原数组,供下一个方向复用。易错点:忘记还原会让后续方向在脏数组上生成后继。
7if (!best.count(ns) || best[ns] > ng):新状态或更优 g 才更新入堆,键为 ng + hval(ns);这就是"g 前进、h 重新估价"的标准 A* 转移。
8main 读入:字符串逐位 st = st*10 + (c-'0') 压成整数 —— 与 hval/反序列化的数位约定配套,三处任一处下标写反,程序不报错但答案全错。
两大易错:① hval 忘排除空格;② 生成后继后忘记把 a[pos] 换回来。

💡 引入

注意"序列化/反序列化"的写法:for (int i = 8; i >= 0; i--) 是从低位往高位取数位,所以 i 正好对应"从左到右的下标"。这类"数位与下标对齐"的细节写反了,程序不报错但答案会整体错位。

思考:如果把 h 换成"错位数",A* 还能保证最优吗?扩展量会变大还是变小?(提示:错位数 < 曼哈顿距离,h 更松 → 扩展更多。)

模块 04

总结提升

①核心要点与万能模板
要点一句话记住易错提醒
A* 估价f = g + h,按 f 出队h 必须可采纳,否则答案错
可采纳性h 绝不高估真实剩余代价"步数 × 10"这类放大估计必错
一致性h(n) ≤ w + h(n'),三角不等式一致 ⟹ 可采纳,反之不一定
八数码 h曼哈顿距离之和(不含空格)别忘了把空格排除,否则 h 偏大
双向 BFS两队列两距离表,先判相遇再判重复交换队列必须同时交换距离表
双向优化每次扩展队列较小的一侧扩展时按"整层"扩,不能只弹一个
迭代加深limit 从 0 递增,第一成功即最浅limit 判定用 dep 大于 limit,别忘了边界
IDA*limit 用 f 值,且直接跳到最小超限值用 limit 加 1 会慢好几倍
C++万能模板 · 三种方法合体
/* ========== 一、A* ========== */
int h(State s);                        // 必须可采纳:h(s) <= 真实剩余代价

struct Nd { int f, g; State s;
    bool operator<(const Nd& o) const { return f > o.f; } };

int astar(State st) {
    priority_queue<Nd> pq; unordered_map<State,int> best;
    best[st] = 0; pq.push({h(st), 0, st});
    while (!pq.empty()) {
        Nd u = pq.top(); pq.pop();
        if (isGoal(u.s)) return u.g;              // 首次弹出即最优
        if (u.g > best[u.s]) continue;             // 过期状态
        for (State v : nxt(u.s)) {
            int ng = u.g + 1;
            if (!best.count(v) || best[v] > ng) { best[v] = ng; pq.push({ng + h(v), ng, v}); }
        }
    }
    return -1;
}

/* ========== 二、双向 BFS ========== */
int bidBFS(State S, State T) {
    if (S == T) return 0;
    queue<State> q1, q2; unordered_map<State,int> d1, d2;
    q1.push(S); d1[S] = 0; q2.push(T); d2[T] = 0;
    while (!q1.empty() && !q2.empty()) {
        if (q1.size() > q2.size()) { swap(q1,q2); swap(d1,d2); }  // 谁小扩谁
        int sz = q1.size();
        while (sz--) {
            State u = q1.front(); q1.pop();
            for (State v : nxt(u)) {
                int step = d1[u] + 1;
                if (d2.count(v)) return step + d2[v];              // 相遇
                if (d1.count(v)) continue;
                d1[v] = step; q1.push(v);
            }
        }
    }
    return -1;
}

/* ========== 三、迭代加深 / IDA* ========== */
int limit, nxtLimit;

bool dfs(State u, int dep) {
    int f = dep + h(u);                    // 纯迭代加深时把 h(u) 换成 0
    if (f > limit) { nxtLimit = min(nxtLimit, f); return false; }
    if (isGoal(u)) return true;
    for (State v : nxt(u))
        if (dfs(v, dep + 1)) return true;
    return false;
}

int solve(State st, int maxLimit) {
    int lim = h(st);
    while (lim <= maxLimit) {
        limit = lim; nxtLimit = INT_MAX;
        if (dfs(st, 0)) return limit;
        if (nxtLimit == INT_MAX) break;     // 完整展开仍无解
        lim = nxtLimit;                     // 跳到最小超限 f 值
    }
    return -1;
}

🔍 代码逐段解析 · 万能模板

一、A*(第 1~16 行) —— Nd 里反向定义 operator< 实现小根堆;弹出时先判目标、再跳过期状态(u.g > best[u.s]),然后才扩展后继,这个顺序不能颠倒。

二、双向 BFS(第 19~36 行) —— 开头 if (S == T) return 0;;循环内先"谁小扩谁"(队列与距离表成对 swap),再整层扩展;后继先查对方表 d2.count(v) 相遇返回,再查自己表去重。

三、迭代加深 / IDA*(第 39~58 行) —— dfs 开头算 f = dep + h(u):纯迭代加深把 h 换成 0,IDA* 用真实估价;超限则用 nxtLimit = min(nxtLimit, f) 记录本轮最小超限值。solve 里 lim 从 h(起点) 起步,失败后直接 lim = nxtLimit,nxtLimit == INT_MAX 说明搜尽无解。

易错点 1 —— 三个模板共用 nxt(u)、isGoal()、h() 三个接口,做题时先把这三个函数写对,主体照抄即可;h 忘写可采纳性验证是最常见的丢分点。

易错点 2 —— IDA* 的全局 limit / nxtLimit 必须在每轮开始时重置(nxtLimit = INT_MAX),否则上一轮的残留值会让本轮提前"假成功"。

②拓展案例
拓展1状态压缩 + 双向 BFS

用康托展开把八数码状态压成整数,再加双向 BFS

思路:9! = 362880,恰好可以开一个大小为 362880 的 int 数组代替 unordered_map。康托展开把"排列"映射成 0 到 9!−1 的整数,映射与逆映射都是 O(9) 级,比字符串哈希快一个数量级。双向 BFS 配合数组距离表,八数码几乎瞬时出解。

// 康托展开:把排列 a[0..8] 映射成 0 ~ 9!-1
const int FAC[10] = {1,1,2,6,24,120,720,5040,40320,362880};

int cantor(int a[9]) {
    int code = 0;
    for (int i = 0; i < 9; i++) {
        int cnt = 0;                       // 统计后面比 a[i] 小的个数
        for (int j = i + 1; j < 9; j++) if (a[j] < a[i]) cnt++;
        code += cnt * FAC[8 - i];
    }
    return code;
}

// 双向 BFS 里的距离表直接开数组
int d1[362880], d2[362880];

void initDist() {
    fill(d1, d1 + 362880, -1);
    fill(d2, d2 + 362880, -1);
}

运行结果:局面 123450678 仍输出 13,但跑完的耗时从几十毫秒降到 1~2 毫秒级别;同样的代码稍加改动就能跑到 15 数码的常规测试点。

拓展2折半搜索

Meet in the Middle:把"双向"思想推广到枚举问题

思路:给定 n 个物品与容量上限,求不超过上限的最大可达价值和(n ≤ 40,价值较大不能背包)。直接枚举 240 太多,但把物品分成两半,各枚举 220 ≈ 106 种方案,排序其中一半后二分查找 —— 这就是双向思想在"枚举"上的翻版。

int n; long long W, a[45];
vector<long long> L, R;

void gen(int l, int r, long long sum, vector<long long>& out) {
    if (sum > W) return;                       // 剪枝:超容量直接停
    if (l == r) { out.push_back(sum); return; }
    gen(l + 1, r, sum, out);                  // 不选 a[l]
    gen(l + 1, r, sum + a[l], out);          // 选 a[l]
}

int main() {
    cin >> n >> W;
    for (int i = 0; i < n; i++) cin >> a[i];
    int mid = n / 2;
    gen(0, mid, 0, L);
    gen(mid, n, 0, R);
    sort(L.begin(), L.end());                  // 只排序一侧
    sort(R.begin(), R.end());

    long long ans = 0;
    for (long long x : L) {
        // 在 R 中找不超过 W - x 的最大值
        auto it = upper_bound(R.begin(), R.end(), W - x);
        if (it != R.begin()) ans = max(ans, x + *(--it));
    }
    cout << ans << "\n";
    return 0;
}

运行结果(样例):输入 4 10 与 3 5 7 8,输出 10(取 3 + 7 或 5 + 3 + ... 的最优不超过 10 的组合)。

对比:朴素枚举 O(2n),折半搜索 O(2n/2 · log 2n/2)。n = 40 时,前者是 1012,后者是 2×107 量级 —— 与双向 BFS 的加速如出一辙。

🔍 代码逐段解析 · 折半搜索

gen(l, r, sum, out) —— 递归枚举 [l, r) 内每个物品"选/不选";if (sum > W) return; 是可行性剪枝,超过容量直接放弃整棵子树;到达边界把 sum 存入对应半区。

main 的划分 —— mid = n / 2,前半存进 L、后半存进 R;两侧都排序后才能二分。

合并答案 —— 对 L 中每个 x,在 R 里用 upper_bound 找 ≤ W−x 的最大值;if (it != R.begin()) 先判空再 --it,否则越界。

易错点 —— n=1 时 R 可能为空,合并循环自然不执行、答案为 0,属正确边界;但若把 W−x 写成 W−a[x] 或忘了排序,二分就完全失效。

拓展3加权启发式

用加权 A*(f = g + w·h)在"最优"与"快"之间做权衡

思路:把估价乘上权重 w > 1(例如 w = 1.5),搜索会更"贪心"、更快,但 h 就不再保证可采纳,因此可能返回次优解。竞赛中若题目允许"输出一个不超过最优解 k 倍的方案",这就是标准做法。

// 加权 A*:只改入堆的键
const double W = 1.5;                // 权重 >= 1;取 1 即退化为标准 A*

int weightedAstar(State st) {
    priority_queue<Nd> pq;
    unordered_map<State,int> best;
    best[st] = 0;
    pq.push({(int)(W * h(st)), 0, st});
    while (!pq.empty()) {
        Nd u = pq.top(); pq.pop();
        if (isGoal(u.s)) return u.g;      // 注意:不保证最优!
        if (u.g > best[u.s]) continue;
        for (State v : nxt(u.s)) {
            int ng = u.g + 1;
            if (!best.count(v) || best[v] > ng) {
                best[v] = ng;
                pq.push({ng + (int)(W * h(v)), ng, v});   // 放大 h
            }
        }
    }
    return -1;
}

运行结果与结论:理论保证是"返回解不超过 w 倍最优解"。在八数码这类题上,w = 1.5 通常能让扩展节点数下降 30%~50%,答案多半仍是 13 或 14。竞赛里除非题目明确允许近似解,否则一律用 w = 1。

③思考与启发

🤔 思考一:为什么双向 BFS 不能直接用于带权图?

在无权图上,"两边各自求出的最短路拼接"就是全局最短路;在带权图上,从 S 到 T 的最短路未必经过"中点",两边"相遇"的点也未必落在最优路径上。

查看参考解答

答案:带权图里,"从 S 到 v 的最短路"与"从 v 到 T 的最短路"拼接起来,确实是一条 S→T 的合法路径,但不是所有最短路径都能这样分解。真正的困难在于:两边交替扩展时,无法保证"第一个相遇点恰好位于最优路径上",因而无法像无权图那样立即判定最优。

正确做法:带权图的双向搜索要用 双向 Dijkstra,终止条件是"两边的堆顶距离之和 ≥ 当前最优答案",判定条件比无权图复杂得多。CSP-S 阶段只需掌握无权双向 BFS。

要点:"双向加速"的可行性依赖于"最短路的可拼接性",无权图的层次结构保证了这一点。

🤔 思考二:h 越"紧"越好,那是不是越复杂越好?

八数码除了曼哈顿距离,还有更强(但更贵)的 h,例如"线性冲突"(linear conflict:同一行两个数字互为目标关系时至少多花 2 步)。更强意味着剪掉更多分支。

查看参考解答

答案:不一定。总时间 = 扩展节点数 × 单次 h 的计算时间。如果 h 变强 20%,但每次计算贵了 3 倍,总体反而更慢。

实操建议:① 八数码这种小问题,用曼哈顿距离足够;② 若状态规模巨大,可以考虑"增量维护 h"(每次滑动只更新受影响的 1~2 个数字)而不是整体重算;③ 当 h 本身接近真实值时,再强的剪枝收益就已饱和。

要点:启发式搜索的调优目标不是"h 最强",而是"总时间最短"。

🤔 思考三:如果状态不能倒推,双向 BFS 还有救吗?

有些问题的后继关系不可逆(例如"每次把某个数替换成它的平方"),从终点根本回不到前驱。

查看参考解答

答案:此时双向 BFS 失效。但可以换思路:① 改用 A*/IDA*,用"距离起点的 g + 估计的 h"单方向加速;② 若能构造"多条终点"或"逆向邻接表",双向依然可用;③ 折半搜索(拓展2)也是"双向思想"的另一形态,它并不要求可逆。

核心启发:同一套"把指数劈成两半"的直觉,可以在 BFS、Dijkstra、枚举多种框架下复用,只是"拼接"的合法性判定各不相同。

要点:算法思想比具体模板更重要 —— 先问"能不能把问题一分为二",再问"合并是否合法"。
模块 05

学习检测

先找错,再背口诀 —— 把启发式与双向搜索的坑消灭在考试之前。

⚡高频错误诊断
易错1

A* 用普通队列而不是优先队列

❌ 错误
用 queue<Node> 按入队顺序扩展,f 值只算不用。

后果:退化成随机顺序的 BFS,第一个目标不是最优解,答案偏大。
✅ 正确
用 priority_queue<Node>,并在结构体里把比较写成 f 大于 o.f(大根堆取反 = 小根堆)。
口诀:f 值算完必须排,优先队列小根堆!
易错2

双向 BFS 交换了队列,却忘了交换距离表

❌ 错误
if (q1.size() > q2.size()) swap(q1, q2);

后果:后面用 d1 读的其实是 q2 的距离,步数全乱,答案偏小或死循环。
✅ 正确
swap(q1, q2); swap(d1, d2);

队列与距离表必须成对交换,它们描述的是同一侧。
口诀:队列换,表也换,两个一起才不错!
易错3

把 h 放大,破坏了可采纳性

❌ 错误
为了"更快",把曼哈顿距离乘 2 当作 h。

后果:h 超过真实代价,A* 可能提前锁定一条较差的路径,答案偏大。
✅ 正确
严格保证 h ≤ 真实剩余代价。想更快就换更紧但仍不高估的 h(如线性冲突)。
口诀:估计只能往小算,放大一分错一题!
易错4

迭代加深的深度边界写反

❌ 错误
写成 if (dep >= limit) return false;

后果:深度恰好等于 limit 的那一层被误剪,答案比真实值大 1 甚至无解。
✅ 正确
写成 if (dep > limit) return false;

limit 是"允许的最大深度",所以等于 limit 的那一层依然要搜。
口诀:limit 是上限不是禁区,等于 limit 还要搜!
易错5

双向 BFS 忘记先判"起点即终点"

❌ 错误
起点等于终点时直接进循环。

后果:q1 与 q2 都是起点,扩展后继时 d2.count(nxt) 判定逻辑混乱,可能返回 2 而不是 0。
✅ 正确
函数开头立刻 if (S == T) return 0; 像八数码输入 123456780 这类边界就能正确输出 0。
口诀:双向搜索先特判,起点终点同即零!
📋速查表
方法核心公式 / 判定复杂度使用前提
A*f = g + h,弹出 f 最小者取决于 h 的紧度h 可采纳(+一致更好)
双向 BFS相遇即返回 d1[v] + d2[v]2·bd/2状态可逆、无权(或等权)
迭代加深limit = 0,1,2,… 逐层 DFS时间约 bd·b/(b−1),空间 O(d)求最浅解、内存紧张
IDA*dep + h 大于 limit 即剪枝空间 O(d),时间因剪枝大幅下降h 可采纳、解浅
八数码 hΣ 各数字曼哈顿距离(不含空格)单次 O(9)可采纳且一致
骑士 hmax(⌈Δx/2⌉, ⌈Δy/2⌉)单次 O(1)可采纳
场景直接抄哪个模板
无权图求最短路,状态可哈希,层数 20 以内双向 BFS 骨架
状态数超过 106,但解答很浅且能写 hIDA* 骨架(limit 用最小超限 f 值)
状态可哈希,h 很紧(如八数码的曼哈顿)A* 骨架
只需要"最浅解",没有 h,内存小纯迭代加深(h 取 0)
📌笔记卡
🧠 必记概念
  • f = g + h,h 是可采纳估价
  • 可采纳 = 从不高估
  • 一致 = 三角不等式,推出可采纳
  • 双向 BFS:两队列两距离表
  • 迭代加深:第一成功即最浅
  • IDA*:limit 是 f 值上限
⌨️ 必背模板
  • A*:小根堆 + 过期状态跳过
  • 双向:swap(q1,q2) + swap(d1,d2)
  • 双向:先判相遇,再判重复
  • 迭代加深:for limit = 0..max
  • IDA*:LIMIT = nxtLimit(不 +1)
  • 八数码:康托展开压缩状态
⚠️ 考场提醒
  • h 一定先自测几个局面验证不高估
  • 双向 BFS 记得特判起点等于终点
  • swap 时表和队列要成对换
  • limit 边界:dep 大于 limit 才剪
  • 无解要输出题目要求的 −1
  • 栈太深时把 DFS 改成手工栈
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

八数码局面 123408765 与目标 123456780,请手算曼哈顿距离 h。

查看参考答案

思路:目标局面中 6、7、8 的位置分别是下标 5、6、7(即第 2 行第 3 列、第 3 行第 1 列、第 3 行第 2 列),空格在下标 8。

逐个算:1、2、3、4 都在原位,距离 0;8 从下标 5 到下标 7:|1−2| + |2−1| = 2;6 从下标 7 到下标 5:|2−1| + |1−2| = 2;5 从下标 8 到下标 4:|2−1| + |2−1| = 2。

答案:h = 0+0+0+0+2+2+2 = 6。而该局面的真实最短步数也是 6,说明这里的 h 恰好等于真值(h 越紧,A* 越快)。

易错:把空格也计入距离(结果偏大,破坏可采纳性);或用"错位数"当作曼哈顿距离(这里错位数是 3,小于 6)。

A2编程题

用双向 BFS 求八数码局面 123456708 到达 123456780 的最少步数。

查看参考答案

思路:直接套用双向 BFS 模板:两个队列、两张距离表,每次扩展较小一侧,生成后继时先查对方表。

#include <bits/stdc++.h>
using namespace std;
string S;
const string T = "123456780";
int dx[4] = {-1,1,0,0}, dy[4] = {0,0,-1,1};

int bfs() {
    if (S == T) return 0;
    queue<string> q1, q2; unordered_map<string,int> d1, d2;
    q1.push(S); d1[S] = 0; q2.push(T); d2[T] = 0;
    while (!q1.empty() && !q2.empty()) {
        if (q1.size() > q2.size()) { swap(q1,q2); swap(d1,d2); }
        int sz = q1.size();
        while (sz--) {
            string cur = q1.front(); q1.pop();
            int id = cur.find('0'), x = id/3, y = id%3;
            for (int k = 0; k < 4; k++) {
                int nx = x+dx[k], ny = y+dy[k];
                if (nx<0||nx>2||ny<0||ny>2) continue;
                string nxt = cur; swap(nxt[id], nxt[nx*3+ny]);
                int step = d1[cur] + 1;
                if (d2.count(nxt)) return step + d2[nxt];
                if (d1.count(nxt)) continue;
                d1[nxt] = step; q1.push(nxt);
            }
        }
    }
    return -1;
}
int main(){ cin >> S; cout << bfs() << "\n"; }

答案:输入 123456708,输出 1(空格与右边的 8 交换一次即可)。

易错:忘记 if (S == T) return 0; 的特判;或 swap 时只换队列不换距离表。

🔍 代码逐段解析

bfs 开头 —— 特判 S==T 后建 q1/d1(正向)、q2/d2(逆向),两套结构成对初始化。

整层扩展 —— int sz = q1.size(); while (sz--) 保证按层推进;find('0') 定位空格,越界方向跳过。

易错点 —— if (d2.count(nxt)) return step + d2[nxt]; 必须先于 if (d1.count(nxt)) continue;,先去重会漏掉相遇点。

A3编程题

在 6×6 棋盘上用迭代加深求骑士从 (0,0) 到 (5,5) 的最少步数。

查看参考答案

思路:limit 从 0 递增,每轮做一次带 dep + h > limit 剪枝的 DFS;h 取 max(⌈Δx/2⌉, ⌈Δy/2⌉)。

int n, sx, sy, tx, ty, limit;
int dx[8]={ 1,2,2,1,-1,-2,-2,-1};
int dy[8]={ 2,1,-1,-2,-2,-1,1,2};

int h(int x,int y){ int a=abs(x-tx), b=abs(y-ty); return max((a+1)/2,(b+1)/2); }

bool dfs(int x,int y,int dep,int px,int py){
    if (dep + h(x,y) > limit) return false;
    if (x==tx && y==ty) return true;
    for (int k=0;k<8;k++){
        int nx=x+dx[k], ny=y+dy[k];
        if (nx<0||nx>=n||ny<0||ny>=n) continue;
        if (nx==px&&ny==py) continue;
        if (dfs(nx,ny,dep+1,x,y)) return true;
    }
    return false;
}
int main(){ cin >> n >> sx >> sy >> tx >> ty;
    for (limit=0; limit<=2*n; limit++)
        if (dfs(sx,sy,0,-1,-1)) { cout << limit; break; } }

答案:输入 6 0 0 5 5,输出 4。

易错:把边界写成 dep >= limit;或 h 里用除法忘记向上取整,导致 h 偏小(虽然仍可采纳,但剪枝变弱)。

🔍 代码逐段解析

h 函数 —— (a+1)/2 实现向上取整:行差 a 每个马步最多消 2,列差同理,取 max 是可采纳下界。

dfs —— 先 dep + h > limit 剪枝、再判命中;枚举 8 方向时用 px,py(上一格坐标)跳过回头路。

main —— for (limit=0; limit<=2*n; limit++) 逐层放宽,命中立即输出;2n 是 n×n 棋盘马步距离的安全上界。

A4判断题

判断下列三个 h 是否可采纳(八数码问题):① 错位数 ② 曼哈顿距离之和 ③ 曼哈顿距离 × 2。

查看参考答案

答案:① 可采纳;② 可采纳;③ 不可采纳。

理由:① 一个错位数字至少要动 1 步,所以错位数 ≤ 真实步数。② 每步移动一个数字一格,总曼哈顿距离每步最多减少 1,所以曼哈顿距离 ≤ 真实步数。③ 乘 2 之后,对于"错位数 = 1、真实步数 = 1"的局面(例如 123456780 交换相邻两个数字),h = 2 大于真实值 1,明显高估。

易错:误以为"只要 h 是某种距离就一定合法"。任何缩放、加权都会破坏可采纳性。

🟡 B组 · 提高(4题)

B1双向 BFS 应用题

数字变换:每次可以把一个数字串的某一位 +1 或 −1(0 与 9 相邻,可循环),求从 A 变到 B 的最少操作数(串长 ≤ 8)。

查看参考答案

分析:每个数字串是一个状态,一次操作改变一位,代价为 1 —— 这是无权图最短路。状态空间 108 太大,但答案深度 ≤ 4×串长,双向 BFS 是最优选择。

string A, B;
int solve() {
    if (A == B) return 0;
    queue<string> q1, q2; unordered_map<string,int> d1, d2;
    q1.push(A); d1[A]=0; q2.push(B); d2[B]=0;
    while (!q1.empty() && !q2.empty()) {
        if (q1.size() > q2.size()) { swap(q1,q2); swap(d1,d2); }
        int sz = q1.size();
        while (sz--) {
            string u = q1.front(); q1.pop();
            for (int i = 0; i < (int)u.size(); i++)
                for (int d = -1; d <= 1; d += 2) {
                    string v = u;
                    v[i] = (v[i] - '0' + d + 10) % 10 + '0';
                    int step = d1[u] + 1;
                    if (d2.count(v)) return step + d2[v];
                    if (d1.count(v)) continue;
                    d1[v] = step; q1.push(v);
                }
        }
    }
    return -1;
}

思路:状态数量巨大但结构规则,双向 BFS 各走 d/2 层即可会合;键用字符串便于位修改。

易错:边界处理时 0−1 变成 −1,必须先加 10 再取模;另外不要把答案估成 Σ|A[i]−B[i]| —— 每位的正确代价是 min(d, 10−d)(数字环上走短的那一侧),它只是答案的参考下界,最终仍要靠双向 BFS 求出精确值。

B2IDA* 应用题

最少操作步数:给定一个整数 n,每次可以把最后一个数字移到最前面(或把最前面移到后面),求变成目标串的最少次数(长度 ≤ 7)。

查看参考答案

分析:状态数 = 排列数(≤ 5040),但每轮有 2 种移动,用 IDA* 更省心。h 可以取"当前排列与目标排列不同的位置数",或者更紧的"最长公共子串长度取负"。最简做法:h 直接取 0,退化为纯迭代加深(本题状态很小,够用)。

string st, ed;
int limit;

int h(string s) {          // 保守估计:错位数
    int c = 0;
    for (int i = 0; i < (int)s.size(); i++) if (s[i] != ed[i]) c++;
    return c;
}
bool dfs(string s, int dep, string pre) {
    if (dep + h(s) > limit) return false;
    if (s == ed) return true;
    string a = s; char c = a.back(); a.pop_back(); a = c + a;   // 后移前
    string b = s; c = b[0]; b.erase(b.begin()); b += c;            // 前移后
    if (a != pre && dfs(a, dep + 1, s)) return true;
    if (b != pre && dfs(b, dep + 1, s)) return true;
    return false;
}

关键:用"上一步状态"来剪掉回头路(后移前与首次前移后互为逆操作)。

易错:h 取"错位数"时注意旋转保持数字集合不变,若两串数字集合不同应直接输出 −1。

B3A* 应用题

网格迷宫:n×m 网格(n, m ≤ 1000),'#' 不可走,'.' 可走,求 (1,1) 到 (n,m) 的最少步数。

查看参考答案

分析:代价为 1 的无权图 + 顶点数最多 106。BFS 已经足够;若一定要用 A*,则 h 取曼哈顿距离(四连通网格每步最多缩短 1,可采纳)。

struct Nd { int f, g, x, y;
    bool operator<(const Nd& o) const { return f > o.f; } };

int astar() {
    priority_queue<Nd> pq;
    vector<vector<int>> best(n+1, vector<int>(m+1, INT_MAX));
    auto h = [](int x,int y){ return (n-x) + (m-y); };
    best[1][1] = 0; pq.push({h(1,1), 0, 1, 1});
    while (!pq.empty()) {
        Nd u = pq.top(); pq.pop();
        if (u.x == n && u.y == m) return u.g;
        if (u.g > best[u.x][u.y]) continue;
        for (int k = 0; k < 4; k++) {
            int nx = u.x + dx[k], ny = u.y + dy[k];
            if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
            if (g[nx][ny] == '#') continue;
            int ng = u.g + 1;
            if (ng < best[nx][ny]) { best[nx][ny] = ng; pq.push({ng + h(nx,ny), ng, nx, ny}); }
        }
    }
    return -1;
}

结论:A* 与 BFS 在这种"障碍稀疏"的迷宫上差别不大;A* 的优势体现在 h 明显收紧的场景(如八数码、带方向约束的滑动问题)。

易错:把 h 定义成"欧氏距离平方"(高估,不可采纳);或网格下标从 0 开始却按 1 开始读入。

B4复杂度分析

八数码有 181440 个可达状态。分别估计单向 BFS、双向 BFS、A*(曼哈顿)在三者最坏情况下的访问量级。

查看参考答案

单向 BFS:答案最大为 31 步(八数码的"神之数字")。虽然总状态只有 181440,但单向 BFS 通常需要展开绝大多数状态才能保证最优,量级就是 105。

双向 BFS:两侧各走约 15 层。由于 15 层以内的局面数量远小于总数,实际访问量一般在 104~105 之间,比单向少一个量级。

A*:曼哈顿距离对八数码非常紧(很多局面 h 接近真值),A* 常只扩展 102~103 个状态就命中目标 —— 在小规模问题上 A* 最快。

易错:把"状态总数 181440"直接当成算法复杂度。实际上访问量才决定耗时,A* 快的原因是它"只访问有希望的少数状态"。

🔴 C组 · 挑战(4题)

C1综合编程

八数码加强版:同时输出最少步数与字典序最小的操作序列(用 U/D/L/R 表示空格移动方向)。

查看参考答案

思路:在双向 BFS 中把"距离表"升级为"距离 + 前驱 + 操作字符",相遇后从相遇点分别向两边回溯,拼接得到完整路径。

struct Info { int d; string path; };   // path 记录从该侧起点到当前的移动串
unordered_map<string, Info> d1, d2;

// 相遇时:d1[v].path + reverse(d2[v].path 取反方向)
// 注意 U↔D、L↔R 互为反向
char rev(char c) {
    if (c == 'U') return 'D';
    if (c == 'D') return 'U';
    if (c == 'L') return 'R';
    return 'L';
}

string merge(string p1, string p2) {   // p2 是从目标倒着搜的路径,需要反转并取反方向
    for (int i = 0, j = p2.size() - 1; i < j; i++, j--) swap(p2[i], p2[j]);
    for (char& c : p2) c = rev(c);
    return p1 + p2;
}

要点:① 从目标倒着搜时,"空格向上走"在正向里是"向下走",所以回溯时必须把方向取反;② 要字典序最小,扩展后继时按 U < D < L < R 的顺序枚举,并在首次相遇时立即返回。

易错:忘记反转 + 取反,输出的操作序列完全反向;或为了"字典序最小"而继续搜索,破坏了"首次相遇即最优"的性质。

C2IDA* 进阶

15 数码(4×4)求最少步数。请写出带强剪枝的 IDA* 方案。

查看参考答案

分析:15 数码状态数约 1013,A* 的哈希表根本装不下,IDA* 是唯一可行路线。

h1:曼哈顿距离之和(可采纳)。h2(更强):曼哈顿距离 + 2 × 线性冲突数。线性冲突指"同一行(列)里两个数字互为目标关系、但顺序相反",每对至少多花 2 步。

int manhattan(int a[16]) {
    int s = 0;
    for (int i = 0; i < 16; i++) {
        if (a[i] == 0) continue;
        int r = i/4, c = i%4, tr = (a[i]-1)/4, tc = (a[i]-1)%4;
        s += abs(r-tr) + abs(c-tc);
    }
    return s;
}

int linearConflict(int a[16]) {     // 同行/同列的逆序目标对,每对 +2
    int extra = 0;
    for (int r = 0; r < 4; r++)              // 逐行检查
        for (int i = 0; i < 4; i++) {
            int v1 = a[r*4+i]; if (!v1 || (v1-1)/4 != r) continue;
            for (int j = i+1; j < 4; j++) {
                int v2 = a[r*4+j]; if (!v2 || (v2-1)/4 != r) continue;
                if ((v1-1)%4 > (v2-1)%4) extra += 2;
            }
        }
    // 逐列同理,把行改成列再算一遍
    return extra;
}

int h(int a[16]) { return manhattan(a) + linearConflict(a); }

结论:线性冲突让 h 更紧,15 数码随机局面通常几秒内可解;再配合"记录上一步空格位置避免回头"与"limit 直接跳到最小超限 f 值",就是竞赛标准写法。

易错:线性冲突只统计"同行且目标也在同行"的数字对,若目标不同行则不构成冲突;统计时容易多算导致 h 高估、答案错。

C3初赛真题风格

下列关于启发式搜索与双向搜索的说法,正确的有( )

A. 若 h 可采纳且一致,A* 首次弹出的目标状态即为最优解。
B. 双向 BFS 一定比单向 BFS 快。
C. 迭代加深的空间复杂度是 O(d),其中 d 是解的最大深度。
D. 只要 h 越大,A* 的效率就一定越高。

查看参考答案

答案:A、C。

A 对:一致性保证每个状态首次弹出即最优,因此第一个弹出的目标状态代价最优。

B 错:双向 BFS 需要两套数据结构、且状态必须可逆。当答案很浅(比如 1~2 层)或状态不可逆时,双向反而不如单向简单直接。

C 对:递归深度不超过 limit,limit ≤ 最大解深度 d,所以栈空间 O(d)。

D 错:h 越大越好的前提是"不破坏可采纳性"。一旦 h 高估,虽然扩展更少,但答案可能变差;此外 h 计算本身也有成本,需要考虑"总时间 = 节点数 × 单次代价"。

易错:把"扩展节点少"等同于"算法更快",忽略了 h 的计算开销与正确性前提。

C4综合设计

设计算法:在 4×4 棋盘上,用最少的骑士移动次数"遍历"指定的 4 个格子(每个格子至少访问一次),求最少步数。

查看参考答案

分析:这是"访问约束型最短路",比纯最短路难,因为状态必须包含"已访问的格子集合"。做法是把状态定义为 (位置, 访问掩码),用 BFS / 双向 BFS / A* 均可。

// 状态:当前位置 (x,y) + 已访问标记 mask(最多 16 位)
struct St { int x, y, mask, d; };

int bfsMask(int sx, int sy, int full) {
    int startMask = 1 << (sx * 4 + sy);
    vector<vector<vector<char>>> vis(4, vector<vector<char>>(4, vector<char>(1 << 16, 0)));
    queue<St> q;
    q.push({sx, sy, startMask, 0});
    vis[sx][sy][startMask] = 1;
    while (!q.empty()) {
        St u = q.front(); q.pop();
        if ((u.mask & full) == full) return u.d;   // 4 个目标格全部访问
        for (int k = 0; k < 8; k++) {
            int nx = u.x + dx[k], ny = u.y + dy[k];
            if (nx < 0 || nx >= 4 || ny < 0 || ny >= 4) continue;
            int nm = u.mask | (1 << (nx * 4 + ny));
            if (vis[nx][ny][nm]) continue;
            vis[nx][ny][nm] = 1;
            q.push({nx, ny, nm, u.d + 1});
        }
    }
    return -1;
}

要点:掩码最多 216 种,配合 16 个位置,状态总数约 106,BFS 足够。若目标格换成 8 个以上,掩码状态爆炸,就要考虑"先跑 4 次最短路预处理任意两点距离,再做状压 DP"。

易错:把已访问集合当成全局变量(回溯时忘记还原),导致该走的格子被标记成已访问。

🏆 洛谷原题精选(12道)

以下 12 道洛谷题目覆盖搜索的启发式、双向与迭代加深思想,按难度从普及-到提高排列。

1
P1443
马的遍历普及-
2
P1605
迷宫普及-
3
P1219
八皇后 Checker Challenge普及-
4
P1135
奇怪的电梯普及-
5
P1032
字串变换(双向 BFS 名题)提高
6
P1379
八数码难题提高
7
P2324
骑士精神(IDA*)提高
8
P1120
小木棍(剪枝 + 迭代加深)提高
9
P1763
埃及分数(IDA* 经典)提高
10
P1784
数独普及+
11
P1074
靶形数独(搜索 + 估价剪枝)提高
12
P1236
算24点提高