每日算法 — 使用java实现双陆棋:Expectimax概率博弈与TD-Learning评估

引言

双陆棋(Backgammon)是起源于近东地区的经典两人棋类游戏,距今已有超过五千年历史。游戏融合了策略规划与骰子概率,每位玩家通过掷两枚六面骰子决定棋子的移动步数,目标是将己方15枚棋子全部绕棋盘移动一圈并移出棋盘。与其他完全信息博弈不同,双陆棋因骰子引入了不确定性,使其AI设计需要同时考虑策略与概率期望。

本文将用Java实现双陆棋的AI决策核心,重点讲解Expectimax算法处理带概率节点的博弈树,以及TD-Learning(时序差分学习) 评估棋局价值的方法。

游戏规则与建模

棋盘结构

标准双陆棋棋盘由24个三角形格位(称为”点”)组成,分为外板、内板各12个点。每位玩家拥有15枚棋子,沿相反方向移动。白方从第24点向第1点移动,黑方从第1点向第24点移动。

核心规则

  1. 移动:根据骰子点数移动棋子,可拆分使用两颗骰子的点数
  2. 吃子:落在仅有一枚对方棋子的点上,将其打至bar(中间隔离区)
  3. 重新入场:被吃棋子需从对方内板重新入场
  4. 移出棋盘:所有棋子进入己方内板后,可按点数移出

状态表示

/**
 * 双陆棋棋盘状态
 * 使用两个长度为25的数组表示(索引0为bar,1~24为棋盘点)
 */
class BackgammonState {
    // whitePieces[i]: 第i点上白棋数量(i=0表示bar上的白棋)
    int[] whitePieces = new int[25];
    // blackPieces[i]: 第i点上黑棋数量(i=0表示bar上的黑棋)
    int[] blackPieces = new int[25];
    // 当前行动方:true=白方,false=黑方
    boolean whiteToMove;

    // 已移出棋盘的棋子数
    int whiteBornOff;
    int blackBornOff;
}

核心算法设计

1. Expectimax算法

Expectimax是Minimax的扩展,用于处理含有机会节点(Chance Node)的博弈树。在双陆棋中:
– MAX节点:AI选择使己方收益最大的走法
– MIN节点:对手选择使AI收益最小的走法
– Chance节点:掷骰子结果,每种结果有确定概率(1/36 或 1/18)

Expectimax的期望价值公式:
$$V(s) = \sum_{d \in Dice} P(d) \cdot \max_{a \in Actions(s,d)} V(Result(s,a))$$

其中 $P(d)$ 为骰子组合 $d$ 的概率。

2. TD-Learning棋局评估

由于双陆棋博弈树深度极大,无法搜索到终局。TD-Learning通过自对弈学习一个价值函数 $V(s)$,直接评估任意状态的胜率期望。

更新规则(TD(0)):
$$V(s_t) \leftarrow V(s_t) + \alpha \cdot [V(s_{t+1}) – V(s_t)]$$

其中 $\alpha$ 为学习率。特征向量包含棋盘上各点的棋子分布、bar上棋子数、已移出棋子数等关键指标。

3. 移动生成与去重

给定骰子点数,生成所有合法移动序列。使用归一化表示消除因骰子顺序不同导致的重复状态。

Java实现

import java.util.*;

/**
 * 双陆棋(Backgammon)AI核心
 * Expectimax概率博弈 + TD-Learning棋局评估
 */
public class BackgammonAI {

    private static final Random rand = new Random();
    private final double[] weights; // TD-Learning评估函数权重
    private final double alpha = 0.1; // 学习率

    // 所有36种骰子组合及其概率
    private static final int[][] DICE_COMBOS = new int[36][];
    static {
        int idx = 0;
        for (int d1 = 1; d1 <= 6; d1++) {
            for (int d2 = 1; d2 <= 6; d2++) {
                DICE_COMBOS[idx++] = new int[]{d1, d2};
            }
        }
    }

    public BackgammonAI() {
        // 初始化评估权重(可通过训练优化)
        this.weights = new double[196]; // 特征维度
        initializeWeights();
    }

    private void initializeWeights() {
        // 基础启发式权重初始化
        // 实际应用中应通过大量自对弈训练得到
        Arrays.fill(weights, 0.01);
    }

    /**
     * 棋盘状态类
     */
    static class State {
        int[] white = new int[25]; // 索引0为bar
        int[] black = new int[25];
        boolean whiteToMove;
        int whiteOff, blackOff;

