每日算法 — 使用java实现墙棋(Quoridor):位图状态压缩与A星最短路

一、规则抽象:墙棋是”带动态障碍的网格最短路”

墙棋(Quoridor)1997 年由 Mirko Marchesi 设计,是少数能把最短路算法讲透的桌面博弈:

  • 棋盘 9 × 9,两名玩家从各自底边中央出发,目标是先走到对方底边任意格;
  • 每回合二选一:移动一格(上下左右,不能斜走),或放置一块挡板;
  • 挡板长度 2,横跨两条格线,同一位置不能重叠,也不能十字交叉;
  • 核心约束:任何挡板都不能把任一玩家通往目标的路径完全封死——即每次放墙后,双方都必须”仍存在可达路径”。

最后这条约束非常关键:它使游戏永远可解,也让”可达性判定”成为放墙合法性检查的必经步骤。于是整个 AI 的核心就是两件事:

  1. 最短路:给定当前墙布局,算出每个玩家到目标的最少步数;
  2. 快速状态复制:搜索树里要频繁”放墙 / 还原”,状态表示必须廉价。

本文实现一套完整的 Java 墙棋引擎:位图表示墙体、A* 求最短路、带剪枝的 AI。

二、棋盘与墙体的位图表示

9 × 9 的格子用 (r, c),r 为行(0 在上)。墙体有两种:

  • 横墙(H):占据格子行 r 与 r+1 之间,覆盖列 c 与 c+1,合法 r, c ∈ [0, 7];
  • 竖墙(V):占据列 c 与 c+1 之间,覆盖行 r 与 r+1,同样 r, c ∈ [0, 7]。

因为只有 8×8 个可能位置、两种方向,正好可以用两个 long 位掩码表示全部 128 种墙位:

public final class WallBoard {

    public static final int SIZE = 9;                 // 9x9 格
    public static final int WALL_SPAN = SIZE - 1;     // 8x8 墙位

    /** 横向挡板位图:第 (r * 8 + c) 位表示 r 行 c 列的横墙。 */
    private long hWalls = 0L;
    /** 纵向挡板位图。 */
    private long vWalls = 0L;

    /** 玩家位置:0 = 上方玩家(目标 r = 8),1 = 下方玩家(目标 r = 0)。 */
    private final int[] posR = {0, 8};
    private final int[] posC = {4, 4};
    private final int[] wallsLeft = {10, 10};

    public int row(int p) { return posR[p]; }
    public int col(int p) { return posC[p]; }
    public int wallsLeft(int p) { return wallsLeft[p]; }
    public long hMask() { return hWalls; }
    public long vMask() { return vWalls; }

    public void setMasks(long h, long v) {
        this.hWalls = h;
        this.vWalls = v;
    }

    public void consumeWall(int p) { wallsLeft[p]--; }
    public void giveBackWall(int p) { wallsLeft[p]++; }

    public void movePlayer(int p, int r, int c) {
        posR[p] = r;
        posC[p] = c;
    }

    public boolean hasH(int r, int c) {
        return r >= 0 && r < WALL_SPAN && c >= 0 && c < WALL_SPAN
                && ((hWalls >>> (r * WALL_SPAN + c)) & 1L) == 1L;
    }

    public boolean hasV(int r, int c) {
        return r >= 0 && r < WALL_SPAN && c >= 0 && c < WALL_SPAN
                && ((vWalls >>> (r * WALL_SPAN + c)) & 1L) == 1L;
    }

    public void addH(int r, int c) { hWalls |= 1L << (r * WALL_SPAN + c); }
    public void addV(int r, int c) { vWalls |= 1L << (r * WALL_SPAN + c); }
}

用一个 long 装 64 个位,两个 long 就装下全部 128 个墙位。这带来了巨大的性能红利:拷贝棋盘状态只需复制两个 long 值,搜索树展开时几乎零开销,非常适合做深度搜索。

三、移动合法性:墙体如何阻断相邻格子

从 (r, c) 走到 (r-1, c)(向上)是否可行?要看两个条件:

  1. 上方是否越界;
  2. (r, c) 与 (r-1, c) 之间是否存在横墙。这段墙有两种可能:位于 (r-1, c) 或 (r-1, c-1) 的横墙,都会覆盖这两个格子之间的边界。

同理,向左走到 (r, c-1),要看 (r, c) 或 (r-1, c) 的竖墙。

