每日算法 — 使用java实现井字棋:Minimax与博弈树

一、游戏介绍与问题建模

井字棋(Tic-Tac-Toe),又称三子棋、圈圈叉叉,是全世界最广为人知的双人纸笔游戏。游戏在 3×3 的方格棋盘上进行,双方轮流在空格中画上自己的符号(通常是 X 和 O),率先在横、竖、斜方向上连成三子的一方获胜。

井字棋规则极其简单,任何孩子都能在一分钟内学会。但正是这种”简单到极致”的特点,使它成为学习博弈算法的最佳入门题材——状态空间足够小,可以穷举完整棵博弈树;规则足够清晰,便于理解 Minimax 的核心思想。

1.1 游戏规则

井字棋的基本规则:

  • 棋盘:3×3 的方格,共 9 个格子
  • 落子:X 方先行,双方轮流在空格中放置己方符号
  • 胜负判定:任意一方在横、竖、斜(两个对角线方向)上连成连续的三个同色棋子,即获胜
  • 平局:棋盘填满且无人获胜,则为平局(俗称”猫赢了”,Cat’s Game)

井字棋的一个重要特性是:如果双方都采取最优策略,游戏必然以平局告终。这意味着井字棋是一个”已被解决的游戏”(Solved Game)——我们可以穷举所有可能的棋局,找到每一步的最优走法。

1.2 博弈树的概念

井字棋是典型的零和博弈(Zero-Sum Game):一方的收益等于另一方的损失,双方利益完全对立。对于这类游戏,我们可以用博弈树(Game Tree)来建模所有可能的对局过程。

博弈树的基本概念

  • 节点:每个节点代表一个棋盘局面(状态)
  • 根节点:空棋盘(游戏开始前的初始状态)
  • :每条边代表一步合法落子
  • 叶子节点:终局局面——一方获胜,或平局
  • :树的每一层对应一方的回合
空棋盘(X方走,第0层)
├── X在左上角 → 局面A(O方走,第1层)
│   ├── O在中心 → 局面A1(X方走,第2层)
│   ├── O在右上角 → 局面A2(X方走,第2层)
│   └── ...(共8种应对)
├── X在中心 → 局面B(O方走,第1层)
│   └── ...
└── ...(共9种开局)

由于井字棋的状态空间很小(总共只有 9! = 362,880 种不同的落子序列,考虑对称性后实际独立局面更少),我们可以完整地搜索整棵博弈树,找到每一步的最优解。

1.3 极小化极大思想

在博弈树中,我们需要为每个局面评估一个”分数”,表示当前玩家的优势程度。对于井字棋这样的零和博弈:

  • MAX 方(通常是 X 方):选择使估值最大的走法
  • MIN 方(通常是 O 方):选择使估值最小的走法

这就是Minimax(极小化极大)算法的核心思想——在对手也采取最优策略的前提下,选择对自己最有利的走法。

对于终局局面,我们定义:
– X 方获胜:+1 分
– O 方获胜:-1 分
– 平局:0 分

然后从叶子节点开始,自底向上计算每个节点的估值:
– MAX 层(X 方回合):取所有子节点估值的最大值
– MIN 层(O 方回合):取所有子节点估值的最小值


二、状态表示与编码

井字棋的棋盘只有 9 个格子,状态表示非常灵活。我们将介绍两种常见的表示方式:数组表示和位掩码表示。

2.1 棋盘类设计

首先定义棋子类型枚举和基础棋盘类:

/**
 * 棋子类型枚举
 */
enum Mark {
    EMPTY('-'),  // 空格
    X('X'),      // X方棋子
    O('O');      // O方棋子

    private final char symbol;

    Mark(char symbol) {
        this.symbol = symbol;
    }

    public char getSymbol() {
        return symbol;
    }

    /**
     * 获取对手的棋子
     */
    public Mark opponent() {
        if (this == X) return O;
        if (this == O) return X;
        return EMPTY;
    }
}

/**
 * 井字棋棋盘类
 */
class TicTacToeBoard {
    public static final int SIZE = 3;

