每日算法 — 使用java实现贪吃蛇:DQN深度Q网络与经验回放策略

一、问题背景

贪吃蛇是一款经典的益智游戏,玩家控制一条蛇在网格中移动,通过吃食物不断增长身体。本文将用Java实现一个基于DQN(Deep Q-Network)深度Q网络的贪吃蛇AI,让蛇通过自我对弈学习最优策略。核心算法包括神经网络近似Q值函数、经验回放池稳定训练、以及ε-贪心策略平衡探索与利用。

二、算法核心思想

2.1 DQN概述

传统Q-Learning使用表格存储每个状态-动作对的Q值,但在高维状态空间下不可行。DQN使用神经网络近似Q函数:

$$Q(s, a; \theta) \approx Q^*(s, a)$$

其中 $\theta$ 为网络参数,通过最小化时序差分误差更新:

$$L(\theta) = \mathbb{E}[(r + \gamma \max_{a’} Q(s’, a’; \theta^-) – Q(s, a; \theta))^2]$$

2.2 经验回放(Experience Replay)

将智能体的经验 $(s, a, r, s’, done)$ 存储在回放缓冲区中,训练时随机采样小批量数据。这打破了数据间的时序相关性,提高样本利用效率。

2.3 ε-贪心策略

以概率 $\varepsilon$ 随机选择动作(探索),以概率 $1-\varepsilon$ 选择Q值最大的动作(利用)。随着训练进行,$\varepsilon$ 逐渐衰减。

三、状态设计与网络结构

3.1 状态表示

将蛇的当前环境编码为11维向量:
– 前方、左侧、右侧是否有危险(3维)
– 当前移动方向(4维one-hot)
– 食物相对位置(左上、右下等,4维)

3.2 神经网络结构

输入层(11) → 隐藏层1(128, ReLU) → 隐藏层2(128, ReLU) → 输出层(3, 线性)

输出3个Q值,分别对应直行、左转、右转。

四、完整Java实现

import java.util.*;

/**
 * DQN贪吃蛇AI - 深度Q网络与经验回放
 */
public class SnakeDQN {

    // ==================== 神经网络层 ====================
    static class NeuralNetwork {
        int inputSize, hiddenSize, outputSize;
        double[][] W1, W2;      // 权重矩阵
        double[] b1, b2;        // 偏置
        double lr;              // 学习率

        public NeuralNetwork(int inputSize, int hiddenSize, int outputSize, double lr) {
            this.inputSize = inputSize;
            this.hiddenSize = hiddenSize;
            this.outputSize = outputSize;
            this.lr = lr;
            W1 = new double[inputSize][hiddenSize];
            W2 = new double[hiddenSize][outputSize];
            b1 = new double[hiddenSize];
            b2 = new double[outputSize];
            initWeights();
        }

        /** Xavier初始化权重 */
        void initWeights() {
            Random rand = new Random(42);
            double scale1 = Math.sqrt(2.0 / inputSize);
            for (int i = 0; i < inputSize; i++)
                for (int j = 0; j < hiddenSize; j++)
                    W1[i][j] = rand.nextGaussian() * scale1;
            double scale2 = Math.sqrt(2.0 / hiddenSize);
            for (int i = 0; i < hiddenSize; i++)
                for (int j = 0; j < outputSize; j++)
                    W2[i][j] = rand.nextGaussian() * scale2;
        }

        /** ReLU激活函数 */
        double relu(double x) { return Math.max(0, x); }

        /** 前向传播,返回输出层和隐藏层结果 */
        double[][] forward(double[] x) {
            double[] h = new double[hiddenSize];
            for (int j = 0; j < hiddenSize; j++) {
                double sum = b1[j];
                for (int i = 0; i < inputSize; i++) sum += x[i] * W1[i][j];
                h[j] = relu(sum);
            }
            double[] out = new double[outputSize];
            for (int j = 0; j < outputSize; j++) {
                double sum = b2[j];
                for (int i = 0; i < hiddenSize; i++) sum += h[i] * W2[i][j];
                out[j] = sum; // 线性输出
            }
            return new double[][]{out, h};
        }

