每日算法 — 使用java实现井字棋:蒙特卡洛树搜索与UCT算法

井字棋(Tic-Tac-Toe)是最简单的完全信息博弈游戏之一,但作为理解蒙特卡洛树搜索(MCTS)算法的入门案例非常合适。本文将使用Java实现基于MCTS和UCT(Upper Confidence Bound for Trees)算法的井字棋AI。

蒙特卡洛树搜索原理

MCTS是一种用于决策过程的启发式搜索算法,通过随机模拟(蒙特卡洛方法)来评估棋局。核心流程包含四个阶段:

  1. 选择(Selection):从根节点开始,使用UCT公式选择最有潜力的子节点,直到到达未完全展开的节点
  2. 扩展(Expansion):为选中的节点添加一个或多个子节点
  3. 模拟(Simulation):从新扩展的节点开始,进行随机对弈直到终局
  4. 回传(Backpropagation):将模拟结果沿路径回传,更新节点统计信息

UCT公式

UCT平衡了 exploitation(利用已有知识)和 exploration(探索未知):

$$UCT = \frac{w_i}{n_i} + c \sqrt{\frac{\ln N}{n_i}}$$

其中wi是胜利次数,ni是访问次数,N是父节点访问次数,c是探索常数。

Java实现

import java.util.*;

/**
 * 井字棋MCTS AI
 * 使用蒙特卡洛树搜索与UCT算法实现井字棋智能对弈
 */
public class TicTacToeMCTS {

    // 棋盘状态:0=空, 1=玩家X, -1=玩家O
    private static final int EMPTY = 0;
    private static final int PLAYER_X = 1;
    private static final int PLAYER_O = -1;
    private static final int SIZE = 3;

    // MCTS参数
    private static final int ITERATIONS = 10000; // 每次决策的模拟次数
    private static final double EXPLORATION = Math.sqrt(2); // UCT探索常数

    /**
     * 棋盘状态类
     */
    static class Board {
        int[][] grid;
        int currentPlayer;

        Board() {
            this.grid = new int[SIZE][SIZE];
            this.currentPlayer = PLAYER_X;
        }

        Board(Board other) {
            this.grid = new int[SIZE][SIZE];
            for (int i = 0; i < SIZE; i++) {
                this.grid[i] = other.grid[i].clone();
            }
            this.currentPlayer = other.currentPlayer;
        }

        /**
         * 获取所有合法移动
         */
        List<int[]> getLegalMoves() {
            List<int[]> moves = new ArrayList<>();
            for (int r = 0; r < SIZE; r++) {
                for (int c = 0; c < SIZE; c++) {
                    if (grid[r][c] == EMPTY) {
                        moves.add(new int[]{r, c});
                    }
                }
            }
            return moves;
        }

        /**
         * 执行移动
         */
        void makeMove(int r, int c) {
            grid[r][c] = currentPlayer;
            currentPlayer = -currentPlayer;
        }

        /**
         * 撤销移动
         */
        void undoMove(int r, int c) {
            grid[r][c] = EMPTY;
            currentPlayer = -currentPlayer;
        }

