搜索(meet in middle,A*,IDA*)
搜索进阶:Meet-in-the-Middle · A* · IDA*
目录
1. 前置知识回顾
1.1 DFS(深度优先搜索)
- 核心思想:一条路走到黑,撞墙再回头
- 数据结构:栈(递归隐式使用系统栈)
- 空间复杂度:\(O(\text{depth})\)(与深度成正比)
- 优点:空间开销小,适合搜索树深的场景
- 缺点:不保证最优解,可能陷入无限深的路径
1
/ | \
2 5 8
/| | \
3 4 6 9
|
7
DFS 遍历顺序(先左后右):1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9
1.2 BFS(广度优先搜索)
- 核心思想:一层一层扩展,逐层推进
- 数据结构:队列
- 空间复杂度:\(O(b^d)\)(\(b\) 为分支因子,\(d\) 为深度)
- 优点:在边权相等的图上保证找到最短路径
- 缺点:空间消耗巨大,深度稍大就不可行
1 ← 第0层
/ | \
2 5 8 ← 第1层
/| | \
3 4 6 9 ← 第2层
|
7 ← 第3层
BFS 遍历顺序:1 → 2 → 5 → 8 → 3 → 4 → 6 → 9 → 7
1.3 暴力搜索的困境
| 算法 | 时间复杂度 | 空间复杂度 | 最优解? |
|---|---|---|---|
| DFS | \(O(b^d)\) | \(O(d)\) | ❌ |
| BFS | \(O(b^d)\) | \(O(b^d)\) | ✅ 等权 |
当搜索空间呈指数增长时,DFS 和 BFS 都会面临组合爆炸。
例如:\(b=4,\ d=20\) → 状态数 \(\approx 4^{20} \approx 10^{12}\),完全不可行。
2. Meet-in-the-Middle(折半搜索)
2.1 核心思想
把问题从中间劈开,两边各搜一半,然后在中间会合。
将 \(n\) 个物品 / \(d\) 层深度的问题拆成两部分,每部分独立搜索并记录所有可能结果,最后用二分查找 / 哈希表 / 双指针合并。
2.2 复杂度分析
| 暴力 | Meet-in-the-Middle | |
|---|---|---|
| 搜索空间 | \(b^d\) | \(2 \times b^{d/2}\) |
| 合并 | — | \(\approx b^{d/2} \cdot \log(b^{d/2})\) |
| 总复杂度 | \(\mathbf{O(b^d)}\) | \(\mathbf{O(b^{d/2} \log b)}\) |
\(b=2,\ d=40\) 时:暴力 \(2^{40} \approx 10^{12}\),折半 \(2 \times 2^{20} \approx 2 \times 10^6\),快了约 50 万倍!
2.3 图示
常规搜索:
起点 ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━● 终点 ← 搜整条路径 (b^d)
Meet-in-the-Middle:
起点 ●━━━━━━━━● ●━━━━━━━━● 终点
搜 d/2 层 ←合并→ 搜 d/2 层
(b^{d/2} 个状态) (b^{d/2} 个状态)
2.4 经典例题 1:01 背包(超大容量)
题目:有 \(n\) 个物品,每个物品有重量 \(w_i\) 和价值 \(v_i\),背包容量为 \(W\),求最大总价值。
数据范围:\(n \le 40,\ W \le 10^9\)(不能用传统 DP,\(O(nW)\) 会爆)
思路:
- 将物品分成两半:前 \(n/2\) 个和后 \(n/2\) 个
- 每半分别爆搜所有 \(2^{n/2}\) 种子集,记录
(总重量, 总价值) - 对每一半的结果按重量排序,去除"重量大但价值小"的无效组合
- 枚举前半的每个选择,在另一半中二分查找最大的合法重量
// 核心伪代码
int n = 40, W;
vector<pair<long long, long long>> A, B;
// 第一步:分别搜两半
dfs(0, n/2, 0, 0, A); // 搜前一半 → A
dfs(n/2, n, 0, 0, B); // 搜后一半 → B
// 第二步:对 B 做"重量前缀最大值"处理
sort(B.begin(), B.end());
for(int i = 1; i < B.size(); i++)
B[i].second = max(B[i].second, B[i-1].second);
// 第三步:枚举 A,二分查 B
long long ans = 0;
for(auto [wa, va] : A) {
if(wa > W) continue;
// 在 B 中二分查找重量 ≤ W-wa 的最大位置
auto it = upper_bound(B.begin(), B.end(), make_pair(W - wa, LLONG_MAX));
if(it != B.begin()) {
--it;
ans = max(ans, va + it->second);
}
}
2.5 经典例题 2:双向 BFS
题目:在 \(N \times M\) 网格上从起点 \(S\) 走到终点 \(T\),每次可以向 8 个方向移动(或按题目规则)。
思路:
- 从起点和终点同时开始 BFS
- 两个方向的搜索交替进行(每次扩展较小的一层)
- 当两个搜索的访问集合出现交集时,找到最短路
// 双向 BFS 伪代码
queue<State> q1, q2;
unordered_set<State> vis1, vis2;
q1.push(start); vis1.insert(start);
q2.push(goal); vis2.insert(goal);
while(!q1.empty() && !q2.empty()) {
// 优先扩展节点少的队列
if(q1.size() > q2.size()) swap(q1, q2), swap(vis1, vis2);
int sz = q1.size();
while(sz--) {
auto cur = q1.front(); q1.pop();
for(auto nxt : expand(cur)) {
if(vis2.count(nxt)) {
// 找到!dist1[cur] + 1 + dist2[nxt]
return dist1[cur] + 1 + dist2[nxt];
}
if(!vis1.count(nxt)) {
vis1.insert(nxt);
q1.push(nxt);
}
}
}
}
双向 BFS 的复杂度从 \(O(b^d)\) 降为 \(O(2 \times b^{d/2})\),和 Meet-in-the-Middle 完全相同的数学原理。
2.6 适用条件
- ✅ 搜索空间可以对半拆分
- ✅ 两半的结果可以高效合并(二分/哈希/双指针)
- ✅ 搜索层数不太大(\(d \le 40 \sim 50\)),但单搜太慢
3. A* 算法
3.1 核心思想
在 BFS/Dijkstra 的基础上加入"启发式估价",让搜索优先走向最有希望的方向。
BFS 是"盲目扩展"——对所有邻居一视同仁。
A* 给每个状态一个"估值",每次挑"最有希望"的状态扩展。
3.2 估价函数
A* 的核心是估价函数 \(f(n) = g(n) + h(n)\):
| 符号 | 含义 | 计算方式 |
|---|---|---|
| \(g(n)\) | 从起点到 \(n\) 的实际代价 | 搜索过程中确定 |
| \(h(n)\) | 从 \(n\) 到终点的估计代价 | 启发式函数(需设计) |
| \(f(n)\) | 经过 \(n\) 的总估计代价 | \(f = g + h\) |
3.3 算法流程
1. 将起点 s 放入优先队列(open list),按 f 值排序
2. while 队列非空:
a. 取出 f 值最小的节点 n
b. 若 n 是终点 → 找到解,回溯路径
c. 将 n 放入 closed list
d. 对 n 的每个邻居 m:
- 若 m 在 closed list → 跳过
- 计算 g(m) = g(n) + cost(n,m)
- 若 m 不在 open list → 加入 open list
- 若 m 在 open list 且新 g(m) 更小 → 更新
3.4 \(h(n)\) 的设计要求
可采纳性(Admissibility)—— 最重要!
其中 \(h^*(n)\) 是从 \(n\) 到终点的真实最短代价。
直觉:\(h(n)\) 永远不能"高估"剩余代价。它是乐观的——告诉算法"至少还需要 \(h(n)\) 步"。
| \(h(n)\) 性质 | 结果 |
|---|---|
| \(h(n) = 0\)(最保守) | 退化为 Dijkstra / 普通 BFS |
| \(h(n) \le h^*(n)\) 且接近 | ✅ 最优解保证,且速度快 |
| \(h(n) > h^*(n)\)(高估) | ❌ 可能错过最优解 |
| \(h(n) = h^*(n)\)(完美) | 直接走最短路,线性时间! |
一致性 / 单调性(Consistency / Monotonicity)
对于任意边 \((n \to m)\) 的代价 \(c(n,m)\):
一致性能保证每个节点只被扩展一次。大多数常用启发式都满足。
3.5 经典例题 1:八数码问题
题目:\(3 \times 3\) 滑块拼图,每次将一个数字滑块滑入空位,目标状态为 12345678x(\(x\) 代表空位)。
估价函数设计:
| 方案 | \(h(n)\) | 效果 |
|---|---|---|
| \(h=0\) | 0 | 退化为 BFS,约 180K 状态 |
| 曼哈顿距离 | \(\sum |x_i - x'_i| + |y_i - y'_i|\) | ✅ 可采纳,约 5K 状态 |
| 错位数 | 不在正确位置的数字个数 | ✅ 可采纳但不如曼哈顿 |
| 欧几里得距离 | \(\sqrt{(x_i-x'_i)^2 + (y_i-y'_i)^2}\) | ✅ 可采纳,但不如曼哈顿 |
曼哈顿距离是最常用的启发式:每个数字独立地考虑自己到目标位置需要的步数,忽略了数字之间的相互阻碍 → 乐观估计 → 可采纳。
// 八数码的估价函数
int heuristic(const string& state) {
int h = 0;
for(int i = 0; i < 9; i++) {
if(state[i] == 'x') continue;
int num = state[i] - '1'; // 当前数字
int cur_x = i / 3, cur_y = i % 3;
int goal_x = num / 3, goal_y = num % 3;
h += abs(cur_x - goal_x) + abs(cur_y - goal_y);
}
return h;
}
// A* 搜索主体
struct Node {
string state;
int g, h;
bool operator<(const Node& o) const { return g + h > o.g + o.h; }
};
int astar(string start) {
priority_queue<Node> pq;
unordered_map<string, int> dist;
pq.push({start, 0, heuristic(start)});
dist[start] = 0;
while(!pq.empty()) {
auto [state, g, h] = pq.top(); pq.pop();
if(state == "12345678x") return g;
if(g > dist[state]) continue; // 懒惰删除
// 找到空位 'x' 并扩展四个方向...
}
return -1; // 无解
}
3.6 经典例题 2:K 短路
题目:在有向图中求从 \(s\) 到 \(t\) 的第 \(K\) 短路径。
A* 解法:
- 先跑一次反向 Dijkstra(从 \(t\) 到所有点),得到精确的 \(h(n) = \text{dist}(n, t)\)
- 再用 A*:\(f(n) = \text{dist}(s, n) + \text{dist}(n, t)\)
- 当终点 \(t\) 第 \(K\) 次被出队时,\(g(t)\) 就是第 \(K\) 短路
⚠️ 退化风险:A* 求 K 短路并非严格多项式——每个节点最多被扩展 \(K\) 次,复杂度 \(O(K \cdot E \log(K \cdot V))\)。以下情况会严重退化甚至 TLE:
- 零权边 / 等长路径密集:大量状态 \(f\) 值相同,优先队列退化为接近 BFS 的层序遍历,扩展量爆炸
- \(K\) 较大且图稠密:如完全图或近完全图中,每个节点都可能被扩展接近 \(K\) 次
- 网格 / 棋盘类图:曼哈顿距离精确时,大量路径拥有相同的 \(f = \text{dist}(s, t)\),搜索几乎等价于枚举所有路径
若题目 \(K\) 较大或图结构特殊,可考虑 Eppstein 算法(\(O(E + V \log V + K)\))或 Yen 算法作为替代。
3.7 A* 小结
| 优点 | 缺点 |
|---|---|
| 找到最优解(若 \(h\) 可采纳) | 需要设计好的 \(h(n)\) |
| 比 BFS/Dijkstra 快很多 | 空间复杂度高(需维护 open/closed 表) |
| 可应用于各种图/网格问题 | 不适合搜索空间超大 → 看 IDA* |
4. IDA* 算法(迭代加深 A*)
4.1 核心思想
A* 的估价函数 + 迭代加深(IDDFS)= IDA*
- A* 的问题:需维护优先队列和 visited 集合,空间复杂度 \(O(b^d)\)
- IDDFS 的优势:DFS 的本质,空间仅 \(O(d)\)
- IDA* = 用估价函数指导迭代加深的 DFS,只在估值不超限时继续搜
4.2 算法流程
1. maxDepth = 0
2. while maxDepth ≤ 答案上界:
a. 从起点开始 DFS,带上当前深度 d 和上限 maxDepth
b. 在 DFS 中,若 f(n) = d + h(n) > maxDepth,立即回溯(剪枝)
c. 若搜到目标 → 返回 maxDepth(即为最优解)
d. 若没搜到 → maxDepth++,重搜
maxDepth=0: ● ← 起点,h=5,f=5 > 0,剪掉
maxDepth=1: ● → ○ ← 即使走一步 f = 1+4 = 5 > 1,剪掉
maxDepth=2: ● → ○ → ○ f=2+3=5>2,剪掉
...
maxDepth=5: ● → ○ → ○ → ○ → ○ → ● 找到!
每次 maxDepth+1,DFS 尝试多深入一层。
估价函数确保我们不会在"没希望"的路径上浪费时间。
4.3 与普通 IDDFS 的对比
普通 IDDFS(无估价): IDA*(有估价):
● ●
├─○ ├─○
│ ├─○ ← 地毯式搜索 │ ├─○ (f=3+3=6 > 5, 剪掉!)
│ │ ├─○ │ └─○ (f=3+2=5 ≤ 5, 继续)
│ │ │ ... │ └─● 找到!
│ │ └─○
│ │ ...
│ └─○
│ ...
└─○
...
盲目深搜,逐层穷举 用 h(n) 判断"此行有戏否",大幅剪枝
4.4 剪枝条件详解
核心剪枝条件:
解读:已经走了 \(d\) 步,至少还需要 \(h(n)\) 步,如果加起来超过了本轮允许的最大深度,这枝一定没戏。
注意:有时需要微调为 \(d + h(n) > \text{maxDepth} + 1\),当最后一步可能同时修正两个错误时(见"骑士精神"例题)。
4.5 经典例题 1:骑士精神(洛谷 P2324)
题目:\(5 \times 5\) 棋盘,12 个白骑士 + 12 个黑骑士 + 1 个空位。每次让一个骑士(走"日"字)跳入空位(等价于空位与骑士交换)。问最少多少步能到达目标布局?超过 15 步输出 -1。
估价函数设计:
// 统计当前棋盘与目标状态不同的格子数(含空位)
int h(int board[6][6]) {
int cnt = 0;
for(int i = 1; i <= 5; i++)
for(int j = 1; j <= 5; j++)
if(board[i][j] != target[i][j])
cnt++;
return cnt;
}
为什么可采纳?
- 每次移动只改变一个骑士的位置,至多减少 1 个错位
- 最后一步可能同时让空位和骑士归位,减少 2 个错位
- 所以 \(h(n)\) 可能比真实剩余步数多估 1 → 剪枝用
h + d > md + 1
8 个移动方向(马的走法):
· · · · ·
· · 1 · 2
· 8 · 3 ·
· · K · · K = 当前位置
· 7 · 4 ·
· · 6 · 5
· · · · ·
(±1, ±2) 和 (±2, ±1) — 共 8 个方向
完整 IDA* 实现:
int target[6][6] = {
{0,0,0,0,0,0},
{0,1,1,1,1,1},
{0,0,1,1,1,1},
{0,0,0,3,1,1}, // 3 代表空位 *
{0,0,0,0,0,1},
{0,0,0,0,0,0}
};
bool dfs(int board[6][6], int depth, int maxDepth, int ex, int ey) {
int h_val = h(board);
if(h_val == 0) return true;
// 剪枝:最后一步可修正2个 → 允许"透支"1步
if(depth + h_val > maxDepth + 1) return false;
int dx[] = {-2,-2,-1,-1, 1, 1, 2, 2};
int dy[] = {-1, 1,-2, 2,-2, 2,-1, 1};
for(int k = 0; k < 8; k++) {
int nx = ex + dx[k], ny = ey + dy[k];
if(nx < 1 || nx > 5 || ny < 1 || ny > 5) continue;
swap(board[ex][ey], board[nx][ny]);
if(dfs(board, depth + 1, maxDepth, nx, ny)) return true;
swap(board[ex][ey], board[nx][ny]); // 回溯!
}
return false;
}
int solve() {
// ...读入棋盘,找到空位 (sx, sy)
for(int maxDepth = 0; maxDepth <= 15; maxDepth++) {
if(dfs(board, 0, maxDepth, sx, sy))
return maxDepth;
}
return -1; // 15步内无解
}
关键细节:
- ⚠️ 回溯:递归前
swap,递归后必须swap回来 - ⚠️ 空位坐标作为参数:每次递归传新的空位坐标,避免每次都遍历棋盘找
- ⚠️ 优化:增量更新估价:可不每次调用
h(),而是通过swap前后比较,\(\Delta h\) 计算 \(O(1)\)
4.6 经典例题 2:埃及分数(UVA 12558)
题目:将一个分数 \(\frac{a}{b}\) 表示为不同单位分数(分子为 1)之和,要求项数最少,同等项数最小分母最大。
例如:\(\frac{19}{45} = \frac{1}{3} + \frac{1}{12} + \frac{1}{180}\)
IDA* 解法:
- 估价函数:当前还剩下分数 \(\frac{a}{b}\),最多还能取 \(k\) 项,每项最小分母为 \(d\)。最大估值:\(k \times \frac{1}{d}\) 能否 \(\ge \frac{a}{b}\)?如果不能,剪枝。
- 迭代加深:从 1 开始增加分母个数
4.7 IDA* 小结
| 项目 | 说明 |
|---|---|
| 时间复杂度 | \(O(b^d)\) 但实际远小于此(剪枝大幅减少) |
| 空间复杂度 | \(\mathbf{O(d)}\)(无需 open/closed 表!) |
| 最优性 | ✅ 保证最优解(\(h\) 可采纳前提) |
| 适用场景 | 状态空间太大、A* 存不下 open 表 |
| 关键 | 设计好的 \(h(n)\),越接近 \(h^*\) 越快 |
5. 三种算法对比总结
| 维度 | Meet-in-the-Middle | A* | IDA* |
|---|---|---|---|
| 核心思想 | 拆分问题,双边搜索 | 估价函数引导 BFS | 估价函数引导 IDDFS |
| 数据结构 | 数组 + 二分/哈希 | 优先队列 + 哈希表 | 系统栈(DFS) |
| 时间复杂度 | \(O(b^{n/2} \log b)\) | \(O(b^d)\) 剪枝 | \(O(b^d)\) 剪枝 |
| 空间复杂度 | \(O(b^{n/2})\) | \(O(b^d)\) ⚠️ 大 | \(\mathbf{O(d)}\) ✅ 小 |
| 最优解保证 | ✅(合并正确) | ✅(\(h\) 可采纳) | ✅(\(h\) 可采纳) |
| 需要框架 | 问题可拆分 | 有估价函数 | 有估价函数 |
| 典型场景 | 子集/组合/双向搜索 | 网格/图/八数码/K短路 | 树深/棋盘/超大空间 |
选择决策图
┌─────────────────┐
│ 需要最优解吗? │
└────────┬────────┘
No ↓ ↓ Yes
DFS/贪心 ┌─────────────────┐
│ 状态空间多大? │
└────────┬────────┘
小/中等 ↓ ↓ 巨大
A* ┌─────────────────┐
│ 问题能拆分吗? │
└────────┬────────┘
Yes ↓ ↓ No
Meet-in- IDA*
the-Middle
共同关键:启发式函数 \(h(n)\)
三者的效率都取决于能否找到一个紧致且可采纳的估价函数:
- A* / IDA*:\(h(n)\) 越接近真实 \(h^*(n)\),搜索越快
- Meet-in-the-Middle:拆分方式影响两半的搜索量(尽量均匀拆分)
💡 设计 \(h(n)\) 的技巧:放松问题的某个约束,使问题变简单,然后求解简化版问题。
例:八数码 → 忽略数字间的阻碍 → 独立计算每个数字的曼哈顿距离
6. 综合练习题单
📌 题单按难度递进排列,标注"可选"的为拔高题,初学者可跳过。
Meet-in-the-Middle
| 题号 | 题目 | 难度 | 备注 |
|---|---|---|---|
| 洛谷 P4799 | [CEOI2015] Ice Hockey World Championship | ⭐⭐ | 折半搜索模板题,必做 |
| 洛谷 P5691 | [NOI2001] 方程的解数 | ⭐⭐ | 折半 + 哈希表 / 双指针 |
| Codeforces 1006F | Xor-Paths | ⭐⭐ | 网格折半,双向 DFS |
| Codeforces 525E | Anya and Cubes | ⭐⭐⭐ | 可选 · 子集 + 阶乘枚举 |
| 洛谷 P3067 | [USACO12OPEN] Balanced Cow Subsets | ⭐⭐⭐ | 可选 · 子集拆分 + map |
A*
| 题号 | 题目 | 难度 | 备注 |
|---|---|---|---|
| 洛谷 P1379 | 八数码难题 | ⭐⭐ | A* 入门必做,练估价函数 |
IDA*
| 题号 | 题目 | 难度 | 备注 |
|---|---|---|---|
| 洛谷 P2324 | [SCOI2005] 骑士精神 | ⭐⭐ | IDA* 模板题,必做 |
| 洛谷 P1294 | 倒水问题 | ⭐⭐ | BFS / IDA* 均可,练剪枝思维 |
| 洛谷 P2534 | [AHOI2012] 铁盘整理 | ⭐⭐⭐ | 可选 · IDA* + 估价设计 |
附录:常用估价函数速查
| 问题类型 | \(h(n)\) 设计 | 原理 |
|---|---|---|
| 网格最短路径 | 曼哈顿距离 \(|x_1-x_2|+|y_1-y_2|\) | 忽略障碍物 |
| 网格8方向 | 切比雪夫距离 \(\max(|x_1-x_2|,|y_1-y_2|)\) | 忽略障碍物 |
| 八数码 | \(\sum\) 每块到目标位置的曼哈顿距离 | 忽略滑块间阻碍 |
| 骑士精神 | 与目标不同的格子数 | 每步至多修正 1 个 |
| 15 数码 | 曼哈顿距离 + 线性冲突 | 常规曼哈顿 × 2 |
| 路网 GPS | 直线距离(欧几里得) | 两点间最短 |
| 汉诺塔 | 不在目标柱上的盘子数 | 至少需要移动它们 |
| 倒水问题 | 当前水量与目标水量的差值 | 忽略容量限制 |
📚 参考资料
- 《算法竞赛进阶指南》— 李煜东
- 《人工智能:一种现代方法》(AIMA) 第 3-4 章
- OI Wiki — 搜索
- 洛谷题单 — 搜索进阶
课件结束。Happy Searching! 🚀

浙公网安备 33010602011771号