每日算法 — 使用java实现井字棋:Q-Learning强化学习与自对弈策略收敛

井字棋(Tic-Tac-Toe)是入门强化学习的经典实验场。本文用Java实现一套完整的Q-Learning自对弈系统:智能体通过与自己对弈数万局,从零开始学会最优策略,无需任何人类棋谱。你会看到Q表的更新公式如何驱动策略收敛,以及探索-利用权衡(ε-贪心)如何让AI在”试错”与”执行”之间找到平衡。

一、问题分析:为什么井字棋适合Q-Learning

井字棋的状态空间约为 3^9 = 19683 种(每个格子为空、X或O),实际合法状态约5478种。这个规模足够小,可以用一张哈希表(Q表)完整记录每个状态-动作对的价值,无需神经网络近似。

Q-Learning的核心思想是:当前状态-动作的价值 = 即时奖励 + 未来折扣奖励的最大期望。智能体通过不断试错,逐步修正这张”经验表”,最终收敛到最优策略。

二、核心算法:Q-Learning更新公式

Q-Learning的贝尔曼更新方程为:

Q(s, a) = Q(s, a) + α × [r + γ × max(Q(s', a')) - Q(s, a)]

其中:
s:当前棋局状态
a:选择的落子动作
r:即时奖励(胜+1,负-1,平0)
s':执行动作后的新状态
α:学习率,控制新旧经验的融合速度
γ:折扣因子,衡量未来奖励的相对重要性
max(Q(s', a')):下一状态中所有可选动作的最大Q值

三、状态编码:将棋盘压缩为字符串键

为了用哈希表存储Q值,需要将9格棋盘编码为紧凑的字符串。约定用 'X''O''-' 分别表示己方棋子、对方棋子和空位。

/**
 * 将3x3棋盘编码为9位字符串,例如 "X-O-XO---"
 * 顺序按行优先:索引0~8对应棋盘位置
 */
public class BoardEncoder {
    public static String encode(char[][] board) {
        StringBuilder sb = new StringBuilder(9);
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                sb.append(board[i][j] == '\0' ? '-' : board[i][j]);
            }
        }
        return sb.toString();
    }

    public static char[][] decode(String key) {
        char[][] board = new char[3][3];
        for (int i = 0; i < 9; i++) {
            char c = key.charAt(i);
            board[i / 3][i % 3] = (c == '-') ? '\0' : c;
        }
        return board;
    }
}

四、Q表设计:嵌套HashMap结构

Q表存储 "状态字符串" → {"动作索引" → Q值} 的映射。动作用0~8的整数表示落子位置。

import java.util.HashMap;
import java.util.Map;
import java.util.Set;

/**
 * Q表:状态 -> (动作 -> 价值)
 * 使用双层HashMap实现,支持快速的增删查改
 */
public class QTable {
    // 外层Map:状态字符串 -> 内层Map
    // 内层Map:动作(0~8) -> Q值(double)
    private final Map<String, Map<Integer, Double>> table = new HashMap<>();

    /**
     * 获取指定状态-动作对的Q值。
     * 若从未访问过,返回默认值0.0(乐观初始化)
     */
    public double getQ(String state, int action) {
        Map<Integer, Double> actions = table.get(state);
        if (actions == null) {
            return 0.0;
        }
        return actions.getOrDefault(action, 0.0);
    }

    /**
     * 更新Q值。若状态不存在则自动创建内层Map
     */
    public void setQ(String state, int action, double value) {
        table.computeIfAbsent(state, k -> new HashMap<>())
             .put(action, value);
    }

    /**
     * 获取某状态下所有已记录的动作
     */
    public Set<Integer> getActions(String state) {
        Map<Integer, Double> actions = table.get(state);
        return actions != null ? actions.keySet() : Set.of();
    }

    /**
     * 获取某状态下已知的最大Q值,用于贝尔曼更新
     */
    public double getMaxQ(String state) {
        Map<Integer, Double> actions = table.get(state);
        if (actions == null || actions.isEmpty()) {
            return 0.0;
        }
        return actions.values().stream()
                      .mapToDouble(Double::doubleValue)
                      .max()
                      .orElse(0.0);
    }

    public int size() {
        return table.size();
    }
}