    private Mark[][] board;
    private int moveCount;  // 已落子数
    private Mark lastPlayer;  // 上一步落子方

    public TicTacToeBoard() {
        board = new Mark[SIZE][SIZE];
        for (int i = 0; i < SIZE; i++) {
            for (int j = 0; j < SIZE; j++) {
                board[i][j] = Mark.EMPTY;
            }
        }
        moveCount = 0;
        lastPlayer = Mark.O;  // X方先行,所以初始"上一步"是O方
    }

    /**
     * 落子
     * @param row 行 (0-2)
     * @param col 列 (0-2)
     * @param mark 棋子
     * @return 是否落子成功
     */
    public boolean placeMark(int row, int col, Mark mark) {
        if (row < 0 || row >= SIZE || col < 0 || col >= SIZE) {
            return false;
        }
        if (board[row][col] != Mark.EMPTY) {
            return false;
        }
        board[row][col] = mark;
        moveCount++;
        lastPlayer = mark;
        return true;
    }

    /**
     * 撤销一步棋(用于回溯搜索)
     */
    public void undoMove(int row, int col) {
        if (board[row][col] != Mark.EMPTY) {
            lastPlayer = board[row][col].opponent();
            board[row][col] = Mark.EMPTY;
            moveCount--;
        }
    }

    /**
     * 获取指定位置的棋子
     */
    public Mark getMark(int row, int col) {
        return board[row][col];
    }

    public int getMoveCount() { return moveCount; }
    public Mark getLastPlayer() { return lastPlayer; }

    /**
     * 棋盘是否已满
     */
    public boolean isFull() {
        return moveCount == SIZE * SIZE;
    }
}

2.2 胜负判断算法

胜负判断是井字棋最核心的功能之一。最直观的方法是检查所有 8 条可能的获胜线(3 行 + 3 列 + 2 条对角线)。

/**
 * 检查游戏是否有获胜方
 * @return 获胜方,EMPTY表示未分出胜负
 */
public Mark checkWinner() {
    // 少于5步不可能分出胜负(X至少3步,O至少2步)
    if (moveCount < 5) {
        return Mark.EMPTY;
    }

    // 检查8条获胜线:3行 + 3列 + 2对角线
    // 行
    for (int row = 0; row < SIZE; row++) {
        if (board[row][0] != Mark.EMPTY 
            && board[row][0] == board[row][1] 
            && board[row][1] == board[row][2]) {
            return board[row][0];
        }
    }

    // 列
    for (int col = 0; col < SIZE; col++) {
        if (board[0][col] != Mark.EMPTY 
            && board[0][col] == board[1][col] 
            && board[1][col] == board[2][col]) {
            return board[0][col];
        }
    }

    // 主对角线(左上到右下)
    if (board[0][0] != Mark.EMPTY 
        && board[0][0] == board[1][1] 
        && board[1][1] == board[2][2]) {
        return board[0][0];
    }

    // 副对角线(右上到左下)
    if (board[0][2] != Mark.EMPTY 
        && board[0][2] == board[1][1] 
        && board[1][1] == board[2][0]) {
        return board[0][2];
    }

    return Mark.EMPTY;
}

2.3 位掩码表示(进阶)

对于 3×3 的井字棋,我们甚至可以用位掩码(Bitmask)来表示棋盘状态。每个玩家的棋子用 9 位二进制数表示,每一位对应一个格子。

格子编号:
 0 | 1 | 2
---+---+---
 3 | 4 | 5
---+---+---
 6 | 7 | 8

例如,X方占据了中心(4)和左上角(0),则 X 的位掩码为:
bit4 + bit0 = 16 + 1 = 17 (二进制: 000010001)

位掩码表示的优势在于:胜负判断可以用位运算在常数时间内完成

/**
 * 位掩码版井字棋(更高效的实现)
 */
class BitTicTacToe {
    private int xBoard;  // X方棋子的位掩码
    private int oBoard;  // O方棋子的位掩码
    private int moveCount;