        State() {
            // 标准初始布局
            white[24] = 2; white[13] = 5; white[8] = 3; white[6] = 5;
            black[1] = 2; black[12] = 5; black[17] = 3; black[19] = 5;
            whiteToMove = true;
        }

        State copy() {
            State s = new State();
            System.arraycopy(white, 0, s.white, 0, 25);
            System.arraycopy(black, 0, s.black, 0, 25);
            s.whiteToMove = whiteToMove;
            s.whiteOff = whiteOff;
            s.blackOff = blackOff;
            return s;
        }

        boolean isTerminal() {
            return whiteOff == 15 || blackOff == 15;
        }

        double utility() {
            if (whiteOff == 15) return 1.0;
            if (blackOff == 15) return -1.0;
            return 0;
        }
    }

    /**
     * 生成指定骰子组合下的所有合法移动序列
     */
    List<State> generateMoves(State state, int d1, int d2) {
        List<State> moves = new ArrayList<>();
        List<int[]> sequences = new ArrayList<>();

        boolean isWhite = state.whiteToMove;
        int[] pieces = isWhite ? state.white : state.black;
        int[] opponent = isWhite ? state.black : state.white;

        // 判断是否为双骰(doubles)
        int[] dice = d1 == d2 ? new int[]{d1, d1, d1, d1} : new int[]{d1, d2};

        generateMoveSequences(state, dice, 0, new ArrayList<>(), sequences, isWhite);

        // 将移动序列转换为新状态
        for (int[] seq : sequences) {
            moves.add(applySequence(state, seq, isWhite));
        }

        // 若无合法移动,返回原状态(切换回合)
        if (moves.isEmpty()) {
            State pass = state.copy();
            pass.whiteToMove = !pass.whiteToMove;
            moves.add(pass);
        }

        return moves;
    }

    /**
     * 递归生成所有合法移动序列
     */
    private void generateMoveSequences(State s, int[] dice, int diceIdx, 
                                       List<int[]> current, List<int[]> results, boolean isWhite) {
        if (diceIdx >= dice.length) {
            results.add(new int[current.size() * 2]);
            for (int i = 0; i < current.size(); i++) {
                results.get(results.size() - 1)[i * 2] = current.get(i)[0];
                results.get(results.size() - 1)[i * 2 + 1] = current.get(i)[1];
            }
            return;
        }

        int die = dice[diceIdx];
        boolean hasMove = false;
        int[] pieces = isWhite ? s.white : s.black;
        int[] opp = isWhite ? s.black : s.white;

        // bar上的棋子优先重新入场
        if (pieces[0] > 0) {
            int entryPoint = isWhite ? (25 - die) : die;
            if (opp[entryPoint] <= 1) {
                hasMove = true;
                State next = s.copy();
                if (isWhite) {
                    next.white[0]--;
                    if (next.black[entryPoint] == 1) {
                        next.black[entryPoint] = 0;
                        next.black[0]++;
                    }
                    next.white[entryPoint]++;
                } else {
                    next.black[0]--;
                    if (next.white[entryPoint] == 1) {
                        next.white[entryPoint] = 0;
                        next.white[0]++;
                    }
                    next.black[entryPoint]++;
                }
                current.add(new int[]{0, entryPoint});
                generateMoveSequences(next, dice, diceIdx + 1, current, results, isWhite);
                current.remove(current.size() - 1);
            }
        } else {
            // 尝试移动棋盘上的棋子
            int start = isWhite ? 1 : 24;
            int end = isWhite ? 24 : 1;
            int step = isWhite ? 1 : -1;

            for (int p = start; isWhite ? p <= end : p >= end; p += step) {
                if (pieces[p] == 0) continue;
                int dest = isWhite ? p + die : p - die;

                // 移出棋盘
                if (isWhite ? dest > 24 : dest < 1) {
                    if (canBearOff(s, isWhite)) {
                        hasMove = true;
                        State next = s.copy();
                        if (isWhite) { next.white[p]--; next.whiteOff++; }
                        else { next.black[p]--; next.blackOff++; }
                        current.add(new int[]{p, -1});
                        generateMoveSequences(next, dice, diceIdx + 1, current, results, isWhite);
                        current.remove(current.size() - 1);
                    }
                    continue;
                }

                // 普通移动
                if (opp[dest] <= 1) {
                    hasMove = true;
                    State next = s.copy();
                    if (isWhite) {
                        next.white[p]--;
                        if (next.black[dest] == 1) {
                            next.black[dest] = 0;
                            next.black[0]++;
                        }
                        next.white[dest]++;
                    } else {
                        next.black[p]--;
                        if (next.white[dest] == 1) {
                            next.white[dest] = 0;
                            next.white[0]++;
                        }
                        next.black[dest]++;
                    }
                    current.add(new int[]{p, dest});
                    generateMoveSequences(next, dice, diceIdx + 1, current, results, isWhite);
                    current.remove(current.size() - 1);
                }
            }
        }

        if (!hasMove) {
            // 无法使用这颗骰子,尝试下一颗
            generateMoveSequences(s, dice, diceIdx + 1, current, results, isWhite);
        }
    }

