每日算法 — 使用java实现推箱子:匈牙利算法与最优指派启发式

推箱子(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. 每个箱子最终必然落到某个目标点上,所以最终局面本身就对应一个完美匹配;
  2. 一次推动最多让一个箱子沿曼哈顿距离靠近目标 1 格,即总代价最多减 1;
  3. 因此”从当前到终局的推动次数” ≥ “当前状态的最优匹配总代价”。

第 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 箱关卡在万级节点内收敛

三条实战优化建议:

  1. 按 h 值排序推箱子分支:让”离目标更近”的推动先被扩展,优先队列自然完成这件事,但也可以手动前置。
  2. 加”箱子连通性”检测:如果某只箱子被推到”贴墙且该行没有目标”就已经 die;更进阶的判定是检查两个箱子是否互相堵死在一个 2×2 区域。
  3. 走廊状态压缩:在长廊里,箱子与玩家只有相对位置有意义,可以把”玩家在某箱子前后”抽象成一个宏动作,减少 A* 的状态数。

推箱子最迷人的地方在于,它的规则简单到可以用三句话讲完,却需要把指派理论、图搜索、死锁分析三套东西组合起来才能写出一个像样的求解器。这也正是它至今仍是 AI 课程作业与搜索算法基准的原因。