五、游戏引擎:胜负判定与合法动作生成

import java.util.ArrayList;
import java.util.List;

/**
 * 井字棋游戏引擎,负责维护棋盘状态、执行落子、判定胜负
 */
public class TicTacToeEngine {
    private final char[][] board = new char[3][3];
    private char currentPlayer = 'X'; // X总是先手

    /**
     * 获取当前可落子的所有位置(0~8)
     */
    public List<Integer> getLegalActions() {
        List<Integer> actions = new ArrayList<>();
        for (int i = 0; i < 9; i++) {
            if (board[i / 3][i % 3] == '\0') {
                actions.add(i);
            }
        }
        return actions;
    }

    /**
     * 在指定位置落子,返回是否成功
     */
    public boolean makeMove(int action) {
        int row = action / 3;
        int col = action % 3;
        if (board[row][col] != '\0') {
            return false; // 位置已被占用
        }
        board[row][col] = currentPlayer;
        currentPlayer = (currentPlayer == 'X') ? 'O' : 'X';
        return true;
    }

    /**
     * 检查指定玩家是否获胜
     */
    public boolean checkWin(char player) {
        // 三行
        for (int i = 0; i < 3; i++) {
            if (board[i][0] == player && board[i][1] == player && board[i][2] == player) return true;
        }
        // 三列
        for (int j = 0; j < 3; j++) {
            if (board[0][j] == player && board[1][j] == player && board[2][j] == player) return true;
        }
        // 两条对角线
        if (board[0][0] == player && board[1][1] == player && board[2][2] == player) return true;
        if (board[0][2] == player && board[1][1] == player && board[2][0] == player) return true;
        return false;
    }

    /**
     * 检查是否平局(棋盘已满且无胜负)
     */
    public boolean isDraw() {
        return getLegalActions().isEmpty();
    }

    /**
     * 游戏是否结束
     */
    public boolean isGameOver() {
        return checkWin('X') || checkWin('O') || isDraw();
    }

    /**
     * 获取游戏结果对当前行动方的奖励值
     */
    public double getReward(char player) {
        if (checkWin(player)) return 1.0;
        if (checkWin(player == 'X' ? 'O' : 'X')) return -1.0;
        return 0.0; // 平局或进行中
    }

    public char[][] getBoard() {
        char[][] copy = new char[3][3];
        for (int i = 0; i < 3; i++) {
            System.arraycopy(board[i], 0, copy[i], 0, 3);
        }
        return copy;
    }

    public char getCurrentPlayer() {
        return currentPlayer;
    }

    public void reset() {
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                board[i][j] = '\0';
            }
        }
        currentPlayer = 'X';
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                sb.append(board[i][j] == '\0' ? '-' : board[i][j]).append(' ');
            }
            sb.append('\n');
        }
        return sb.toString();
    }
}

六、智能体:ε-贪心策略与Q值更新

import java.util.List;
import java.util.Random;

/**
 * Q-Learning智能体,包含ε-贪心动作选择和Q表更新逻辑
 */
public class QLearningAgent {
    private final QTable qTable;
    private final Random random = new Random();

    // 超参数
    private double epsilon;   // 探索率,随训练逐渐衰减
    private final double alpha;     // 学习率
    private final double gamma;     // 折扣因子
    private final double epsilonDecay;
    private final double epsilonMin;

    // 记录单局轨迹,用于终局回溯更新
    private final java.util.List<Transition> episodeHistory = new java.util.ArrayList<>();

    public QLearningAgent(double epsilon, double alpha, double gamma,
                          double epsilonDecay, double epsilonMin) {
        this.qTable = new QTable();
        this.epsilon = epsilon;
        this.alpha = alpha;
        this.gamma = gamma;
        this.epsilonDecay = epsilonDecay;
        this.epsilonMin = epsilonMin;
    }

    /**
     * 根据当前棋盘状态选择动作
     * ε-贪心策略:以ε概率随机探索,以1-ε概率选择当前最优动作
     */
    public int selectAction(String state, List<Integer> legalActions) {
        if (legalActions.isEmpty()) {
            throw new IllegalStateException("无合法动作可选");
        }

        // 探索:随机选择
        if (random.nextDouble() < epsilon) {
            return legalActions.get(random.nextInt(legalActions.size()));
        }

        // 利用:选择Q值最高的动作
        double bestQ = Double.NEGATIVE_INFINITY;
        int bestAction = legalActions.get(0);
        for (int action : legalActions) {
            double q = qTable.getQ(state, action);
            if (q > bestQ) {
                bestQ = q;
                bestAction = action;
            }
        }
        return bestAction;
    }

