引言
双陆棋(Backgammon)是起源于近东地区的经典两人棋类游戏,距今已有超过五千年历史。游戏融合了策略规划与骰子概率,每位玩家通过掷两枚六面骰子决定棋子的移动步数,目标是将己方15枚棋子全部绕棋盘移动一圈并移出棋盘。与其他完全信息博弈不同,双陆棋因骰子引入了不确定性,使其AI设计需要同时考虑策略与概率期望。
本文将用Java实现双陆棋的AI决策核心,重点讲解Expectimax算法处理带概率节点的博弈树,以及TD-Learning(时序差分学习) 评估棋局价值的方法。
游戏规则与建模
棋盘结构
标准双陆棋棋盘由24个三角形格位(称为”点”)组成,分为外板、内板各12个点。每位玩家拥有15枚棋子,沿相反方向移动。白方从第24点向第1点移动,黑方从第1点向第24点移动。
核心规则
- 移动:根据骰子点数移动棋子,可拆分使用两颗骰子的点数
- 吃子:落在仅有一枚对方棋子的点上,将其打至bar(中间隔离区)
- 重新入场:被吃棋子需从对方内板重新入场
- 移出棋盘:所有棋子进入己方内板后,可按点数移出
状态表示
/**
* 双陆棋棋盘状态
* 使用两个长度为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设计,如扑克、麻将等不完全信息博弈。