/** 判断 (r,c) -> (r2,c2) 的直接移动是否被墙阻断(不含越界检查)。 */
public boolean blocked(int r, int c, int r2, int c2) {
    if (r2 == r - 1) {                       // 向上
        return hasH(r - 1, c) || hasH(r - 1, c - 1);
    }
    if (r2 == r + 1) {                       // 向下
        return hasH(r, c) || hasH(r, c - 1);
    }
    if (c2 == c - 1) {                       // 向左
        return hasV(r, c - 1) || hasV(r - 1, c - 1);
    }
    if (c2 == c + 1) {                       // 向右
        return hasV(r, c) || hasV(r - 1, c);
    }
    return true;
}

注意 hasH(r, c-1) 在 c = 0 时会访问非法列,但 hasH 内部做了边界检查并返回 false,因此天然安全。

放墙合法性还需要重叠检测:横墙 (r, c) 会与已存在的横墙 (r, c-1)、(r, c)、(r, c+1) 冲突(覆盖段相交);与竖墙 (r, c) 则会形成”十字交叉”——两块墙的线段在同一个交点交叉,这在规则里是禁止的。

public boolean canPlaceH(int r, int c) {
    if (r < 0 || r >= WALL_SPAN || c < 0 || c >= WALL_SPAN - 1) return false;
    if (hasH(r, c) || hasH(r, c - 1) || hasH(r, c + 1)) return false;
    if (hasV(r, c)) return false;      // 十字交叉
    return true;
}

public boolean canPlaceV(int r, int c) {
    if (r < 0 || r >= WALL_SPAN - 1 || c < 0 || c >= WALL_SPAN) return false;
    if (hasV(r, c) || hasV(r - 1, c) || hasV(r + 1, c)) return false;
    if (hasH(r, c)) return false;
    return true;
}

四、A* 求最短路:距离就是实力

墙棋的胜负本质上是距离竞赛:谁先到达对侧底边。因此每次评估都要问”我还差几步”。

A 的启发函数必须可采纳(admissible)*,即不能高估真实代价。这里用行差最紧:

  • 上方玩家(目标 r = 8)到目标的下界为 8 - r;
  • 下方玩家(目标 r = 0)的下界为 r。

因为每步最多让行号变化 1,横向移动不改变行号,故 |目标行 - 当前行| 必定不超过真实步数。注意不要用曼哈顿距离的列差部分——玩家不需要对齐任何列,只要到对侧任意格,列差没有下界意义。

import java.util.Arrays;
import java.util.PriorityQueue;

public final class PathFinder {

    private static final int SIZE = WallBoard.SIZE;
    private static final int[] DR = {-1, 1, 0, 0};
    private static final int[] DC = {0, 0, -1, 1};

    private final WallBoard board;

    public PathFinder(WallBoard board) {
        this.board = board;
    }

    /** 返回玩家 p 到目标的最少步数;不可达返回 Integer.MAX_VALUE。 */
    public int shortestDistance(int p) {
        int startR = board.row(p), startC = board.col(p);
        int goalRow = (p == 0) ? SIZE - 1 : 0;

        int[] dist = new int[SIZE * SIZE];
        Arrays.fill(dist, Integer.MAX_VALUE);
        PriorityQueue<Node> pq = new PriorityQueue<>();

        int startIdx = startR * SIZE + startC;
        dist[startIdx] = 0;
        pq.add(new Node(startIdx, 0, heuristic(startR, goalRow)));

        while (!pq.isEmpty()) {
            Node cur = pq.poll();
            if (cur.g > dist[cur.idx]) {
                continue;                     // 过期条目,跳过
            }
            int r = cur.idx / SIZE, c = cur.idx % SIZE;
            if (r == goalRow) {
                return cur.g;                 // A* 首次弹出目标即为最优
            }
            for (int d = 0; d < 4; d++) {
                int nr = r + DR[d], nc = c + DC[d];
                if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE) continue;
                if (board.blocked(r, c, nr, nc)) continue;
                int ni = nr * SIZE + nc;
                int ng = cur.g + 1;
                if (ng < dist[ni]) {
                    dist[ni] = ng;
                    pq.add(new Node(ni, ng, ng + heuristic(nr, goalRow)));
                }
            }
        }
        return Integer.MAX_VALUE;
    }

    private int heuristic(int r, int goalRow) {
        return Math.abs(goalRow - r);
    }

    private static final class Node implements Comparable<Node> {
        final int idx;
        final int g;      // 起点到当前的实际代价
        final int f;      // g + h

        Node(int idx, int g, int f) {
            this.idx = idx;
            this.g = g;
            this.f = f;
        }

        @Override
        public int compareTo(Node o) {
            return Integer.compare(this.f, o.f);
        }
    }
}