        /** 反向传播更新参数 */
        void backward(double[] x, double[] h, double[] out, double[] target) {
            // 输出层梯度
            double[] dOut = new double[outputSize];
            for (int i = 0; i < outputSize; i++) dOut[i] = 2 * (out[i] - target[i]) / outputSize;

            // 隐藏层梯度
            double[] dH = new double[hiddenSize];
            for (int i = 0; i < hiddenSize; i++) {
                double sum = 0;
                for (int j = 0; j < outputSize; j++) sum += dOut[j] * W2[i][j];
                dH[i] = h[i] > 0 ? sum : 0; // ReLU导数
            }

            // 更新W2, b2
            for (int i = 0; i < hiddenSize; i++)
                for (int j = 0; j < outputSize; j++)
                    W2[i][j] -= lr * dOut[j] * h[i];
            for (int j = 0; j < outputSize; j++) b2[j] -= lr * dOut[j];

            // 更新W1, b1
            for (int i = 0; i < inputSize; i++)
                for (int j = 0; j < hiddenSize; j++)
                    W1[i][j] -= lr * dH[j] * x[i];
            for (int j = 0; j < hiddenSize; j++) b1[j] -= lr * dH[j];
        }
    }

    // ==================== 经验回放缓冲区 ====================
    static class ReplayBuffer {
        List<Experience> buffer = new ArrayList<>();
        int capacity;
        Random rand = new Random();

        public ReplayBuffer(int capacity) { this.capacity = capacity; }

        void add(double[] state, int action, double reward, double[] nextState, boolean done) {
            if (buffer.size() >= capacity) buffer.remove(0);
            buffer.add(new Experience(state, action, reward, nextState, done));
        }

        List<Experience> sample(int batchSize) {
            List<Experience> batch = new ArrayList<>();
            int n = Math.min(batchSize, buffer.size());
            for (int i = 0; i < n; i++)
                batch.add(buffer.get(rand.nextInt(buffer.size())));
            return batch;
        }

        int size() { return buffer.size(); }
    }

    static class Experience {
        double[] state, nextState;
        int action;
        double reward;
        boolean done;
        Experience(double[] s, int a, double r, double[] ns, boolean d) {
            state = s; action = a; reward = r; nextState = ns; done = d;
        }
    }

    // ==================== 贪吃蛇环境 ====================
    static class SnakeEnv {
        int width = 10, height = 10;
        int[] head;              // 蛇头位置 [x, y]
        List<int[]> body;        // 蛇身
        int[] food;              // 食物位置
        int direction;           // 0:上 1:右 2:下 3:左
        int score;
        boolean gameOver;
        Random rand = new Random();

        public SnakeEnv() { reset(); }

        void reset() {
            head = new int[]{width / 2, height / 2};
            body = new ArrayList<>();
            body.add(new int[]{head[0], head[1]});
            direction = 1;
            score = 0;
            gameOver = false;
            spawnFood();
        }

        void spawnFood() {
            do {
                food = new int[]{rand.nextInt(width), rand.nextInt(height)};
            } while (isOnSnake(food));
        }

        boolean isOnSnake(int[] pos) {
            for (int[] p : body) if (p[0] == pos[0] && p[1] == pos[1]) return true;
            return false;
        }

