每日算法 — 使用java实现井字棋:逆推分析与状态图完全解

井字棋的规则简单到三岁小孩都能学会,但它恰好是「博弈论算法」最好的教学沙盘。大多数教程讲到 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」这三个数字,也就是井字棋二十年教学价值的全部数学内核。