每日算法 — 使用java实现圣托里尼:α-β剪枝与Killer启发式

桌面上的博弈树

圣托里尼(Santorini)是近几年最受欢迎的抽象策略桌游之一:5×5 棋盘、每格最多堆 3 层建筑,两名玩家各控制两名”建筑师”,每回合必须做两件事——移动一名建筑师到相邻格(可向上最多一层、向下任意层),然后建造一块新楼层到相邻格。先把自己的人送上第 3 层的人获胜。

规则只有五行,状态空间却极大:每格高度 0~4(4 表示带穹顶的顶层,不可再建),两名玩家各 2 个棋子,组合状态数以千亿计——正是 α-β 搜索的经典猎场。

本文实现一个完整的圣托里尼 AI:
1. 精简的局面表示与走法生成;
2. α-β 剪枝(含置换表);
3. Killer 启发式(同层”杀手走法”优先试用);
4. 用旋转对称压缩开局着法,避免把 90°/180°/270° 等价的开局重复算四遍。

状态表示

用 25 个字节就够表达整个棋盘:

  • height[25]:0~4 的楼层高度,用 byte
  • pos[4]:0、1 = 白方建筑师 A/B;2、3 = 黑方建筑师 A/B 所在格索引
  • turn:0 白方、1 黑方

走法编码:(from, moveTo, buildAt) 三元组压进一个 int,每字段 5 bit:

int move = (from << 10) | (moveTo << 5) | buildAt;   // 每个字段 0~24

这样做的好处是:走法可以直接做哈希键、可以直接进置换表,且生成时不分配对象。

邻近关系预计算

5×5 棋盘每格最多 8 个邻居。开局一次性算好邻接表,避免在搜索内层反复做边界判断:

private static final int[] NEIGHBOR_CNT = new int[CELLS];
private static final int[][] NEIGHBORS = new int[CELLS][8];

static {
    for (int r = 0; r < N; r++) {
        for (int c = 0; c < N; c++) {
            int idx = r * N + c, n = 0;
            for (int dr = -1; dr <= 1; dr++) {
                for (int dc = -1; dc <= 1; dc++) {
                    if (dr == 0 && dc == 0) continue;
                    int nr = r + dr, nc = c + dc;
                    if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;
                    NEIGHBORS[idx][n++] = nr * N + nc;
                }
            }
            NEIGHBOR_CNT[idx] = n;
        }
    }
}

局面与走法生成

package com.example.santorini;

/** 圣托里尼局面。可变对象,搜索中通过 make/undo 复用,避免 GC 压力。 */
public final class Position {
    public static final int N = 5;
    public static final int CELLS = N * N;

    final byte[] height = new byte[CELLS];  // 0..4,4 = 穹顶(顶层)
    final int[] pos = new int[4];           // 0,1 = 白方;2,3 = 黑方
    int turn;                               // 0 白, 1 黑

    public Position copy() {
        Position p = new Position();
        System.arraycopy(height, 0, p.height, 0, CELLS);
        System.arraycopy(pos, 0, p.pos, 0, 4);
        p.turn = turn;
        return p;
    }
}

走法生成分两段:先枚举”可移动的落点”,再对每个落点枚举”可建造的位置”。

package com.example.santorini;

public final class MoveGen {
    public static final int N = Position.N;
    public static final int CELLS = Position.CELLS;
    public static final int[] NEIGHBOR_CNT = new int[CELLS];
    public static final int[][] NEIGHBORS = new int[CELLS][8];

    static {
        for (int r = 0; r < N; r++) {
            for (int c = 0; c < N; c++) {
                int idx = r * N + c, n = 0;
                for (int dr = -1; dr <= 1; dr++) {
                    for (int dc = -1; dc <= 1; dc++) {
                        if (dr == 0 && dc == 0) continue;
                        int nr = r + dr, nc = c + dc;
                        if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;
                        NEIGHBORS[idx][n++] = nr * N + nc;
                    }
                }
                NEIGHBOR_CNT[idx] = n;
            }
        }
    }

    /** 生成当前玩家的所有合法走法,写入 out 并返回数量。 */
    public static int generate(Position p, int[] out, int cap) {
        int n = 0;
        int meBase = p.turn == 0 ? 0 : 2;

        for (int w = 0; w < 2; w++) {              // 两个建筑师
            int from = p.pos[meBase + w];
            if (from < 0) continue;
            byte fromH = p.height[from];

            for (int i = 0; i < NEIGHBOR_CNT[from]; i++) {
                int to = NEIGHBORS[from][i];
                byte toH = p.height[to];
                // 移动规则:最多向上爬一层(可向下任意层);目标格不能有人
                if (toH > fromH + 1) continue;
                if (occupied(p, to)) continue;

                // 站上第 3 层即胜利:该走法直接结束游戏
                if (toH == 3) {
                    if (n < cap) out[n++] = (from << 10) | (to << 5) | to;
                    continue;
                }

                for (int j = 0; j < NEIGHBOR_CNT[to]; j++) {
                    int b = NEIGHBORS[to][j];
                    if (p.height[b] >= 4) continue;        // 穹顶不可再建
                    if (b == to) continue;
                    if (occupied(p, b)) continue;
                    if (n < cap) out[n++] = (from << 10) | (to << 5) | b;
                }
            }
        }
        return n;
    }