    /**
     * 记录一次状态转移,终局时统一回溯更新
     */
    public void recordTransition(String state, int action) {
        episodeHistory.add(new Transition(state, action));
    }

    /**
     * 终局后根据最终奖励,按时间倒序回溯更新Q表
     * 这种"终局更新"方式更稳定,避免中间奖励的噪声干扰
     */
    public void finalizeEpisode(double finalReward) {
        double futureReward = 0.0;
        // 从终局向开局倒序遍历,TD(0)更新
        for (int i = episodeHistory.size() - 1; i >= 0; i--) {
            Transition t = episodeHistory.get(i);
            double oldQ = qTable.getQ(t.state, t.action);
            // 贝尔曼方程:Q = Q + α × (r + γ × maxQ' - Q)
            double target = finalReward + gamma * futureReward;
            double newQ = oldQ + alpha * (target - oldQ);
            qTable.setQ(t.state, t.action, newQ);
            futureReward = qTable.getMaxQ(t.state);
        }
        episodeHistory.clear();

        // 衰减探索率,让策略逐渐从"探索"转向"利用"
        epsilon = Math.max(epsilonMin, epsilon * epsilonDecay);
    }

    public void resetEpisode() {
        episodeHistory.clear();
    }

    public QTable getQTable() {
        return qTable;
    }

    public double getEpsilon() {
        return epsilon;
    }

    private record Transition(String state, int action) {}
}

七、训练器:自对弈循环与收敛监控

/**
 * 自对弈训练器:让两个共享Q表的智能体对弈,交替扮演X和O
 * 每局结束后双方各自更新自己的Q值(从各自视角)
 */
public class SelfPlayTrainer {
    private final QLearningAgent agentX;
    private final QLearningAgent agentO;
    private final TicTacToeEngine engine = new TicTacToeEngine();

    public SelfPlayTrainer(QLearningAgent agent) {
        // 两个智能体共享同一张Q表,确保策略对称收敛
        this.agentX = agent;
        this.agentO = new QLearningAgent(
            agent.getEpsilon(), 0.1, 0.95, 0.9995, 0.01
        );
        // 通过反射或额外方法让agentO也使用同一张Q表
        // 简化处理:实际上在完整项目中应使用共享QTable实例
    }

    /**
     * 执行一局完整的自对弈,返回对X的终局奖励
     */
    public double playOneEpisode() {
        engine.reset();
        agentX.resetEpisode();
        // agentO同样需要重置轨迹

        while (!engine.isGameOver()) {
            char player = engine.getCurrentPlayer();
            String state = BoardEncoder.encode(engine.getBoard());
            List<Integer> actions = engine.getLegalActions();
            int action;

            if (player == 'X') {
                action = agentX.selectAction(state, actions);
                agentX.recordTransition(state, action);
            } else {
                // O方:为了加速收敛,将棋盘视角翻转(O看成X)
                // 这样同一局面只需学习一次
                String flippedState = flipPerspective(state);
                action = agentX.selectAction(flippedState, actions);
                agentX.recordTransition(flippedState, action);
            }
            engine.makeMove(action);
        }

        // 计算终局奖励:X获胜+1,失败-1,平局0
        double rewardX;
        if (engine.checkWin('X')) rewardX = 1.0;
        else if (engine.checkWin('O')) rewardX = -1.0;
        else rewardX = 0.0;

        // 双方各自更新(从各自视角的奖励是相反的)
        agentX.finalizeEpisode(rewardX);
        // O方奖励 = -rewardX
        // 由于我们使用了视角翻转,O方的轨迹已记录在翻转后的状态中
        // 其奖励也应翻转
        return rewardX;
    }

