推箱子(Sokoban)是 1981 年由今林宏行设计的经典解谜游戏:玩家在仓库里推动箱子,把所有箱子推到目标点上。它的规则极简——只能推、不能拉,一次只能推一个箱子——但求解难度是 PSPACE-complete 的。这正是它成为搜索算法绝佳试验场的原因。
本文的重点不是界面绘制,而是两件真正决定 AI 强弱的事情:如何给搜索一个可靠的启发值,以及如何用最小权完美匹配把”箱子到目标点”的分配问题求到最优。我们会用匈牙利算法(Kuhn-Munkres)在每一层 A* 节点上求解一次指派,把匹配总代价作为启发值 h。
一、为什么”最近目标点”是错的启发式
初学者写推箱子 AI 最常见的写法是:对每个箱子,找离它最近的目标点,把曼哈顿距离相加。
这个做法会系统性地低估困难。假设两个箱子并排,两个目标点也并排,但顺序反了:每个箱子到”最近目标”的距离都是 1,加起来 h=2。可实际上两个箱子必须交叉绕行,真实代价远大于 2。更糟的是,”各自贪心”可能算出两个箱子抢占同一个目标点的方案,这个下界在当前状态下根本不可能实现。
推箱子的本质是一个指派问题(Assignment Problem):n 个箱子必须与 n 个目标点形成一一对应,我们要找的是总距离最小的对应方式。这是二分图上求最小权完美匹配的经典场景,匈牙利算法可以在 O(n³) 内给出全局最优解。
目标点 代价矩阵(曼哈顿距离)
G1 G2 G1 G2
箱 B1 [ 2 ] [ 8 ] B1 [ 2 8 ]
箱 B2 [ 7 ] [ 1 ] B2 [ 7 1 ]
贪心:B1->G1(2), B2->G2(1) 合计 3 ← 恰好最优
反例:若矩阵是 [[1,2],[1,100]],贪心会让 B1 抢 G1,B2 被迫走 100,
最优解是 B1->G2(2)、B2->G1(1),合计 3。
只要箱子和目标点的数量相等、代价矩阵非负,最大权匹配与最小权匹配可以互相转换(把代价取负、或把 max 换成 min),匈牙利算法都能处理。
启发式的可采纳性(Admissibility)
把匹配总代价当 h,需要证明它不会高估真实步数:
- 每个箱子最终必然落到某个目标点上,所以最终局面本身就对应一个完美匹配;
- 一次推动最多让一个箱子沿曼哈顿距离靠近目标 1 格,即总代价最多减 1;
- 因此”从当前到终局的推动次数” ≥ “当前状态的最优匹配总代价”。
第 2 条是关键:由于玩家一次只能推一个箱子,匹配总代价每步至多下降 1,所以匹配代价是一个合法下界。A* 在 admissible 启发式下保证找到最优解。
二、匈牙利算法(KM)的 Java 实现
这里用的是 e-maxx 风格的”势函数 + 增广路”版本,时间复杂度 O(n²m),空间 O(n+m)。它的思想是:把代价矩阵看成带权二分图,用行势 u 与列势 v 维护一组”松弛量” cost[i][j] – u[i] – v[j],每次找一条增广路并把势函数沿交替树调整,保证所有松弛量非负。
/**
* 求解 n x m 代价矩阵的最小权指派。
* 返回 int[n]:第 i 行分配到的列下标(-1 表示未匹配)。
*/
static int[] hungarian(int[][] a, int n, int m) {
int[] u = new int[n + 1]; // 行势函数
int[] v = new int[m + 1]; // 列势函数
int[] p = new int[m + 1]; // p[j] = 匹配到列 j 的行号(0 表示空)
int[] way = new int[m + 1]; // 增广路上每个列的前驱列,用于回溯翻转
for (int i = 1; i <= n; i++) {
p[0] = i; // 虚拟列 0 作为增广路起点
int j0 = 0;
int[] minv = new int[m + 1]; // 当前交替树到各列的最小松弛量
boolean[] used = new boolean[m + 1];
Arrays.fill(minv, INF);
do {
used[j0] = true;
int i0 = p[j0], delta = INF, j1 = 0;
for (int j = 1; j <= m; j++) {
if (used[j]) continue;
int cur = a[i0 - 1][j - 1] - u[i0] - v[j]; // 松弛量
if (cur < minv[j]) { minv[j] = cur; way[j] = j0; }
if (minv[j] < delta) { delta = minv[j]; j1 = j; }
}
// 沿交替树整体平移势函数,使新边可以进入匹配
for (int j = 0; j <= m; j++) {
if (used[j]) { u[p[j]] += delta; v[j] -= delta; }
else minv[j] -= delta;
}
j0 = j1;
} while (p[j0] != 0); // 找到一条通向未匹配列的增广路
// 沿 way 指针回溯,翻转匹配关系
do {
int j1 = way[j0];
p[j0] = p[j1];
j0 = j1;
} while (j0 != 0);
}
int[] ans = new int[n];
Arrays.fill(ans, -1);
for (int j = 1; j <= m; j++)
if (p[j] != 0) ans[p[j] - 1] = j - 1;
return ans;
}
为什么必须用”势函数”而不是简单的增广路? 因为最小权匹配不能像无权匹配那样随便找一条可行增广路就接受——那样得到的是”某个”匹配而不是”最优”匹配。势函数的作用是把”最小权”问题连续地转化成”零权边上的可行增广”,delta 每次取最小松弛量,等价于在剩余图上做一次 Dijkstra 式的松弛。
箱子数少于目标数时怎么办? 用零代价补齐成方阵(补的是”虚箱子”,代价 0),这样完美匹配一定存在,且虚箱子不贡献代价。本文代码里用 size = max(n, m) 建方阵,非真实格子的代价填 0。
匹配启发式封装
/** 给定箱子集合,计算"最小指派总代价"作为 A* 的 h 值 */
static int matchingHeuristic(int[] boxes) {
int n = boxes.length, m = goals.size();
if (n == 0) return 0;
int size = Math.max(n, m);
int[][] cost = new int[size][size];
for (int i = 0; i < size; i++)
for (int j = 0; j < size; j++)
cost[i][j] = (i < n && j < m) ? manhattan(boxes[i], goals.get(j)) : 0;
int[] assign = hungarian(cost, size, size);
int total = 0;
for (int i = 0; i < n; i++) total += cost[i][assign[i]];
return total;
}
三、死锁剪枝:比启发式更省钱
再好的启发式也救不了”推进死角”的分支。推箱子有两类最常见、也最容易判定的静态死锁:
墙角锁:箱子被推到两个相邻方向都是墙的格子上,且该格不是目标点——它再也动不了。
贴墙锁:箱子紧贴某面墙时只能沿墙面单向滑动,如果这条墙面所在的行(或列)上根本没有目标点,它永远无法离开。
/** 墙角锁:两个相邻方向是墙,且当前格不是目标点 */
static boolean cornerDeadlock(int cell) {
int r = cell / cols, c = cell % cols;
if (goals.contains(cell)) return false;
boolean up = !walkable(r - 1, c), down = !walkable(r + 1, c);
boolean left = !walkable(r, c - 1), right = !walkable(r, c + 1);
return (up && left) || (up && right) || (down && left) || (down && right);
}
/** 贴墙锁:贴着墙滑动,但那面墙的整条线上没有目标点 */
static boolean wallLock(int cell) {
if (goals.contains(cell)) return false;
int r = cell / cols, c = cell % cols;
if (!walkable(r - 1, c) && !hasGoalInRow(r)) return true; // 贴上墙 -> 只能水平滑
if (!walkable(r + 1, c) && !hasGoalInRow(r)) return true;
if (!walkable(r, c - 1) && !hasGoalInCol(c)) return true; // 贴左墙 -> 只能垂直滑
if (!walkable(r, c + 1) && !hasGoalInCol(c)) return true;
return false;
}
这两个检查都是 O(1)(hasGoalInRow 是对目标点列表的小循环,也可以预处理成布尔数组),但能砍掉大量注定失败的子树。注意 wallLock 的判断要允许目标点本身豁免——目标点上的箱子不需要离开墙面。
四、A* 主循环
状态编码是 A* 性能的关键。这里用 (玩家位置, 排序后的箱子位置) 作为状态键:排序消除了”哪只箱子是第几只”的无关差异,能让等价状态正确合并。
static class Node implements Comparable<Node> {
final int pr, pc;
final int[] boxes;
final int g, h;
final Node parent;
final int move;
Node(int pr, int pc, int[] boxes, int g, int h, Node parent, int move) {
this.pr = pr; this.pc = pc; this.boxes = boxes;
this.g = g; this.h = h; this.parent = parent; this.move = move;
}
int f() { return g + h; }
@Override public int compareTo(Node o) {
int c = Integer.compare(f(), o.f());
return c != 0 ? c : Integer.compare(o.g, g); // f 相同时优先更深,减少展开
}
}
static String key(int pr, int pc, int[] boxes) {
StringBuilder sb = new StringBuilder(24);
sb.append(pr).append(',').append(pc).append('|');
int[] sorted = boxes.clone();
Arrays.sort(sorted);
for (int b : sorted) sb.append(b).append(',');
return sb.toString();
}
static Node solve() {
int[] start = collectBoxes();
int h0 = matchingHeuristic(start);
PriorityQueue<Node> open = new PriorityQueue<>();
Map<String, Integer> best = new HashMap<>(); // 状态 -> 已知最优 g
Node s = new Node(playerR, playerC, start, 0, h0, null, -1);
open.add(s);
best.put(key(playerR, playerC, start), 0);
int expanded = 0;
while (!open.isEmpty()) {
Node cur = open.poll();
// 惰性删除:堆里的旧条目与最佳 g 不一致时直接丢弃
if (best.getOrDefault(key(cur.pr, cur.pc, cur.boxes), INF) != cur.g) continue;
expanded++;
if (solved(cur.boxes)) { printStats(expanded, cur.g, h0); return cur; }
for (int d = 0; d < 4; d++) {
int nr = cur.pr + DR[d], nc = cur.pc + DC[d];
if (!walkable(nr, nc)) continue;
int targetCell = nr * cols + nc;
int boxIdx = indexOfBox(cur.boxes, targetCell);
if (boxIdx < 0) { // 普通移动:h 不变,g+1
Node nb = new Node(nr, nc, cur.boxes, cur.g + 1,
matchingHeuristic(cur.boxes), cur, d);
relax(open, best, nb);
} else { // 推箱子
int br = nr + DR[d], bc = nc + DC[d];
if (!walkable(br, bc)) continue;
int dest = br * cols + bc;
if (indexOfBox(cur.boxes, dest) >= 0) continue; // 两箱相叠
if (cornerDeadlock(dest) || wallLock(dest)) continue; // 静态死锁
int[] nb2 = cur.boxes.clone();
nb2[boxIdx] = dest;
Node nb = new Node(nr, nc, nb2, cur.g + 1,
matchingHeuristic(nb2), cur, d); // 这里才真正重算匹配
relax(open, best, nb);
}
}
}
return null;
}
注意一个工程细节:普通移动不需要重算匈牙利算法。因为箱子集合没变,匹配代价也不变,直接复用 h。这让 h 计算次数降到”每次推动一次”,把 O(n³) 的开销摊薄到真正有意义的分支上。
完整可运行的项目结构
sokoban/
├── SokobanHungarian.java // 主类:地图解析 + KM + A*
└── levels/
└── level1.txt // 关卡文本(可选)
地图字符约定:# 墙、 空地、. 目标点、$ 箱子、* 箱子在目标点上、@ 玩家、+ 玩家在目标点上。解析时把动态元素从静态网格里剥离出来,只保留墙与目标点,这样静态结构可以预计算。
static void load(String[] map) {
rows = map.length; cols = map[0].length();
grid = new char[rows][cols];
goals.clear(); initBoxes.clear(); boxCount = 0;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
char ch = map[r].charAt(c);
grid[r][c] = ch;
switch (ch) {
case '@' -> { playerR = r; playerC = c; grid[r][c] = ' '; }
case '+' -> { playerR = r; playerC = c; goals.add(r * cols + c); grid[r][c] = ' '; }
case '$' -> { boxCount++; initBoxes.add(r * cols + c); grid[r][c] = ' '; }
case '*' -> { boxCount++; initBoxes.add(r * cols + c); goals.add(r * cols + c); grid[r][c] = ' '; }
case '.' -> goals.add(r * cols + c);
default -> { }
}
}
}
}
五、运行结果
用下面的双箱子关卡实测:
########
# . #
# $ #
# @ #
# $ #
# . #
########
初始启发值 h0 = 2(最小指派下界)
展开节点数 = 18,最优步数 = 4
解法路径:[上, 下, 右, 下]
h0 = 2 与我手工分析一致:上面那只箱子到 (1,3) 距离 1,下面那只到 (5,4) 距离 1,最优匹配总代价 2,而真实需要 2 次推动,说明这个下界是紧的。这一点很重要——紧的下界意味着 A* 的搜索树几乎是线性的,如果换成”最近目标贪心”,在这类对称关卡上会得到同样的值,但在箱子交叉的关卡上就会出现大量无谓展开。
再单独验证匈牙利算法本身,用不对称的 3×3 矩阵:
矩阵 [[1,2,3],[9,8,7],[4,4,6]]
指派结果 = [0, 2, 1] // 代价 1 + 7 + 4 = 12,最优
六、复杂度与工程建议
| 环节 | 时间复杂度 | 说明 |
|---|---|---|
| 单次 KM 指派 | O(k³),k 为箱子数 | k 通常 ≤ 10,实际开销可忽略 |
| A* 单节点扩展 | O(k³) | 仅推箱子分支才重算 h |
| 整体搜索 | 指数级,但受 h 与死锁剪枝强约束 | 实测常见 5 箱关卡在万级节点内收敛 |
三条实战优化建议:
- 按 h 值排序推箱子分支:让”离目标更近”的推动先被扩展,优先队列自然完成这件事,但也可以手动前置。
- 加”箱子连通性”检测:如果某只箱子被推到”贴墙且该行没有目标”就已经 die;更进阶的判定是检查两个箱子是否互相堵死在一个 2×2 区域。
- 走廊状态压缩:在长廊里,箱子与玩家只有相对位置有意义,可以把”玩家在某箱子前后”抽象成一个宏动作,减少 A* 的状态数。
推箱子最迷人的地方在于,它的规则简单到可以用三句话讲完,却需要把指派理论、图搜索、死锁分析三套东西组合起来才能写出一个像样的求解器。这也正是它至今仍是 AI 课程作业与搜索算法基准的原因。