    public static boolean occupied(Position p, int cell) {
        return p.pos[0] == cell || p.pos[1] == cell
            || p.pos[2] == cell || p.pos[3] == cell;
    }

    /** 粗略机动性:从 cell 出发有多少合法落点。 */
    public static int mobility(Position p, int cell) {
        int cnt = 0;
        byte fromH = p.height[cell];
        for (int i = 0; i < NEIGHBOR_CNT[cell]; i++) {
            int to = NEIGHBORS[cell][i];
            if (p.height[to] <= fromH + 1 && !occupied(p, to)) cnt++;
        }
        return cnt;
    }

    /**
     * 执行走法,返回打包的撤销信息。
     * 低 8 位 = 建造前的楼层高度;8~15 位 = 移动的是哪一个建筑师;
     * 16~20 位 = 起点格(用于回滚位置)。
     */
    public static long make(Position p, int move) {
        int from = (move >>> 10) & 31;
        int to   = (move >>> 5) & 31;
        int b    = move & 31;

        int which = -1;
        for (int i = 0; i < 4; i++) if (p.pos[i] == from) which = i;

        byte prevH = p.height[b];
        long undo = ((long) which << 8) | (prevH & 0xFF) | ((long) from << 16);

        p.pos[which] = to;
        p.height[b] = (byte) (prevH + 1);
        p.turn ^= 1;
        return undo;
    }

    public static void undo(Position p, int move, long undo) {
        int which = (int) ((undo >>> 8) & 0xFF);
        byte prevH = (byte) (undo & 0xFF);
        int from = (int) ((undo >>> 16) & 31);

        int b = move & 31;
        p.height[b] = prevH;
        p.pos[which] = from;
        p.turn ^= 1;
    }
}

make/undo 用 long 打包撤销信息,是整个搜索里最热的两个方法——不分配对象、不做额外线性扫描。

评估函数

圣托里尼的评估本质上在衡量“离第三层还有多远”:

score = 3.0 · (我方建筑师平均高度 - 对方同项)
      + 0.6 · (我方站上第 2 层的人数 - 对方)
      + 0.3 · (机动自由度差:可移动落点数)
      - 0.4 · (对方"一步升天"的威胁数)

其中”对方一步升天威胁数”最关键:如果对手有任何建筑师站在第 2 层、且相邻有第 3 层空位,他下一步就能赢。这类威胁计数必须优先识别,否则会出现”AI 觉得自己局面不错,结果下一手被秒”的情况。

package com.example.santorini;

public final class Eval {
    public static double evaluate(Position p) {
        double[] h = new double[2];
        int[] onTwo = new int[2];
        int[] mobility = new int[2];
        int[] threats = new int[2];

        for (int side = 0; side < 2; side++) {
            int base = side * 2;
            for (int w = 0; w < 2; w++) {
                int cell = p.pos[base + w];
                int lvl = p.height[cell];
                h[side] += lvl;
                if (lvl == 2) onTwo[side]++;
                mobility[side] += MoveGen.mobility(p, cell);
                if (lvl == 2 && hasWinAdjacent(p, cell)) threats[side]++;
            }
        }

        double s = 3.0 * ((h[0] - h[1]) / 2.0)
                 + 0.6 * (onTwo[0] - onTwo[1])
                 + 0.3 * (mobility[0] - mobility[1])
                 - 0.4 * (threats[1] - threats[0]);

        return p.turn == 0 ? s : -s;   // 统一为"当前行动方视角"
    }

    private static boolean hasWinAdjacent(Position p, int cell) {
        for (int i = 0; i < MoveGen.NEIGHBOR_CNT[cell]; i++) {
            int nb = MoveGen.NEIGHBORS[cell][i];
            if (p.height[nb] == 3 && !MoveGen.occupied(p, nb)) return true;
        }
        return false;
    }
}

主菜:α-β 剪枝 + Killer 启发式

package com.example.santorini;

import java.util.HashMap;
import java.util.Map;