    // 8条获胜线的位掩码
    private static final int[] WIN_LINES = {
        0b000000111,  // 第0行
        0b000111000,  // 第1行
        0b111000000,  // 第2行
        0b001001001,  // 第0列
        0b010010010,  // 第1列
        0b100100100,  // 第2列
        0b100010001,  // 主对角线
        0b001010100   // 副对角线
    };

    public BitTicTacToe() {
        xBoard = 0;
        oBoard = 0;
        moveCount = 0;
    }

    /**
     * 落子
     * @param pos 位置 0-8
     * @param isX 是否为X方
     */
    public boolean placeMark(int pos, boolean isX) {
        int mask = 1 << pos;
        // 检查该位置是否为空
        if ((xBoard & mask) != 0 || (oBoard & mask) != 0) {
            return false;
        }
        if (isX) {
            xBoard |= mask;
        } else {
            oBoard |= mask;
        }
        moveCount++;
        return true;
    }

    /**
     * 检查某方是否获胜
     * 位运算的巧妙之处:只需8次按位与操作
     */
    public boolean hasWon(boolean isX) {
        int board = isX ? xBoard : oBoard;
        for (int line : WIN_LINES) {
            if ((board & line) == line) {
                return true;
            }
        }
        return false;
    }

    /**
     * 获取所有空位
     */
    public java.util.List<Integer> getEmptyPositions() {
        java.util.List<Integer> empties = new java.util.ArrayList<>();
        int full = xBoard | oBoard;
        for (int i = 0; i < 9; i++) {
            if ((full & (1 << i)) == 0) {
                empties.add(i);
            }
        }
        return empties;
    }
}

位掩码版本虽然代码量略少、性能更高,但可读性稍差。对于井字棋这种规模的问题,数组版本已经完全够用。位掩码更适合用于状态空间极大、需要极致性能优化的场景。


三、Minimax算法原理与Java实现

Minimax 是双人零和博弈的经典算法。它的思想朴素而深刻:在所有可能的走法中,我方选择使估值最大的走法,而对手会选择使估值最小的走法

3.1 Minimax 递归原理

Minimax 通过深度优先搜索遍历博弈树,递归地计算每个节点的估值:

递归过程
1. 如果当前局面是终局(一方获胜或平局),返回终局分值
2. 如果当前是 MAX 方回合,递归计算所有子节点,取最大值
3. 如果当前是 MIN 方回合,递归计算所有子节点,取最小值

/**
 * 井字棋 Minimax AI
 */
class MinimaxAI {
    private TicTacToeBoard board;
    private Mark aiMark;  // AI执棋

    // 终局分值常量
    private static final int X_WIN = 1;
    private static final int O_WIN = -1;
    private static final int DRAW = 0;

    public MinimaxAI(TicTacToeBoard board, Mark aiMark) {
        this.board = board;
        this.aiMark = aiMark;
    }

    /**
     * AI选择最佳落子位置
     * @return [row, col]
     */
    public int[] findBestMove() {
        int bestScore = (aiMark == Mark.X) ? Integer.MIN_VALUE : Integer.MAX_VALUE;
        int[] bestMove = new int[]{-1, -1};

        // 遍历所有空位
        for (int row = 0; row < TicTacToeBoard.SIZE; row++) {
            for (int col = 0; col < TicTacToeBoard.SIZE; col++) {
                if (board.getMark(row, col) == Mark.EMPTY) {
                    // 尝试落子
                    board.placeMark(row, col, aiMark);
                    int score = minimax(0, aiMark.opponent());
                    board.undoMove(row, col);

                    // 根据AI是MAX方还是MIN方,选择最优
                    if (aiMark == Mark.X) {
                        if (score > bestScore) {
                            bestScore = score;
                            bestMove[0] = row;
                            bestMove[1] = col;
                        }
                    } else {
                        if (score < bestScore) {
                            bestScore = score;
                            bestMove[0] = row;
                            bestMove[1] = col;
                        }
                    }
                }
            }
        }

        return bestMove;
    }