        /** 执行动作:0=直行, 1=左转, 2=右转 */
        double step(int action) {
            if (gameOver) return 0;
            // 更新方向
            if (action == 1) direction = (direction + 3) % 4;      // 左转
            else if (action == 2) direction = (direction + 1) % 4; // 右转

            int[][] dirs = {{-1,0},{0,1},{1,0},{0,-1}}; // 上右下左
            int nx = head[0] + dirs[direction][0];
            int ny = head[1] + dirs[direction][1];

            // 撞墙或撞自己
            if (nx < 0 || nx >= height || ny < 0 || ny >= width || isOnSnake(new int[]{nx, ny})) {
                gameOver = true;
                return -10; // 死亡惩罚
            }

            head = new int[]{nx, ny};
            body.add(0, new int[]{nx, ny});

            double reward = -0.1; // 每步小惩罚,鼓励快速吃食物
            if (nx == food[0] && ny == food[1]) {
                score++;
                reward = 10; // 吃食物奖励
                spawnFood();
            } else {
                body.remove(body.size() - 1); // 未吃食物则去尾
            }
            return reward;
        }

        /** 获取11维状态向量 */
        double[] getState() {
            double[] state = new double[11];
            int[][] dirs = {{-1,0},{0,1},{1,0},{0,-1}};
            int leftDir = (direction + 3) % 4;
            int rightDir = (direction + 1) % 4;

            // 前方、左侧、右侧是否有危险
            int fx = head[0] + dirs[direction][0], fy = head[1] + dirs[direction][1];
            int lx = head[0] + dirs[leftDir][0], ly = head[1] + dirs[leftDir][1];
            int rx = head[0] + dirs[rightDir][0], ry = head[1] + dirs[rightDir][1];

            state[0] = isDanger(fx, fy) ? 1 : 0;
            state[1] = isDanger(lx, ly) ? 1 : 0;
            state[2] = isDanger(rx, ry) ? 1 : 0;

            // 当前移动方向
            for (int i = 0; i < 4; i++) state[3 + i] = (direction == i) ? 1 : 0;

            // 食物相对位置
            state[7] = (food[0] < head[0]) ? 1 : 0; // 食物在上
            state[8] = (food[0] > head[0]) ? 1 : 0; // 食物在下
            state[9] = (food[1] < head[1]) ? 1 : 0; // 食物在左
            state[10] = (food[1] > head[1]) ? 1 : 0; // 食物在右

            return state;
        }

        boolean isDanger(int x, int y) {
            return x < 0 || x >= height || y < 0 || y >= width || isOnSnake(new int[]{x, y});
        }
    }

    // ==================== DQN训练主循环 ====================
    public static void main(String[] args) {
        final int STATE_SIZE = 11;
        final int ACTION_SIZE = 3;
        final int HIDDEN_SIZE = 128;
        final double LR = 0.001;
        final double GAMMA = 0.95;
        final double EPSILON_START = 1.0;
        final double EPSILON_END = 0.01;
        final double EPSILON_DECAY = 0.995;
        final int BATCH_SIZE = 64;
        final int MEMORY_SIZE = 10000;
        final int EPISODES = 2000;

        NeuralNetwork policyNet = new NeuralNetwork(STATE_SIZE, HIDDEN_SIZE, ACTION_SIZE, LR);
        NeuralNetwork targetNet = new NeuralNetwork(STATE_SIZE, HIDDEN_SIZE, ACTION_SIZE, LR);
        // 复制参数到目标网络
        copyWeights(policyNet, targetNet);

        ReplayBuffer buffer = new ReplayBuffer(MEMORY_SIZE);
        SnakeEnv env = new SnakeEnv();
        double epsilon = EPSILON_START;
        Random rand = new Random();

        List<Double> scores = new ArrayList<>();

        for (int ep = 0; ep < EPISODES; ep++) {
            env.reset();
            double[] state = env.getState();
            double totalReward = 0;
            int steps = 0;
            final int MAX_STEPS = 500;

            while (!env.gameOver && steps < MAX_STEPS) {
                // ε-贪心选择动作
                int action;
                if (rand.nextDouble() < epsilon) {
                    action = rand.nextInt(ACTION_SIZE);
                } else {
                    double[][] out = policyNet.forward(state);
                    action = argMax(out[0]);
                }

                double reward = env.step(action);
                double[] nextState = env.getState();
                boolean done = env.gameOver;
                totalReward += reward;

                buffer.add(state, action, reward, nextState, done);
                state = nextState;
                steps++;

                // 经验回放训练
                if (buffer.size() >= BATCH_SIZE) {
                    List<Experience> batch = buffer.sample(BATCH_SIZE);
                    for (Experience e : batch) {
                        double[][] out = policyNet.forward(e.state);
                        double[] target = out[0].clone();

                        double[][] nextOut = targetNet.forward(e.nextState);
                        double maxNextQ = max(nextOut[0]);
                        double tdTarget = e.done ? e.reward : e.reward + GAMMA * maxNextQ;
                        target[e.action] = tdTarget;

                        policyNet.backward(e.state, out[1], out[0], target);
                    }
                }
            }

            scores.add((double) env.score);
            epsilon = Math.max(EPSILON_END, epsilon * EPSILON_DECAY);

            // 每50回合同步目标网络
            if (ep % 50 == 0) copyWeights(policyNet, targetNet);

            if (ep % 200 == 0) {
                double avgScore = scores.subList(Math.max(0, scores.size() - 100), scores.size())
                        .stream().mapToDouble(Double::doubleValue).average().orElse(0);
                System.out.printf("Episode %d, AvgScore(100)=%.2f, Epsilon=%.3f%n", ep, avgScore, epsilon);
            }
        }

        System.out.println("训练完成!最终测试:");
        testAgent(policyNet);
    }