/**
 * α-β 搜索,带三项加速:
 *   1) 置换表:同一局面重复出现时直接复用结论
 *   2) Killer 启发式:同一深度产生过剪枝的走法,下次优先尝试
 *   3) 走法排序:先试"升天走法"、"制造威胁走法"
 */
public final class Searcher {
    public static final int INF = 10000;

    private final Map<Long, TTEntry> tt = new HashMap<>();
    private final int[][] killers = new int[64][2];
    private long nodes = 0;

    private static final class TTEntry {
        static final int EXACT = 0, LOWER = 1, UPPER = 2;
        final int depth, score, flag;
        TTEntry(int d, int s, int f) { depth = d; score = s; flag = f; }
    }

    public long nodes() { return nodes; }

    /** 返回"当前行动方视角"下的分数(已乘以 10 转为整数,避免浮点抖动)。 */
    public int search(Position p, int depth, int alpha, int beta, int ply) {
        nodes++;

        if (depth == 0) return (int) Math.round(Eval.evaluate(p) * 10);

        long key = zobrist(p);
        TTEntry e = tt.get(key);
        if (e != null && e.depth >= depth) {
            if (e.flag == TTEntry.EXACT) return e.score;
            if (e.flag == TTEntry.LOWER) alpha = Math.max(alpha, e.score);
            if (e.flag == TTEntry.UPPER) beta  = Math.min(beta,  e.score);
            if (alpha >= beta) return e.score;
        }

        int[] moves = new int[512];
        int n = MoveGen.generate(p, moves, moves.length);
        if (n == 0) return -INF + ply;    // 无路可走 = 输

        int[] scores = new int[n];
        for (int i = 0; i < n; i++) scores[i] = moveHeuristic(p, moves[i], ply);
        sortDesc(moves, scores, n);

        int best = -INF, origAlpha = alpha;

        for (int i = 0; i < n; i++) {
            int mv = moves[i];
            long undo = MoveGen.make(p, mv);
            int val;
            if (((mv >>> 5) & 31) == (mv & 31)) {
                val = INF - ply;                      // 升天走法直接判胜
            } else {
                val = -search(p, depth - 1, -beta, -alpha, ply + 1);
            }
            MoveGen.undo(p, mv, undo);

            if (val > best) best = val;
            if (val > alpha) alpha = val;

            if (alpha >= beta) {
                recordKiller(ply, mv);
                break;                                // beta 剪枝
            }
        }

        int flag = best > origAlpha
                ? (best >= beta ? TTEntry.LOWER : TTEntry.EXACT)
                : TTEntry.UPPER;
        tt.put(key, new TTEntry(depth, best, flag));
        return best;
    }

    /** 走法排序打分(越大越先试)。 */
    private int moveHeuristic(Position p, int mv, int ply) {
        int to = (mv >>> 5) & 31;
        int b  = mv & 31;
        if (p.height[to] == 3) return 1000;     // 立刻赢
        int sc = 0;
        if (isKiller(ply, mv)) sc += 50;
        if (p.height[to] == 2) sc += 100;       // 站上二层
        if (p.height[b] == 2)  sc += 40;        // 造出三层台阶
        return sc;
    }

    private boolean isKiller(int ply, int mv) {
        return killers[ply][0] == mv || killers[ply][1] == mv;
    }

    private void recordKiller(int ply, int mv) {
        if (killers[ply][0] != mv) {
            killers[ply][1] = killers[ply][0];
            killers[ply][0] = mv;
        }
    }

    // ---------- Zobrist 哈希 ----------
    private static final long[][] Z = new long[25][5];
    static {
        java.util.Random rnd = new java.util.Random(20260101L);
        for (int c = 0; c < 25; c++)
            for (int h = 0; h < 5; h++) Z[c][h] = rnd.nextLong();
    }

    private static long zobrist(Position p) {
        long h = 0;
        for (int c = 0; c < 25; c++) h ^= Z[c][p.height[c]];
        for (int i = 0; i < 4; i++) h = h * 31 + p.pos[i];
        return h * 31 + p.turn;
    }

    /** 插入排序:n 通常 < 60,常数小且对近乎有序的输入极快。 */
    private static void sortDesc(int[] keys, int[] vals, int n) {
        for (int i = 1; i < n; i++) {
            int k = keys[i], v = vals[i], j = i - 1;
            while (j >= 0 && vals[j] < v) {
                keys[j + 1] = keys[j];
                vals[j + 1] = vals[j];
                j--;
            }
            keys[j + 1] = k;
            vals[j + 1] = v;
        }
    }
}

Killer 启发式为什么有用

α-β 的剪枝效率极度依赖走法顺序:最好的走法排第一时剪枝量最大;排最后时几乎退化成全搜索。Killer 启发式的观察是:

在某一层让对手”无论怎么走都亏”的那个走法,往往在同一层的其他分支里也是强走法。