    private boolean canBearOff(State s, boolean isWhite) {
        int[] pieces = isWhite ? s.white : s.black;
        // 所有棋子都在己方内板或已移出
        int homeStart = isWhite ? 1 : 19;
        int homeEnd = isWhite ? 6 : 24;
        for (int i = 0; i < 25; i++) {
            if (pieces[i] == 0) continue;
            if (i == 0) return false; // bar上还有棋子
            if (isWhite ? (i < 1 || i > 6) : (i < 19 || i > 24)) return false;
        }
        return true;
    }

    private State applySequence(State s, int[] seq, boolean isWhite) {
        State next = s.copy();
        next.whiteToMove = !next.whiteToMove;
        return next;
    }

    /**
     * Expectimax搜索:考虑骰子概率的期望价值
     */
    double expectimax(State state, int depth, boolean isMax) {
        if (state.isTerminal()) return state.utility();
        if (depth == 0) return evaluate(state);

        if (state.whiteToMove) { // 白方行动(MAX)
            double maxValue = Double.NEGATIVE_INFINITY;
            for (int[] dice : DICE_COMBOS) {
                double prob = (dice[0] == dice[1]) ? 1.0/36.0 : 2.0/36.0;
                List<State> moves = generateMoves(state, dice[0], dice[1]);
                double bestForThisDice = Double.NEGATIVE_INFINITY;
                for (State m : moves) {
                    double v = expectimax(m, depth - 1, false);
                    if (v > bestForThisDice) bestForThisDice = v;
                }
                maxValue = Math.max(maxValue, prob * bestForThisDice);
            }
            return maxValue;
        } else { // 黑方行动(MIN)
            double minValue = Double.POSITIVE_INFINITY;
            for (int[] dice : DICE_COMBOS) {
                double prob = (dice[0] == dice[1]) ? 1.0/36.0 : 2.0/36.0;
                List<State> moves = generateMoves(state, dice[0], dice[1]);
                double worstForThisDice = Double.POSITIVE_INFINITY;
                for (State m : moves) {
                    double v = expectimax(m, depth - 1, true);
                    if (v < worstForThisDice) worstForThisDice = v;
                }
                minValue = Math.min(minValue, prob * worstForThisDice);
            }
            return minValue;
        }
    }

    /**
     * TD-Learning棋局评估函数
     * 提取关键特征并计算线性加权评分
     */
    double evaluate(State state) {
        double[] features = extractFeatures(state);
        double score = 0;
        for (int i = 0; i < features.length; i++) {
            score += weights[i] * features[i];
        }
        return Math.tanh(score); // 映射到[-1, 1]
    }

    /**
     * 特征提取:将棋盘状态转换为特征向量
     */
    double[] extractFeatures(State state) {
        double[] f = new double[196];
        int idx = 0;

        // 白方各点棋子数(24个特征)
        for (int i = 1; i <= 24; i++) f[idx++] = state.white[i] / 15.0;
        // 黑方各点棋子数(24个特征)
        for (int i = 1; i <= 24; i++) f[idx++] = state.black[i] / 15.0;
        // bar上棋子数
        f[idx++] = state.white[0] / 2.0;
        f[idx++] = state.black[0] / 2.0;
        // 已移出棋子数
        f[idx++] = state.whiteOff / 15.0;
        f[idx++] = state.blackOff / 15.0;

        // 关键点位特征:己方内板占领数、锚点(anchor)数、 prime 长度等
        int whiteHome = 0, blackHome = 0;
        for (int i = 1; i <= 6; i++) {
            if (state.white[i] >= 2) whiteHome++;
            if (state.black[25 - i] >= 2) blackHome++;
        }
        f[idx++] = whiteHome / 6.0;
        f[idx++] = blackHome / 6.0;

        //  Pip count(棋子到终点的总距离)
        int whitePip = 0, blackPip = 0;
        for (int i = 0; i < 25; i++) {
            whitePip += state.white[i] * i;
            blackPip += state.black[i] * (25 - i);
        }
        f[idx++] = (blackPip - whitePip) / 300.0;

        return f;
    }