    /**
     * Minimax 递归搜索
     * @param depth 当前深度(井字棋中可以用来调整获胜优先级)
     * @param currentPlayer 当前落子方
     * @return 局面估值
     */
    private int minimax(int depth, Mark currentPlayer) {
        // 检查终局
        Mark winner = board.checkWinner();
        if (winner == Mark.X) {
            return X_WIN;
        }
        if (winner == Mark.O) {
            return O_WIN;
        }
        if (board.isFull()) {
            return DRAW;
        }

        if (currentPlayer == Mark.X) {
            // MAX层:X方回合,取最大值
            int maxScore = Integer.MIN_VALUE;
            for (int row = 0; row < TicTacToeBoard.SIZE; row++) {
                for (int col = 0; col < TicTacToeBoard.SIZE; col++) {
                    if (board.getMark(row, col) == Mark.EMPTY) {
                        board.placeMark(row, col, Mark.X);
                        int score = minimax(depth + 1, Mark.O);
                        board.undoMove(row, col);
                        maxScore = Math.max(maxScore, score);
                    }
                }
            }
            return maxScore;
        } else {
            // MIN层:O方回合,取最小值
            int minScore = Integer.MAX_VALUE;
            for (int row = 0; row < TicTacToeBoard.SIZE; row++) {
                for (int col = 0; col < TicTacToeBoard.SIZE; col++) {
                    if (board.getMark(row, col) == Mark.EMPTY) {
                        board.placeMark(row, col, Mark.O);
                        int score = minimax(depth + 1, Mark.X);
                        board.undoMove(row, col);
                        minScore = Math.min(minScore, score);
                    }
                }
            }
            return minScore;
        }
    }
}

3.2 为什么 Minimax 是最优的?

Minimax 的最优性建立在一个关键假设之上:对手也会采取最优策略

这是一种保守但稳健的策略——它不指望对手犯错,而是在”最坏情况下争取最好的结果”。对于井字棋这样信息完全、状态有限的游戏,Minimax 确实能找到最优解。

一个小优化:让 AI 选择最快获胜的路径

上面的实现中,所有获胜局面的分值都是 +1 或 -1。这意味着 AI 不会区分”两步赢”和”八步赢”——只要能赢就行。我们可以对分值做一个微调,让 AI 更偏好快速获胜、拖延失败:

// 在checkWinner返回后,根据深度调整分值
if (winner == Mark.X) {
    return 10 - depth;  // 越早赢,分数越高
}
if (winner == Mark.O) {
    return depth - 10;  // 越晚输,分数越高(越接近0)
}

这个小改动不会改变最终结果(赢还是赢,输还是输),但会让 AI 的行为更”聪明”——在多条获胜路径中选择最快的一条,在必输的局面下尽可能拖延。

3.3 完整可运行示例

下面是一个完整的人机对弈程序,可以直接运行:

import java.util.Scanner;

/**
 * 井字棋人机对弈 - 完整可运行版本
 */
public class TicTacToeGame {

    public static void main(String[] args) {
        TicTacToeBoard board = new TicTacToeBoard();
        MinimaxAI ai = new MinimaxAI(board, Mark.O);  // AI执O,玩家执X
        Scanner scanner = new Scanner(System.in);

        System.out.println("=== 井字棋人机对弈 ===");
        System.out.println("你执 X,AI执 O");
        System.out.println("输入格式:行 列(0-2),例如:1 1 表示中心");
        printBoard(board);

        while (true) {
            // 玩家回合(X方)
            System.out.print("请输入你的落子(行 列): ");
            int row = scanner.nextInt();
            int col = scanner.nextInt();

            if (!board.placeMark(row, col, Mark.X)) {
                System.out.println("无效落子,请重试!");
                continue;
            }

            printBoard(board);

            // 检查玩家是否获胜
            Mark winner = board.checkWinner();
            if (winner == Mark.X) {
                System.out.println("🎉 恭喜你赢了!");
                break;
            }
            if (board.isFull()) {
                System.out.println("🤝 平局!");
                break;
            }

            // AI回合(O方)
            System.out.println("AI思考中...");
            int[] aiMove = ai.findBestMove();
            board.placeMark(aiMove[0], aiMove[1], Mark.O);
            System.out.println("AI落子:" + aiMove[0] + " " + aiMove[1]);

            printBoard(board);

            // 检查AI是否获胜
            winner = board.checkWinner();
            if (winner == Mark.O) {
                System.out.println("😢 AI赢了!");
                break;
            }
            if (board.isFull()) {
                System.out.println("🤝 平局!");
                break;
            }
        }

        scanner.close();
    }

