桌面上的博弈树
圣托里尼(Santorini)是近几年最受欢迎的抽象策略桌游之一:5×5 棋盘、每格最多堆 3 层建筑,两名玩家各控制两名”建筑师”,每回合必须做两件事——移动一名建筑师到相邻格(可向上最多一层、向下任意层),然后建造一块新楼层到相邻格。先把自己的人送上第 3 层的人获胜。
规则只有五行,状态空间却极大:每格高度 0~4(4 表示带穹顶的顶层,不可再建),两名玩家各 2 个棋子,组合状态数以千亿计——正是 α-β 搜索的经典猎场。
本文实现一个完整的圣托里尼 AI:
1. 精简的局面表示与走法生成;
2. α-β 剪枝(含置换表);
3. Killer 启发式(同层”杀手走法”优先试用);
4. 用旋转对称压缩开局着法,避免把 90°/180°/270° 等价的开局重复算四遍。
状态表示
用 25 个字节就够表达整个棋盘:
height[25]:0~4 的楼层高度,用bytepos[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 的重复计算。把这套结构换个评估函数,就能直接用于五子棋、黑白棋、井字棋等任何双人零和游戏。