    /**
     * TD(0)自对弈训练
     */
    void train(int episodes) {
        for (int ep = 0; ep < episodes; ep++) {
            State state = new State();
            List<State> history = new ArrayList<>();

            while (!state.isTerminal()) {
                history.add(state.copy());
                int d1 = rand.nextInt(6) + 1;
                int d2 = rand.nextInt(6) + 1;
                List<State> moves = generateMoves(state, d1, d2);

                // epsilon-贪心策略选择动作
                State bestMove = moves.get(0);
                double bestValue = state.whiteToMove ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;

                for (State m : moves) {
                    double v = evaluate(m);
                    if (state.whiteToMove && v > bestValue) { bestValue = v; bestMove = m; }
                    if (!state.whiteToMove && v < bestValue) { bestValue = v; bestMove = m; }
                }

                state = bestMove;
            }

            // TD更新
            double reward = state.utility();
            for (int t = history.size() - 1; t >= 0; t--) {
                double[] f = extractFeatures(history.get(t));
                double v = evaluate(history.get(t));
                double target = (t == history.size() - 1) ? reward : evaluate(history.get(t + 1));
                double delta = target - v;
                for (int i = 0; i < weights.length; i++) {
                    weights[i] += alpha * delta * f[i];
                }
            }
        }
    }

    /**
     * AI选择最佳走法(用于实际对局)
     */
    State chooseBestMove(State state, int d1, int d2, int searchDepth) {
        List<State> moves = generateMoves(state, d1, d2);
        State best = moves.get(0);
        double bestValue = state.whiteToMove ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;

        for (State m : moves) {
            double v = expectimax(m, searchDepth, state.whiteToMove);
            if (state.whiteToMove && v > bestValue) { bestValue = v; best = m; }
            if (!state.whiteToMove && v < bestValue) { bestValue = v; best = m; }
        }
        return best;
    }

    public static void main(String[] args) {
        BackgammonAI ai = new BackgammonAI();
        System.out.println("开始TD-Learning训练...");
        long start = System.currentTimeMillis();
        ai.train(10000);
        long end = System.currentTimeMillis();
        System.out.println("训练完成,耗时: " + (end - start) + "ms");

        // 演示一局
        State state = new State();
        int turn = 0;
        while (!state.isTerminal() && turn < 200) {
            int d1 = rand.nextInt(6) + 1;
            int d2 = rand.nextInt(6) + 1;
            state = ai.chooseBestMove(state, d1, d2, 2);
            turn++;
        }
        System.out.println("对局结束,白方移出: " + state.whiteOff + ", 黑方移出: " + state.blackOff);
    }
}

算法复杂度分析

时间复杂度

  • 移动生成:每步最多生成 $O(n^k)$ 种移动序列,其中 $n$ 为可移动棋子数,$k$ 为骰子数(2或4)
  • Expectimax搜索:深度为 $d$ 时,时间复杂度为 $O(36^d \cdot b^d)$,其中 $b$ 为分支因子。实际中搜索深度通常限制为2~3层
  • TD-Learning训练:每局约50~100步,训练10000局可在数秒内完成

空间复杂度

  • 棋盘状态:$O(1)$(两个25元素数组)
  • 移动生成:递归栈深度 $O(k)$
  • Expectimax:递归栈 $O(d)$
  • 权重向量:$O(F)$,$F$ 为特征维度

扩展与改进

1. 深度神经网络评估

将线性评估函数替换为神经网络(如多层感知机),可捕捉更复杂的非线性特征交互。TD-Gammon是此方向的里程碑工作,使用两层神经网络达到大师级水平。

2. 蒙特卡洛树搜索(MCTS)

用MCTS替代Expectimax,通过随机模拟评估状态价值,避免了精确概率计算的高开销,适合更深的搜索。

3. 局面数据库

对终局阶段(双方剩余少量棋子)建立精确数据库,实现完美终局策略,显著提升胜率。

总结

双陆棋AI展示了概率博弈的核心挑战:如何在不确定性中做出最优期望决策。Expectimax通过显式建模骰子概率节点,将Minimax扩展到随机环境;TD-Learning则通过自对弈习得强大的棋局评估函数。这套框架不仅适用于双陆棋,也可推广至所有含随机因素的游戏AI设计,如扑克、麻将等不完全信息博弈。