引言
叠叠乐(Jenga)是一款经典的物理益智游戏,玩家轮流从木塔中抽取积木并叠放到塔顶,使塔倒塌者判负。本文将叠叠乐中的最优抽取决策建模为不完全信息博弈问题,并使用蒙特卡洛树搜索(Monte Carlo Tree Search, MCTS)结合风险评估函数,让AI在考虑物理稳定性、对手反应和塔结构变化的多重因素下做出最优决策。
问题建模
塔的结构表示
标准叠叠乐由54块积木组成,每层3块,共18层。相邻层之间旋转90度交错堆叠。每块积木的状态由以下属性定义:
- 位置 (layer, index):层号(0为底层)和该层内的序号(0~2)
- 质心偏移:该块被抽出后,剩余结构的质心偏移量
- 支撑状态:该块是否为上方某层的直接支撑
博弈树模型
将叠叠乐建模为双人零和博弈:
- 状态:当前塔的三维结构(每块积木是否存在)
- 动作:选择一块合法积木抽出并放置到塔顶
- 转移:更新塔结构,检查是否倒塌
- 终止:塔倒塌时,当前行动玩家判负(得分为-1),对手得+1
由于塔的实际物理行为具有随机性(同一块积木的抽出可能导致不同倾斜),我们引入随机节点:每次抽取后,塔的倾斜角度服从以质心偏移为均值的高斯分布。
蒙特卡洛树搜索
MCTS通过四个阶段迭代构建搜索树:
- 选择(Selection):从根节点出发,使用UCB1公式选择子节点,直到到达未完全展开的节点
- 扩展(Expansion):为该节点添加一个未访问的子节点
- 模拟(Simulation):从新节点出发,使用默认策略(随机或启发式)快速模拟至终局
- 回溯(Backpropagation):将模拟结果沿路径更新所有祖先节点的统计值
UCB1公式:
$$
\text{UCB1} = \bar{X}_j + C \sqrt{\frac{2 \ln n}{n_j}}
$$
其中 $\bar{X}_j$ 为子节点平均收益,$n$ 为父节点访问次数,$n_j$ 为子节点访问次数,$C$ 为探索常数。
Java实现
核心数据结构
import java.util.*;
/**
* 叠叠乐MCTS求解器
* 蒙特卡洛树搜索 + 风险评估
*/
public class JengaMCTSSolver {
static final int BLOCKS_PER_LAYER = 3;
static final int TOTAL_LAYERS = 18;
static final int TOTAL_BLOCKS = 54;
static final double EXPLORATION_C = 1.414; // UCB1探索常数 sqrt(2)
static final int SIMULATION_DEPTH = 20; // 最大模拟深度
static final int MAX_ITERATIONS = 5000; // MCTS迭代次数
// 积木块
static class Block {
int layer;
int index; // 0~2
boolean present; // 是否仍在塔中
double mass = 1.0;
Block(int layer, int index) {
this.layer = layer;
this.index = index;
this.present = true;
}
}
// 塔状态
static class TowerState {
Block[][] layers; // layers[layer][index]
int currentLayerCount; // 当前总层数
int nextLayerOrientation; // 0=水平, 1=垂直(交替)
TowerState() {
layers = new Block[TOTAL_LAYERS * 2][BLOCKS_PER_LAYER];
currentLayerCount = TOTAL_LAYERS;
nextLayerOrientation = 0;
for (int l = 0; l < TOTAL_LAYERS; l++) {
for (int i = 0; i < BLOCKS_PER_LAYER; i++) {
layers[l][i] = new Block(l, i);
}
}
}
TowerState copy() {
TowerState clone = new TowerState();
clone.currentLayerCount = this.currentLayerCount;
clone.nextLayerOrientation = this.nextLayerOrientation;
for (int l = 0; l < layers.length; l++) {
for (int i = 0; i < BLOCKS_PER_LAYER; i++) {
if (this.layers[l][i] != null) {
Block b = new Block(l, i);
b.present = this.layers[l][i].present;
clone.layers[l][i] = b;
}
}
}
return clone;
}
}
// 动作:从某层抽取某块,放置到新层
static class Action {
int fromLayer;
int fromIndex;
Action(int layer, int index) {
this.fromLayer = layer;
this.fromIndex = index;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Action)) return false;
Action other = (Action) o;
return fromLayer == other.fromLayer && fromIndex == other.fromIndex;
}
@Override
public int hashCode() {
return fromLayer * 10 + fromIndex;
}
@Override
public String toString() {
return String.format("抽取第%d层第%d块", fromLayer, fromIndex);
}
}
// MCTS节点
static class MCTSNode {
TowerState state;
Action action; // 到达此节点的动作
MCTSNode parent;
List<MCTSNode> children;
int visits;
double totalValue;
boolean fullyExpanded;
MCTSNode(TowerState state, Action action, MCTSNode parent) {
this.state = state;
this.action = action;
this.parent = parent;
this.children = new ArrayList<>();
this.visits = 0;
this.totalValue = 0;
this.fullyExpanded = false;
}
double getUCB1() {
if (visits == 0) return Double.MAX_VALUE;
double exploitation = totalValue / visits;
double exploration = EXPLORATION_C * Math.sqrt(2 * Math.log(parent.visits) / visits);
return exploitation + exploration;
}
}
风险评估函数
/**
* 计算塔的风险评估分数(越高越危险)
*/
double evaluateRisk(TowerState tower) {
double risk = 0;
// 1. 质心偏移风险:计算每层上方的质心是否偏离支撑基础
for (int l = 1; l < tower.currentLayerCount; l++) {
double[] com = computeCenterOfMass(tower, l); // 从第l层往上的质心
double[] supportBase = computeSupportBase(tower, l - 1); // 第l-1层的支撑范围
double xOffset = Math.abs(com[0] - supportBase[0]);
double zOffset = Math.abs(com[1] - supportBase[1]);
// 偏移量越大,风险越高,且随高度指数增长
risk += (xOffset + zOffset) * Math.pow(1.1, l);
}
// 2. 支撑缺失风险:检查每层缺失的支撑块数
for (int l = 1; l < tower.currentLayerCount; l++) {
int missingSupport = countMissingSupport(tower, l);
risk += missingSupport * 5.0;
}
// 3. 高层的脆弱性:高层积木缺失惩罚更大
int topMissing = 0;
for (int i = 0; i < BLOCKS_PER_LAYER; i++) {
if (!tower.layers[tower.currentLayerCount - 1][i].present) topMissing++;
}
risk += topMissing * 3.0;
return risk;
}
/**
* 计算从startLayer往上的质心坐标 (x, z)
*/
double[] computeCenterOfMass(TowerState tower, int startLayer) {
double totalMass = 0, sumX = 0, sumZ = 0;
for (int l = startLayer; l < tower.currentLayerCount; l++) {
for (int i = 0; i < BLOCKS_PER_LAYER; i++) {
Block b = tower.layers[l][i];
if (b != null && b.present) {
double[] pos = getBlockPosition(l, i);
sumX += pos[0] * b.mass;
sumZ += pos[1] * b.mass;
totalMass += b.mass;
}
}
}
return new double[]{sumX / totalMass, sumZ / totalMass};
}
double[] getBlockPosition(int layer, int index) {
// 简化模型:每层长3单位,每块宽1单位
boolean isHorizontal = (layer % 2 == 0);
if (isHorizontal) {
return new double[]{index - 1.0, 0.0}; // x偏移,z=0
} else {
return new double[]{0.0, index - 1.0}; // x=0, z偏移
}
}
double[] computeSupportBase(TowerState tower, int layer) {
// 支撑基础的范围中心
double minX = 1, maxX = -1, minZ = 1, maxZ = -1;
for (int i = 0; i < BLOCKS_PER_LAYER; i++) {
Block b = tower.layers[layer][i];
if (b != null && b.present) {
double[] pos = getBlockPosition(layer, i);
minX = Math.min(minX, pos[0] - 0.5);
maxX = Math.max(maxX, pos[0] + 0.5);
minZ = Math.min(minZ, pos[1] - 0.5);
maxZ = Math.max(maxZ, pos[1] + 0.5);
}
}
return new double[]{(minX + maxX) / 2, (minZ + maxZ) / 2};
}
int countMissingSupport(TowerState tower, int layer) {
int count = 0;
for (int i = 0; i < BLOCKS_PER_LAYER; i++) {
Block b = tower.layers[layer][i];
if (b == null || !b.present) {
// 检查上方是否有块依赖此支撑
if (layer + 1 < tower.currentLayerCount) {
boolean hasDependent = false;
for (int j = 0; j < BLOCKS_PER_LAYER; j++) {
Block above = tower.layers[layer + 1][j];
if (above != null && above.present) {
hasDependent = true;
break;
}
}
if (hasDependent) count++;
}
}
}
return count;
}
合法动作生成与状态转移
/**
* 获取当前状态下所有合法抽取动作
* 规则:不能抽取顶层,不能抽取导致立即倒塌的块
*/
List<Action> getLegalActions(TowerState tower) {
List<Action> actions = new ArrayList<>();
for (int l = 0; l < tower.currentLayerCount - 1; l++) { // 不抽顶层
for (int i = 0; i < BLOCKS_PER_LAYER; i++) {
Block b = tower.layers[l][i];
if (b != null && b.present) {
// 检查抽出后是否导致结构不稳定(简化:允许抽取,但在模拟中评估风险)
actions.add(new Action(l, i));
}
}
}
return actions;
}
/**
* 执行动作并返回新状态
*/
TowerState applyAction(TowerState tower, Action action) {
TowerState next = tower.copy();
// 移除原位置的块
next.layers[action.fromLayer][action.fromIndex].present = false;
// 放置到塔顶新层
int newLayer = next.currentLayerCount;
// 检查新层是否已满,若满则创建下一层
int placedCount = 0;
for (int i = 0; i < BLOCKS_PER_LAYER; i++) {
if (next.layers[newLayer][i] != null && next.layers[newLayer][i].present) {
placedCount++;
}
}
if (placedCount >= BLOCKS_PER_LAYER) {
newLayer++;
next.currentLayerCount++;
}
if (newLayer < next.layers.length) {
if (next.layers[newLayer][placedCount] == null) {
next.layers[newLayer][placedCount] = new Block(newLayer, placedCount);
}
next.layers[newLayer][placedCount].present = true;
}
return next;
}
/**
* 检查塔是否倒塌
* 简化模型:若风险评估超过阈值,则以概率判定倒塌
*/
boolean isCollapsed(TowerState tower) {
double risk = evaluateRisk(tower);
double threshold = 50.0;
if (risk > threshold) {
double collapseProb = Math.min(0.95, (risk - threshold) / 100.0);
return Math.random() < collapseProb;
}
return false;
}
MCTS核心算法
/**
* MCTS主循环
*/
Action mctsSearch(TowerState rootState) {
MCTSNode root = new MCTSNode(rootState, null, null);
for (int iter = 0; iter < MAX_ITERATIONS; iter++) {
// 1. 选择
MCTSNode node = select(root);
// 2. 扩展
if (!isTerminal(node.state)) {
node = expand(node);
}
// 3. 模拟
double reward = simulate(node.state);
// 4. 回溯
backpropagate(node, reward);
}
// 返回访问次数最多的动作(稳健选择)
MCTSNode bestChild = null;
int maxVisits = -1;
for (MCTSNode child : root.children) {
if (child.visits > maxVisits) {
maxVisits = child.visits;
bestChild = child;
}
}
return bestChild != null ? bestChild.action : null;
}
MCTSNode select(MCTSNode node) {
while (!node.children.isEmpty() && node.fullyExpanded) {
node = getBestUCB1Child(node);
}
return node;
}
MCTSNode getBestUCB1Child(MCTSNode node) {
MCTSNode best = null;
double bestUCB = Double.NEGATIVE_INFINITY;
for (MCTSNode child : node.children) {
double ucb = child.getUCB1();
if (ucb > bestUCB) {
bestUCB = ucb;
best = child;
}
}
return best;
}
MCTSNode expand(MCTSNode node) {
List<Action> legalActions = getLegalActions(node.state);
// 找出未访问过的动作
Set<Action> tried = new HashSet<>();
for (MCTSNode child : node.children) {
tried.add(child.action);
}
for (Action action : legalActions) {
if (!tried.contains(action)) {
TowerState nextState = applyAction(node.state, action);
MCTSNode child = new MCTSNode(nextState, action, node);
node.children.add(child);
if (node.children.size() == legalActions.size()) {
node.fullyExpanded = true;
}
return child;
}
}
node.fullyExpanded = true;
return node;
}
/**
* 默认策略模拟:随机选择动作直到终局或深度限制
*/
double simulate(TowerState state) {
TowerState simState = state.copy();
int depth = 0;
int currentPlayer = 0; // 0或1
while (depth < SIMULATION_DEPTH) {
if (isCollapsed(simState)) {
// 上一个行动的玩家导致倒塌,当前玩家获胜
return currentPlayer == 0 ? 1.0 : -1.0;
}
List<Action> actions = getLegalActions(simState);
if (actions.isEmpty()) break;
// 启发式随机:优先选择风险较低的动作
Action chosen = selectHeuristicAction(simState, actions);
simState = applyAction(simState, chosen);
currentPlayer = 1 - currentPlayer;
depth++;
}
// 若未倒塌,用风险评估作为终局估值
double risk = evaluateRisk(simState);
return -risk / 100.0; // 风险越低,估值越高
}
/**
* 启发式动作选择:倾向于选择风险低的动作
*/
Action selectHeuristicAction(TowerState state, List<Action> actions) {
double[] weights = new double[actions.size()];
double totalWeight = 0;
for (int i = 0; i < actions.size(); i++) {
TowerState next = applyAction(state, actions.get(i));
double risk = evaluateRisk(next);
// 权重与风险成反比
weights[i] = Math.exp(-risk / 20.0);
totalWeight += weights[i];
}
double r = Math.random() * totalWeight;
double cumsum = 0;
for (int i = 0; i < actions.size(); i++) {
cumsum += weights[i];
if (r <= cumsum) return actions.get(i);
}
return actions.get(actions.size() - 1);
}
void backpropagate(MCTSNode node, double reward) {
while (node != null) {
node.visits++;
node.totalValue += reward;
// 奖励取反(零和博弈)
reward = -reward;
node = node.parent;
}
}
boolean isTerminal(TowerState state) {
return isCollapsed(state);
}
测试入口
public static void main(String[] args) {
JengaMCTSSolver solver = new JengaMCTSSolver();
TowerState tower = new TowerState();
System.out.println("初始风险评估: " + solver.evaluateRisk(tower));
// 模拟对局
for (int turn = 0; turn < 20; turn++) {
if (solver.isCollapsed(tower)) {
System.out.println("塔在回合 " + turn + " 倒塌!");
break;
}
Action bestAction = solver.mctsSearch(tower);
if (bestAction == null) {
System.out.println("无合法动作,对局结束");
break;
}
System.out.println("回合 " + (turn + 1) + ": " + bestAction);
tower = solver.applyAction(tower, bestAction);
System.out.println(" 当前风险评估: " + String.format("%.2f", solver.evaluateRisk(tower)));
}
}
}
算法分析
| 指标 | 数值 |
|---|---|
| MCTS迭代次数 | 5000 |
| 每次模拟深度 | 20 步 |
| 分支因子 | 约 30~50(中层) |
| 风险评估复杂度 | O(L),L为层数 |
| 单次MCTS耗时 | 100~500ms(Java实现) |
| UCB1探索常数 | √2 ≈ 1.414 |
MCTS在不完全信息博弈中的优势在于:无需精确的终局估值函数,通过大量随机模拟即可收敛到近似最优策略。叠叠乐的物理随机性天然适合MCTS的概率框架。
扩展方向
- 神经网络策略网络(AlphaZero范式):用ResNet替代随机模拟,指导MCTS的走子概率和局面估值
- 对手建模:根据对手历史行为调整其模拟策略,实现自适应对策
- 三维物理引擎集成:用Bullet或Box2D精确模拟积木抽出时的摩擦力、振动和倾倒过程
总结
本文将叠叠乐的抽取决策建模为双人零和博弈,设计了包含质心偏移、支撑缺失和高层脆弱性等多维度的风险评估函数,并基于蒙特卡洛树搜索在庞大的动作空间中寻找最优策略。读者可以调整MCTS的迭代次数和模拟深度来平衡决策质量与响应速度,也可以将框架迁移到其他物理博弈类游戏AI中。