        /**
         * 检查游戏是否结束
         * @return 1=X胜, -1=O胜, 0=平局, null=未结束
         */
        Integer checkWinner() {
            // 检查行和列
            for (int i = 0; i < SIZE; i++) {
                if (grid[i][0] != EMPTY && grid[i][0] == grid[i][1] && grid[i][1] == grid[i][2]) {
                    return grid[i][0];
                }
                if (grid[0][i] != EMPTY && grid[0][i] == grid[1][i] && grid[1][i] == grid[2][i]) {
                    return grid[0][i];
                }
            }
            // 检查对角线
            if (grid[0][0] != EMPTY && grid[0][0] == grid[1][1] && grid[1][1] == grid[2][2]) {
                return grid[0][0];
            }
            if (grid[0][2] != EMPTY && grid[0][2] == grid[1][1] && grid[1][1] == grid[2][0]) {
                return grid[0][2];
            }
            // 检查平局
            if (getLegalMoves().isEmpty()) {
                return 0;
            }
            return null;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Board)) return false;
            Board b = (Board) o;
            return Arrays.deepEquals(grid, b.grid) && currentPlayer == b.currentPlayer;
        }

        @Override
        public int hashCode() {
            return Arrays.deepHashCode(grid) * 31 + currentPlayer;
        }
    }

    /**
     * MCTS树节点
     */
    static class TreeNode {
        Board state;
        TreeNode parent;
        Map<String, TreeNode> children;
        int visits;
        double wins; // 从父节点视角的胜利次数
        int[] move; // 到达此节点的移动

        TreeNode(Board state, TreeNode parent, int[] move) {
            this.state = state;
            this.parent = parent;
            this.children = new HashMap<>();
            this.visits = 0;
            this.wins = 0;
            this.move = move;
        }

        /**
         * 检查是否完全展开(所有合法移动都有对应子节点)
         */
        boolean isFullyExpanded() {
            return children.size() == state.getLegalMoves().size();
        }

        /**
         * 使用UCT公式选择最佳子节点
         */
        TreeNode bestChild() {
            TreeNode best = null;
            double bestValue = Double.NEGATIVE_INFINITY;

            for (TreeNode child : children.values()) {
                if (child.visits == 0) {
                    return child; // 优先探索未访问节点
                }

                // UCT公式: exploitation + exploration
                double exploitation = child.wins / child.visits;
                double exploration = EXPLORATION * Math.sqrt(Math.log(this.visits) / child.visits);
                double uctValue = exploitation + exploration;

                if (uctValue > bestValue) {
                    bestValue = uctValue;
                    best = child;
                }
            }

            return best;
        }

        /**
         * 选择最有希望获胜的移动(最高胜率)
         */
        TreeNode bestMove() {
            TreeNode best = null;
            double bestRate = -1;

            for (TreeNode child : children.values()) {
                double winRate = child.visits > 0 ? child.wins / child.visits : 0;
                if (winRate > bestRate) {
                    bestRate = winRate;
                    best = child;
                }
            }

            return best;
        }
    }

    private Random random;

    public TicTacToeMCTS() {
        this.random = new Random();
    }

    /**
     * MCTS搜索主流程
     */
    public int[] findBestMove(Board board) {
        TreeNode root = new TreeNode(new Board(board), null, null);

        for (int i = 0; i < ITERATIONS; i++) {
            // 1. 选择
            TreeNode node = select(root);

            // 2. 扩展
            if (node.state.checkWinner() == null) {
                node = expand(node);
            }

            // 3. 模拟
            double result = simulate(node);

            // 4. 回传
            backpropagate(node, result);
        }

        TreeNode best = root.bestMove();
        return best != null ? best.move : null;
    }

    /**
     * 选择阶段:使用UCT递归选择最有潜力的节点
     */
    TreeNode select(TreeNode node) {
        while (!node.children.isEmpty() && node.isFullyExpanded()) {
            node = node.bestChild();
        }
        return node;
    }

    /**
     * 扩展阶段:为节点添加一个未扩展的子节点
     */
    TreeNode expand(TreeNode node) {
        List<int[]> legalMoves = node.state.getLegalMoves();

        // 找到未扩展的移动
        for (int[] move : legalMoves) {
            String moveKey = move[0] + "," + move[1];
            if (!node.children.containsKey(moveKey)) {
                Board newState = new Board(node.state);
                newState.makeMove(move[0], move[1]);
                TreeNode child = new TreeNode(newState, node, move);
                node.children.put(moveKey, child);
                return child;
            }
        }

        return node;
    }

    /**
     * 模拟阶段:随机对弈直到终局
     * @return 从父节点视角的结果:1=胜, 0.5=平, 0=负
     */
    double simulate(TreeNode node) {
        Board simState = new Board(node.state);
        Integer winner;

        while ((winner = simState.checkWinner()) == null) {
            List<int[]> moves = simState.getLegalMoves();
            int[] move = moves.get(random.nextInt(moves.size()));
            simState.makeMove(move[0], move[1]);
        }

        // 从当前节点视角评估结果
        // 当前节点 state's currentPlayer 是下一步要走的玩家
        // 模拟开始时,simState.currentPlayer 已经是对手了(因为node.state执行了到达此节点的移动)
        // 实际上 winner 是最后一步玩家的胜利
        // 我们需要从 node.parent 的视角来评估(即执行了到达node这步移动的玩家)

        int myPlayer = node.parent != null ? -node.state.currentPlayer : PLAYER_X;

        if (winner == myPlayer) {
            return 1.0;
        } else if (winner == 0) {
            return 0.5;
        } else {
            return 0.0;
        }
    }

    /**
     * 回传阶段:将模拟结果沿路径更新
     */
    void backpropagate(TreeNode node, double result) {
        while (node != null) {
            node.visits++;

            // 从父节点视角更新胜利次数
            // 如果当前节点轮到对手走,那么父节点执行的上一步对自己有利时result应该增加
            if (node.parent != null) {
                // 父节点的移动导致了当前局面
                // 如果result是从当前节点视角的,需要转换为父节点视角
                node.wins += result;
            }

            // 切换视角:1 - result
            result = 1 - result;
            node = node.parent;
        }
    }

    /**
     * 打印棋盘
     */
    void printBoard(Board board) {
        char[] symbols = {'O', ' ', 'X'};
        System.out.println("  0 1 2");
        for (int r = 0; r < SIZE; r++) {
            System.out.print(r + " ");
            for (int c = 0; c < SIZE; c++) {
                System.out.print(symbols[board.grid[r][c] + 1]);
                if (c < SIZE - 1) System.out.print("|");
            }
            System.out.println();
            if (r < SIZE - 1) System.out.println("  -----");
        }
        System.out.println();
    }

    /**
     * 人机对战演示
     */
    public void playGame() {
        Board board = new Board();
        Scanner scanner = new Scanner(System.in);

        System.out.println("井字棋MCTS对战!你是X,AI是O");
        System.out.println("输入行和列(如:1 2)进行移动\n");

        while (board.checkWinner() == null) {
            printBoard(board);

            if (board.currentPlayer == PLAYER_X) {
                // 人类玩家
                System.out.print("你的回合(行 列):");
                int r = scanner.nextInt();
                int c = scanner.nextInt();

                if (r < 0 || r >= SIZE || c < 0 || c >= SIZE || board.grid[r][c] != EMPTY) {
                    System.out.println("非法移动!");
                    continue;
                }

                board.makeMove(r, c);
            } else {
                // AI回合
                System.out.println("AI思考中...");
                long start = System.currentTimeMillis();
                int[] move = findBestMove(board);
                long end = System.currentTimeMillis();

                if (move != null) {
                    board.makeMove(move[0], move[1]);
                    System.out.println("AI落子:(" + move[0] + ", " + move[1] + "),耗时" + (end - start) + "ms");
                }
            }
        }

        printBoard(board);
        Integer winner = board.checkWinner();
        if (winner == PLAYER_X) {
            System.out.println("你赢了!");
        } else if (winner == PLAYER_O) {
            System.out.println("AI赢了!");
        } else {
            System.out.println("平局!");
        }
    }

    /**
     * AI自对弈测试
     */
    public void selfPlay(int games) {
        int xWins = 0, oWins = 0, draws = 0;

        for (int g = 0; g < games; g++) {
            Board board = new Board();

            while (board.checkWinner() == null) {
                int[] move = findBestMove(board);
                if (move != null) {
                    board.makeMove(move[0], move[1]);
                }
            }

            Integer winner = board.checkWinner();
            if (winner == PLAYER_X) xWins++;
            else if (winner == PLAYER_O) oWins++;
            else draws++;
        }

        System.out.println("AI自对弈" + games + "局结果:");
        System.out.println("X胜:" + xWins + ",O胜:" + oWins + ",平局:" + draws);
        System.out.println("先手优势:" + (xWins - oWins) + "局");
    }

    public static void main(String[] args) {
        TicTacToeMCTS mcts = new TicTacToeMCTS();

        // AI自对弈测试
        mcts.selfPlay(100);

        System.out.println("\n开始人机对战:");
        mcts.playGame();
    }
}