    static void copyWeights(NeuralNetwork src, NeuralNetwork dst) {
        for (int i = 0; i < src.W1.length; i++) System.arraycopy(src.W1[i], 0, dst.W1[i], 0, src.W1[i].length);
        for (int i = 0; i < src.W2.length; i++) System.arraycopy(src.W2[i], 0, dst.W2[i], 0, src.W2[i].length);
        System.arraycopy(src.b1, 0, dst.b1, 0, src.b1.length);
        System.arraycopy(src.b2, 0, dst.b2, 0, src.b2.length);
    }

    static int argMax(double[] arr) {
        int idx = 0;
        for (int i = 1; i < arr.length; i++) if (arr[i] > arr[idx]) idx = i;
        return idx;
    }

    static double max(double[] arr) {
        double m = arr[0];
        for (double v : arr) if (v > m) m = v;
        return m;
    }

    static void testAgent(NeuralNetwork net) {
        SnakeEnv env = new SnakeEnv();
        double[] state = env.getState();
        int steps = 0;
        while (!env.gameOver && steps < 200) {
            double[][] out = net.forward(state);
            int action = argMax(out[0]);
            env.step(action);
            state = env.getState();
            steps++;
        }
        System.out.println("测试得分: " + env.score + ", 步数: " + steps);
    }
}

五、算法复杂度分析

模块 时间复杂度 空间复杂度
前向传播 $O(H \cdot (I + O))$ $O(H)$
经验回放训练(每步) $O(B \cdot H \cdot (I + O))$ $O(M \cdot S)$
环境模拟 $O(L)$(L为蛇长) $O(L)$

其中 $I=11$ 为输入维度,$H=128$ 为隐藏层大小,$O=3$ 为动作数,$B=64$ 为批量大小,$M=10000$ 为缓冲区容量,$S=25$ 为每条经验大小。

六、总结

本文实现了基于DQN的贪吃蛇AI,核心要点包括:
– 状态抽象:将复杂游戏局面压缩为11维向量,降低网络输入维度
– 经验回放:随机采样打破数据相关性,提升训练稳定性
– 目标网络:延迟更新减少自举偏差,加速收敛
– ε-贪心:平衡探索与利用,逐步从随机策略过渡到最优策略

通过2000回合训练,智能体能够学会绕开障碍物、朝向食物移动的基本策略。读者可以在此基础上扩展:增加更多网络层、使用优先经验回放、或引入Dueling DQN等改进结构。