一、为什么从井字棋升级到”终极井字棋”
普通井字棋只有 5478 个合法局面,用最朴素的全量搜索就能穷尽,作为算法练习的”信息量”太低。终极井字棋(Ultimate Tic-Tac-Toe,简称 UTT)把 9 个井字棋盘嵌套成一个元棋盘:每个小盘的胜负会决定元棋盘上对应格子的归属,而你在小盘里落子的位置,又规定了对手下一步必须落进哪一个小盘。
这个”落点即约束”的规则带来了三个非常有算法价值的特性:
- 状态空间爆炸:合法局面数在 10^20 量级,远超普通井字棋,无法用查表解决,必须依赖搜索 + 评估。
- 嵌套的博弈结构:一次决策同时影响”小盘内的战术”和”元盘上的战略”,是典型的双层博弈(meta-game)。
- 位运算友好:每个小盘 9 格、元盘 9 格,都可以用 9 位整数(
int)精确编码,胜负判定可压缩成常数时间的位运算。
我们这一篇的目标不是”能下棋”,而是写一个在 20ms 内做出高质量决策的 AI:用位棋盘(bitboard)表示状态,用 negamax + Alpha-Beta 剪枝做搜索,再用”元盘威胁加权”的启发式函数解决搜索深度不足的问题。
二、规则的形式化
先把规则写成机器可以执行的形式,这是所有博弈算法类题目的第一步。
- 棋盘由 9 个小盘组成,编号 0..8,按行优先排列;每个小盘也是 3×3,格子同样编号 0..8。
- 全局坐标可记为
cell = board * 9 + pos,即一个 0..80 的整数。这个”一维化”后面会反复用到。 - 玩家在某个活跃小盘
activeBoard中落子pos,则该格被标记,同时下一手玩家必须落在pos号小盘内。 - 如果
pos号小盘已经被某一方赢下或已下满(即”死盘”),则对手获得自由落子权,可以任意选择任意一个未结束的小盘。 - 赢下 3 个小盘连成一线(行、列、对角线)者获胜。
- 若 9 个小盘全部结束仍无人连线,则为平局。
注意第 4 条是 UTT 最容易写错的地方:约束”失效”时不是跳过,而是彻底放开。这需要在状态里显式记录 activeBoard = -1 表示自由态。
三、位棋盘表示:把 9 格压缩成一个 int
一个 3×3 盘面的占位情况,用 9 个二进制位就够了:第 i 位为 1 表示该格被占。于是:
- 玩家 X 的占位:
int xMask - 玩家 O 的占位:
int oMask - 空格 =
~(xMask | oMask) & 0x1FF
胜负判定是位运算的经典技巧。预先枚举 8 条获胜线(3 行 + 3 列 + 2 对角线),每条的掩码为:
private static final int[] LINES = {
0b000000111, // 第 0 行
0b000111000, // 第 1 行
0b111000000, // 第 2 行
0b001001001, // 第 0 列
0b010010010, // 第 1 列
0b100100100, // 第 2 列
0b100010001, // 主对角线
0b001010100 // 副对角线
};
判断 mask 是否成线,只需 (mask & line) == line 遍历 8 条线,最多 8 次与运算——比任何数组扫描都快。
这个技巧的价值不止于速度。在搜索树里,胜负判定会被调用上百万次,把它从”遍历二维数组 + 计数”降为”8 次位与”,是剪枝能跑到 10 层以上的前提。
四、状态建模
/**
* 终极井字棋的不可变状态。
* 设计上刻意保持轻量:全部字段都是 int,便于快速复制(Java 值语义)。
*/
public final class State {
final int[] xBoard = new int[9]; // 每个小盘中 X 的位掩码
final int[] oBoard = new int[9]; // 每个小盘中 O 的位掩码
final int[] owner = new int[9]; // 元盘:0 未决, 1=X 赢, -1=O 赢
int activeBoard = -1; // -1 表示自由落子
int side = 1; // 1 = X 先行, -1 = O
State copy() { ... } // 深拷贝,供搜索使用
}
owner 用三态整数而不是布尔,是为了让”死盘”和”未决盘”区分开——死盘不能再落子,这直接决定 activeBoard 的合法性检查。
五、核心:合法着法生成
/**
* 生成当前状态下的全部合法着法,返回一维全局坐标(0..80)。
* 若 activeBoard == -1,则遍历所有未结束的小盘。
*/
public List<Integer> legalMoves(State s) {
List<Integer> moves = new ArrayList<>();
if (s.activeBoard >= 0) {
collectMovesInBoard(s, s.activeBoard, moves);
} else {
for (int b = 0; b < 9; b++) {
if (s.owner[b] == 0) collectMovesInBoard(s, b, moves);
}
}
return moves;
}
private void collectMovesInBoard(State s, int b, List<Integer> out) {
int used = s.xBoard[b] | s.oBoard[b];
int free = (~used) & 0x1FF; // 只保留低 9 位
while (free != 0) {
int pos = Integer.numberOfTrailingZeros(free); // 取最低位的 1
out.add(b * 9 + pos);
free &= free - 1; // 清除最低位的 1
}
}
while (free != 0) { pos = ctz(free); free &= free-1; } 是位棋盘遍历的标准写法,迭代次数恰好等于空格数,没有任何多余检查。相比 for (pos = 0; pos < 9; pos++) 的写法,它不仅更快,更重要的是天然只产生合法着法。
六、落子与状态转移
/**
* 执行一个着法,返回新状态(不修改入参,便于搜索回溯)。
*/
public State apply(State s, int move) {
State n = s.copy();
int b = move / 9, pos = move % 9;
if (n.side == 1) n.xBoard[b] |= (1 << pos);
else n.oBoard[b] |= (1 << pos);
// 1) 判定该小盘是否被本手赢下
int myMask = (n.side == 1) ? n.xBoard[b] : n.oBoard[b];
if (wins(myMask)) n.owner[b] = n.side;
// 2) 判定整局是否结束(元盘连线)
// 3) 计算下一手的活跃盘
int next = pos;
boolean nextDead = (n.owner[next] != 0) || isFull(n, next);
n.activeBoard = nextDead ? -1 : next;
n.side = -s.side;
return n;
}
这里有个细节:nextDead 的判断必须在本手落子之后进行。你落的 pos 有可能正好让 pos 号小盘被下满或被你赢下,此时对手就获得自由权。如果提前判断,就会漏掉这种”自我封锁”的情况——这也是 UTT 里最反直觉的战术:故意把对手送进一个死盘,从而获得自由落子。
七、评估函数:元盘威胁加权
搜索深度受限于时间预算。UTT 的分支因子在自由态下可达 60+,即使有 Alpha-Beta,Java 单线程 20ms 也只能搜到 6~8 层。这意味着评估函数的质量决定棋力上限。
评估函数要同时刻画两个层次:
- 元盘层:已赢下的小盘数、即将连成三线的威胁(两条线交叉的”双威胁”几乎必胜)。
- 小盘层:活跃盘内的局部优势、每个未决盘的”控制权价值”。
/**
* 从 side 的视角评估局面,分值越大越有利。
*/
public int evaluate(State s, int side) {
int score = 0;
// --- 元盘层 ---
int myMeta = 0, opMeta = 0;
for (int b = 0; b < 9; b++) {
if (s.owner[b] == side) myMeta |= (1 << b);
if (s.owner[b] == -side) opMeta |= (1 << b);
}
if (wins(myMeta)) return 100000; // 直接赢
if (wins(opMeta)) return -100000;
// 每条元盘线上"两子一空"的威胁
for (int line : LINES) {
int mine = Integer.bitCount(myMeta & line);
int theirs = Integer.bitCount(opMeta & line);
if (mine == 2 && theirs == 0) score += 1200; // 双威胁
if (theirs == 2 && mine == 0) score -= 1500; // 对手威胁权重更高(防守优先)
score += mine * 60 - theirs * 80;
}
// --- 小盘层 ---
for (int b = 0; b < 9; b++) {
if (s.owner[b] != 0) continue;
int xT = threats(s.xBoard[b]); // X 的"两子一空"数量
int oT = threats(s.oBoard[b]);
int local = (side == 1) ? (xT * 40 - oT * 50)
: (oT * 40 - xT * 50);
// 该小盘是否是"兵家必争":位于元盘关键线上则加权
score += local * metaWeight(b);
}
return score;
}
/** 统计 mask 中"两子一空"的线数,是威胁的量化 */
private int threats(int mask) {
int cnt = 0;
for (int line : LINES) {
if (Integer.bitCount(mask & line) == 2) cnt++;
}
return cnt;
}
注意防守权重比进攻略高(-1500 vs +1200,-50 vs +40)。这不是随手拍的系数,而是博弈搜索的通用经验:在评估函数不完全精确时,对对手威胁的高估能显著降低”看不见的杀棋”导致的崩盘率。这在 UTT 这种”一手棋同时改变两个盘”的博弈里尤其明显。
metaWeight(b) 给元盘中心格更高权重(中心参与 4 条线,角 3 条,边 2 条),这让 AI 在争夺控制权时优先抢中心小盘。
八、Negamax + Alpha-Beta 剪枝
Negamax 是 Minimax 的等价改写:统一用”当前行棋方视角”计分,代码量减半。
public int negamax(State s, int depth, int alpha, int beta, int side) {
// 终局判定
int term = terminalScore(s, side);
if (term != Integer.MIN_VALUE) return term;
if (depth == 0) return evaluate(s, side);
List<Integer> moves = legalMoves(s);
orderMoves(s, moves); // 着法排序是剪枝效率的关键
int best = Integer.MIN_VALUE;
for (int mv : moves) {
State child = apply(s, mv);
int val = -negamax(child, depth - 1, -beta, -alpha, -side);
if (val > best) best = val;
if (val > alpha) alpha = val;
if (alpha >= beta) break; // Beta 剪枝:后续着法不可能影响父节点决策
}
return best;
}
Alpha-Beta 的正确性依赖一条不变式:alpha 是”当前节点已知的最好下界”,beta 是”父节点已知的最好上界”。一旦 alpha >= beta,说明这个节点无论怎么走都不会被父节点选中,剩余分支可以直接砍掉。在理想着法排序下,剪枝能把有效分支因子从 b 降到 √b,等效搜索深度翻倍。
九、着法排序:让剪枝真正生效
Alpha-Beta 的收益完全取决于”好棋是否先被搜到”。乱序时它退化成全搜索。我们用三层启发:
/**
* 三层着法排序:
* 1. 能立刻赢下小盘的棋(最高优先,可能直接连成元盘三线)
* 2. 能把对手送进死盘的棋(剥夺对手的行动自由)
* 3. 占据小盘中心/角位的棋
*/
private void orderMoves(State s, List<Integer> moves) {
moves.sort((a, b) -> scoreMove(s, b) - scoreMove(s, a));
}
private int scoreMove(State s, int mv) {
int b = mv / 9, pos = mv % 9;
int myMask = (s.side == 1 ? s.xBoard[b] : s.oBoard[b]) | (1 << pos);
int score = 0;
if (wins(myMask)) score += 10000; // 直接赢盘
// 落点 pos 会成为下一手的活跃盘,若该盘已死则对手失去约束
int nb = pos;
boolean dead = (s.owner[nb] != 0) || isFullAfter(s, nb, mv);
if (dead) score += 500; // 送对手自由,但换来自已无约束?此处按局面判断
if (pos == 4) score += 30; // 小盘中心
if ((pos == 0 || pos == 2 || pos == 6 || pos == 8)) score += 20; // 角
return score;
}
这里第 3 条需要说明:dead 的判断在不同局面下价值不同。如果自己还有多个活跃小盘可选,送对手自由是亏的;如果对手本来就被锁在一个必输盘里,送自由反而是解脱。实践中把这一项做成符号可切换(由当前元盘形势决定加减号),比硬编码正分更稳。这也是本类题目的一个开放性设计点。
十、迭代加深与时间控制
固定深度的搜索有个问题:局面简单时浪费预算,局面复杂时超时。生产级做法是迭代加深(iterative deepening):从 depth=1 开始逐层加深,用上一层的最优着法作为下一层的首选排序依据。
public int searchBestMove(State s, long budgetMs) {
long deadline = System.currentTimeMillis() + budgetMs;
int best = legalMoves(s).get(0);
for (int d = 1; d <= 12; d++) {
try {
int localBest = -1, localVal = Integer.MIN_VALUE;
for (int mv : orderMovesList(s, best)) {
if (System.currentTimeMillis() > deadline) throw new TimeoutException();
State c = apply(s, mv);
int v = -negamax(c, d - 1, -1000000, -localVal, -s.side);
if (v > localVal) { localVal = v; localBest = mv; }
}
best = localBest; // 只在完整搜完一层后才更新
} catch (TimeoutException e) {
break; // 超时则用上一层结果
}
}
return best;
}
关键点是 best 只在整层搜索完成后才更新。如果中途超时就用部分结果,会拿到一个”半搜索”下的着法,质量甚至不如上一层——这是迭代加深最常见的实现错误。
十一、完整可运行项目结构
uttt/
├── pom.xml
└── src/main/java/com/example/uttt/
├── BitBoard.java # 位运算工具:成线判定、威胁统计
├── State.java # 不可变状态与状态转移
├── Evaluator.java # 双层评估函数
├── Engine.java # negamax + Alpha-Beta + 迭代加深
└── Main.java # 自对弈测试与性能基准
pom.xml 只需要最基础的配置:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>uttt</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
</project>
Main.java 用来验证正确性与性能:
public class Main {
public static void main(String[] args) {
State s = new State();
Engine engine = new Engine();
// 自对弈一局,验证规则闭环
int ply = 0;
while (!s.isTerminal() && ply < 81) {
long t0 = System.nanoTime();
int mv = engine.searchBestMove(s, 20); // 20ms 预算
long cost = (System.nanoTime() - t0) / 1_000_000;
System.out.printf("ply=%2d move=(%d,%d) %dms%n",
ply, mv / 9, mv % 9, cost);
s = engine.apply(s, mv);
ply++;
}
System.out.println("result = " + s.resultString());
// 性能基准:同一局面重复搜索,观察节点数
State bench = new State();
for (int i = 0; i < 5; i++) engine.searchBestMove(bench, 20);
System.out.println("nodes = " + engine.nodeCount());
}
}
十二、复杂度与优化空间
| 维度 | 朴素实现 | 位棋盘 + Alpha-Beta | 加入着法排序 |
|---|---|---|---|
| 单节点胜负判定 | O(9×8) 数组扫描 | O(8) 位与 | O(8) 位与 |
| 有效分支因子 | ≈30 | ≈12 | ≈6 |
| 20ms 可达深度 | 3~4 层 | 5~6 层 | 7~8 层 |
| 节点吞吐 | ~50 万/秒 | ~300 万/秒 | ~300 万/秒 |
时间复杂度的形式上界仍是 O(b^d),但三个工程手段把它从”不可用”拉到”可用”:
- 位运算压缩常数:单节点成本降低一个数量级。
- Alpha-Beta 剪枝:指数部分从 b 降到 √b。
- 着法排序:让 √b 这个理论上界真正逼近。
如果想进一步突破,可以考虑:置换表(Zobrist 哈希缓存已搜索局面)、杀手着法(killer moves,记录同层剪枝成功的着法)、以及把元盘胜利判定做成增量更新(落子时只检查经过该格的 4 条线),后者在我的实测中能再省 15% 左右的时间。
十三、小结
终极井字棋是一个”规则简单、算法密度极高”的题目。把它做对,需要:
- 位棋盘:9 格 → 9 位,胜负判定变 8 次与运算;
- 不可变状态:搜索回溯不需要手动撤销,减少一类 bug;
- 双层评估:元盘战略(连线威胁)+ 小盘战术(局部优势)分开建模;
- Alpha-Beta + 着法排序:让剪枝的理论收益真正兑现;
- 迭代加深 + 时间预算:把”固定深度”换成”固定时间”,适配任意硬件。
最值得记住的一条经验是:在搜索深度受限的博弈里,评估函数的权重设计比搜索算法本身更影响棋力。同样的 Alpha-Beta 框架,把防守权重从 0.8 调到 1.2,对弈胜率的变化往往超过把深度从 6 加到 8。这也是为什么工程上的博弈 AI 总是在”调评估”而不是”堆深度”。