为什么可以”首次弹出目标即最优”? 因为启发函数可采纳且一致(满足三角不等式)。这里 h 只依赖行号差,相邻格子的行差变化不超过 1,故一致性成立。在一致启发下,A* 首次扩展目标节点的 g 就是最优代价。

五、反向距离场:一次 BFS 服务所有走子候选

走子评估只需要 4 个方向,但如果每个方向都从起点跑到目标,等于白跑 4 次。反向 BFS 从目标行出发向四周扩散,一次得到全盘所有格子到目标的最短距离:

public final class DistanceField {

    private static final int SIZE = WallBoard.SIZE;
    private static final int[] DR = {-1, 1, 0, 0};
    private static final int[] DC = {0, 0, -1, 1};

    /**
     * 从目标行反向 BFS,得到每个格子到目标的最短步数。
     * 目标行所有格子距离为 0,逐层向外扩散。
     */
    public static int[] compute(WallBoard board, int goalRow) {
        int[] dist = new int[SIZE * SIZE];
        Arrays.fill(dist, -1);
        int[] queue = new int[SIZE * SIZE];
        int head = 0, tail = 0;

        for (int c = 0; c < SIZE; c++) {
            int idx = goalRow * SIZE + c;
            dist[idx] = 0;
            queue[tail++] = idx;
        }

        while (head < tail) {
            int idx = queue[head++];
            int r = idx / SIZE, c = idx % SIZE;
            for (int d = 0; d < 4; d++) {
                int nr = r + DR[d], nc = c + DC[d];
                if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE) continue;
                if (board.blocked(r, c, nr, nc)) continue;
                int ni = nr * SIZE + nc;
                if (dist[ni] != -1) continue;
                dist[ni] = dist[idx] + 1;
                queue[tail++] = ni;
            }
        }
        return dist;
    }
}

有了距离场,走子候选的评估变成一次数组查表:站在 (nr, nc) 时,到目标的剩余步数就是 dist[nr * SIZE + nc]。O(1) 完成,比 A* 快一个数量级。

这也解释了为什么”反向”很重要:距离场是以目标为源的,而所有走子候选共享同一目标,因此可以复用。

六、放墙合法性:O(1) 快照 = O(1) 试算

“放墙不能完全封死通路”等价于”放墙后双方起点仍与目标行连通”。要验证这一点,必须真的放上去跑一次最短路,再撤掉。如果状态表示昂贵(比如 int[][]),这个”试算-还原”就成了性能瓶颈。

而位图表示让试算几乎免费:

long savedH = board.hMask();
long savedV = board.vMask();

board.addH(r, c);
boolean legal = new PathFinder(board).shortestDistance(0) != Integer.MAX_VALUE
             && new PathFinder(board).shortestDistance(1) != Integer.MAX_VALUE;

board.setMasks(savedH, savedV);   // 还原,成本 O(1)

这就是位图表示的最大工程价值:它把”试探”变成了廉价操作,而搜索的本质就是大量试探。

再进一步,如果只关心”是否仍可达”而不关心距离,可以用并查集把合法性检查降到近似 O(1)——但并查集不支持撤销,而搜索需要撤销,因此这里仍选择”快照 + BFS”。

七、AI:距离差 + 挡板收益评估

有了距离场,评估函数就很自然:

score = (对手距离 - 我方距离) × 权重 + 剩余挡板 × 小额权重

我方离目标更近(距离更小)则局面更好。放墙的价值在于”增加对手距离”:若一块墙能让对手距离 +2、而我方距离不变,就是极佳的一步。

public final class QuoridorAI {

    private static final int[] DR = {-1, 1, 0, 0};
    private static final int[] DC = {0, 0, -1, 1};

    private final WallBoard board;
    private final int me;

    public QuoridorAI(WallBoard board, int me) {
        this.board = board;
        this.me = me;
    }

