贪吃蛇是承载无数人童年记忆的经典益智游戏。传统的贪吃蛇AI通常依赖A星寻路计算最短路径,但在复杂局面下容易陷入”贪吃陷阱”——为了吃到眼前的食物而将自己逼入死胡同。本文将引入一种源自围棋AI AlphaGo的决策框架蒙特卡洛树搜索(Monte Carlo Tree Search, MCTS),结合UCB1公式(Upper Confidence Bound)构建一棵决策树,让蛇在多步前瞻中自主评估”这条路线能活多久”,从而做出更长寿的决策。全文以Java实现,包含可直接编译运行的完整代码与复杂度分析。
一、为什么需要MCTS?A星的局限
A星算法在贪吃蛇中的核心逻辑是:计算蛇头到食物的最短曼哈顿路径,然后沿路径移动。这个策略在空旷地图上表现优异,但当蛇身逐渐填满网格时,A星给出的”最短路径”往往会让蛇头钻入由自己身体围成的封闭区域,最终无处可去。
MCTS的核心思想截然不同:它不直接求解一条确定路径,而是通过大量随机模拟来统计”在当前局面下走某一步后,最终能存活多少步/获得多少分”。得分最高的动作被选为当前步的决策。这种基于统计的决策方式天然具有”前瞻性”,能规避很多局部最优陷阱。
二、MCTS的四步循环
MCTS算法围绕一棵不断生长的搜索树进行,每次决策包含四个阶段,循环执行直到计算预算耗尽:
2.1 选择(Selection)
从根节点(当前游戏状态)出发,递归选择子节点,直到到达一个”未完全扩展”的节点。节点选择依据UCT公式:
$$\text{UCT} = \frac{w_i}{n_i} + C \cdot \sqrt{\frac{\ln N}{n_i}}$$
其中:
– $w_i$:第 $i$ 个子节点的累计奖励值
– $n_i$:第 $i$ 个子节点的访问次数
– $N$:父节点的总访问次数
– $C$:探索常数,通常取 $\sqrt{2}$
UCT公式的前半部分是利用项(exploitation),倾向于选择胜率高的节点;后半部分是探索项(exploration),保证访问次数少的节点也有机会被选中,避免过早陷入局部最优。
2.2 扩展(Expansion)
当选择阶段抵达一个尚未穷举所有合法动作的节点时,为其创建一个新的子节点,对应一个尚未被探索的动作。
2.3 模拟(Simulation / Rollout)
从新扩展的节点出发,不再使用UCT选择,而是以随机策略(或简单的启发式策略)快速走完整局游戏,直到蛇死亡。记录本局游戏的最终得分(通常用存活步数或吃到的食物数量衡量)。
2.4 反向传播(Backpropagation)
将模拟得到的奖励值,沿着从扩展节点到根节点的路径逐层回传,更新路径上每个节点的 $w_i$ 和 $n_i$。
三、Java核心实现
3.1 游戏状态建模
import java.util.*;
/**
* 贪吃蛇游戏状态
* 包含蛇身、食物、地图大小,以及动作执行与胜负判定
*/
public class GameState {
// 地图尺寸
public final int width;
public final int height;
// 蛇身:头部在列表末尾,尾部在列表开头
public final LinkedList<int[]> snake;
// 食物位置
public int[] food;
// 已存活步数(用于奖励计算)
public int stepsAlive;
// 自上次吃食物以来经过的步数(用于检测死循环)
public int stepsSinceLastFood;
// 四个方向:上、下、左、右
public static final int[][] DIRECTIONS = {
{0, -1}, {0, 1}, {-1, 0}, {1, 0}
};
public GameState(int width, int height) {
this.width = width;
this.height = height;
this.snake = new LinkedList<>();
// 初始蛇位于地图中央,长度为1
int startX = width / 2;
int startY = height / 2;
this.snake.add(new int[]{startX, startY});
this.stepsAlive = 0;
this.stepsSinceLastFood = 0;
spawnFood();
}
// 私有构造方法,用于生成子状态
private GameState(int width, int height, LinkedList<int[]> snake,
int[] food, int stepsAlive, int stepsSinceLastFood) {
this.width = width;
this.height = height;
this.snake = new LinkedList<>();
for (int[] p : snake) {
this.snake.add(new int[]{p[0], p[1]});
}
this.food = new int[]{food[0], food[1]};
this.stepsAlive = stepsAlive;
this.stepsSinceLastFood = stepsSinceLastFood;
}
/**
* 随机生成食物,确保不与蛇身重叠
*/
private void spawnFood() {
Random rand = new Random();
boolean valid;
do {
valid = true;
int fx = rand.nextInt(width);
int fy = rand.nextInt(height);
for (int[] p : snake) {
if (p[0] == fx && p[1] == fy) {
valid = false;
break;
}
}
if (valid) {
food = new int[]{fx, fy};
}
} while (!valid);
}
/**
* 执行一个方向动作,返回新的游戏状态(深拷贝)
* @param dirIndex 方向索引 0=上 1=下 2=左 3=右
* @return 新状态,若动作导致死亡则返回null
*/
public GameState applyAction(int dirIndex) {
int[] head = snake.getLast();
int nx = head[0] + DIRECTIONS[dirIndex][0];
int ny = head[1] + DIRECTIONS[dirIndex][1];
// 边界检测
if (nx < 0 || nx >= width || ny < 0 || ny >= height) {
return null; // 撞墙死亡
}
// 自身碰撞检测(尾部即将移动,所以头部进入原尾部位置不算碰撞)
boolean willEat = (nx == food[0] && ny == food[1]);
for (int i = 0; i < snake.size() - (willEat ? 0 : 1); i++) {
int[] p = snake.get(i);
if (p[0] == nx && p[1] == ny) {
return null; // 撞到自己的身体
}
}
// 创建新状态
GameState next = new GameState(width, height, snake, food,
stepsAlive + 1, stepsSinceLastFood + 1);
next.snake.add(new int[]{nx, ny});
if (willEat) {
// 吃到食物,长度增加,重置饥饿计数器
next.stepsSinceLastFood = 0;
next.spawnFood();
} else {
// 未吃到食物,移除尾部
next.snake.removeFirst();
}
// 饥饿保护:如果太久没吃到食物,判定死亡(避免无限绕圈)
if (next.stepsSinceLastFood > width * height * 2) {
return null;
}
return next;
}
/**
* 判断游戏是否结束
*/
public boolean isTerminal() {
// 由applyAction返回null表示结束,这里仅作补充
return false;
}
/**
* 获取当前状态下所有合法动作
*/
public List<Integer> getLegalActions() {
List<Integer> actions = new ArrayList<>();
for (int i = 0; i < 4; i++) {
if (applyAction(i) != null) {
actions.add(i);
}
}
return actions;
}
/**
* 计算奖励值:存活步数 + 长度 * 100
*/
public double getReward() {
return stepsAlive + snake.size() * 100.0;
}
}
3.2 MCTS节点
/**
* MCTS树节点
* 每个节点对应一个游戏状态,以及到达该状态所采取的动作
*/
public class MCTSNode {
// 父节点
public MCTSNode parent;
// 到达此节点所执行的动作(根节点为-1)
public int action;
// 当前节点对应的游戏状态
public GameState state;
// 子节点列表
public List<MCTSNode> children;
// 访问次数
public int visits;
// 累计奖励值
public double totalReward;
// 尚未扩展的合法动作
public List<Integer> untriedActions;
// UCT探索常数
public static final double C = Math.sqrt(2);
public MCTSNode(GameState state, int action, MCTSNode parent) {
this.state = state;
this.action = action;
this.parent = parent;
this.children = new ArrayList<>();
this.visits = 0;
this.totalReward = 0.0;
this.untriedActions = state.getLegalActions();
}
/**
* 使用UCT公式选择最优子节点
*/
public MCTSNode selectChild() {
MCTSNode best = null;
double bestValue = Double.NEGATIVE_INFINITY;
for (MCTSNode child : children) {
// UCT = 平均奖励 + C * sqrt(ln(父访问次数) / 子访问次数)
double exploitation = child.totalReward / child.visits;
double exploration = C * Math.sqrt(Math.log(this.visits) / child.visits);
double uctValue = exploitation + exploration;
if (uctValue > bestValue) {
bestValue = uctValue;
best = child;
}
}
return best;
}
/**
* 判断是否完全扩展(所有合法动作都已有对应子节点)
*/
public boolean isFullyExpanded() {
return untriedActions.isEmpty();
}
/**
* 扩展一个未尝试的动作,创建新子节点
*/
public MCTSNode expand() {
int action = untriedActions.remove(untriedActions.size() - 1);
GameState nextState = state.applyAction(action);
if (nextState == null) {
return null; // 理论上不应发生,因为untriedActions已过滤非法动作
}
MCTSNode child = new MCTSNode(nextState, action, this);
children.add(child);
return child;
}
/**
* 更新节点的统计信息
*/
public void update(double reward) {
this.visits++;
this.totalReward += reward;
}
}
3.3 MCTS核心算法
import java.util.*;
/**
* 贪吃蛇MCTS决策引擎
*/
public class SnakeMCTS {
private final Random random = new Random();
/**
* 根据当前状态,运行MCTS算法返回最优动作
* @param rootState 当前游戏状态
* @param iterations 模拟迭代次数(预算)
* @return 最优动作方向索引
*/
public int search(GameState rootState, int iterations) {
MCTSNode root = new MCTSNode(rootState, -1, null);
for (int i = 0; i < iterations; i++) {
MCTSNode node = root;
// ---- 1. 选择(Selection)----
// 从根节点出发,递归选择UCT值最大的子节点,直到叶子节点
while (node.isFullyExpanded() && !node.children.isEmpty()) {
node = node.selectChild();
}
// ---- 2. 扩展(Expansion)----
// 如果当前节点还有未尝试的动作,则扩展一个新子节点
if (!node.isFullyExpanded()) {
MCTSNode expanded = node.expand();
if (expanded != null) {
node = expanded;
}
}
// ---- 3. 模拟(Simulation / Rollout)----
// 从当前节点状态开始,随机走子直到游戏结束
double reward = rollout(node.state);
// ---- 4. 反向传播(Backpropagation)----
// 将奖励沿路径回传,更新所有祖先节点
while (node != null) {
node.update(reward);
node = node.parent;
}
}
// 返回访问次数最多的子节点对应的动作(最稳健的选择)
MCTSNode bestChild = null;
int bestVisits = -1;
for (MCTSNode child : root.children) {
if (child.visits > bestVisits) {
bestVisits = child.visits;
bestChild = child;
}
}
return bestChild != null ? bestChild.action : 0;
}
/**
* 随机模拟(Rollout)
* 从给定状态开始,采用完全随机策略走完整局,返回最终奖励
*/
private double rollout(GameState state) {
GameState simState = deepCopy(state);
// 设定最大模拟步数,防止无限循环
int maxSteps = simState.width * simState.height * 3;
for (int step = 0; step < maxSteps; step++) {
List<Integer> actions = simState.getLegalActions();
if (actions.isEmpty()) {
break; // 无路可走,游戏结束
}
// 完全随机选择动作
int action = actions.get(random.nextInt(actions.size()));
GameState next = simState.applyAction(action);
if (next == null) {
break; // 死亡
}
simState = next;
}
return simState.getReward();
}
/**
* 深拷贝游戏状态(用于模拟,不影响真实游戏)
*/
private GameState deepCopy(GameState original) {
// 利用GameState的私有构造方法逻辑,这里通过applyAction链重建
// 为简化,直接在GameState中添加clone方法更佳
// 这里使用序列化思路的简化版:手动复制
return original.cloneState();
}
}
3.4 完善GameState的克隆方法
为了让上述代码能够完整编译运行,需要在 GameState 类中补充 cloneState 方法:
/**
* 深度克隆当前游戏状态
*/
public GameState cloneState() {
GameState copy = new GameState(width, height, snake, food,
stepsAlive, stepsSinceLastFood);
return copy;
}
将之前 GameState 中 private 的构造方法改为 public 或在同一个包内访问即可。
3.5 主程序与游戏循环
/**
* 贪吃蛇MCTS主程序
* 纯命令行运行,可观察AI自动进行游戏
*/
public class SnakeGameMCTS {
public static void main(String[] args) {
// 地图尺寸
int width = 10;
int height = 10;
// MCTS迭代次数:越大AI越强,但决策越慢
int iterations = 2000;
GameState state = new GameState(width, height);
SnakeMCTS mcts = new SnakeMCTS();
System.out.println("=== 贪吃蛇 MCTS AI ===");
System.out.println("地图大小: " + width + "x" + height);
System.out.println("MCTS迭代次数: " + iterations);
System.out.println();
while (true) {
printBoard(state);
List<Integer> actions = state.getLegalActions();
if (actions.isEmpty()) {
System.out.println("\n游戏结束!最终得分: " + state.getReward());
System.out.println("存活步数: " + state.stepsAlive);
System.out.println("蛇身长度: " + state.snake.size());
break;
}
// MCTS决策
int bestAction = mcts.search(state, iterations);
String[] dirNames = {"上", "下", "左", "右"};
System.out.println("AI决策: " + dirNames[bestAction] + "\n");
GameState next = state.applyAction(bestAction);
if (next == null) {
System.out.println("\n游戏结束!最终得分: " + state.getReward());
break;
}
state = next;
// 为便于观察,每步暂停一小段时间
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
/**
* 打印当前游戏面板到控制台
*/
private static void printBoard(GameState state) {
char[][] board = new char[state.height][state.width];
for (char[] row : board) {
Arrays.fill(row, '.');
}
// 绘制食物
board[state.food[1]][state.food[0]] = '*';
// 绘制蛇身
for (int i = 0; i < state.snake.size(); i++) {
int[] p = state.snake.get(i);
if (i == state.snake.size() - 1) {
board[p[1]][p[0]] = 'H'; // 头部
} else {
board[p[1]][p[0]] = 'o'; // 身体
}
}
System.out.println("步数: " + state.stepsAlive + " | 长度: " + state.snake.size());
for (char[] row : board) {
for (char c : row) {
System.out.print(c + " ");
}
System.out.println();
}
}
}
四、模拟策略的优化空间
上述实现中的 rollout 采用完全随机策略,这是MCTS最基础的版本。在实际应用中,可以通过以下方式提升AI强度:
- 引入启发式rollout:模拟阶段优先选择朝向食物的方向,或避开狭窄空间,让单次模拟的质量更高。
- 结合A星进行混合决策:在模拟阶段用A星寻找食物路径,仅在A星无路可走时随机探索。
- RAVE(Rapid Action Value Estimation):跨兄弟节点共享动作价值信息,加速收敛。
- 渐进式扩展(Progressive Widening):在节点访问次数较低时只扩展部分动作,避免早期过度分散搜索资源。
五、时间复杂度分析
| 阶段 | 时间复杂度 | 说明 |
|---|---|---|
| 选择(Selection) | $O(D)$ | $D$ 为搜索树深度,通常不超过地图格子数 |
| 扩展(Expansion) | $O(1)$ | 仅创建一个新节点 |
| 模拟(Simulation) | $O(W \cdot H)$ | 一次rollout最多走满整个地图 |
| 反向传播(Backpropagation) | $O(D)$ | 沿路径逐层更新 |
| 单次迭代 | $O(W \cdot H)$ | 主导开销为模拟阶段 |
| 完整搜索 | $O(K \cdot W \cdot H)$ | $K$ 为迭代次数,本例中 $K=2000$,$W=H=10$ |
空间复杂度为 $O(K \cdot D)$,即迭代过程中创建的节点总数乘以节点大小。由于MCTS只保留一棵树,不存储所有模拟轨迹,内存开销可控。
六、总结
本文以Java完整实现了基于蒙特卡洛树搜索的贪吃蛇AI。与A星寻路不同,MCTS不追求”到食物的最短路径”,而是通过统计大量随机模拟的结果,在多步前瞻中寻找”最可能活得久”的动作。UCT公式在”利用已知好策略”与”探索未知新策略”之间取得了数学上的最优平衡。
读者可以动手调整两个关键参数来观察AI行为变化:
– 迭代次数 iterations:从500提升到10000,AI的存活时间通常会显著增长。
– 探索常数 C:增大C会让AI更愿意探索冷门路线,减小C则会让AI更保守地坚持当前最优路线。
通过这种统计驱动的决策框架,即使是看似简单的贪吃蛇,也能展现出令人惊讶的策略深度。