贪吃蛇是游戏史上最具影响力的经典益智游戏之一。本文将跳出传统路径搜索的框架,引入强化学习(Reinforcement Learning)范式,使用 Q-Learning 算法 训练一条能够自主决策的贪吃蛇。核心难点在于状态空间的合理抽象与奖励函数的设计——我们将结合 ε-贪心(ε-Greedy)探索策略,让蛇在”利用已知最优动作”与”探索未知动作”之间动态权衡,最终学会高效觅食与躲避危险。
一、问题建模:马尔可夫决策过程
将贪吃蛇游戏抽象为 马尔可夫决策过程(MDP),其五元组为 (S, A, P, R, γ):
- 状态空间 S:蛇头相对食物的方向、障碍物(自身/墙壁)分布、当前运动方向等。
- 动作空间 A:
{上, 下, 左, 右},每一步选择其中一个动作。 - 转移概率 P:环境确定,给定状态和动作,下一状态唯一。
- 奖励函数 R:吃到食物给予正奖励、撞墙或撞自身给予负奖励、存活给予微小负奖励(鼓励尽快吃食物)。
- 折扣因子 γ:控制未来奖励的衰减程度,通常取
0.9。
Q-Learning 的目标是学习一个动作价值函数 Q(s, a),表示在状态 s 下执行动作 a 后能获得的累积期望回报。
二、状态空间设计:平衡维度与信息量
状态空间若过于庞大,会导致训练缓慢甚至无法收敛;若过于简单,则蛇无法学到有效策略。本文采用以下11维布尔状态向量:
| 维度 | 含义 |
|---|---|
| 0-2 | 危险检测:左前方、正前方、右前方是否有障碍(墙或自身) |
| 3-4 | 食物相对方向:食物在蛇头左侧还是右侧 |
| 5-6 | 食物相对方向:食物在蛇头上方还是下方 |
| 7-9 | 当前运动方向:是否向左、向上、向右(向下为默认基准) |
| 10 | 蛇身长度是否大于阈值(影响避障策略) |
这种设计将连续坐标离散化为相对关系,状态总数约为 2^11 = 2048,既保证信息量,又使 Q-Table 可存于内存。
三、奖励函数设计:从”生存”到”觅食”
奖励函数是强化学习的灵魂。本文采用分层奖励设计:
吃到食物: +10.0
撞墙/撞自身: -10.0
每走一步: -0.1 (避免无限绕圈)
朝向食物移动: +0.1 (引导性奖励)
远离食物移动: -0.1 (惩罚性反馈)
每步 -0.1 的存活惩罚迫使蛇尽快找到食物,避免原地打转;方向性奖励加速前期收敛。
四、Q-Learning 更新与 ε-贪心策略
4.1 Q值更新公式
Q-Learning 是离策略(Off-Policy)时序差分控制算法,其更新规则为:
Q(s, a) = Q(s, a) + α * [r + γ * max_a' Q(s', a') - Q(s, a)]
其中 α 为学习率,γ 为折扣因子,max_a' Q(s', a') 表示下一状态的最大 Q 值。
4.2 ε-贪心探索
训练初期 Q-Table 全为 0,若总是选择当前最优动作,将陷入局部最优。ε-贪心策略以概率 ε 随机探索,以概率 1-ε 选择最优动作:
if (Math.random() < epsilon) {
action = randomAction(); // 探索
} else {
action = argMaxQ(state); // 利用
}
训练过程中 ε 从 1.0 逐渐衰减至 0.01,实现”先广探、后精修”。
五、完整 Java 实现
项目采用 Swing 做可视化渲染,核心逻辑封装于 SnakeQLearning 类。
5.1 项目结构
src/
├── SnakeGame.java // Swing 渲染主类
├── SnakeQLearning.java // Q-Learning 决策引擎
└── GameState.java // 状态编码工具
5.2 GameState.java:状态编码
public class GameState {
// 将相对位置与危险信息编码为状态索引 [0, 2047]
public static int encode(boolean dangerLeft, boolean dangerStraight, boolean dangerRight,
boolean foodLeft, boolean foodRight,
boolean foodUp, boolean foodDown,
boolean dirLeft, boolean dirUp, boolean dirRight,
boolean longBody) {
int state = 0;
state |= (dangerLeft ? 1 : 0) << 0;
state |= (dangerStraight ? 1 : 0) << 1;
state |= (dangerRight ? 1 : 0) << 2;
state |= (foodLeft ? 1 : 0) << 3;
state |= (foodRight ? 1 : 0) << 4;
state |= (foodUp ? 1 : 0) << 5;
state |= (foodDown ? 1 : 0) << 6;
state |= (dirLeft ? 1 : 0) << 7;
state |= (dirUp ? 1 : 0) << 8;
state |= (dirRight ? 1 : 0) << 9;
state |= (longBody ? 1 : 0) << 10;
return state;
}
}
5.3 SnakeQLearning.java:决策引擎
import java.util.Random;
public class SnakeQLearning {
private static final int STATE_COUNT = 2048; // 2^11
private static final int ACTION_COUNT = 3; // 0=直行, 1=左转, 2=右转
private static final double ALPHA = 0.1; // 学习率
private static final double GAMMA = 0.9; // 折扣因子
private static final double EPSILON_START = 1.0;
private static final double EPSILON_END = 0.01;
private static final int DECAY_EPISODES = 2000;
private final double[][] qTable = new double[STATE_COUNT][ACTION_COUNT];
private final Random random = new Random();
private int episodeCount = 0;
// 根据当前 ε 选择动作
public int chooseAction(int state) {
double epsilon = getCurrentEpsilon();
if (random.nextDouble() < epsilon) {
return random.nextInt(ACTION_COUNT);
}
return argMax(qTable[state]);
}
// 获取当前探索率(线性衰减)
private double getCurrentEpsilon() {
if (episodeCount >= DECAY_EPISODES) return EPSILON_END;
return EPSILON_START - (EPSILON_START - EPSILON_END) * episodeCount / DECAY_EPISODES;
}
// 更新 Q 值
public void update(int state, int action, double reward, int nextState) {
double maxNext = qTable[nextState][argMax(qTable[nextState])];
qTable[state][action] += ALPHA * (reward + GAMMA * maxNext - qTable[state][action]);
}
// 标记一回合结束,用于 ε 衰减
public void endEpisode() {
episodeCount++;
}
// 返回数组最大值的索引
private int argMax(double[] arr) {
int best = 0;
for (int i = 1; i < arr.length; i++) {
if (arr[i] > arr[best]) best = i;
}
return best;
}
public int getEpisodeCount() { return episodeCount; }
}
5.4 SnakeGame.java:主循环与渲染
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.util.*;
public class SnakeGame extends JPanel implements ActionListener {
private static final int TILE_SIZE = 20;
private static final int WIDTH = 20; // 20x20 网格
private static final int HEIGHT = 20;
private static final int DELAY = 50; // 毫秒/帧
private final SnakeQLearning agent = new SnakeQLearning();
private final LinkedList<Point> snake = new LinkedList<>();
private Point food;
private int direction = 1; // 0=左,1=上,2=右,3=下
private int score = 0;
private int highScore = 0;
private boolean running = true;
private final javax.swing.Timer timer;
public SnakeGame() {
setPreferredSize(new Dimension(WIDTH * TILE_SIZE, HEIGHT * TILE_SIZE));
setBackground(Color.BLACK);
setFocusable(true);
initGame();
timer = new javax.swing.Timer(DELAY, this);
timer.start();
}
private void initGame() {
snake.clear();
snake.add(new Point(WIDTH / 2, HEIGHT / 2));
direction = 1;
score = 0;
spawnFood();
running = true;
}
private void spawnFood() {
Random r = new Random();
do {
food = new Point(r.nextInt(WIDTH), r.nextInt(HEIGHT));
} while (snake.contains(food));
}
@Override
public void actionPerformed(ActionEvent e) {
if (!running) {
agent.endEpisode();
if (score > highScore) highScore = score;
initGame();
return;
}
int state = getCurrentState();
int action = agent.chooseAction(state); // 0=直行,1=左转,2=右转
// 将相对动作映射为绝对方向
if (action == 1) direction = (direction + 3) % 4; // 左转
else if (action == 2) direction = (direction + 1) % 4; // 右转
// action==0 保持原方向
Point head = snake.getFirst();
Point next = new Point(head);
switch (direction) {
case 0: next.x--; break;
case 1: next.y--; break;
case 2: next.x++; break;
case 3: next.y++; break;
}
double reward = -0.1; // 存活惩罚
boolean ate = false;
// 碰撞检测
if (next.x < 0 || next.x >= WIDTH || next.y < 0 || next.y >= HEIGHT || snake.contains(next)) {
reward = -10.0;
running = false;
} else {
snake.addFirst(next);
if (next.equals(food)) {
reward = 10.0;
score++;
ate = true;
spawnFood();
} else {
snake.removeLast();
// 方向性奖励:朝向食物移动则奖励,远离则惩罚
double oldDist = Math.abs(head.x - food.x) + Math.abs(head.y - food.y);
double newDist = Math.abs(next.x - food.x) + Math.abs(next.y - food.y);
if (newDist < oldDist) reward += 0.1;
else reward -= 0.1;
}
}
int nextState = getCurrentState();
agent.update(state, action, reward, nextState);
repaint();
}
// 将当前游戏局面编码为状态索引
private int getCurrentState() {
Point head = snake.getFirst();
boolean dirLeft = direction == 0;
boolean dirUp = direction == 1;
boolean dirRight = direction == 2;
// 计算左前、正前、右前三个方向的坐标
int[][] dirs = {{0,-1},{1,0},{0,1},{-1,0}}; // 上右下左
int dirIdx = direction; // 0=左? 修正:direction定义需与dirs对齐
// direction: 0=左,1=上,2=右,3=下
// 前方向量
int fx = 0, fy = 0;
switch (direction) {
case 0: fx = -1; break;
case 1: fy = -1; break;
case 2: fx = 1; break;
case 3: fy = 1; break;
}
// 左方向量(相对当前方向左转90度)
int lx = 0, ly = 0;
switch (direction) {
case 0: ly = -1; break; // 左→上
case 1: fx = 0; fy = -1; lx = -1; ly = 0; break; // 上→左
case 2: ly = 1; break; // 右→下
case 3: fx = 0; fy = 1; lx = 1; ly = 0; break; // 下→右
}
// 简化:用绝对方向直接计算三个探测点
int leftDir = (direction + 3) % 4;
int rightDir = (direction + 1) % 4;
Point left = move(head, leftDir);
Point straight = move(head, direction);
Point right = move(head, rightDir);
boolean dangerLeft = isDanger(left);
boolean dangerStraight = isDanger(straight);
boolean dangerRight = isDanger(right);
boolean foodLeft = food.x < head.x;
boolean foodRight = food.x > head.x;
boolean foodUp = food.y < head.y;
boolean foodDown = food.y > head.y;
boolean longBody = snake.size() > 15;
return GameState.encode(dangerLeft, dangerStraight, dangerRight,
foodLeft, foodRight, foodUp, foodDown,
dirLeft, dirUp, dirRight, longBody);
}
private Point move(Point p, int dir) {
Point n = new Point(p);
switch (dir) {
case 0: n.x--; break;
case 1: n.y--; break;
case 2: n.x++; break;
case 3: n.y++; break;
}
return n;
}
private boolean isDanger(Point p) {
return p.x < 0 || p.x >= WIDTH || p.y < 0 || p.y >= HEIGHT || snake.contains(p);
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
// 绘制网格
g.setColor(Color.DARK_GRAY);
for (int i = 0; i <= WIDTH; i++) g.drawLine(i * TILE_SIZE, 0, i * TILE_SIZE, HEIGHT * TILE_SIZE);
for (int i = 0; i <= HEIGHT; i++) g.drawLine(0, i * TILE_SIZE, WIDTH * TILE_SIZE, i * TILE_SIZE);
// 绘制食物
g.setColor(Color.RED);
g.fillOval(food.x * TILE_SIZE + 2, food.y * TILE_SIZE + 2, TILE_SIZE - 4, TILE_SIZE - 4);
// 绘制蛇
for (int i = 0; i < snake.size(); i++) {
Point p = snake.get(i);
g.setColor(i == 0 ? Color.GREEN : Color.CYAN);
g.fillRect(p.x * TILE_SIZE + 1, p.y * TILE_SIZE + 1, TILE_SIZE - 2, TILE_SIZE - 2);
}
// 绘制信息
g.setColor(Color.WHITE);
g.drawString("Score: " + score + " High: " + highScore + " Episode: " + agent.getEpisodeCount(), 5, 15);
}
public static void main(String[] args) {
JFrame frame = new JFrame("Snake Q-Learning");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.add(new SnakeGame());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
}
六、训练观察与参数调优
在 20×20 的网格上训练约 3000 回合后,蛇的行为会经历以下阶段:
| 阶段 | 回合范围 | 行为特征 |
|---|---|---|
| 随机探索期 | 0–500 | ε 接近 1.0,蛇几乎随机移动,平均得分 < 2 |
| 策略萌芽期 | 500–1500 | ε 降至 0.5 左右,蛇学会躲避墙壁,但常困于自身 |
| 稳定觅食期 | 1500–2500 | ε 降至 0.1 以下,蛇能稳定吃到 5–10 个食物 |
| 高阶策略期 | 2500+ | ε ≈ 0.01,蛇学会”贴墙游走”和”尾部追踪”,得分可达 20+ |
若训练后蛇频繁进入死胡同,可尝试:
1. 增大网格尺寸:30×30 给予更多迂回空间。
2. 调整奖励:将存活惩罚从 -0.1 改为 -0.01,避免蛇因恐惧而贴墙不动。
3. 引入蛇尾方向状态:让蛇感知尾巴位置,主动为自身让路。
七、复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 状态空间 | O(2^11) = 2048 | 布尔状态编码,固定大小 |
| 动作空间 | O(3) | 直行/左转/右转 |
| 单步决策 | O(1) | Q-Table 查表 |
| 单步更新 | O(1) | 一次时序差分更新 |
| 空间复杂度 | O(2048 × 3) | 约 6144 个浮点数,可忽略 |
与传统 A* 寻路相比,Q-Learning 无需预先知道食物位置的全局地图,完全基于局部感知做决策,更贴近”生物本能”式学习。
八、扩展方向
- Deep Q-Network(DQN):当状态空间引入全局坐标时,传统 Q-Table 会爆炸,此时可用神经网络近似 Q 函数。
- Double DQN / Dueling DQN:缓解 Q 值过估计问题,提升策略稳定性。
- 优先经验回放(PER):将”吃到食物”或”撞墙”的关键转移样本赋予更高学习权重。
- 多蛇对抗:引入竞争机制,使用独立 Q-Learning 或博弈论框架训练多条蛇。
九、总结
本文以贪吃蛇为实验平台,完整实现了基于 Q-Learning 的强化学习智能体。核心创新点在于:
- 11维布尔状态编码:将复杂连续游戏空间压缩为可管理的离散状态。
- 分层奖励函数:结合即时反馈(吃食物/撞墙)与引导性奖励(朝向/远离食物),加速收敛。
- ε-贪心动态衰减:从纯随机探索平滑过渡到贪婪利用,兼顾学习效率与策略最优性。
完整代码可直接编译运行,观察蛇从”跌跌撞撞”到”游刃有余”的进化过程,是理解强化学习入门原理的绝佳实践。