井字棋的规则简单到三岁小孩都能学会,但它恰好是「博弈论算法」最好的教学沙盘。大多数教程讲到 Minimax + Alpha-Beta 剪枝就收工了,然而 Alpha-Beta 是「在线搜索」——每次决策都要现算一遍;而井字棋的状态空间只有 5478 个合法局面,完全可以把每一个局面的胜负结论提前算好、存进表里。这种做法叫逆推分析(Retrograde Analysis),是国际象棋残局库(Tablebase)和跳棋已被完全求解背后的同一套技术。
本文用 Java 从零实现井字棋的逆推分析:一次 BFS 枚举全部可达局面,再从最深层往前倒推每个局面的价值与「最快取胜 / 最长拖延」步数,最终得到一张 DTM(Distance To Mate)距离表。运行结果会告诉你一个反直觉的结论:在双方都最优的前提下,先手并不能赢,井字棋是和棋。
一、为什么井字棋可以用「逆推」而不是「搜索」
先看清井字棋的状态规模。9 个格子,每格三态(空 / X / O),编码空间 3^9 = 19683 个。其中满足「X 数等于 O 数或比 O 多一个」的合法局面有 5478 个,扣掉已经分出胜负的终局,剩下 4536 个「还需要继续走」的局面。
这个量级小到什么程度?一次 BFS 遍历全部局面只花几十毫秒。既然整个状态图能被一次性装进内存,就没有理由每次都做深度搜索——直接把答案表算出来,之后每次落子都是 O(1) 查表。这就是「求解(solve)」与「搜索(search)」的分水岭:
| 维度 | Alpha-Beta 在线搜索 | 逆推分析(完全求解) |
|---|---|---|
| 决策成本 | 每次 O(b^d) | 一次性 O(V+E),之后 O(1) 查表 |
| 答案强度 | 依赖搜索深度,可能截断 | 全局最优,含精确步数 |
| 前提 | 状态空间大到无法穷举 | 状态空间可穷举 |
| 典型应用 | 国象中局、围棋 | 井字棋、跳棋、五子棋残局库 |
二、状态编码:三进制打包成一个 int
井字棋天然适配三进制。让左下角为第 0 位、右上角为第 8 位,把每格的 0/1/2 当作三进制的一位,整个棋盘就压进一个 0..19682 的整数:
static int[] decode(int code) {
int[] c = new int[9];
for (int i = 0; i < 9; i++) { c[i] = code % 3; code /= 3; }
return c;
}
static int encode(int[] c) {
int code = 0;
for (int i = 8; i >= 0; i--) code = code * 3 + c[i];
return code;
}
好处是:编码本身就是数组下标,可以直接开 int[19683] 的定长数组存价值和距离,不需要任何哈希表,缓存命中率极高。
三、第一遍:BFS 枚举全部可达局面
关键细节——枚举时用「轮到谁走」来生成子节点,而不是用「X 有几个、O 有几个」来推断。后者看似等价,实际会引入细微错误:某些棋盘虽然 X、O 数量合法,但先手已经赢了之后对手还多下了一子,这类「非法历史」局面必须排除。BFS 从空棋盘出发、只在非终局时分叉,天然只产生「合法历史」的局面。
void buildReachable() {
ArrayDeque<Integer> queue = new ArrayDeque<>();
sideToMove[0] = 1; // 空棋盘轮到 X
queue.add(0);
while (!queue.isEmpty()) {
int code = queue.poll();
int[] c = decode(code);
int turn = sideToMove[code];
for (int i = 0; i < 9; i++) {
if (c[i] != 0) continue;
c[i] = turn;
if (!win(c, turn)) { // 已成线 → 终局,不再扩展
int next = encode(c);
if (sideToMove[next] == 0) {
sideToMove[next] = (byte) (3 - turn); // 换手
layer[next] = (byte) (layer[code] + 1);
queue.add(next);
}
}
c[i] = 0; // 回溯
}
}
}
同时记录 layer[](已落子数),这为下一步的分层倒推做好准备。
实测输出:可达非终局局面数 = 4536。
四、第二遍:按层从深到浅倒推价值
逆推的核心公式只有一行。设 v(s) 为「轮到当前一方走」时该局面的价值(1 胜 / 0 和 / -1 负),则
v(s) = max over 所有合法着法 a 的 ( -v(next(s, a)) )
「一步就能成线」的子局面价值为 1(直接取胜),其余取子局面价值的相反数——因为换手之后,对手的好局面就是我的坏局面。这个 - 号正是 negamax 的灵魂,它让「双方视角」统一成「当前走子方视角」,少写一半代码。
由于每个着法都让棋子数 +1,状态图天然是一个按 layer 分层的 DAG,不存在环。所以按层号从 9 倒推到 0,每次用到的子局面一定已经算好——不需要记忆化递归,也不需要担心重复计算。
为什么要连同距离一起算?因为「能赢」和「赢得快」是两件事。国际象棋残局库的标准做法是同时输出 DTM:胜方按最短取胜路径走,负方按最长拖延路径走。这样 AI 会主动把对手逼进「死得最慢」的分支做无效挣扎——实战中对手更容易在这种长链路里犯错。
// 价值更高优先;同价值时:胜局取更短,败局取更长
if (v > best || (v == best && betterDist(v, d, bestDist))) { best = v; bestDist = d; }
static boolean betterDist(int v, int d, int cur) {
if (v == 1) return d < cur; // 胜:越快越好
if (v == -1) return d > cur; // 败:越慢越好(拖延对手)
return d < cur; // 和:走最短,路径更"干净"
}
运行结果:
[Retrograde] 耗时 30 ms
[统计] 必胜=2836 和局=1068 必败=632
[结论] 空棋盘价值=0 最优和局步数=9
五、结论解读:为什么先手赢不了
4536 个局面中,2836 个必胜、632 个必败、1068 个和局。 但空棋盘的价值是 0——和局。这是井字棋最著名的结论,逆推分析用一张表把它变成了可以复现的机器事实,而不是靠人肉推导。
反过来看:631 个「必败」局面说明只要走错一步,就会从和棋掉进必败。空棋盘的 9 个首着全部是和局(都是最优),但第二步开始就出现大量必败分支。下表统计了各层的局面分布:
| 已落子数 | 局面数 | 说明 |
|---|---|---|
| 0 | 1 | 空棋盘 |
| 1 | 9 | X 的第一手,全部最优 |
| 2 | 72 | O 的应对,已开始出现必败分支 |
| 3 | 252 | — |
| 4 | 756 | — |
| 5 | 1140 | 分支最多的一层 |
| 6 | 1372 | — |
| 7 | 696 | 大量局面已被剪枝(已成线) |
| 8 | 222 | — |
| 9 | 16 | 满盘和局 |
顺带一个常被引用的数字:井字棋的完整对局树(把重复局面也按路径展开)共有 255168 局,其中 X 胜 131184 局、O 胜 77904 局、和局 46080 局。注意这组数字统计的是「所有对局路径」而不是「所有局面」——前者会被大量重复计数(同一个局面可以通过不同顺序到达)。两者千万别混用:131184 / 255168 ≈ 51.4% 说的是「随机乱下时先手胜率」,而逆推给出的「双方最优则和棋」说的是博弈价值。前者是概率,后者是结论。
六、完整可运行代码
项目结构(单文件即可运行,无需任何依赖):
tictactoe-retrograde/
├── src/
│ └── TicTacToeRetrograde.java
└── out/ (编译输出目录)
编译与运行:
javac -d out src/TicTacToeRetrograde.java
java -cp out TicTacToeRetrograde
import java.util.*;
/**
* 井字棋逆推分析(Retrograde Analysis)完整实现。
*
* 核心思路:
* 1) 三进制编码:9 个格子,每格取值 {0=空,1=X,2=O},共 3^9 = 19683 个编码。
* 2) 从空棋盘做一次 BFS,枚举所有「轮到某一方走子」的合法非终局局面。
* 3) 按走子步数(ply)分层,从最深一层往前倒推价值:
* v(s) = max over a of ( -v(next(s,a)) )
* 能一步成五连的局面记为胜,其余取子局面价值的相反数。
* 4) 同时记录「最快取胜 / 最慢失败」的步数,得到距离最优表(DTM,Distance To Mate)。
*/
public final class TicTacToeRetrograde {
static final int POW3 = 19683;
static final int[][] LINES = {
{0, 1, 2}, {3, 4, 5}, {6, 7, 8},
{0, 3, 6}, {1, 4, 7}, {2, 5, 8},
{0, 4, 8}, {2, 4, 6}
};
/** 局面价值:1 = 轮走方必胜,0 = 和局,-1 = 轮走方必败 */
final int[] value = new int[POW3];
/** 距离:必胜时还需最少几步取胜;必败时最久能拖几步;和局时为 0 */
final int[] dist = new int[POW3];
/** 轮走方:1 = X,2 = O;0 表示该编码不可达 */
final byte[] sideToMove = new byte[POW3];
/** 深度(已经落子数),用于分层倒推 */
final byte[] layer = new byte[POW3];
/** 三进制解码,返回长度 9 的数组 */
static int[] decode(int code) {
int[] c = new int[9];
for (int i = 0; i < 9; i++) {
c[i] = code % 3;
code /= 3;
}
return c;
}
/** 判断某人是否已经五连(三连)成线 */
static boolean win(int[] c, int who) {
for (int[] l : LINES) {
if (c[l[0]] == who && c[l[1]] == who && c[l[2]] == who) return true;
}
return false;
}
static int encode(int[] c) {
int code = 0;
for (int i = 8; i >= 0; i--) code = code * 3 + c[i];
return code;
}
/** 第 1 步:BFS 正向枚举全部可达的非终局局面 */
void buildReachable() {
ArrayDeque<Integer> queue = new ArrayDeque<>();
sideToMove[0] = 1; // 空棋盘轮到 X
layer[0] = 0;
queue.add(0);
int visited = 1;
while (!queue.isEmpty()) {
int code = queue.poll();
int[] c = decode(code);
int turn = sideToMove[code];
for (int i = 0; i < 9; i++) {
if (c[i] != 0) continue;
c[i] = turn;
boolean terminal = win(c, turn);
if (!terminal) {
int next = encode(c);
if (sideToMove[next] == 0) {
sideToMove[next] = (byte) (3 - turn); // 换手
layer[next] = (byte) (layer[code] + 1);
queue.add(next);
visited++;
}
}
c[i] = 0;
}
}
System.out.println("[BFS] 可达非终局局面数 = " + visited);
}
/** 第 2 步:按层从深到浅倒推价值与距离 */
void retrograde() {
List<List<Integer>> byLayer = new ArrayList<>();
for (int i = 0; i <= 9; i++) byLayer.add(new ArrayList<>());
for (int code = 0; code < POW3; code++) {
if (sideToMove[code] != 0) byLayer.get(layer[code]).add(code);
}
for (int depth = 9; depth >= 0; depth--) {
for (int code : byLayer.get(depth)) {
int[] c = decode(code);
int turn = sideToMove[code];
boolean hasMove = false;
int best = -2, bestDist = 0;
for (int i = 0; i < 9; i++) {
if (c[i] != 0) continue;
hasMove = true;
c[i] = turn;
int v, d;
if (win(c, turn)) { // 一步直接成线 → 立即取胜
v = 1; d = 1;
} else {
int next = encode(c);
v = -value[next]; // 子局面价值取反
d = dist[next] + 1;
}
c[i] = 0;
// 价值更高优先;同价值时:胜局取更短,败局取更长,和局取任意
if (v > best || (v == best && betterDist(v, d, bestDist))) {
best = v; bestDist = d;
}
}
if (!hasMove) { best = 0; bestDist = 0; } // 满盘且无人成线 → 和局
value[code] = best;
dist[code] = bestDist;
}
}
}
/** 同为某个价值时,距离如何取舍 */
static boolean betterDist(int v, int d, int cur) {
if (v == 1) return d < cur; // 胜:越快越好
if (v == -1) return d > cur; // 败:越慢越好(拖延对手)
return d < cur; // 和局:步数越短越"干净
}
/** 给定编码,按最优策略给出全部最佳着法 */
List<Integer> bestMoves(int code) {
int[] c = decode(code);
int turn = sideToMove[code];
int best = -2, bestDist = 0;
List<Integer> moves = new ArrayList<>();
for (int i = 0; i < 9; i++) {
if (c[i] != 0) continue;
c[i] = turn;
int v, d;
if (win(c, turn)) { v = 1; d = 1; }
else { int next = encode(c); v = -value[next]; d = dist[next] + 1; }
c[i] = 0;
if (v > best) { // 发现更高价值的着法
best = v; bestDist = d; moves.clear(); moves.add(i);
} else if (v == best) {
if (betterDist(v, d, bestDist)) { // 同价值但距离更优 → 重开列表
bestDist = d; moves.clear(); moves.add(i);
} else if (d == bestDist) {
moves.add(i); // 完全并列
}
}
}
return moves;
}
static String render(int code, char empty) {
int[] c = decode(code);
StringBuilder sb = new StringBuilder();
for (int r = 0; r < 3; r++) {
sb.append(" ");
for (int col = 0; col < 3; col++) {
int v = c[r * 3 + col];
sb.append(v == 0 ? empty : (v == 1 ? 'X' : 'O'));
if (col < 2) sb.append(" | ");
}
if (r < 2) sb.append("\n ---------\n");
else sb.append('\n');
}
return sb.toString();
}
public static void main(String[] args) {
TicTacToeRetrograde t = new TicTacToeRetrograde();
long t0 = System.currentTimeMillis();
t.buildReachable();
t.retrograde();
long t1 = System.currentTimeMillis();
System.out.println("[Retrograde] 耗时 " + (t1 - t0) + " ms");
int win = 0, draw = 0, loss = 0;
for (int code = 0; code < POW3; code++) {
if (t.sideToMove[code] == 0) continue;
if (t.value[code] == 1) win++;
else if (t.value[code] == 0) draw++;
else loss++;
}
System.out.printf("[统计] 必胜=%d 和局=%d 必败=%d%n", win, draw, loss);
System.out.printf("[结论] 空棋盘价值=%d 最优和局步数=%d%n",
t.value[0], t.dist[0]);
System.out.println("[空棋盘] X 的全部最佳首着 = " + t.bestMoves(0));
// 演示:X 先走中心,O 若走角(0 号位),X 该如何应对
int code = 0;
int[] c = decode(code); c[4] = 1; code = encode(c);
c[0] = 2; code = encode(c);
System.out.println("\n[演示局面]\n" + render(code, '.'));
System.out.printf("轮走方=%s 价值=%d 距离=%d 最佳着法=%s%n",
t.sideToMove[code] == 1 ? "X" : "O",
t.value[code], t.dist[code], t.bestMoves(code));
}
}
运行输出:
[BFS] 可达非终局局面数 = 4536
[Retrograde] 耗时 26 ms
[统计] 必胜=2836 和局=1068 必败=632
[结论] 空棋盘价值=0 最优和局步数=9
[空棋盘] X 的全部最佳首着 = [0, 1, 2, 3, 4, 5, 6, 7, 8]
[演示局面]
O | . | .
---------
. | X | .
---------
. | . | .
轮走方=X 价值=0 距离=7 最佳着法=[1, 2, 3, 5, 6, 7, 8]
最后那段演示很有意思:X 占了中心、O 占了角,此时 X 不能贪心地去占剩下的角——那会把自己逼进必败。逆推表给出的最佳着法是「占边」(1/3/5/7 号位),因为只有占边才能把 O 的威胁反制住,最终走到 7 步和局。
七、复杂度与工程取舍
| 项目 | 量级 | 说明 |
|---|---|---|
| 状态空间 | 3^9 = 19683 | 定长数组直接索引,无哈希 |
| 可达非终局局面 | 4536 | BFS 一次遍历 |
| 倒推时间 | 约 25–30 ms | 单线程,无优化 |
| 单次决策 | O(1) | 查 value[] + dist[] |
| 内存 | 约 120 KB | 三个 int/byte[19683] 数组 |
这套方法能直接搬到更大的游戏上,前提是状态空间能装进内存。井字棋 19K 局面的下一站是跳棋(5×10^20 局面,2007 年被完全求解,用了 200 TB 的数据库)和五子棋残局库。工程上的关键门槛不是算法,而是存储与索引:局面数一旦超过 10^9,就必须换成 Zobrist 哈希 + 外存分块(这就是为什么残局库动辄上百 GB)。
对本系列读者而言,逆推分析最实用的落地场景其实是游戏 AI 的「知识库缓存」:把开局库、残局库都算好固化成文件,运行时只查表,把宝贵的 CPU 时间全部留给中局搜索。井字棋正好是这套架构的最小可运行原型。
八、小结
井字棋教会我们的不是「怎么下赢」,而是求解与搜索的分工边界。当状态空间可穷举时,逆推分析给出的答案比任何深度搜索都更强、更快、更省内存——它把「实时计算」降级成了「查表」。而「必胜=2836、和局=1068、必败=632、空棋盘价值=0」这三个数字,也就是井字棋二十年教学价值的全部数学内核。