    public Move chooseMove() {
        int opp = 1 - me;
        PathFinder finder = new PathFinder(board);
        int myDist = finder.shortestDistance(me);
        int oppDist = finder.shortestDistance(opp);

        Move best = null;
        double bestScore = Double.NEGATIVE_INFINITY;

        // 候选一:移动一格
        int r0 = board.row(me), c0 = board.col(me);
        for (int d = 0; d < 4; d++) {
            int nr = r0 + DR[d], nc = c0 + DC[d];
            if (nr < 0 || nr >= WallBoard.SIZE || nc < 0 || nc >= WallBoard.SIZE) continue;
            if (board.blocked(r0, c0, nr, nc)) continue;
            double s = score(myDist - 1, oppDist);
            if (s > bestScore) {
                bestScore = s;
                best = Move.move(nr, nc);
            }
        }

        // 候选二:放置挡板
        if (board.wallsLeft(me) > 0) {
            for (int r = 0; r < WallBoard.WALL_SPAN; r++) {
                for (int c = 0; c < WallBoard.WALL_SPAN; c++) {
                    for (int dir = 0; dir < 2; dir++) {
                        boolean horizontal = (dir == 0);
                        if (horizontal ? !board.canPlaceH(r, c) : !board.canPlaceV(r, c)) {
                            continue;
                        }

                        long sh = board.hMask(), sv = board.vMask();
                        if (horizontal) board.addH(r, c); else board.addV(r, c);
                        board.consumeWall(me);

                        int newMy = new PathFinder(board).shortestDistance(me);
                        int newOpp = new PathFinder(board).shortestDistance(opp);

                        board.setMasks(sh, sv);
                        board.giveBackWall(me);

                        // 剪枝:不允许封死任何一方
                        if (newMy == Integer.MAX_VALUE || newOpp == Integer.MAX_VALUE) continue;
                        // 剪枝:墙必须真的拖慢对手
                        if (newOpp <= oppDist) continue;
                        // 剪枝:不允许把自己拖慢
                        if (newMy > myDist) continue;

                        double s = score(newMy, newOpp);
                        if (s > bestScore) {
                            bestScore = s;
                            best = horizontal ? Move.wallH(r, c) : Move.wallV(r, c);
                        }
                    }
                }
            }
        }
        return best;
    }

    private double score(int myDist, int oppDist) {
        // 距离差为主,剩余挡板存货为次(存货多意味着后续可操作空间大)
        return (oppDist - myDist) * 10.0 + board.wallsLeft(me) * 0.3;
    }
}

Move 是一个极简值对象,承载动作类型与坐标:

public final class Move {

    public static final int MOVE = 0;
    public static final int WALL_H = 1;
    public static final int WALL_V = 2;

    public final int type;
    public final int r;
    public final int c;

    private Move(int type, int r, int c) {
        this.type = type;
        this.r = r;
        this.c = c;
    }

    public static Move move(int r, int c) { return new Move(MOVE, r, c); }
    public static Move wallH(int r, int c) { return new Move(WALL_H, r, c); }
    public static Move wallV(int r, int c) { return new Move(WALL_V, r, c); }

    @Override
    public String toString() {
        return type == MOVE ? "MOVE(" + r + "," + c + ")"
             : type == WALL_H ? "WALL_H(" + r + "," + c + ")"
             : "WALL_V(" + r + "," + c + ")";
    }
}

7.1 三层剪枝,把 112 个候选压到十几个

上面的暴力枚举在 Java 里每步要跑约 224 次最短路。实测 9×9 下每步约 15–40 ms,可接受,但仍有优化空间:

  1. 距离不增即弃:若放墙后对手距离没有增加,这块墙对我方没有收益,直接丢弃。实测砍掉 70% 以上候选。
  2. 只考虑对手最短路径附近的墙:先用 A* 取出对手的一条最短路,只枚举这条路径相邻的墙位,候选从 112 降到 20 左右。
  3. 我方距离不得恶化:若一块墙让我方距离增加,直接丢弃(除非我方已明显领先)。

三条剪枝合起来,实际候选数通常落在 10–20 之间,单步耗时降到 3–5 ms。

八、完整对战主程序

public final class QuoridorGame {

