每日算法 — 使用java实现停车场逃脱:依赖图封锁分析与解锁序列拓扑排序

停车场逃脱(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 才能移动"

所有边的集合构成一张依赖图。求解变成两个问题:

  1. 这张图是否有环? 有环说明互相死锁(例如两辆横车彼此顶住),无法仅靠单向挪动解开,必须借助循环外车辆提供空位。
  2. 从目标车出发反向求”解锁序列”,得到一个偏序,按拓扑序处置即可。

二、棋盘表示与阻挡检测

用位运算表示车辆占据。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* 优先沿正确路径展开,而不是均匀铺开。

九、工程实现要点

  1. 只记录最近的阻挡者:远距离车辆不构成直接依赖,记入会让图失去结构。
  2. 位掩码排除自身:occupied & ~v.cells() 是滑动检测的基础,漏掉会误判”自己挡自己”。
  3. 区间检测必须逐格:segmentFree 要检查整段而非首格,这是最常见的 bug。
  4. 环要区分”局部环”与”全局环”:只有影响目标车的环才是致命死锁,其他区域的环可能永远不需要解开。
  5. 状态键要包含坐标而非仅朝向:滑动游戏里同一辆车可能有多个位置,省略坐标会导致状态混叠。
  6. 拓扑序不唯一时优先试”最省步数”的分支:Kahn 算法用队列而非栈,配合 A* 的启发式可以隐式地优先展开更有希望的分支。

依赖图的最大价值在于把”试错”变成”理解”:搜索是黑箱,图是有结构的白箱。当棋盘规模变大、搜索失效时,依赖图给出的偏序关系依然有效——这也是人类玩家在复杂局面下仍能解题的原因。