第 14、15 章的 BFS 有个躲不掉的毛病:它必须把所有到过的状态记在一张表里。
八数码只有 36 万个状态,撑得住。可一旦状态空间上亿(十五数码、魔方、 或者任何一道「状态是一串数字」的题),那张表就装不下了。
这一章的两个工具分别对付两个问题:
| 工具 | 解决什么 | 代价 |
|---|---|---|
| 迭代加深(IDDFS) | 内存 —— 只要一个递归栈 | 会重复搜前面的层 |
| 双向 BFS | 时间 —— 把搜索深度砍一半 | 必须知道终点是什么 |
题目继续用第 15 章那个八数码,这样能直接对比。
1回顾:单向 BFS 的代价
// 八数码 —— 单向 BFS(第 15 章那份,搬过来当对照组)//// 输入:一行 9 个数字(0 是空格)// 输出:最少几步能拼成 123804765;无解输出 -1//// 它是对的、也不慢,但有一个躲不掉的毛病:**内存**。// BFS 必须把「所有到过的状态」记在一张表里,否则会绕圈子。// 八数码只有 36 万个状态还撑得住,可一旦状态空间大到上亿(十五数码、魔方),// 这张表就装不下了。//// 这一章的两个工具,就是分别对付「内存」和「时间」这两个问题的:// 迭代加深 —— 几乎不用内存(只要一个递归栈)// 双向 BFS —— 把搜索树的深度砍一半
#include <bits/stdc++.h>using namespace std;
const string GOAL = "123804765";
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string s; if (!(cin >> s)) return 0; if (s.size() != 9) { cout << -1 << "\n"; return 0; } if (s == GOAL) { cout << 0 << "\n"; return 0; }
unordered_map<string, int> dist; dist.reserve(1 << 16); queue<string> q; dist[s] = 0; q.push(s);
const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1}; while (!q.empty()) { string cur = q.front(); q.pop(); int d = dist[cur]; int p = (int)cur.find('0'), i = p / 3, j = p % 3;
for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue; string nxt = cur; swap(nxt[p], nxt[x * 3 + y]); if (dist.count(nxt)) continue; dist[nxt] = d + 1; if (nxt == GOAL) { cout << d + 1 << "\n"; return 0; } q.push(nxt); } } cout << -1 << "\n"; return 0;}点「运行 ▶」看结果
它没错也不慢,问题在那张 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 时,前面全部重搜的总量还不到最后一层的一半。
指数增长下,最后一层就占了绝大多数 —— 前面重来几遍根本无所谓。 这条性质对所有指数级搜索都成立,值得记死。
// 八数码 —— 迭代加深 DFS(IDDFS)//// 输入输出和 bfs.cpp 完全一样。//// ============ 迭代加深是什么 ============//// 「限定深度的 DFS」:只往下搜到第 limit 层,到了就掉头。// limit 从 0 开始,一层层往上加:0 搜不到就试 1,1 搜不到就试 2……// **第一次搜到目标时,limit 就是最短步数。**//// 它同时拿到了 DFS 和 BFS 的好处:// 像 DFS 一样省内存(只要一个递归栈,几十个字节)// 像 BFS 一样保证最短(因为是按深度一层层放开的)//// ============ 「重复搜」不是很亏吗 ============//// 每加一层,前面所有层都要重搜一遍 —— 听起来浪费得离谱。但算一笔账://// 设每个状态平均能扩展出 b 个新状态(八数码 b ≈ 2~3)。// 深度 d 的那一层有 b^d 个节点// 前面所有层加起来是 1 + b + b² + … + b^(d-1) ≈ b^d / (b-1)//// 也就是说,**重复搜的总量还不到最后一层的一半**(b = 3 时约 1/2)。// 指数增长下,最后一层就占了绝大多数 —— 前面重来几遍根本无所谓。//// **这是所有指数级搜索共有的性质,值得记住。**//// ============ 两个细节 ============//// 1. 不能走回头路(`(t ^ 1) == prevMove` 那一句):// 刚把空格往右挪,下一步又往左挪回来,等于原地打转。// 0/1 是上/下,2/3 是左/右,所以异或 1 正好是反方向。//// 2. 深度上限 24:八数码的最优解最多 31 步,但配套的生成器只造浅局面,// 24 层足够了。加这个上限是为了让无解数据不至于跑到天荒地老。
#include <bits/stdc++.h>using namespace std;
const string GOAL = "123804765";string cur;int limitDepth;const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1};
bool dfs(int depth, int prevMove) { if (cur == GOAL) return true; if (depth >= limitDepth) return false; // 到达深度上限,掉头
int p = (int)cur.find('0'), i = p / 3, j = p % 3; for (int t = 0; t < 4; t++) { if (prevMove >= 0 && (t ^ 1) == prevMove) continue; // 不走回头路 int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue;
swap(cur[p], cur[x * 3 + y]); if (dfs(depth + 1, t)) return true; swap(cur[p], cur[x * 3 + y]); // 撤销 } return false;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> cur)) return 0; if (cur.size() != 9) { cout << -1 << "\n"; return 0; }
for (limitDepth = 0; limitDepth <= 24; limitDepth++) if (dfs(0, -1)) { cout << limitDepth << "\n"; return 0; }
cout << -1 << "\n"; return 0;}点「运行 ▶」看结果
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*(迭代加深 + 估价函数)//// 输入输出和前两份完全一样。//// ============ 只比 iddfs.cpp 多了一个函数 ============//// 迭代加深有个明显的浪费:明明离目标还差十万八千里,它还在傻乎乎地往下搜,// 直到撞上深度上限才掉头。//// 如果能**估计**出「从现在这个局面出发,至少还要走多少步」,// 就可以提前掐掉://// 已经走了 g 步 + 至少还要 h 步 > 深度上限 → 这条路不可能在本轮走通,直接返回//// 这就是**估价函数**(第 16 章第 9 步提过的那个东西),也是 A* 家族的核心。//// ============ 这里用的估价:曼哈顿距离 ============//// 每个数字块「离它该在的位置」还差几格(横向差 + 纵向差),全部加起来。//// 为什么它是「至少还要走的步数」?// 每走一步,只有一个数字块移动一格,它的曼哈顿距离最多减 1。// 所以要把总距离降到 0,**至少**需要「总距离」那么多步。//// ⚠ 估价函数必须**不高估**(专业说法叫「可接纳」)。// 一旦估多了,就可能把真正的最优解剪掉 —— 那就不是剪枝,是剪错了。// (回想第 16 章那句话:剪枝不该改变答案。)//// 效果:局面越深,IDA* 越占便宜。跑一下 count.cpp 看具体数字。
#include <bits/stdc++.h>using namespace std;
const string GOAL = "123804765";string cur;int limitDepth;int goalPos[256]; // goalPos['5'] = '5' 在目标里的下标const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1};
/** 曼哈顿距离之和:至少还要走这么多步 */int h() { int s = 0; for (int p = 0; p < 9; p++) { char c = cur[p]; if (c == '0') continue; // 空格不算 int q = goalPos[(int)c]; s += abs(p / 3 - q / 3) + abs(p % 3 - q % 3); } return s;}
bool dfs(int g, int prevMove) { int est = h(); if (est == 0) return true; // 已经到目标 if (g + est > limitDepth) return false; // ★ 估价剪枝:本轮不可能走通
int p = (int)cur.find('0'), i = p / 3, j = p % 3; for (int t = 0; t < 4; t++) { if (prevMove >= 0 && (t ^ 1) == prevMove) continue; int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue;
swap(cur[p], cur[x * 3 + y]); if (dfs(g + 1, t)) return true; swap(cur[p], cur[x * 3 + y]); } return false;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> cur)) return 0; if (cur.size() != 9) { cout << -1 << "\n"; return 0; }
for (int p = 0; p < 9; p++) goalPos[(int)GOAL[p]] = p;
// 起手的深度上限直接从 h() 开始 —— 比它小的层数根本不可能有解 for (limitDepth = h(); limitDepth <= 40; limitDepth++) if (dfs(0, -1)) { cout << limitDepth << "\n"; return 0; }
cout << -1 << "\n"; return 0;}点「运行 ▶」看结果
4★ 双向 BFS:两头一起搜
单向 BFS 要铺开一棵深度 d 的树,节点数约 b^d。
如果从起点和终点同时往中间搜,两边各铺 d/2 层就会撞上:
单向: b^d
双向: 2 · b^(d/2)b = 3、d = 20 时:
单向:3²⁰ ≈ 35 亿
双向:2 × 3¹⁰ ≈ 12 万快了三万倍。这不是常数优化 —— 它把指数砍了一半。
三个实现要点:
- 每次扩展节点少的那一边,让两棵树长得均衡。
- 相遇判定:新扩展出的状态如果在对面那张表里,答案 = 这边步数 + 对面步数。
- 必须整层整层地扩,否则可能先撞上一条不是最短的路径。
「求最少步数到某个确定状态」→ 可以用。
「求最少步数到任意一个满足某条件的状态」→ 不行,你没法从终点倒着搜。
这个限制很实在。拿到题先确认「终点是不是唯一且已知」,再决定用不用它。
// 八数码 —— 双向 BFS//// 输入输出和前几份完全一样。//// ============ 关键的一步:两头一起搜 ============//// 单向 BFS 要铺开一棵深度为 d 的树,节点数约 b^d。//// 但如果**从起点和终点同时往中间搜**,两边各铺开 d/2 层就会撞上://// b^(d/2) + b^(d/2) = 2·b^(d/2)//// 举个具体的:b = 3、d = 20 时// 单向:3²⁰ ≈ 35 亿// 双向:2 × 3¹⁰ ≈ 12 万// **快了三万倍。** 这不是常数优化,是把指数砍了一半。//// ============ 实现要点 ============//// 1. **每次扩展节点少的那一边。**这样两棵树长得均衡,效果最好。// 2. **相遇判定**:新扩展出的状态如果在对面那张表里出现过,// 答案就是 `这边的步数 + 对面的步数`。// 3. **必须整层整层地扩**(一次把队列里当前这一层全处理完),// 否则可能先撞上一条不是最短的路径。//// ⚠ 双向 BFS 的前提:**终点必须是明确知道的**。// 「求最少步数到某个确定状态」可以用;// 「求最少步数到任意一个满足某条件的状态」就不行 —— 你没法从终点倒着搜。
#include <bits/stdc++.h>using namespace std;
const string GOAL = "123804765";const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1};
/** 把 q 这一层整层扩展一次;如果撞上了 other,返回总步数,否则返回 -1 */int expandLayer(queue<string>& q, unordered_map<string, int>& mine, unordered_map<string, int>& other) { int sz = (int)q.size(); for (int c = 0; c < sz; c++) { string cur = q.front(); q.pop(); int d = mine[cur]; int p = (int)cur.find('0'), i = p / 3, j = p % 3;
for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue; string nxt = cur; swap(nxt[p], nxt[x * 3 + y]);
if (mine.count(nxt)) continue; // 自己这边走过了 auto it = other.find(nxt); if (it != other.end()) return d + 1 + it->second; // ★ 撞上了
mine[nxt] = d + 1; q.push(nxt); } } return -1;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string s; if (!(cin >> s)) return 0; if (s.size() != 9) { cout << -1 << "\n"; return 0; } if (s == GOAL) { cout << 0 << "\n"; return 0; }
unordered_map<string, int> da, db; // 分别记「离起点多远」「离终点多远」 queue<string> qa, qb; da[s] = 0; qa.push(s); db[GOAL] = 0; qb.push(GOAL);
while (!qa.empty() && !qb.empty()) { // 总是先扩展小的那一边 int got = (qa.size() <= qb.size()) ? expandLayer(qa, da, db) : expandLayer(qb, db, da); if (got >= 0) { cout << got << "\n"; return 0; } }
cout << -1 << "\n"; return 0;}点「运行 ▶」看结果
5★ 四种方法,同一个局面,工作量并排数出来
// 四种搜索方式,同一个局面,工作量并排数出来//// 输入:一行 9 个数字// 输出:单向 BFS / 双向 BFS / 迭代加深 / IDA* 各自访问了多少节点、用了多久、// 以及各自要记住多少个状态(内存)//// 这张表是这一章的核心。要看的有两件事://// 1. **双向 BFS 的节点数** 大约是单向的平方根级别 —— 深度砍一半的直接后果。// 2. **迭代加深和 IDA* 记住的状态数是 0** —— 它们只有一个递归栈。// 时间上迭代加深可能比 BFS 还慢(毕竟要重复搜),// 但**它能解决那些 BFS 内存爆掉的题**,这是本质区别。//// 换几个不同深度的局面跑一跑(浅的、深的),四种方法的强弱会随深度变化 ——// 这也是要体会的:**没有哪个方法永远最好。**
#include <bits/stdc++.h>using namespace std;
const string GOAL = "123804765";const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1};
long long nodes;
/* ------------------------------ 单向 BFS ------------------------------ */int runBfs(const string& s, long long& visited) { if (s == GOAL) { visited = 1; return 0; } unordered_map<string, int> dist; queue<string> q; dist[s] = 0; q.push(s); nodes = 0;
while (!q.empty()) { string cur = q.front(); q.pop(); nodes++; int d = dist[cur], p = (int)cur.find('0'), i = p / 3, j = p % 3; for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue; string nxt = cur; swap(nxt[p], nxt[x * 3 + y]); if (dist.count(nxt)) continue; dist[nxt] = d + 1; if (nxt == GOAL) { visited = (long long)dist.size(); return d + 1; } q.push(nxt); } } visited = (long long)dist.size(); return -1;}
/* ------------------------------ 双向 BFS ------------------------------ */int expandLayer(queue<string>& q, unordered_map<string, int>& mine, unordered_map<string, int>& other) { int sz = (int)q.size(); for (int c = 0; c < sz; c++) { string cur = q.front(); q.pop(); nodes++; int d = mine[cur], p = (int)cur.find('0'), i = p / 3, j = p % 3; for (int t = 0; t < 4; t++) { int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue; string nxt = cur; swap(nxt[p], nxt[x * 3 + y]); if (mine.count(nxt)) continue; auto it = other.find(nxt); if (it != other.end()) return d + 1 + it->second; mine[nxt] = d + 1; q.push(nxt); } } return -1;}
int runBiBfs(const string& s, long long& visited) { if (s == GOAL) { visited = 2; return 0; } unordered_map<string, int> da, db; queue<string> qa, qb; da[s] = 0; qa.push(s); db[GOAL] = 0; qb.push(GOAL); nodes = 0;
while (!qa.empty() && !qb.empty()) { int got = (qa.size() <= qb.size()) ? expandLayer(qa, da, db) : expandLayer(qb, db, da); if (got >= 0) { visited = (long long)(da.size() + db.size()); return got; } } visited = (long long)(da.size() + db.size()); return -1;}
/* ---------------------------- 迭代加深 / IDA* ---------------------------- */string cur;int limitDepth;int goalPos[256];bool useH;
int hVal() { int s = 0; for (int p = 0; p < 9; p++) { char c = cur[p]; if (c == '0') continue; int q = goalPos[(int)c]; s += abs(p / 3 - q / 3) + abs(p % 3 - q % 3); } return s;}
bool dfs(int g, int prevMove) { nodes++; if (cur == GOAL) return true; if (useH) { if (g + hVal() > limitDepth) return false; } else if (g >= limitDepth) return false;
int p = (int)cur.find('0'), i = p / 3, j = p % 3; for (int t = 0; t < 4; t++) { if (prevMove >= 0 && (t ^ 1) == prevMove) continue; int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue; swap(cur[p], cur[x * 3 + y]); if (dfs(g + 1, t)) return true; swap(cur[p], cur[x * 3 + y]); } return false;}
int runDeepening(const string& s, bool withH) { cur = s; useH = withH; nodes = 0; int start = withH ? hVal() : 0; for (limitDepth = start; limitDepth <= 30; limitDepth++) { cur = s; if (dfs(0, -1)) return limitDepth; } return -1;}
int main() { string s; if (!(cin >> s)) return 0; if (s.size() != 9) { cout << "请输入 9 个数字(0 表示空格)\n"; return 0; } for (int p = 0; p < 9; p++) goalPos[(int)GOAL[p]] = p;
cout << "局面 " << s << " 目标 " << GOAL << "\n\n"; cout << "方法 答案 展开的节点数 记住的状态数 耗时(ms)\n"; cout << "-------------- ------ --------------- --------------- ------------\n";
auto row = [&](const char* name, int ans, long long nd, long long mem, double ms) { cout << name << setw(8) << ans << setw(17) << nd << setw(17) << mem << setw(14) << fixed << setprecision(2) << ms << "\n"; };
{ long long mem = 0; auto t0 = chrono::steady_clock::now(); int ans = runBfs(s, mem); auto t1 = chrono::steady_clock::now(); row("单向 BFS ", ans, nodes, mem, chrono::duration<double, milli>(t1 - t0).count()); } { long long mem = 0; auto t0 = chrono::steady_clock::now(); int ans = runBiBfs(s, mem); auto t1 = chrono::steady_clock::now(); row("双向 BFS ", ans, nodes, mem, chrono::duration<double, milli>(t1 - t0).count()); } { auto t0 = chrono::steady_clock::now(); int ans = runDeepening(s, false); auto t1 = chrono::steady_clock::now(); row("迭代加深 ", ans, nodes, 0, chrono::duration<double, milli>(t1 - t0).count()); } { auto t0 = chrono::steady_clock::now(); int ans = runDeepening(s, true); auto t1 = chrono::steady_clock::now(); row("IDA*(曼哈顿)", ans, nodes, 0, chrono::duration<double, milli>(t1 - t0).count()); }
cout << "\n「记住的状态数」那一列就是内存开销的来源。\n"; cout << "迭代加深和 IDA* 是 0 —— 它们只有一个递归栈,这是它们最大的价值。\n"; return 0;}点「运行 ▶」看结果
本机实测(局面 845201376,最优 18 步):
| 方法 | 答案 | 展开的节点数 | 记住的状态数 | 耗时 |
|---|---|---|---|---|
| 单向 BFS | 18 | 19 437 | 29 446 | 14.53 毫秒 |
| 双向 BFS | 18 | 557 | 901 | 0.38 毫秒 |
| 迭代加深 | 18 | 190 706 | 0 | 3.48 毫秒 |
| IDA*(曼哈顿) | 18 | 149 | 0 | 0.01 毫秒 |
1. 「记住的状态数」那一列就是内存。 迭代加深和 IDA* 是 0 —— 它们只有一个递归栈。 这是它们存在的全部理由:能解决那些 BFS 内存爆掉的题。
2. 迭代加深展开了 19 万个节点,却比只展开 1.9 万个的 BFS 还快。 因为它的每个节点极其便宜(就是几次交换), 而 BFS 每个节点都要往哈希表里插一个字符串。 节点数不等于时间 —— 还要看每个节点有多贵。
3. IDA* 只用 149 个节点。 一个好的估价函数,比任何常数优化都值钱。
因为八数码规模太小 —— 四种方法都在几十毫秒内跑完, 进程启动的时间比算法本身还长,测出来的数字全是噪音。
所以这一章改用 count.cpp 在程序内部计时和计数。
这本身也是个值得学的做法:当被测对象比测量误差还小的时候,
就要把测量搬到程序内部去。
6单步看「一个大圆 vs 两个小圆」
八数码画不出来(状态是九个数字的排列),所以这个动画换成迷宫 —— 道理一模一样。
换一张迷宫(1 = 能走,0 = 墙)
- 单向:从起点铺开一个圆,一直铺到终点。访问的格子 ≈ 半径
d的圆面积。 - 双向:起点和终点各铺一个小圆,在中间撞上。访问的格子 ≈ 两个半径
d/2的圆。
上面那行状态栏实时显示两种模式各自访问了多少格。 迷宫小的时候差别不大,但格子数是按「半径的平方」涨的 —— 图一大,这个差距就出来了。(在状态图上是按「指数」涨,差距更夸张。)
把迷宫里的墙拆掉几堵(改成全 1),再对比一次 —— 空旷的图上双向的优势最明显。
7★ 对拍:四种方法互相验证
把「IDA*」那一栏换成你自己写的(迭代加深或双向 BFS 都行),再点开始。
标准答案用的是第 15 章那份单向 BFS —— 和你要验的东西机制完全不同, 这才是有意义的交叉验证。
// 八数码 —— IDA*(迭代加深 + 估价函数)//// 输入输出和前两份完全一样。//// ============ 只比 iddfs.cpp 多了一个函数 ============//// 迭代加深有个明显的浪费:明明离目标还差十万八千里,它还在傻乎乎地往下搜,// 直到撞上深度上限才掉头。//// 如果能**估计**出「从现在这个局面出发,至少还要走多少步」,// 就可以提前掐掉://// 已经走了 g 步 + 至少还要 h 步 > 深度上限 → 这条路不可能在本轮走通,直接返回//// 这就是**估价函数**(第 16 章第 9 步提过的那个东西),也是 A* 家族的核心。//// ============ 这里用的估价:曼哈顿距离 ============//// 每个数字块「离它该在的位置」还差几格(横向差 + 纵向差),全部加起来。//// 为什么它是「至少还要走的步数」?// 每走一步,只有一个数字块移动一格,它的曼哈顿距离最多减 1。// 所以要把总距离降到 0,**至少**需要「总距离」那么多步。//// ⚠ 估价函数必须**不高估**(专业说法叫「可接纳」)。// 一旦估多了,就可能把真正的最优解剪掉 —— 那就不是剪枝,是剪错了。// (回想第 16 章那句话:剪枝不该改变答案。)//// 效果:局面越深,IDA* 越占便宜。跑一下 count.cpp 看具体数字。
#include <bits/stdc++.h>using namespace std;
const string GOAL = "123804765";string cur;int limitDepth;int goalPos[256]; // goalPos['5'] = '5' 在目标里的下标const int di[4] = {-1, 1, 0, 0}, dj[4] = {0, 0, -1, 1};
/** 曼哈顿距离之和:至少还要走这么多步 */int h() { int s = 0; for (int p = 0; p < 9; p++) { char c = cur[p]; if (c == '0') continue; // 空格不算 int q = goalPos[(int)c]; s += abs(p / 3 - q / 3) + abs(p % 3 - q % 3); } return s;}
bool dfs(int g, int prevMove) { int est = h(); if (est == 0) return true; // 已经到目标 if (g + est > limitDepth) return false; // ★ 估价剪枝:本轮不可能走通
int p = (int)cur.find('0'), i = p / 3, j = p % 3; for (int t = 0; t < 4; t++) { if (prevMove >= 0 && (t ^ 1) == prevMove) continue; int x = i + di[t], y = j + dj[t]; if (x < 0 || x >= 3 || y < 0 || y >= 3) continue;
swap(cur[p], cur[x * 3 + y]); if (dfs(g + 1, t)) return true; swap(cur[p], cur[x * 3 + y]); } return false;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> cur)) return 0; if (cur.size() != 9) { cout << -1 << "\n"; return 0; }
for (int p = 0; p < 9; p++) goalPos[(int)GOAL[p]] = p;
// 起手的深度上限直接从 h() 开始 —— 比它小的层数根本不可能有解 for (limitDepth = h(); limitDepth <= 40; limitDepth++) if (dfs(0, -1)) { cout << limitDepth << "\n"; return 0; }
cout << -1 << "\n"; return 0;}值得故意写错的:
- 估价函数把空格也算进曼哈顿距离 → 高估了,会剪掉最优解,答案偏大
if (g + h() > limit)写成>=→ 把恰好等于上限的解也剪了,答案偏大- 迭代加深忘了「不走回头路」 → 答案还是对的,但慢好几倍
- 双向 BFS 不是整层扩,而是一次弹一个节点 → 可能得到非最短的答案
- 双向 BFS 相遇时只算一边的步数 → 答案差一半
8阶段 3 小结:搜索的工具箱
| 情况 | 用什么 |
|---|---|
| 求「能不能到 / 有多少种方案 / 所有方案」 | DFS(第 13 章) |
| 求「最少多少步」,状态数不大 | BFS(第 14、15 章) |
| 求「最少多少步」,状态数大到内存装不下 | 迭代加深(本章) |
| 同上,而且能想出一个不高估的估价函数 | IDA*(本章) |
| 求「最少多少步」,起点终点都明确、深度大 | 双向 BFS(本章) |
| 状态会重复出现,且只关心结果不关心路径 | 记忆化搜索(第 17 章) |
| 搜索树太大 | 剪枝(第 16 章)—— 这条和上面所有情况都能叠加 |
这七行就是阶段 3 的全部。 剩下的都是往里面填「什么是状态」「什么是一步」。
9自测
- 洛谷 P1379 八数码难题解析 → —— 本章原题。用双向 BFS 或 IDA* 各交一遍,对比一下耗时
- 洛谷 P2324 骑士精神解析 → —— SCOI2005。IDA* 的经典题,估价函数是「有几个棋子不在位」
- 洛谷 P1032 字串变换解析 → —— NOIP2002。双向 BFS 的经典题,起点终点都明确 —— 正好符合前提
- 洛谷 P1516 青蛙的约会解析 → —— 换换脑子:这题看着像搜索,其实是数学(扩展欧几里得)。练「先判断该不该搜」
六章走完,你手上有:DFS、BFS、多源与状态图、剪枝、记忆化、迭代加深与双向搜索。
这是信息学竞赛里最能靠「想清楚」拿分的一块。 遇到不会做的题,写个搜索加几个剪枝,往往就能拿到一半以上的分。
接下来是阶段 4(贪心)—— 那一块最难的从来不是写代码,是证明它为什么对。 而第 9 章的二分答案里,你其实已经证过一次贪心了(「多装绝不吃亏」)。