    /**
     * 翻转棋盘视角:将X和O互换,让同一策略同时服务双方
     * 这样Q表规模减半,收敛速度翻倍
     */
    private String flipPerspective(String state) {
        StringBuilder sb = new StringBuilder(9);
        for (char c : state.toCharArray()) {
            if (c == 'X') sb.append('O');
            else if (c == 'O') sb.append('X');
            else sb.append(c);
        }
        return sb.toString();
    }

    /**
     * 训练主循环
     */
    public void train(int episodes) {
        int xWins = 0, oWins = 0, draws = 0;
        for (int i = 1; i <= episodes; i++) {
            double result = playOneEpisode();
            if (result > 0) xWins++;
            else if (result < 0) oWins++;
            else draws++;

            // 每1000局打印一次统计
            if (i % 1000 == 0) {
                System.out.printf("Episode %d: X=%d O=%d Draw=%d | Q表状态数=%d | ε=%.4f%n",
                    i, xWins, oWins, draws,
                    agentX.getQTable().size(),
                    agentX.getEpsilon());
                xWins = oWins = draws = 0;
            }
        }
    }
}

八、主程序入口与运行演示

public class TicTacToeQLearning {
    public static void main(String[] args) {
        // 初始化智能体:高探索率,中等学习率,高折扣因子
        QLearningAgent agent = new QLearningAgent(
            1.0,    // epsilon:初始完全随机探索
            0.2,    // alpha:学习率
            0.95,   // gamma:重视未来奖励
            0.9995, // epsilonDecay:每局衰减0.05%
            0.05    // epsilonMin:最低保留5%探索
        );

        SelfPlayTrainer trainer = new SelfPlayTrainer(agent);

        // 第一阶段:训练50000局
        System.out.println("=== 开始自对弈训练 ===");
        trainer.train(50000);

        // 第二阶段:关闭探索,测试策略强度
        System.out.println("\n=== 测试阶段(ε=0,纯利用) ===");
        // 此处可添加与随机对手或Minimax对手的测试代码

        System.out.println("训练完成!Q表已记录 " + agent.getQTable().size() + " 个状态的价值");
    }
}

九、关键技巧:加速收敛的三个工程细节

1. 视角翻转(Perspective Flipping)

井字棋具有对称性:X的最优策略与O的最优策略在翻转棋子和回合顺序后完全一致。通过在训练时将O方的棋盘做 X↔O 替换,再用同一张Q表查询,可将状态空间缩减约50%,显著加速收敛。

2. 终局统一更新 vs 即时更新

本实现采用终局回溯更新:每步只记录轨迹,游戏结束后再从终局向开局倒序更新Q值。这种方式避免了中间状态的奖励估计噪声,特别适合井字棋这种只有终局才有明确奖励的稀疏奖励环境。

3. ε衰减调度

初始 ε=1.0(完全随机)保证充分探索;随着训练进行逐步衰减到 ε=0.05,让智能体更多地依赖已学经验。衰减率 0.9995 意味着约每1400局探索率减半,兼顾了探索深度与收敛稳定性。

十、复杂度分析

指标 复杂度 说明
状态空间 O(5478) 井字棋合法状态数
Q表存储 O(S × A) S为访问过的状态数,A≤9为合法动作数
单局时间 O(9 × A) 每步查询Q表,最多9步
训练收敛 O(10^4 ~ 10^5) 局 经验值,取决于ε衰减速度

十一、扩展方向

  1. 引入神经网络(DQN):当状态空间增大到无法存储完整Q表时(如五子棋、象棋),可用神经网络近似Q函数。
  2. 对抗Minimax对手:训练后期让Q-Learning智能体与Minimax算法对弈,检验策略是否达到纳什均衡。
  3. 优先经验回放(PER):为终局前关键步赋予更高更新权重,如阻止对方三连的防守动作。

十二、总结

本文通过Java实现了一套完整的Q-Learning自对弈系统。核心要点回顾:

  • Q表用双层HashMap高效存储状态-动作价值;
  • ε-贪心策略在探索与利用之间动态权衡;
  • 贝尔曼方程驱动Q值向最优策略收敛;
  • 视角翻转技巧利用对称性将学习效率翻倍。

完整代码可直接编译运行,训练约5万局后智能体即可达到接近不可战胜的水平。将 main 方法中的测试模块替换为人机对战循环,你就能亲手与AI对弈,感受强化学习从零开始”悟出”最优策略的过程。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注