MCTS的优势与局限

优势

  1. 无需领域知识:MCTS不需要人工设计评估函数,完全通过随机模拟来学习
  2. 随时可用:可以在任意时刻停止搜索,返回当前最优结果
  3. 渐进收敛:随着迭代次数增加,结果逐渐收敛到最优
  4. 非对称搜索:自动将更多计算资源投入到更有前景的分支

局限

  1. 模拟质量依赖:随机模拟的质量直接影响搜索结果
  2. 计算开销大:需要大量模拟才能获得可靠结果
  3. 井字棋特殊性:井字棋已被完全破解(最优策略必平局),MCTS在此更多是教学意义

UCT公式的平衡艺术

UCT公式中的探索常数c至关重要:
– c过大:过度探索,忽略已有知识,搜索效率低
– c过小:过早收敛到局部最优,错过更好的策略
– c=√2:理论最优值(针对 rewards ∈ [0,1] 的情况)

在井字棋中,由于状态空间较小(约5,478种合法状态),MCTS能快速收敛到最优策略。

总结

本文使用蒙特卡洛树搜索(MCTS)和UCT算法实现了井字棋AI。MCTS通过选择、扩展、模拟、回传四个阶段的循环,逐步构建搜索树并收敛到最优策略。UCT公式巧妙地平衡了探索与利用,是MCTS算法的核心。虽然井字棋已被完全破解,但MCTS作为通用博弈算法,在五子棋、围棋等复杂游戏中展现了强大的实力。