    private static void printBoard(TicTacToeBoard board) {
        System.out.println();
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                System.out.print(" " + board.getMark(i, j).getSymbol() + " ");
                if (j < 2) System.out.print("|");
            }
            System.out.println();
            if (i < 2) System.out.println("---+---+---");
        }
        System.out.println();
    }
}

四、Alpha-Beta剪枝优化

Minimax 算法虽然正确,但它需要遍历整棵博弈树的每一个节点。对于井字棋来说这还可以接受,但对于更复杂的游戏(如象棋、围棋),完整搜索是不可能的。

Alpha-Beta 剪枝是 Minimax 最重要的优化技术。它可以在不改变最终结果的前提下,剪掉大量不必要的分支,从而大幅减少搜索的节点数。

4.1 Alpha-Beta 剪枝原理

Alpha-Beta 剪枝维护两个关键值:

  • Alpha(α):MAX 方目前能保证的最好估值(下界)
  • Beta(β):MIN 方目前能保证的最差估值(上界)

剪枝规则
– 在 MAX 层,如果当前节点的估值 >= Beta,说明 MIN 方一定不会选择这条路径,可以剪枝(不再搜索后续子节点)
– 在 MIN 层,如果当前节点的估值 <= Alpha,说明 MAX 方一定不会选择这条路径,可以剪枝

直观理解:如果已经找到了一个足够好的走法,就不需要再看其他更差的选项了。

剪枝示例:

          MAX(α=-∞, β=+∞)
         /       \
        /         \
    MIN(α=-∞,β=+∞)  剪枝!
      /   \         (因为左子树返回3,α=3,
     3     5         而MIN层发现5>=3,就不用再看了)

4.2 Java 实现

/**
 * Alpha-Beta 剪枝版井字棋 AI
 */
class AlphaBetaAI {
    private TicTacToeBoard board;
    private Mark aiMark;

    private static final int WIN_SCORE = 10;
    private static final int LOSE_SCORE = -10;
    private static final int DRAW_SCORE = 0;

    public AlphaBetaAI(TicTacToeBoard board, Mark aiMark) {
        this.board = board;
        this.aiMark = aiMark;
    }

    /**
     * AI选择最佳落子
     */
    public int[] findBestMove() {
        int bestScore = (aiMark == Mark.X) ? Integer.MIN_VALUE : Integer.MAX_VALUE;
        int[] bestMove = new int[]{-1, -1};

        int alpha = Integer.MIN_VALUE;
        int beta = Integer.MAX_VALUE;

        for (int row = 0; row < TicTacToeBoard.SIZE; row++) {
            for (int col = 0; col < TicTacToeBoard.SIZE; col++) {
                if (board.getMark(row, col) == Mark.EMPTY) {
                    board.placeMark(row, col, aiMark);
                    int score = alphaBeta(0, aiMark.opponent(), alpha, beta);
                    board.undoMove(row, col);

                    if (aiMark == Mark.X) {
                        if (score > bestScore) {
                            bestScore = score;
                            bestMove[0] = row;
                            bestMove[1] = col;
                        }
                        alpha = Math.max(alpha, score);
                    } else {
                        if (score < bestScore) {
                            bestScore = score;
                            bestMove[0] = row;
                            bestMove[1] = col;
                        }
                        beta = Math.min(beta, score);
                    }
                }
            }
        }

        return bestMove;
    }

