每日算法 — 使用java实现终极井字棋:嵌套博弈树与位棋盘Alpha-Beta剪枝

一、为什么从井字棋升级到”终极井字棋”

普通井字棋只有 5478 个合法局面,用最朴素的全量搜索就能穷尽,作为算法练习的”信息量”太低。终极井字棋(Ultimate Tic-Tac-Toe,简称 UTT)把 9 个井字棋盘嵌套成一个元棋盘:每个小盘的胜负会决定元棋盘上对应格子的归属,而你在小盘里落子的位置,又规定了对手下一步必须落进哪一个小盘。

这个”落点即约束”的规则带来了三个非常有算法价值的特性:

  • 状态空间爆炸:合法局面数在 10^20 量级,远超普通井字棋,无法用查表解决,必须依赖搜索 + 评估。
  • 嵌套的博弈结构:一次决策同时影响”小盘内的战术”和”元盘上的战略”,是典型的双层博弈(meta-game)。
  • 位运算友好:每个小盘 9 格、元盘 9 格,都可以用 9 位整数(int)精确编码,胜负判定可压缩成常数时间的位运算。

我们这一篇的目标不是”能下棋”,而是写一个在 20ms 内做出高质量决策的 AI:用位棋盘(bitboard)表示状态,用 negamax + Alpha-Beta 剪枝做搜索,再用”元盘威胁加权”的启发式函数解决搜索深度不足的问题。

二、规则的形式化

先把规则写成机器可以执行的形式,这是所有博弈算法类题目的第一步。

  1. 棋盘由 9 个小盘组成,编号 0..8,按行优先排列;每个小盘也是 3×3,格子同样编号 0..8。
  2. 全局坐标可记为 cell = board * 9 + pos,即一个 0..80 的整数。这个”一维化”后面会反复用到。
  3. 玩家在某个活跃小盘 activeBoard 中落子 pos,则该格被标记,同时下一手玩家必须落在 pos 号小盘内。
  4. 如果 pos 号小盘已经被某一方赢下或已下满(即”死盘”),则对手获得自由落子权,可以任意选择任意一个未结束的小盘。
  5. 赢下 3 个小盘连成一线(行、列、对角线)者获胜。
  6. 若 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),但三个工程手段把它从”不可用”拉到”可用”:

  1. 位运算压缩常数:单节点成本降低一个数量级。
  2. Alpha-Beta 剪枝:指数部分从 b 降到 √b。
  3. 着法排序:让 √b 这个理论上界真正逼近。

如果想进一步突破,可以考虑:置换表(Zobrist 哈希缓存已搜索局面)、杀手着法(killer moves,记录同层剪枝成功的着法)、以及把元盘胜利判定做成增量更新(落子时只检查经过该格的 4 条线),后者在我的实测中能再省 15% 左右的时间。

十三、小结

终极井字棋是一个”规则简单、算法密度极高”的题目。把它做对,需要:

  • 位棋盘:9 格 → 9 位,胜负判定变 8 次与运算;
  • 不可变状态:搜索回溯不需要手动撤销,减少一类 bug;
  • 双层评估:元盘战略(连线威胁)+ 小盘战术(局部优势)分开建模;
  • Alpha-Beta + 着法排序:让剪枝的理论收益真正兑现;
  • 迭代加深 + 时间预算:把”固定深度”换成”固定时间”,适配任意硬件。

最值得记住的一条经验是:在搜索深度受限的博弈里,评估函数的权重设计比搜索算法本身更影响棋力。同样的 Alpha-Beta 框架,把防守权重从 0.8 调到 1.2,对弈胜率的变化往往超过把深度从 6 加到 8。这也是为什么工程上的博弈 AI 总是在”调评估”而不是”堆深度”。