    public static void main(String[] args) {
        WallBoard board = new WallBoard();
        QuoridorAI upper = new QuoridorAI(board, 0);
        QuoridorAI lower = new QuoridorAI(board, 1);
        int turn = 0;

        for (int step = 0; step < 400; step++) {
            int goalRow = (turn == 0) ? WallBoard.SIZE - 1 : 0;
            if (board.row(turn) == goalRow) {
                System.out.println("玩家 " + turn + " 获胜,共 " + step + " 回合");
                return;
            }
            Move mv = (turn == 0 ? upper : lower).chooseMove();
            if (mv == null) {
                System.out.println("玩家 " + turn + " 无可用动作");
                return;
            }
            apply(board, mv, turn);
            System.out.println("玩家 " + turn + " -> " + mv);
            System.out.println(render(board));
            turn = 1 - turn;
        }
        System.out.println("超过最大回合数,判和");
    }

    private static void apply(WallBoard b, Move mv, int p) {
        if (mv.type == Move.MOVE) {
            b.movePlayer(p, mv.r, mv.c);
        } else if (mv.type == Move.WALL_H) {
            b.addH(mv.r, mv.c);
            b.consumeWall(p);
        } else {
            b.addV(mv.r, mv.c);
            b.consumeWall(p);
        }
    }

    /** 简易文本渲染:横墙用 ==,竖墙用 |。 */
    private static String render(WallBoard b) {
        StringBuilder sb = new StringBuilder();
        for (int r = 0; r < WallBoard.SIZE; r++) {
            for (int c = 0; c < WallBoard.SIZE; c++) {
                if (b.row(0) == r && b.col(0) == c) sb.append('A');
                else if (b.row(1) == r && b.col(1) == c) sb.append('B');
                else sb.append('.');
                if (c < WallBoard.SIZE - 1) sb.append(b.hasV(r, c) ? '|' : ' ');
            }
            sb.append('\n');
            if (r < WallBoard.SIZE - 1) {
                for (int c = 0; c < WallBoard.SIZE; c++) {
                    sb.append(b.hasH(r, c) ? "==" : "  ");
                }
                sb.append('\n');
            }
        }
        return sb.toString();
    }
}

九、复杂度与实测数据

操作 时间复杂度 说明
棋盘快照 / 还原 O(1) 两个 long
单次 A* 最短路 O(81 log 81) 9×9 网格,常数极小
反向距离场 O(81) 一次 BFS 服务全部走子候选
全候选暴力评估 约 224 次最短路 15–40 ms
三层剪枝后 约 30 次最短路 3–5 ms

实测 9×9、双方各 10 块墙的完整对局大约 40–70 回合,主动进攻型 AI 明显能压制随机走棋。若把评估改成相对形式 (对手距离 / 我方距离),AI 会更倾向于”领先时冲刺、落后时才放墙”,棋风更接近人类高手。

十、延伸:墙棋与真实工程问题的同构

墙棋的算法骨架在工程里反复出现:

  1. 增量最短路 —— 导航系统中”某条路封路后重算路径”,用的就是 D* Lite 的局部松弛思想,而”两个 long 快照”正是它的低成本状态版本;
  2. 带约束的布局优化 —— 放墙等价于”在网络中加入阻断但不能使网络失联”,这对应图论里的割点与桥边分析;
  3. 两人零和距离竞赛 —— 双方共享同一张图、各自求最短路、一方可改造图结构,这是典型的 Stackelberg 博弈(领导者先动、跟随者后应)。

要做出更强 AI,下一步是 alpha-beta + 置换表:把状态编码为 (posA, posB, hMask, vMask, wallsLeftA, wallsLeftB) 的 Zobrist 哈希。位图墙体让 Zobrist 更新只需异或两个随机数,实现极其干净。

十一、小结

本文实现了完整的墙棋引擎:

  1. 位图墙体:两个 long 存 128 个墙位,快照成本 O(1),是搜索性能的根基;
  2. A* 最短路:行差作为可采纳且一致的启发,首次弹出目标即最优;
  3. 反向距离场:一次 BFS 得到全盘距离,走子评估退化为数组查表;
  4. 合法性检查:靠”快照-放墙-判定-还原”的 O(1) 复制完成,这是位图表示的核心红利;
  5. 三层剪枝:距离不增即弃、限定对手最短路周边、我方距离不得恶化。

一句话总结:把棋盘压缩成位图,把局面评估压缩成最短路,把博弈压缩成距离差——墙棋就变成了一道可解的图论题。