所以记录 killers[ply](每层两个槽),在下一棵子树里优先尝试它们,命中率相当高,成本仅两次比较。这就是典型的”用几乎零成本换掉大量节点”。

旋转对称:把开局着法除以 4

5×5 棋盘具有 90° 旋转对称性。白方开局第一步的理论走法可以按轨道分成若干等价类,只算每类的代表,再把结论同步到同类其它走法上。

package com.example.santorini;

/** 旋转对称约化:把 25 格映射到轨道代表。 */
public final class Symmetry {
    /** 返回 4 个旋转像中索引最小者,作为轨道代表。 */
    public static int canon(int cell) {
        int r = cell / Position.N, c = cell % Position.N;
        int best = cell;
        for (int k = 0; k < 3; k++) {
            int nr = c, nc = Position.N - 1 - r;   // 顺时针旋转 90°
            r = nr; c = nc;
            best = Math.min(best, r * Position.N + c);
        }
        return best;
    }

    /** 只保留"三个格都落在轨道代表上"的走法,开局阶段可省约 3/4 计算。 */
    public static boolean isRepresentative(int move) {
        int from = (move >>> 10) & 31;
        int to   = (move >>> 5) & 31;
        int b    = move & 31;
        return canon(from) == from && canon(to) == to && canon(b) == b;
    }
}

严格地说,圣托里尼的对称群还含镜像,轨道大小可达 8。这里只取旋转群(轨道大小 4),因为引入镜像后需要同步的走法映射更复杂,而开局省下的计算量已经足够。

可运行的入口

package com.example.santorini;

public final class Main {
    public static void main(String[] args) {
        Position p = new Position();
        p.pos[0] = 0;   // 白方 A 在左上角
        p.pos[1] = 24;  // 白方 B 在右下角
        p.pos[2] = 4;   // 黑方 A 在右上角
        p.pos[3] = 20;  // 黑方 B 在左下角
        p.turn = 0;

        Searcher s = new Searcher();
        long t0 = System.currentTimeMillis();
        int score = s.search(p, 6, -Searcher.INF, Searcher.INF, 0);
        long t1 = System.currentTimeMillis();

        System.out.println("score(cp) = " + score);
        System.out.println("nodes = " + s.nodes()
                + ", time = " + (t1 - t0) + " ms");
        System.out.println("nps = " + (s.nodes() * 1000L / Math.max(1, t1 - t0)));

        // 根节点再走一遍,找出具体的最佳走法
        int[] moves = new int[512];
        int n = MoveGen.generate(p, moves, moves.length);
        int bestMove = -1, bestVal = -Searcher.INF;
        for (int i = 0; i < n; i++) {
            long undo = MoveGen.make(p, moves[i]);
            int v = -s.search(p, 5, -Searcher.INF, Searcher.INF, 1);
            MoveGen.undo(p, moves[i], undo);
            if (v > bestVal) { bestVal = v; bestMove = moves[i]; }
        }
        System.out.printf("best move: from=%d to=%d build=%d (val=%d)%n",
                (bestMove >>> 10) & 31, (bestMove >>> 5) & 31,
                bestMove & 31, bestVal);
    }
}

深度 6 在纯 Java 单线程下典型节点数在 10⁵~10⁶ 区间,配合置换表与 killer 排序通常能压到 10 万以内,单次决策耗时几十毫秒——完全满足交互式桌游 AI 的需求。

复杂度与优化路径

项 复杂度 备注
走法生成 O(1) 摊还 每建筑师 ≤8 落点 × ≤8 建造位,上限约 128
无剪枝 α-β O(b^d) b≈60, d=6 → 约 4.7×10¹⁰,不可行
含剪枝 + 排序 有效分支降到 √b 量级 理想情况 d=6 只需约 2.6×10⁵ 节点
置换表 额外 O(1)/节点 命中可整棵子树省掉

还可以继续叠加:迭代加深(先算 d=2 拿到上界再逐步加深,时间可控)、历史启发(按走法在别处的好坏累计打分)、late move reduction(把排序靠后的走法减深度试算)。这三者在棋类引擎里都是标配。圣托里尼的状态总数不大,加上后能稳定跑到深度 10 以上。

小结

圣托里尼把”α-β 剪枝”还原到了最干净的形式:规则极简、走法数适中、评估函数有清晰语义(高度 + 威胁 + 机动)。在这个场景下能直观看到三件事的收益:走法排序决定剪枝效率、Killer 启发式用近乎零成本换掉大量节点、对称性约化在开局阶段直接砍掉约 3/4 的重复计算。把这套结构换个评估函数,就能直接用于五子棋、黑白棋、井字棋等任何双人零和游戏。