    /**
     * Alpha-Beta 剪枝搜索
     * @param depth 当前深度
     * @param currentPlayer 当前落子方
     * @param alpha MAX方下界
     * @param beta MIN方上界
     * @return 局面估值
     */
    private int alphaBeta(int depth, Mark currentPlayer, int alpha, int beta) {
        // 终局检查
        Mark winner = board.checkWinner();
        if (winner == Mark.X) return WIN_SCORE - depth;
        if (winner == Mark.O) return LOSE_SCORE + depth;
        if (board.isFull()) return DRAW_SCORE;

        if (currentPlayer == Mark.X) {
            // MAX层
            int maxScore = Integer.MIN_VALUE;
            for (int row = 0; row < TicTacToeBoard.SIZE; row++) {
                for (int col = 0; col < TicTacToeBoard.SIZE; col++) {
                    if (board.getMark(row, col) == Mark.EMPTY) {
                        board.placeMark(row, col, Mark.X);
                        int score = alphaBeta(depth + 1, Mark.O, alpha, beta);
                        board.undoMove(row, col);

                        maxScore = Math.max(maxScore, score);
                        alpha = Math.max(alpha, score);

                        // 剪枝:当前值已经超过beta,MIN方不会选这条路
                        if (alpha >= beta) {
                            return maxScore;  // 直接返回,不再搜索剩余子节点
                        }
                    }
                }
            }
            return maxScore;
        } else {
            // MIN层
            int minScore = Integer.MAX_VALUE;
            for (int row = 0; row < TicTacToeBoard.SIZE; row++) {
                for (int col = 0; col < TicTacToeBoard.SIZE; col++) {
                    if (board.getMark(row, col) == Mark.EMPTY) {
                        board.placeMark(row, col, Mark.O);
                        int score = alphaBeta(depth + 1, Mark.X, alpha, beta);
                        board.undoMove(row, col);

                        minScore = Math.min(minScore, score);
                        beta = Math.min(beta, score);

                        // 剪枝:当前值已经低于alpha,MAX方不会选这条路
                        if (alpha >= beta) {
                            return minScore;  // 直接返回
                        }
                    }
                }
            }
            return minScore;
        }
    }
}

4.3 剪枝效果分析

Alpha-Beta 剪枝的效率高度依赖于落子顺序。如果最优的走法最先被搜索到,剪枝效果最好。

落子顺序 搜索节点数(第0层,空棋盘) 效率提升
朴素 Minimax 549,946 个节点
Alpha-Beta(随机顺序) 约 100,000 个节点 ~5×
Alpha-Beta(最优顺序) 约 15,000 个节点 ~37×

对于井字棋来说,即使不优化,搜索也能在毫秒级完成。但 Alpha-Beta 剪枝的真正价值体现在更复杂的博弈问题中——它可以让搜索深度翻倍。


五、复杂度分析与棋局状态总数

5.1 时间复杂度分析

算法 时间复杂度 说明
朴素 Minimax O(b^d) b为分支因子,d为最大深度
Alpha-Beta(最优排序) O(b^(d/2)) 搜索深度等效翻倍
Alpha-Beta(随机排序) O(b^(3d/4)) 介于两者之间

井字棋的具体参数
– 最大深度 d = 9(棋盘共9格)
– 初始分支因子 b = 9(第一步有9种选择)
– 分支因子逐层递减:9 → 8 → 7 → … → 1

理论搜索节点数(朴素 Minimax):

9! + 9!/1! + 9!/2! + ... + 9!/9! ≈ 986,410 个节点

但实际上,由于很多局面在第5-8步就已经分出胜负,实际搜索的节点数约为 549,946 个。

5.2 空间复杂度分析

数据结构 空间复杂度 说明
棋盘状态 O(1) 固定 3×3 = 9 个格子
递归栈 O(d) d 最大为 9 层
其他变量 O(1) 常数级额外空间

井字棋的空间复杂度完全可以忽略不计。即使是用位掩码存储所有已计算局面,也只需要极小的空间。

5.3 棋局状态总数

井字棋的状态空间虽然不大,但精确计算也需要一些技巧:

统计维度 数量 说明
所有可能的落子序列 362,880 9!(9个格子全排列)
合法终局局面数 255,168 排除提前结束的对局
不同的棋盘状态 5,478 考虑X先走、合法局面
考虑旋转/镜像对称 765 去除对称等价后的独立局面数

