AIGC标识 搜索(meet in middle,A*,IDA*)

搜索进阶:Meet-in-the-Middle · A* · IDA*


目录

  1. 前置知识回顾
  2. Meet-in-the-Middle(折半搜索)
  3. A* 算法
  4. IDA* 算法(迭代加深 A*)
  5. 三种算法对比总结
  6. 综合练习题单

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)\) 会爆)

思路

  1. 将物品分成两半:前 \(n/2\) 个和后 \(n/2\)
  2. 每半分别爆搜所有 \(2^{n/2}\) 种子集,记录 (总重量, 总价值)
  3. 对每一半的结果按重量排序,去除"重量大但价值小"的无效组合
  4. 枚举前半的每个选择,在另一半中二分查找最大的合法重量
// 核心伪代码
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 适用条件

  1. ✅ 搜索空间可以对半拆分
  2. ✅ 两半的结果可以高效合并(二分/哈希/双指针)
  3. ✅ 搜索层数不太大\(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)—— 最重要!

\[\forall n,\quad h(n) \le h^*(n) \]

其中 \(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)\)

\[h(n) \le c(n,m) + h(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) > \text{maxDepth} \quad \Longrightarrow \quad \text{剪枝} \]

解读:已经走了 \(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 直线距离(欧几里得) 两点间最短
汉诺塔 不在目标柱上的盘子数 至少需要移动它们
倒水问题 当前水量与目标水量的差值 忽略容量限制

📚 参考资料


课件结束。Happy Searching! 🚀

posted @ 2026-07-19 15:55  lxyt-415x  阅读(21)  评论(2)    收藏  举报