迭代加深与IDA*——有限步数内的智能搜索
较难3深度有限,智能无限——迭代加深搜索与IDA*详解
有时候我们面对一个问题,比如玩“八数码”拼图(一个3×3格子,0代表空格,通过移动数字拼成12345678顺序),我们不知道最少需要多少步才能达到目标。如果直接用深度优先搜索(DFS),可能会沿着一条错误路径越走越深,永远找不到解;如果用广度优先搜索(BFS),虽然能保证找到最短路,但需要把所有状态都存下来,内存根本不够用。迭代加深搜索(Iterative Deepening Depth-First Search, IDDFS)就是一种折中方案:它像爬楼梯一样,一层一层地放宽搜索深度,每次都用深度优先搜索来遍历完整棵树,但只搜到当前限定的深度。这样既不需要大量内存,又能找到最浅的解。
更进一步,如果我们在每次搜索时,利用一个“启发函数”估算当前状态离目标还有多远,就可以在深度限制的基础上提前剪枝——这就是IDA*(Iterative Deepening A*)。它把迭代加深的框架和A*的启发式估计结合起来,特别适合在内存紧张的情况下寻找最优解,是许多谜题类问题的标准解法。
1. 为什么要反复从头搜索?——迭代加深的机智之处
很多初学者会问:“每次加深深度都重新从根节点搜一遍,那不是浪费了很多时间吗?” 其实不然。我们来看一棵树:假设每个节点平均有b个分支(分支因子),深度为d的树,节点总数大约是b^d。而迭代加深搜索中,每次搜索的深度依次为1,2,3,...,d。所有深度搜索扩展的总节点数大约是:
- 深度1:b^1
- 深度2:b^2
- ...
- 深度d:b^d
总节点数 = b + b^2 + ... + b^d ≈ (b^(d+1) - b)/(b-1)。当b比较大时,这个总和与单独一次深度d的BFS所扩展的节点数(也大约是b^d)相比,只增加了一个常数因子(大约是b/(b-1))。比如b=4时,总节点数约是4/3倍的b^d,也就是只多了33%。而内存占用呢?DFS只需要O(d)的空间,而BFS需要O(b^d),差异巨大。所以迭代加深用少量的时间换取了巨大的空间节省。
生活例子:假设你在玩一个“猜数字”游戏,数字是三位数,但你不确定是几位。你可以先猜所有1位数(09),没猜中;再猜所有2位数(0099),没猜中;再猜3位数,终于猜中。虽然前两次猜了很多次,但总次数(10+100+1000=1110)相比直接猜三位数(1000)只多了一点点,而你不需要记住所有猜过的数。这不是更省脑子吗?
2. 启发式函数:如何估计到目标还有多远?
在A算法中,我们用一个估价函数 f(n) = g(n) + h(n),其中g(n)是从起点到当前节点已经走过的步数,h(n)是当前节点到目标节点的估计代价(启发值)。在IDA中,我们同样需要这样一个启发函数,用来判断“当前深度 + 启发值”是否已超过限制。
启发函数必须满足两个性质:
- 可采纳性(Admissible):h(n) ≤ 实际最小步数。这样才能保证IDA*找到最优解。
- 一致性(Consistency):对于相邻状态,h(n) ≤ 1 + h(n')。这能避免重复搜索(但并非必需)。
常用的八数码启发函数:
-
错位数(Misplaced Tiles):统计当前棋盘与目标棋盘位置不同的数字个数(空格不计或计为0)。比如下面状态与目标相比,错位数字有3个(6、7、8位置不对),h=3。
当前:1 2 3 目标:1 2 3 4 0 5 4 5 6 7 8 6 7 8 0
-
曼哈顿距离(Manhattan Distance):每个数字当前位置到目标位置的横纵坐标差之和(不包括空格)。这比错位数更精确,通常能带来更强的剪枝效果。
生活比喻:从家到学校,你知道直线距离是500米(启发值),但实际路可能绕远。如果你已经走了300米,那么总步数至少还有500米,如果限定的总步数只有700米,那就别走了,肯定来不及。这个“直线距离”就是可采纳的启发值。
3. IDA*:让搜索更聪明地剪枝
IDA*的搜索流程如下:
- 设定初始深度限制
max_depth = h(初始状态)(也可以从0开始)。 - 从根节点开始深度优先搜索,同时记录当前深度
depth。 - 对每个状态,计算
f = depth + h(state)。 - 如果
f > max_depth,则剪枝(不再继续深入)。 - 如果找到目标,返回成功。
- 如果当前深度内搜索完所有分支都没找到解,则增加
max_depth(通常增加到本次搜索中出现的最小超过限制的f值,这样比每次+1更高效),然后重新搜索。
为什么能剪枝? 因为h(state)是最优步数的下界,所以depth + h(state)是从起点经过当前状态到达目标所需的最少总步数。如果这个最少步数已经超过了我们允许的最大步数,那么无论怎么走都不可能达到目标,自然可以剪掉。
代码解释(以八数码、错位数为启发式):
bool dfs(int x, int y, int depth, int max_depth, int prev_dir) {
int h = heuristic(); // 计算当前启发值
if (h == 0) return true; // 已经到达目标
if (depth + h > max_depth) return false; // 剪枝:不可能在剩余步数内到达
for (int dir = 0; dir < 4; dir++) {
int nx = x + dx[dir], ny = y + dy[dir];
if (nx < 0 || nx >= 3 || ny < 0 || ny >= 3) continue;
if (dir + prev_dir == 3) continue; // 避免来回走(方向相反)
swap(board[x][y], board[nx][ny]); // 移动空格
if (dfs(nx, ny, depth + 1, max_depth, dir)) return true;
swap(board[x][y], board[nx][ny]); // 回溯
}
return false;
}
注意:dir + prev_dir == 3 这个技巧是基于我们对上下左右方向的编码:假设dx[0]=-1(上), dx[1]=1(下), dy[2]=-1(左), dy[3]=1(右),那么上(0)和下(1)互为相反,其方向编号和为1?等等,这里代码中定义的是dx[] = {-1,1,0,0}, dy[] = {0,0,-1,1},方向0是上,1是下,2是左,3是右。那么上和下是方向0和1,和为1;左和右是方向2和3,和为5。所以 dir + prev_dir == 3 并不是通用的相反方向检测。实际上,常见的做法是用 prev_dir + dir == 3 当方向定义为上0下1左2右3时,上+下=1,左+右=5,不过这里用3可能是个错误?我们需要修正:通常为了判断相反方向,可以预先定义反方向数组 opp[4] = {1,0,3,2},然后用 if (dir == opp[prev_dir]) continue;。但原代码中用 dir + prev_dir == 3,恰好对于 (0,3) 和 (1,2) 组合和为3,但这并不是所有相反方向。所以这是一个潜在的小错误,我们在后面“常见错误”中会指出。为了保持原样,我们可以保留并说明。
4. 完整代码示例:使用曼哈顿距离的八数码IDA*
下面提供一个使用曼哈顿距离作为启发函数的完整可运行代码(C++)。曼哈顿距离更精确,能剪掉更多分支,因此求解速度更快。代码中每行变量定义都加了中文注释,方便理解。
#include <iostream>
#include <string>
#include <algorithm>
#include <cmath>
using namespace std;
// 目标状态
const int goal[3][3] = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 0}
};
int board[3][3]; // 当前棋盘状态
int dx[4] = {-1, 1, 0, 0}; // 上下左右移动的行变化
int dy[4] = {0, 0, -1, 1}; // 上下左右移动的列变化
int opposite[4] = {1, 0, 3, 2}; // 反方向数组:0-上,1-下,2-左,3-右
// 曼哈顿距离启发函数
int heuristic() {
int total = 0;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
int val = board[i][j];
if (val == 0) continue; // 空格不计算
// 目标位置:值val应该在第 (val-1)/3 行,第 (val-1)%3 列
int target_row = (val - 1) / 3;
int target_col = (val - 1) % 3;
total += abs(i - target_row) + abs(j - target_col);
}
}
return total;
}
// 深度受限的DFS,返回是否找到解
bool dfs(int x, int y, int depth, int max_depth, int prev_dir) {
int h = heuristic();
if (h == 0) return true; // 达到目标状态
if (depth + h > max_depth) return false; // 剪枝:即使按最优走也会超过限制
for (int dir = 0; dir < 4; dir++) {
int nx = x + dx[dir];
int ny = y + dy[dir];
if (nx < 0 || nx >= 3 || ny < 0 || ny >= 3) continue;
if (dir == opposite[prev_dir]) continue; // 避免来回走(和上一步方向相反)
swap(board[x][y], board[nx][ny]); // 移动空格
if (dfs(nx, ny, depth + 1, max_depth, dir)) {
return true;
}
swap(board[x][y], board[nx][ny]); // 回溯
}
return false;
}
int main() {
// 初始状态(可以修改测试)
int start[3][3] = {
{1, 2, 3},
{4, 0, 5},
{7, 8, 6}
};
int sx = 1, sy = 1; // 空格初始位置(行0~2,列0~2)
// 复制到全局board
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
board[i][j] = start[i][j];
int max_depth = heuristic(); // 初始深度限制设为启发值(至少需要这么多步)
while (!dfs(sx, sy, 0, max_depth, -1)) {
max_depth++;
if (max_depth > 30) {
cout << "无解(超过30步)" << endl;
break;
}
}
if (max_depth <= 30)
cout << "找到解,最少步数 = " << max_depth << endl;
return 0;
}
运行说明:将代码复制到C++编译器中,运行即可。初始状态 {{1,2,3},{4,0,5},{7,8,6}} 是一个经典可解例子,最少需要两步(把6和5交换,然后0向下移动)。程序会输出最少步数。
5. 新手容易犯的错误
-
启发函数不可采纳:比如使用错位数时,把空格也算作一个错误位置,会导致h可能大于实际步数,从而可能剪掉最优解。正确做法是:空格不计入错位,或者使用曼哈顿距离时忽略空格。
-
方向回退处理错误:原来的代码中使用了
if (dir + prev_dir == 3)来判断是否来回走,这并不正确。上面修正版使用了opposite数组。如果你用原来的代码,可能在某些情况下错误地剪掉了一些合法路径,或者没能剪掉来回走的分支,导致搜索变慢甚至死循环。正确做法是定义一个反方向数组。 -
深度限制更新策略不当:有些初学者每次只增加1(
max_depth++),对于较难的问题,这样会反复搜索很多次。更好的做法是:记录本次搜索中所有被剪枝的depth + h的最小值(超过当前限制的最小f值),然后将max_depth更新为该值。这样能快速跳过大段无效深度。 -
忘记回溯:在DFS中,移动空格后必须恢复原状,否则后续分支的状态会错乱。这是一个经典错误,记得每一个
swap都要对应一个反向swap。 -
初始深度限制设为0:如果从0开始,第一次搜索只允许0步,显然不可能(除非初始状态就是目标),然后一步步增加到
heuristic(),浪费了时间。更高效的做法是将max_depth初始设为启发函数值。
6. 总结与相关指引
迭代加深搜索(IDDFS)是解决“不知最优解深度”问题的利器,而IDA*通过启发式剪枝大幅提升了效率。它们广泛应用于:
- 拼图类游戏(八数码、15数码、华容道)
- 博弈搜索(如五子棋、围棋的有限深度决策)
- 机器人路径规划(在有限步数内寻找最优路径)
如果你对A算法感兴趣,可以进一步学习**A与优先队列来实现最优搜索(但会占用更多内存)。对于更复杂的搜索问题,还可以结合双向BFS**(从起点和终点同时搜索),或者使用Zobrist哈希对状态进行记忆化,避免重复访问同一状态。此外,启发函数的可采纳性证明也是竞赛中的常见考点。
一句话总结:IDA*是一种空间效率极高、能找到最优解的搜索方法,适合状态空间大但深度有限的问题,是每个CSP-S选手必须掌握的算法策略之一。
例题精讲
迭代加深搜索(IDS)的空间复杂度是多少?
在IDA*算法中,如果启发式函数h(n)是可采纳的(admissible),那么IDA*一定能够找到最优解。
以下是IDA*算法递归搜索函数的部分代码,请在横线处填入正确的语句:
int dfs(int g, int limit) {
int f = ___;
if (f > limit) return f;
// ...
}