正因为状态空间如此之小,井字棋成为了一个”已被彻底解决”的游戏——我们可以预先计算出每一个局面的最优走法,做成一张查找表,AI 只需要查表就能做出最优决策。


六、适用场景与扩展思路

井字棋虽然简单,但它所蕴含的 Minimax 和 Alpha-Beta 剪枝思想,是整个博弈 AI 领域的基石。理解了井字棋的算法,就掌握了打开复杂博弈大门的钥匙。

6.1 从井字棋到复杂博弈的推广路径

井字棋是学习博弈算法的第一站,沿着这条路径可以逐步深入:

井字棋(3×3,可穷举)
    ↓
四子棋(Connect Four,7列6行,已被弱解决)
    ↓
五子棋(15×15,Alpha-Beta + 估值函数)
    ↓
国际象棋(8×8,Alpha-Beta + 置换表 + 开局库)
    ↓
围棋(19×19,深度学习 + MCTS)

每上一个台阶,状态空间都会指数级增长,算法也需要相应地进化:

游戏 状态空间 核心算法
井字棋 ~10^3 纯 Minimax,可穷举
四子棋 ~10^13 Alpha-Beta + 启发式
五子棋 ~10^50 Alpha-Beta + 估值函数 + 启发式排序
国际象棋 ~10^47 Alpha-Beta + 置换表 + 开局/残局库
围棋 ~10^170 深度学习 + 蒙特卡洛树搜索

6.2 进阶优化方向

即使是井字棋,也有很多值得探索的优化技巧:

1. 记忆化搜索(Memoization)

用哈希表存储已经计算过的局面及其估值,避免重复计算。对于井字棋来说,这可以将搜索节点数从 50 多万降到只有几千。

// 思路:用Map存储已计算局面的最优值
Map<Long, Integer> memo = new HashMap<>();

// 在递归开始时检查
long stateHash = encodeBoard(board);
if (memo.containsKey(stateHash)) {
    return memo.get(stateHash);
}

// 在递归返回前存储
memo.put(stateHash, result);
return result;

2. 对称去重

井字棋有 8 种对称变换(4 种旋转 + 4 种镜像)。搜索时只需要计算对称等价类中的一个代表局面,可以将搜索量减少约 8 倍。

3. 迭代加深(Iterative Deepening)

从深度 1 开始,逐步增加搜索深度。虽然井字棋不需要这么做,但这是学习更复杂博弈算法的重要概念。

6.3 工程实践中的应用

博弈算法的思想不仅限于棋类游戏,在实际工程中也有广泛应用:

  1. 决策论与经济学:在竞争环境中做出最优决策
  2. 网络安全:攻防对抗中的策略选择
  3. 自动驾驶:在复杂交通场景中预测其他车辆的行为并做出最优决策
  4. 游戏开发:NPC 的 AI 决策系统
  5. 资源调度:在资源竞争场景中的分配策略

6.4 总结

井字棋是博弈算法的”Hello World”——它足够简单,让我们可以完整地理解 Minimax 和 Alpha-Beta 的每一个细节;它又足够经典,蕴含着博弈论最核心的思想。

通过本文的学习,你应该掌握了:

  1. 博弈树的概念:如何将一个双人博弈问题建模为一棵树
  2. Minimax 算法:极小化极大的核心思想与递归实现
  3. Alpha-Beta 剪枝:如何在不改变结果的前提下大幅优化搜索效率
  4. 状态表示:数组表示与位掩码表示的取舍

当你理解了井字棋的 AI 是如何思考的,再去看五子棋、象棋甚至围棋的 AI,就会发现它们的底层逻辑是相通的——只是搜索的深度、估值的精度、优化的手段不同而已。

思考练习:如果要给井字棋 AI 增加”难度调节”功能(简单、中等、困难三个级别),你会如何设计?除了随机犯错之外,还有什么方法可以控制 AI 的棋力?如果让 AI 表现得像一个”会思考但偶尔失误的人类玩家”,又该如何实现?