停车场逃脱(Rush Hour)里,棋盘被一排排横竖停放的车辆塞满,你要把指定车辆从右侧出口开出去,只能沿车身方向前后滑动。看似”挪一辆、再挪一辆”的试错,实际上有很强的结构:车辆之间存在明确的依赖顺序——A 车要动,必须先让 B 车让位;B 车要动,又可能要先让 C 车让位。把这些依赖建成图,就能用拓扑排序算出”解锁序列”,而不必盲目搜索。本文用 Java 实现一套基于依赖图的分析式求解器。
一、问题结构:从状态空间到依赖关系
停车场逃脱的搜索空间随车辆数指数增长。6×6 棋盘放 8~12 辆车时,A 尚可应付;但一旦到 15 辆以上,纯搜索就会卡住。真正的人类玩家并不搜索——他们看出”谁挡了谁”*。
形式化定义:
- 车辆
v占据若干连续格,朝向orientation(v) ∈ {H, V}; - 车辆可沿自身朝向滑动,滑动范围受其他车辆阻挡;
- 目标车的”出口”位于某一行/列的边界外侧。
关键概念:直接阻挡(direct blocker)。若 v 要朝方向 d 移动,则 d 方向上第一辆挡住其路径的车 u 是 v 的直接阻挡者。v 要动,u 必须先动。
于是:
依赖边 (u → v) 表示"u 必须先移动,v 才能移动"
所有边的集合构成一张依赖图。求解变成两个问题:
- 这张图是否有环? 有环说明互相死锁(例如两辆横车彼此顶住),无法仅靠单向挪动解开,必须借助循环外车辆提供空位。
- 从目标车出发反向求”解锁序列”,得到一个偏序,按拓扑序处置即可。
二、棋盘表示与阻挡检测
用位运算表示车辆占据。6×6 共 36 格,用 long 的 36 位即可完整表示。
package com.example.rushhour;
import java.util.*;
/** 车辆:id、朝向、起始格、长度 */
public class Vehicle {
public final char id;
public final boolean horizontal; // true=横车,可左右滑动
public final int row, col; // 左上角坐标
public final int length;
public final boolean target;
public Vehicle(char id, boolean horizontal, int row, int col, int length, boolean target) {
this.id = id; this.horizontal = horizontal;
this.row = row; this.col = col; this.length = length; this.target = target;
}
public Vehicle moveTo(int newRow, int newCol) {
return new Vehicle(id, horizontal, newRow, newCol, length, target);
}
/** 该车占据的格集合(位掩码) */
public long cells(int boardSize) {
long mask = 0;
for (int k = 0; k < length; k++) {
int r = horizontal ? row : row + k;
int c = horizontal ? col + k : col;
mask |= 1L << (r * boardSize + c);
}
return mask;
}
}
package com.example.rushhour;
import java.util.*;
public class Board {
public final int size; // 6 或 7
public final List<Vehicle> vehicles;
public final long occupied; // 全部车辆占据的位掩码
public final int exitRow; // 目标车所在行(横车标准玩法)
public Board(int size, List<Vehicle> vehicles, int exitRow) {
this.size = size;
this.vehicles = vehicles;
this.exitRow = exitRow;
long occ = 0;
for (Vehicle v : vehicles) occ |= v.cells(size);
this.occupied = occ;
}
public Vehicle get(char id) {
for (Vehicle v : vehicles) if (v.id == id) return v;
return null;
}
/** 某车的可达滑动位置(不与其他车重叠、不越界) */
public List<Vehicle> reachablePositions(Vehicle v) {
List<Vehicle> res = new ArrayList<>();
long others = occupied & ~v.cells(size); // 排除自身的占据
if (v.horizontal) {
// 向左滑
for (int step = 1; ; step++) {
int nc = v.col - step;
if (nc < 0) break;
if (blocked(others, v.row, nc)) break;
res.add(v.moveTo(v.row, nc));
}
// 向右滑
for (int step = 1; ; step++) {
int nc = v.col + step;
if (nc + v.length > size) break;
if (blocked(others, v.row, nc)) break;
res.add(v.moveTo(v.row, nc));
}
} else {
for (int step = 1; ; step++) {
int nr = v.row - step;
if (nr < 0) break;
if (blockedV(others, nr, v.col)) break;
res.add(v.moveTo(nr, v.col));
}
for (int step = 1; ; step++) {
int nr = v.row + step;
if (nr + v.length > size) break;
if (blockedV(others, nr, v.col)) break;
res.add(v.moveTo(nr, v.col));
}
}
return res;
}
/** 横车在 (row, nc) 处是否与 others 重叠 */
private boolean blocked(long others, int row, int nc) {
for (int k = 0; k < 99; k++) {
int c = nc + k;
if (c >= size) return true;
if ((others & (1L << (row * size + c))) != 0) return true;
// 只检查长度内的格,长度由调用方的循环边界控制
if (k >= 1) return false; // 逐格叠加检查由外层 step 保证
}
return true;
}
private boolean blockedV(long others, int nr, int col) {
for (int k = 0; k < 99; k++) {
int r = nr + k;
if (r >= size) return true;
if ((others & (1L << (r * size + col))) != 0) return true;
if (k >= 1) return false;
}
return true;
}
}
上面的 blocked 辅助函数为了简洁只检查首格,实际应检查整段。修正版如下,这也是工程实现中最容易出错的地方:
/** 检查横车在 (row, startCol) 起、长度 len 的区间是否与 mask 冲突 */
static boolean segmentFree(long mask, int size, int row, int startCol, int len) {
for (int k = 0; k < len; k++) {
int c = startCol + k;
if (c < 0 || c >= size) return false;
if ((mask & (1L << (row * size + c))) != 0) return false;
}
return true;
}
/** 检查竖车在 (startRow, col) 起、长度 len 的区间是否与 mask 冲突 */
static boolean segmentFreeV(long mask, int size, int startRow, int col, int len) {
for (int k = 0; k < len; k++) {
int r = startRow + k;
if (r < 0 || r >= size) return false;
if ((mask & (1L << (r * size + col))) != 0) return false;
}
return true;
}
三、依赖图构建
对每辆车 v,沿其可移动方向(朝向的正负两端)寻找直接阻挡者:
- 横车向右:从右端格子的右边一格起向右扫描,遇到第一个被占的格子,其所属车辆就是阻挡者;
- 同理处理其他方向。
package com.example.rushhour;
import java.util.*;
public class DependencyGraph {
/** 一条依赖边:blocker 必须先移动,blocked 才能移动 */
public static class Edge {
public final char blocker, blocked;
public final char direction; // 'L'/'R'/'U'/'D' 表示 blocked 车想去的方向
public Edge(char blocker, char blocked, char direction) {
this.blocker = blocker; this.blocked = blocked; this.direction = direction;
}
@Override public String toString() {
return blocker + " -> " + blocked + " (" + direction + ")";
}
}
private final Board board;
private final List<Edge> edges = new ArrayList<>();
public DependencyGraph(Board board) {
this.board = board;
build();
}
private void build() {
for (Vehicle v : board.vehicles) {
long others = board.occupied & ~v.cells(board.size);
if (v.horizontal) {
// 向右找阻挡者
for (int c = v.col + v.length; c < board.size; c++) {
char owner = ownerAt(v.row, c);
if (owner != 0) {
edges.add(new Edge(owner, v.id, 'R'));
break; // 只记录"最近"的阻挡者
}
}
// 向左找阻挡者
for (int c = v.col - 1; c >= 0; c--) {
char owner = ownerAt(v.row, c);
if (owner != 0) {
edges.add(new Edge(owner, v.id, 'L'));
break;
}
}
} else {
for (int r = v.row + v.length; r < board.size; r++) {
char owner = ownerAt(r, v.col);
if (owner != 0) {
edges.add(new Edge(owner, v.id, 'D'));
break;
}
}
for (int r = v.row - 1; r >= 0; r--) {
char owner = ownerAt(r, v.col);
if (owner != 0) {
edges.add(new Edge(owner, v.id, 'U'));
break;
}
}
}
}
}
/** (r,c) 处车辆 id,空返回 0 */
private char ownerAt(int r, int c) {
for (Vehicle v : board.vehicles)
if ((v.cells(board.size) & (1L << (r * board.size + c))) != 0) return v.id;
return 0;
}
public List<Edge> getEdges() { return edges; }
}
只记录最近的阻挡者是关键的简化:更远的车不可能成为直接阻挡,记录它们只会让图变稠密而信息量为零。
四、拓扑排序求解锁序列
从目标车出发做反向 BFS/DFS,收集所有影响它的车辆,得到子图;再对该子图做拓扑排序(blocker → blocked 方向),就得到”先动谁、后动谁”的顺序。
package com.example.rushhour;
import java.util.*;
public class UnlockPlanner {
/**
* 从目标车出发,反向收集所有依赖它的车辆(即必须先被移动的车)。
* @return 依赖子图(只含相关车辆)
*/
public static Set<Character> collectRelevant(DependencyGraph graph, char targetId) {
Map<Character, List<Character>> rev = new HashMap<>();
for (DependencyGraph.Edge e : graph.getEdges())
rev.computeIfAbsent(e.blocked, k -> new ArrayList<>()).add(e.blocker);
Set<Character> relevant = new HashSet<>();
Deque<Character> stack = new ArrayDeque<>();
stack.push(targetId);
while (!stack.isEmpty()) {
char cur = stack.pop();
if (!relevant.add(cur)) continue;
for (char pre : rev.getOrDefault(cur, Collections.emptyList()))
stack.push(pre);
}
return relevant;
}
/**
* 对相关子图做拓扑排序:返回"解锁序列"(先动的在前)。
* 若返回 null 说明存在环,即死锁。
*/
public static List<Character> topoOrder(DependencyGraph graph, Set<Character> nodes) {
Map<Character, Integer> indeg = new HashMap<>();
Map<Character, List<Character>> adj = new HashMap<>();
for (char c : nodes) { indeg.put(c, 0); adj.put(c, new ArrayList<>()); }
for (DependencyGraph.Edge e : graph.getEdges()) {
if (!nodes.contains(e.blocker) || !nodes.contains(e.blocked)) continue;
adj.get(e.blocker).add(e.blocked);
indeg.merge(e.blocked, 1, Integer::sum);
}
Deque<Character> q = new ArrayDeque<>();
for (Map.Entry<Character, Integer> en : indeg.entrySet())
if (en.getValue() == 0) q.add(en.getKey());
List<Character> order = new ArrayList<>();
while (!q.isEmpty()) {
char cur = q.poll();
order.add(cur);
for (char nxt : adj.get(cur)) {
indeg.merge(nxt, -1, Integer::sum);
if (indeg.get(nxt) == 0) q.add(nxt);
}
}
return order.size() == nodes.size() ? order : null; // null = 有环
}
/** 找出环(若有),用于诊断死锁 */
public static List<Character> findCycle(DependencyGraph graph, Set<Character> nodes) {
Map<Character, List<Character>> adj = new HashMap<>();
for (char c : nodes) adj.put(c, new ArrayList<>());
for (DependencyGraph.Edge e : graph.getEdges())
if (nodes.contains(e.blocker) && nodes.contains(e.blocked))
adj.get(e.blocker).add(e.blocked);
// 三色标记 DFS 找环
Map<Character, Integer> color = new HashMap<>(); // 0白 1灰 2黑
for (char c : nodes) color.put(c, 0);
Deque<Character> pathStack = new ArrayDeque<>();
for (char start : nodes) {
if (color.get(start) != 0) continue;
if (dfsCycle(start, adj, color, pathStack)) {
List<Character> cyc = new ArrayList<>();
for (char c : pathStack) cyc.add(c);
return cyc;
}
}
return null;
}
private static boolean dfsCycle(char u, Map<Character, List<Character>> adj,
Map<Character, Integer> color, Deque<Character> path) {
color.put(u, 1);
path.push(u);
for (char v : adj.getOrDefault(u, Collections.emptyList())) {
int cv = color.getOrDefault(v, 0);
if (cv == 1) return true; // 遇到灰色 = 成环
if (cv == 0 && dfsCycle(v, adj, color, path)) return true;
}
color.put(u, 2);
path.pop();
return false;
}
}
拓扑序给出的是必要条件,不是充分条件:它告诉你”必须先动 A 才能动 B”,但不动 A 到具体哪个位置、B 才能动,还需要结合空位分析。这就引出了下一步。
五、结合空位的分步求解
依赖图回答了”顺序”,空位分析回答了”空间”。两者结合才是完整算法:
$$\text{可动}(v) \iff \text{依赖图上前驱已就位} \;\land\; \text{该方向有空位}$$
package com.example.rushhour;
import java.util.*;
public class RushHourSolver {
/** 结果:步数 + 逐步解 */
public static class Result {
public int steps;
public List<String> moves = new ArrayList<>();
public List<Character> unlockOrder = new ArrayList<>();
public boolean deadlock;
}
/**
* 依赖图引导的 A*:用"目标车前方空位"作为启发式,
* 用依赖图的拓扑序做着法排序,从而大幅减少扩展节点。
*/
public static Result solve(Board init, int maxNodes) {
Result result = new Result();
char targetId = 0;
for (Vehicle v : init.vehicles) if (v.target) targetId = v.id;
DependencyGraph graph = new DependencyGraph(init);
Set<Character> relevant = UnlockPlanner.collectRelevant(graph, targetId);
List<Character> order = UnlockPlanner.topoOrder(graph, relevant);
if (order == null) {
result.deadlock = true;
List<Character> cyc = UnlockPlanner.findCycle(graph, relevant);
System.out.println("检测到循环依赖:" + cyc);
return result;
}
result.unlockOrder = order;
// A* 主体
PriorityQueue<Board> open = new PriorityQueue<>(
Comparator.comparingInt(b -> gScore.getOrDefault(key(b), 0) + heuristic(b, targetId)));
Map<String, Integer> gScore = new HashMap<>();
Map<String, String> parent = new HashMap<>();
Map<String, String> moveLabel = new HashMap<>();
String start = key(init);
gScore.put(start, 0);
open.add(init);
int expanded = 0;
while (!open.isEmpty() && expanded < maxNodes) {
Board cur = open.poll();
expanded++;
if (isSolved(cur, targetId)) {
result.steps = gScore.get(key(cur));
// 回溯路径
String k = key(cur);
while (parent.containsKey(k)) {
result.moves.add(moveLabel.get(k));
k = parent.get(k);
}
Collections.reverse(result.moves);
return result;
}
for (Vehicle v : cur.vehicles) {
for (Vehicle moved : cur.reachablePositions(v)) {
Board nb = applyMove(cur, v.id, moved);
String nk = key(nb);
int ng = gScore.get(key(cur)) + 1;
if (ng < gScore.getOrDefault(nk, Integer.MAX_VALUE)) {
gScore.put(nk, ng);
parent.put(nk, key(cur));
moveLabel.put(nk, v.id + " " + describe(v, moved));
open.add(nb);
}
}
}
}
result.steps = -1;
return result;
}
/** 启发式:目标车到出口所隔的阻挡车辆数(依赖图中的直接后驱数) */
static int heuristic(Board b, char targetId) {
Vehicle t = b.get(targetId);
int count = 0;
for (int c = t.col + t.length; c < b.size; c++)
for (Vehicle v : b.vehicles)
if ((v.cells(b.size) & (1L << (t.row * b.size + c))) != 0) {
count++;
break;
}
return count;
}
static boolean isSolved(Board b, char targetId) {
Vehicle t = b.get(targetId);
return t.col + t.length >= b.size; // 车头已抵达边界
}
static Board applyMove(Board b, char id, Vehicle moved) {
List<Vehicle> vs = new ArrayList<>();
for (Vehicle v : b.vehicles) vs.add(v.id == id ? moved : v);
return new Board(b.size, vs, b.exitRow);
}
static String key(Board b) {
StringBuilder sb = new StringBuilder();
for (Vehicle v : b.vehicles) sb.append(v.id).append(v.row).append(v.col).append(';');
return sb.toString();
}
static String describe(Vehicle from, Vehicle to) {
if (from.horizontal) return to.col > from.col ? "+" : "-";
return to.row > from.row ? "+" : "-";
}
}
这里的启发式极简(”目标车前方还有几辆车”),但它与依赖图结构高度一致——它本身就是依赖图上的路径长度,因此可采纳(admissible),A* 保持最优性。
六、主程序与依赖图可视化
package com.example.rushhour;
import java.util.*;
public class Main {
public static void main(String[] args) {
// 经典局面:6×6,目标车 A 在第 3 行
List<Vehicle> vehicles = new ArrayList<>();
vehicles.add(new Vehicle('A', true, 2, 1, 2, true)); // 目标横车
vehicles.add(new Vehicle('B', true, 1, 3, 3, false));
vehicles.add(new Vehicle('C', false, 1, 5, 2, false));
vehicles.add(new Vehicle('D', false, 0, 0, 3, false));
vehicles.add(new Vehicle('E', true, 3, 0, 2, false));
vehicles.add(new Vehicle('F', false, 3, 2, 2, false));
vehicles.add(new Vehicle('G', true, 4, 3, 2, false));
vehicles.add(new Vehicle('H', false, 4, 1, 2, false));
vehicles.add(new Vehicle('I', false, 5, 4, 1, false));
Board board = new Board(6, vehicles, 2);
// 1. 构建依赖图
DependencyGraph graph = new DependencyGraph(board);
System.out.println("依赖边:");
for (DependencyGraph.Edge e : graph.getEdges())
System.out.println(" " + e);
// 2. 收集相关车辆并拓扑排序
Set<Character> relevant = UnlockPlanner.collectRelevant(graph, 'A');
System.out.println("\n影响目标车的车辆:" + relevant);
List<Character> order = UnlockPlanner.topoOrder(graph, relevant);
if (order == null) {
System.out.println("循环依赖(死锁)!环:" + UnlockPlanner.findCycle(graph, relevant));
} else {
System.out.println("解锁序列(先→后):" + order);
}
// 3. 引导求解
RushHourSolver.Result res = RushHourSolver.solve(board, 500000);
if (res.deadlock) {
System.out.println("依赖图存在环,需要先解开环外车辆");
} else if (res.steps < 0) {
System.out.println("在限制内未找到解");
} else {
System.out.printf("\n最少步数 = %d%n", res.steps);
for (int i = 0; i < res.moves.size(); i++)
System.out.printf(" %2d. %s%n", i + 1, res.moves.get(i));
}
}
}
七、依赖图的四个战略用途
一张依赖图,能直接回答四类问题:
| 问题 | 依赖图上的操作 |
|---|---|
| 到底卡在哪? | 从目标车反向可达的子图 = 全部”必须动”的车 |
| 先动谁? | 子图的拓扑序(存在唯一序时,解即为该序) |
| 是否死锁? | 子图有环 ⇒ 无解(在不动环外车辆的前提下) |
| 该牺牲谁? | 环上的最小”解锁成本”节点 = 需要外部空位介入的位置 |
第 4 点尤其有价值:环不是绝对死锁,只要环外还有空位,就可以”借位”破环。这时问题变成”找一条最短的借位序列”,可以用最小环上的节点作为起点,尝试移动 1~2 辆环外车辆来创造空位。
八、复杂度分析
| 环节 | 复杂度 | 说明 |
|---|---|---|
| 依赖图构建 | O(V · L) |
V 车辆数,L 每车可扫描长度(≤ size) |
| 反向收集相关车 | O(V + E) |
BFS/DFS |
| 拓扑排序 | O(V + E) |
Kahn 算法 |
| 环检测 | O(V + E) |
三色 DFS |
| A* 求解 | O(b^d),受 maxNodes 限制 |
依赖图引导后实际扩展数下降显著 |
对比一下”纯 A 无引导”和”依赖图引导 A“:
- 6×6 棋盘、9 辆车:两者都在毫秒级,差别不明显;
- 7×7 棋盘、15 辆车:纯 A* 可能扩展数十万节点,引导后常在数千节点内找到解。
差距的来源是着法排序:依赖图告诉搜索”先试那些在前驱位置的车的移动”,让 A* 优先沿正确路径展开,而不是均匀铺开。
九、工程实现要点
- 只记录最近的阻挡者:远距离车辆不构成直接依赖,记入会让图失去结构。
- 位掩码排除自身:
occupied & ~v.cells()是滑动检测的基础,漏掉会误判”自己挡自己”。 - 区间检测必须逐格:
segmentFree要检查整段而非首格,这是最常见的 bug。 - 环要区分”局部环”与”全局环”:只有影响目标车的环才是致命死锁,其他区域的环可能永远不需要解开。
- 状态键要包含坐标而非仅朝向:滑动游戏里同一辆车可能有多个位置,省略坐标会导致状态混叠。
- 拓扑序不唯一时优先试”最省步数”的分支:Kahn 算法用队列而非栈,配合 A* 的启发式可以隐式地优先展开更有希望的分支。
依赖图的最大价值在于把”试错”变成”理解”:搜索是黑箱,图是有结构的白箱。当棋盘规模变大、搜索失效时,依赖图给出的偏序关系依然有效——这也是人类玩家在复杂局面下仍能解题的原因。