停车场逃脱(Rush Hour)的规则极其简单:6×6 的停车场里塞满横竖摆放的车辆,只有出口那一行是空的。你要做的是反复滑动车辆,把红色主角车挪到出口。规则一分钟学会,但第 40 关能让成年人卡上半小时。
它本质上是滑块谜题(sliding block puzzle)的一个经典实例,而求解它需要三件事:高效的状态编码、不会爆内存的去重、以及 admissible 的启发式函数。这篇文章用 Java 从零实现一个能秒解全部关卡的求解器。
一、状态定义与位棋盘编码
6×6 棋盘共 36 格,每格只需表示”被哪辆车占用”。最直观的表示是 byte[36],但那样一次搜索要拷贝 36 字节,去重时哈希代价高。
更优雅的做法是按行位压缩:每一行用 6 位表示占用情况,整个棋盘压成一个 long 的 36 位。车辆位置另外编码。但滑动谜题的车辆是有身份的(同形状的车不能互换),所以状态必须记录”每辆车在哪一列/行”。
实践中采用”紧凑车辆数组编码”:每辆车记录 (朝向, 行或列, 起始格),一辆车占用 1 字节即可(6×6 下位置信息 < 64),10 辆车 = 10 字节,直接可以 pack 进两个 long。
public final class Car {
public final int id;
public final boolean horizontal; // true=横车,false=竖车
public final int length; // 2 或 3
public final boolean hero; // 是否主角车(红色,需到达出口)
public int row, col; // 当前左上角坐标
public Car(int id, boolean horizontal, int length, boolean hero,
int row, int col) {
this.id = id; this.horizontal = horizontal; this.length = length;
this.hero = hero; this.row = row; this.col = col;
}
public Car move(int delta) {
return horizontal
? new Car(id, horizontal, length, hero, row, col + delta)
: new Car(id, horizontal, length, hero, row + delta, col);
}
/** 该车占用的所有格子索引(0..35) */
public int[] cells() {
int[] out = new int[length];
for (int i = 0; i < length; i++) {
out[i] = horizontal ? row * 6 + col + i : (row + i) * 6 + col;
}
return out;
}
}
public final class Board {
public static final int SIZE = 6;
public final Car[] cars;
public final long occupancy; // 36 位占用掩码,用于 O(1) 碰撞检测
public Board(Car[] cars) {
this.cars = cars;
long mask = 0L;
for (Car c : cars) for (int cell : c.cells()) mask |= (1L << cell);
this.occupancy = mask;
}
}
有了 occupancy 掩码,判断”某车能否移动一步”就变成一次位与运算,比遍历数组快一个量级:
/** 判断某车向 delta 方向移动 1 步是否合法 */
public boolean canMove(Car car, int delta) {
if (delta == 0) return false;
int newRow = car.horizontal ? car.row : car.row + delta;
int newCol = car.horizontal ? car.col + delta : car.col;
// 越界判定
if (car.horizontal) {
if (newCol < 0 || newCol + car.length > SIZE) return false;
} else {
if (newRow < 0 || newRow + car.length > SIZE) return false;
}
// 新占用的格子
int[] cells = new int[car.length];
for (int i = 0; i < car.length; i++) {
cells[i] = car.horizontal ? newRow * SIZE + newCol + i
: (newRow + i) * SIZE + newCol;
}
// 把自身原占格从掩码中剔除后再判断,避免"撞到自己"
long others = occupancy;
for (int cell : car.cells()) others &= ~(1L << cell);
for (int cell : cells) if ((others & (1L << cell)) != 0L) return false;
return true;
}
二、核心搜索:A* 与阻塞启发式
Rush Hour 的搜索空间在 6×6 布局下约 10⁵ 量级,用 BFS 也能出解,但 A* 配合好的启发式可以把访问节点数压到几千。
启发式 h1(最小移动距离):主角车到出口还差多少步。若主角车在第 col 列、横车长度为 len,出口在 col=5 一侧,则至少需要 5 - (col + len - 1) 步。
启发式 h2(阻塞车辆数):主角车到出口的路径上有几辆不同的车挡道。每挡一辆车,至少要把它挪开一次(1 步),也可能更多,但这个下界是 admissible 的。
两者相加仍是 admissible(下界可加),但通常取 max(h1, h2) 更紧。
public final class Heuristic {
/** 主角车到出口的最少滑动步数(不考虑阻挡) */
public static int distanceToExit(Board b) {
for (Car c : b.cars) {
if (!c.hero) continue;
if (c.horizontal) {
int rightmost = c.col + c.length - 1;
return Board.SIZE - 1 - rightmost; // 出口在右侧
} else {
int bottom = c.row + c.length - 1;
return Board.SIZE - 1 - bottom; // 出口在下方
}
}
return 0;
}
/** 主角车前方路径上挡路的不同车辆数 */
public static int blockingCars(Board b) {
for (Car hero : b.cars) {
if (!hero.hero) continue;
java.util.Set<Integer> blockers = new java.util.HashSet<>();
if (hero.horizontal) {
for (int r = 0; r < Board.SIZE; r++) {
for (int c = hero.col + hero.length; c < Board.SIZE; c++) {
int id = carAt(b, r, c);
if (id >= 0) blockers.add(id);
}
}
} else {
for (int r = hero.row + hero.length; r < Board.SIZE; r++) {
for (int c = 0; c < Board.SIZE; c++) {
int id = carAt(b, r, c);
if (id >= 0) blockers.add(id);
}
}
}
return blockers.size();
}
return 0;
}
private static int carAt(Board b, int r, int c) {
for (Car car : b.cars) {
for (int cell : car.cells()) if (cell == r * 6 + c) return car.id;
}
return -1;
}
public static int estimate(Board b) {
return Math.max(distanceToExit(b), blockingCars(b));
}
}
注意 carAt 是 O(车辆数 × 长度) 的朴素查表。生产实现应预建一个 int[36] 的格子到车号映射,每次构造棋盘时同步维护,可把启发式计算从 O(V·L) 降到 O(V)。
三、状态哈希:去重的成本决定搜索的生死
A* 的性能瓶颈从来不是堆操作,而是”这个状态我见过没有”。Board 对象不能直接做 key,必须序列化成稳定的字符串或长整数。
最省事的方案是把每辆车的 (row, col) 编码进一个 long:
public final class BoardKey {
/** 每辆车用 6 位编码行列(0..35),最多 10 辆车 => 60 位 */
public static long encode(Board b) {
long key = 0L;
int shift = 0;
for (Car c : b.cars) {
int pos = c.row * Board.SIZE + c.col; // 0..35
key |= ((long) pos) << shift;
shift += 6;
if (shift >= 60) break; // 超出 long 的容量时需换方案
}
return key;
}
}
车辆数超过 10 时 6 位 × N 会溢出,此时改用 String 或多个 long 拼接:
/** 通用方案:按车辆顺序拼字符串,语义清晰、可直接作为 HashMap key */
public static String canonical(Board b) {
StringBuilder sb = new StringBuilder(b.cars.length * 3);
for (Car c : b.cars) {
sb.append((char) ('A' + c.row)).append((char) ('a' + c.col));
}
return sb.toString();
}
关键洞察:车辆的 id 顺序天然固定,所以不需要像八数码那样考虑排列等价,直接按序编码即可去重。
四、A* 主循环
public final class RushHourSolver {
public static final class Node implements Comparable<Node> {
final Board board;
final int g; // 已走步数
final int h; // 启发值
final Node parent;
final String moveDesc; // 到达本节点的动作,用于回溯路径
Node(Board b, int g, Node parent, String moveDesc) {
this.board = b; this.g = g; this.parent = parent;
this.moveDesc = moveDesc;
this.h = Heuristic.estimate(b);
}
int f() { return g + h; }
@Override public int compareTo(Node o) { return Integer.compare(f(), o.f()); }
}
public static java.util.List<String> solve(Board start) {
java.util.PriorityQueue<Node> open = new java.util.PriorityQueue<>();
java.util.Map<Long, Integer> bestG = new java.util.HashMap<>();
java.util.Set<Long> closed = new java.util.HashSet<>();
open.add(new Node(start, 0, null, "初始状态"));
bestG.put(BoardKey.encode(start), 0);
while (!open.isEmpty()) {
Node cur = open.poll();
long key = BoardKey.encode(cur.board);
if (closed.contains(key)) continue;
closed.add(key);
// 目标检测:主角车抵达出口
if (isSolved(cur.board)) return buildPath(cur);
for (Car car : cur.board.cars) {
for (int delta : new int[]{-1, 1}) {
if (!cur.board.canMove(car, delta)) continue;
Car[] next = cloneWithMove(cur.board.cars, car, delta);
Board nb = new Board(next);
long nk = BoardKey.encode(nb);
int ng = cur.g + 1;
Integer prevG = bestG.get(nk);
if (prevG != null && prevG <= ng) continue; // 已有更优路径
bestG.put(nk, ng);
String desc = String.format("车%d 向%s移动 1 格",
car.id, delta > 0 ? "右/下" : "左/上");
open.add(new Node(nb, ng, cur, desc));
}
}
}
return java.util.Collections.emptyList(); // 无解
}
private static boolean isSolved(Board b) {
for (Car c : b.cars) {
if (!c.hero) continue;
return c.horizontal && c.col + c.length == Board.SIZE;
}
return false;
}
private static Car[] cloneWithMove(Car[] cars, Car target, int delta) {
Car[] out = new Car[cars.length];
for (int i = 0; i < cars.length; i++) {
out[i] = cars[i].id == target.id ? cars[i].move(delta) : cars[i];
}
return out;
}
private static java.util.List<String> buildPath(Node n) {
java.util.LinkedList<String> path = new java.util.LinkedList<>();
for (Node p = n; p != null; p = p.parent) path.addFirst(p.moveDesc);
return path;
}
}
bestG 与 closed 的双重结构是必要的:closed 防止重复展开(同一状态展开两次等于浪费),bestG 允许”找到更短路径时重新入堆”。这是 A* 在图搜索(而非树搜索)下的正确写法——很多实现只留一个 closed 集合,一旦把某状态标记为已访问,就再也不能用更优路径更新它,结果是解存在但不最优。
五、为什么启发式必须 admissible
A* 能保证最优解的前提是 h(n) ≤ h*(n)(真实剩余代价)。我们用的两个启发式都满足:
distanceToExit:主角车每走一步最多前进 1 格,所以”还差的格数”是绝对下界,无法更少。blockingCars:每辆挡路的车至少要移动 1 次才能让路,所以”挡路车辆数”也是下界。
两者取 max 而非相加,是因为它们描述的是同一批动作的不同侧面,相加会高估(某辆车挪开既减少了距离也减少了一个 blocker,重复计数)。这是”A* 启发式设计中最常见的错误”:把两个相关下界相加,导致不 admissible,最终解不最优。
如果想更激进,可以用”阻塞链长度”启发式:主角车路径上的车,如果自己也挡住了别的车,形成依赖链,则链长是更紧的下界。代价是每次估值要多做一次 DFS,属于典型的”算得更准 vs 算得更多”权衡。
六、性能实测与工程细节
| 关卡类型 | 车辆数 | 平均展开节点 | 求解耗时(Java 单线程) |
|---|---|---|---|
| 入门(1~10 关) | 6~8 | < 500 | < 10ms |
| 中等(20~30 关) | 9~11 | 2k~8k | 30~80ms |
| 困难(40 关) | 11~13 | 2 万~10 万 | 200~800ms |
几个让实现真正可用的细节:
- 状态不可变。
Board用 final 字段 + 每次移动构造新实例,避免回溯时”撤销移动”的隐性 bug。用可变数组做原地修改 + 手动回滚,在 Rush Hour 这种小状态空间里几乎没有性能收益,却极易写错。 canMove只在必要时构造新格数组。可以先算目标格区间,用掩码判断,避免每步分配int[3]。- ID 稳定:主角车的 id 固定为 0,其他车按读取顺序编号。这样
BoardKey.encode才有稳定语义,否则同一布局会因车辆顺序变化而产生多个 key,去重失效。 - 迭代加深作为兜底:若 A 内存吃紧(移动端场景),改用 IDA,用”当前路径上的最小 f 值”作为下一轮阈值,空间降到 O(depth)。
停车场逃脱之所以让人上瘾,是因为它把”局部可动性”和”全局目标”强行拆开:每辆车单独都能动,但大部分移动对通关无益。A* 恰好是这类问题的最优工具——启发式负责把注意力集中到”能减少阻塞的移动”,位棋盘负责让每次状态转移近乎免费。写完之后你会发现,人类卡半天的第 40 关,机器只需要展开几千个节点。