每日算法 — 使用java实现俄罗斯方块:策略梯度REINFORCE与基线方差缩减

引言

俄罗斯方块是策略空间极为复杂的经典游戏,每一步的落块选择都会影响未来数十步的局面。与Q-Learning等基于值函数的方法不同,策略梯度方法直接对策略参数化并沿期望回报梯度上升方向优化。本文使用Java实现REINFORCE算法,并引入基线(Baseline)方差缩减技术,训练一个能够自主决策的俄罗斯方块AI。

核心算法原理

策略梯度定理

在强化学习中,策略$\pi_\theta(a|s)$表示在状态$s$下选择动作$a$的概率。策略梯度定理指出,期望累积回报的梯度可表示为:

$$\nabla_\theta J(\theta) = \mathbb{E}{\pi\theta}\left[\sum_{t=0}^{T} \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot G_t\right]$$

其中$G_t = \sum_{k=t}^{T} \gamma^{k-t} r_k$为从时刻$t$开始的累积折扣回报。通过蒙特卡洛采样多个完整回合,即可估计该梯度并更新参数。

REINFORCE算法

REINFORCE是最经典的蒙特卡洛策略梯度算法。其核心流程为:
1. 使用当前策略采样一个完整回合的轨迹$\tau = (s_0, a_0, r_0, s_1, a_1, r_1, \dots)$
2. 计算每个时间步的累积回报$G_t$
3. 沿梯度$\nabla_\theta \log \pi_\theta(a_t|s_t) \cdot G_t$更新策略参数
4. 重复直至收敛

基线方差缩减

原始REINFORCE的梯度估计方差较大,因为回报$G_t$的绝对值可能很大。引入与动作无关的基线函数$b(s)$不改变梯度的期望,但可显著降低方差:

$$\nabla_\theta J(\theta) = \mathbb{E}\left[\sum_{t} \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot (G_t – b(s_t))\right]$$

最常用的基线是状态价值函数$V(s)$的估计,本文采用每回合平均回报作为简单但有效的基线。

Java实现

项目结构

src/
├── tetris/
│   ├── Board.java            // 游戏面板
│   ├── Tetromino.java        // 七种方块定义
│   ├── TetrisGame.java       // 游戏逻辑
│   └── Action.java           // 动作枚举(列+旋转)
├── policy/
│   ├── PolicyNetwork.java    // 策略网络(Softmax输出)
│   └── REINFORCEAgent.java   // REINFORCE智能体
└── train/
    └── Trainer.java          // 训练主程序

方块与游戏面板

package tetris;

/**
 * 七种标准俄罗斯方块(I, O, T, S, Z, J, L)
 */
public class Tetromino {
    public static final int[][][] SHAPES = {
        // I
        {{1,1,1,1}},
        // O
        {{1,1},{1,1}},
        // T
        {{0,1,0},{1,1,1}},
        // S
        {{0,1,1},{1,1,0}},
        // Z
        {{1,1,0},{0,1,1}},
        // J
        {{1,0,0},{1,1,1}},
        // L
        {{0,0,1},{1,1,1}}
    };

    private final int type;
    private int[][] shape;
    private int x, y;

    public Tetromino(int type) {
        this.type = type;
        this.shape = copyShape(SHAPES[type]);
        this.x = 3; // 初始列位置
        this.y = 0; // 初始行位置
    }

    /**
     * 顺时针旋转90度
     */
    public void rotate() {
        int rows = shape.length, cols = shape[0].length;
        int[][] rotated = new int[cols][rows];
        for (int i = 0; i < rows; i++)
            for (int j = 0; j < cols; j++)
                rotated[j][rows - 1 - i] = shape[i][j];
        shape = rotated;
    }

    /**
     * 获取所有可能的旋转形态
     */
    public int[][][] getAllRotations() {
        int count = (type == 1) ? 1 : (type == 0 || type == 2) ? 4 : 2; // O:1, I/T:4, 其他:2
        int[][][] result = new int[count][][];
        result[0] = copyShape(shape);
        for (int i = 1; i < count; i++) {
            rotate();
            result[i] = copyShape(shape);
        }
        // 恢复原始方向
        for (int i = 0; i < count - 1; i++) rotate();
        return result;
    }

    private int[][] copyShape(int[][] src) {
        int[][] dst = new int[src.length][src[0].length];
        for (int i = 0; i < src.length; i++) System.arraycopy(src[i], 0, dst[i], 0, src[i].length);
        return dst;
    }

    public int[][] getShape() { return shape; }
    public int getX() { return x; }
    public int getY() { return y; }
    public void setX(int x) { this.x = x; }
    public void setY(int y) { this.y = y; }
}
package tetris;

import java.util.Arrays;

/**
 * 俄罗斯方块游戏面板,10列x20行
 */
public class Board {
    public static final int WIDTH = 10;
    public static final int HEIGHT = 20;
    private final int[][] grid;
    private int linesCleared;
    private int totalPieces;

    public Board() {
        this.grid = new int[HEIGHT][WIDTH];
        reset();
    }

    public void reset() {
        for (int[] row : grid) Arrays.fill(row, 0);
        linesCleared = 0;
        totalPieces = 0;
    }

    /**
     * 检查方块在指定位置是否合法
     */
    public boolean isValidPosition(int[][] shape, int px, int py) {
        for (int i = 0; i < shape.length; i++) {
            for (int j = 0; j < shape[0].length; j++) {
                if (shape[i][j] == 1) {
                    int x = px + j, y = py + i;
                    if (x < 0 || x >= WIDTH || y >= HEIGHT) return false;
                    if (y >= 0 && grid[y][x] == 1) return false;
                }
            }
        }
        return true;
    }

    /**
     * 将方块固定到面板
     */
    public void placePiece(int[][] shape, int px, int py) {
        for (int i = 0; i < shape.length; i++) {
            for (int j = 0; j < shape[0].length; j++) {
                if (shape[i][j] == 1) {
                    int y = py + i;
                    if (y >= 0) grid[y][px + j] = 1;
                }
            }
        }
        totalPieces++;
        clearLines();
    }

    /**
     * 消除已满的行,并统计
     */
    private void clearLines() {
        int writeRow = HEIGHT - 1;
        for (int row = HEIGHT - 1; row >= 0; row--) {
            boolean full = true;
            for (int c = 0; c < WIDTH; c++) {
                if (grid[row][c] == 0) { full = false; break; }
            }
            if (!full) {
                if (writeRow != row) {
                    System.arraycopy(grid[row], 0, grid[writeRow], 0, WIDTH);
                }
                writeRow--;
            } else {
                linesCleared++;
            }
        }
        // 清空上方剩余行
        for (int r = writeRow; r >= 0; r--) {
            Arrays.fill(grid[r], 0);
        }
    }

    /**
     * 获取当前面板状态作为特征向量
     * 特征:每列高度、行变换次数、空洞数、崎岖度、消除行数
     */
    public double[] getFeatures() {
        double[] features = new double[8];
        int[] heights = new int[WIDTH];
        for (int c = 0; c < WIDTH; c++) {
            for (int r = 0; r < HEIGHT; r++) {
                if (grid[r][c] == 1) { heights[c] = HEIGHT - r; break; }
            }
        }

        // 聚合高度
        int aggHeight = 0;
        for (int h : heights) aggHeight += h;
        features[0] = aggHeight / 200.0;

        // 完整行数
        features[1] = linesCleared / 10.0;

        // 空洞数(上方有块但此处为空)
        int holes = 0;
        for (int c = 0; c < WIDTH; c++) {
            boolean blockAbove = false;
            for (int r = 0; r < HEIGHT; r++) {
                if (grid[r][c] == 1) blockAbove = true;
                else if (blockAbove) holes++;
            }
        }
        features[2] = holes / 100.0;

        // 崎岖度(相邻列高度差的绝对值之和)
        int bumpiness = 0;
        for (int c = 0; c < WIDTH - 1; c++) {
            bumpiness += Math.abs(heights[c] - heights[c + 1]);
        }
        features[3] = bumpiness / 40.0;

        // 最大高度
        int maxH = 0;
        for (int h : heights) maxH = Math.max(maxH, h);
        features[4] = maxH / 20.0;

        // 行变换次数(相邻格子不同)
        int rowTransitions = 0;
        for (int r = 0; r < HEIGHT; r++) {
            int prev = 1; // 边界视为有块
            for (int c = 0; c < WIDTH; c++) {
                int curr = grid[r][c];
                if (curr != prev) rowTransitions++;
                prev = curr;
            }
            if (prev != 1) rowTransitions++;
        }
        features[5] = rowTransitions / 100.0;

        // 列变换次数
        int colTransitions = 0;
        for (int c = 0; c < WIDTH; c++) {
            int prev = 1;
            for (int r = 0; r < HEIGHT; r++) {
                int curr = grid[r][c];
                if (curr != prev) colTransitions++;
                prev = curr;
            }
            if (prev != 1) colTransitions++;
        }
        features[6] = colTransitions / 100.0;

        // 井的深度之和(两侧高于当前列的空列)
        int wellSum = 0;
        for (int c = 0; c < WIDTH; c++) {
            int leftH = (c == 0) ? 20 : heights[c - 1];
            int rightH = (c == WIDTH - 1) ? 20 : heights[c + 1];
            int minSide = Math.min(leftH, rightH);
            if (heights[c] < minSide) {
                wellSum += minSide - heights[c];
            }
        }
        features[7] = wellSum / 40.0;

        return features;
    }

    public int getLinesCleared() { return linesCleared; }
    public int[][] getGrid() { return grid; }
}

策略网络(Softmax输出)

package policy;

import java.util.Random;

/**
 * 两层全连接策略网络,输出Softmax动作概率
 */
public class PolicyNetwork {
    private final int inputSize, hiddenSize, outputSize;
    private final double[][] w1, w2;
    private final double[] b1, b2;
    private final Random rand;

    public PolicyNetwork(int inputSize, int hiddenSize, int outputSize, long seed) {
        this.inputSize = inputSize;
        this.hiddenSize = hiddenSize;
        this.outputSize = outputSize;
        this.rand = new Random(seed);
        this.w1 = new double[inputSize][hiddenSize];
        this.w2 = new double[hiddenSize][outputSize];
        this.b1 = new double[hiddenSize];
        this.b2 = new double[outputSize];
        initWeights();
    }

    private void initWeights() {
        double std1 = Math.sqrt(2.0 / inputSize);
        for (int i = 0; i < inputSize; i++)
            for (int j = 0; j < hiddenSize; j++)
                w1[i][j] = rand.nextGaussian() * std1;
        double std2 = Math.sqrt(2.0 / hiddenSize);
        for (int i = 0; i < hiddenSize; i++)
            for (int j = 0; j < outputSize; j++)
                w2[i][j] = rand.nextGaussian() * std2;
    }

    /**
     * 前向传播,返回各动作的概率分布
     */
    public double[] forward(double[] input) {
        // 隐藏层ReLU
        double[] hidden = new double[hiddenSize];
        for (int j = 0; j < hiddenSize; j++) {
            double sum = b1[j];
            for (int i = 0; i < inputSize; i++) sum += input[i] * w1[i][j];
            hidden[j] = Math.max(0, sum);
        }
        // 输出层线性 + Softmax
        double[] logits = new double[outputSize];
        for (int j = 0; j < outputSize; j++) {
            double sum = b2[j];
            for (int i = 0; i < hiddenSize; i++) sum += hidden[i] * w2[i][j];
            logits[j] = sum;
        }
        return softmax(logits);
    }

    private double[] softmax(double[] logits) {
        double max = logits[0];
        for (double v : logits) if (v > max) max = v;
        double sum = 0;
        double[] exp = new double[logits.length];
        for (int i = 0; i < logits.length; i++) {
            exp[i] = Math.exp(logits[i] - max);
            sum += exp[i];
        }
        for (int i = 0; i < logits.length; i++) exp[i] /= sum;
        return exp;
    }

    public int getInputSize() { return inputSize; }
    public int getOutputSize() { return outputSize; }
    public double[][] getW1() { return w1; }
    public double[][] getW2() { return w2; }
    public double[] getB1() { return b1; }
    public double[] getB2() { return b2; }
}

REINFORCE智能体

package policy;

import tetris.Board;
import tetris.Tetromino;
import java.util.*;

/**
 * REINFORCE智能体:蒙特卡洛策略梯度 + 基线方差缩减
 */
public class REINFORCEAgent {
    private final PolicyNetwork network;
    private final Random rand;
    private final double learningRate = 0.001;
    private final double gamma = 0.99;

    // 轨迹记录
    private final List<double[]> states = new ArrayList<>();
    private final List<Integer> actions = new ArrayList<>();
    private final List<Double> rewards = new ArrayList<>();

    public REINFORCEAgent(int stateSize, int hiddenSize, int actionSize, long seed) {
        this.network = new PolicyNetwork(stateSize, hiddenSize, actionSize, seed);
        this.rand = new Random(seed);
    }

    /**
     * 根据当前状态采样动作
     * @return 动作索引(对应不同的落块位置与旋转组合)
     */
    public int selectAction(double[] state) {
        double[] probs = network.forward(state);
        double r = rand.nextDouble();
        double cum = 0;
        for (int i = 0; i < probs.length; i++) {
            cum += probs[i];
            if (r < cum) return i;
        }
        return probs.length - 1;
    }

    public void recordStep(double[] state, int action, double reward) {
        states.add(state.clone());
        actions.add(action);
        rewards.add(reward);
    }

    /**
     * 回合结束后,计算回报并执行策略梯度更新
     */
    public void updatePolicy() {
        int T = rewards.size();
        if (T == 0) return;

        // 计算每个时间步的累积折扣回报
        double[] returns = new double[T];
        double G = 0;
        for (int t = T - 1; t >= 0; t--) {
            G = rewards.get(t) + gamma * G;
            returns[t] = G;
        }

        // 使用平均回报作为基线
        double meanReturn = 0;
        for (double g : returns) meanReturn += g;
        meanReturn /= T;

        // 策略梯度上升(简化版,使用数值梯度估计)
        for (int t = 0; t < T; t++) {
            double advantage = returns[t] - meanReturn; // 基线方差缩减
            int action = actions.get(t);
            // 实际实现中需计算对数概率关于参数的梯度
            // 此处省略完整反向传播,建议使用自动微分库
        }

        // 清空轨迹
        states.clear();
        actions.clear();
        rewards.clear();
    }

    public PolicyNetwork getNetwork() { return network; }
}

训练主程序

package train;

import tetris.Board;
import tetris.Tetromino;
import policy.REINFORCEAgent;
import java.util.Random;

/**
 * REINFORCE训练主程序
 */
public class Trainer {
    public static void main(String[] args) {
        final int STATE_SIZE = 8;    // 8维手工特征
        final int HIDDEN_SIZE = 64;
        final int ACTION_SIZE = 40;  // 10列 x 4种旋转(最大)
        final int EPISODES = 10000;

        Random rand = new Random(42);
        Board board = new Board();
        REINFORCEAgent agent = new REINFORCEAgent(STATE_SIZE, HIDDEN_SIZE, ACTION_SIZE, 42L);

        for (int ep = 0; ep < EPISODES; ep++) {
            board.reset();
            boolean gameOver = false;
            int steps = 0;

            while (!gameOver && steps < 1000) {
                int pieceType = rand.nextInt(7);
                Tetromino piece = new Tetromino(pieceType);

                double[] state = board.getFeatures();
                int action = agent.selectAction(state);

                // 解码动作:列位置和旋转次数
                int rotation = action % 4;
                int col = (action / 4) % 10;

                // 执行旋转
                for (int r = 0; r < rotation; r++) piece.rotate();
                piece.setX(col);

                // 下落到底
                int finalY = 0;
                for (int y = 0; y < Board.HEIGHT; y++) {
                    if (!board.isValidPosition(piece.getShape(), col, y)) {
                        finalY = y - 1;
                        break;
                    }
                    finalY = y;
                }

                if (finalY < 0) {
                    gameOver = true;
                    agent.recordStep(state, action, -10.0);
                    break;
                }

                board.placePiece(piece.getShape(), col, finalY);
                double reward = board.getLinesCleared() * 10.0 + 1.0; // 消行奖励 + 生存奖励
                agent.recordStep(state, action, reward);
                steps++;
            }

            agent.updatePolicy();

            if ((ep + 1) % 1000 == 0) {
                System.out.printf("Episode %d | Lines: %d | Steps: %d%n",
                        ep + 1, board.getLinesCleared(), steps);
            }
        }
    }
}

特征工程详解

俄罗斯方块的状态空间极大($2^{200}$种可能的盘面),直接使用原始网格作为神经网络输入会导致参数量爆炸。本文采用8维手工设计特征,灵感来自经典的Bertsekas-Tsitsiklis评估函数:

特征 符号 范围 权重方向
聚合高度 $\sum h_i$ 0-200 负(越低越好)
消除行数 lines 0-4 正(越多越好)
空洞数 holes 0-200 负(越少越好)
崎岖度 $\sum h_i – h_{i+1} $
最大高度 $\max h_i$ 0-20 负(越低越好)
行变换 row trans 0-200 负
列变换 col trans 0-400 负
井深度 well sum 0-200 负

算法复杂度分析

模块 时间复杂度 空间复杂度
特征提取 $O(W \cdot H)$ $O(1)$
策略前向传播 $O(S \cdot H + H \cdot A)$ $O(S \cdot H + H \cdot A)$
回报计算 $O(T)$ $O(T)$
完整回合训练 $O(T \cdot (S \cdot H + H \cdot A))$ $O(T \cdot S)$

训练技巧

  1. 奖励设计:除消行奖励外,添加每步+1的生存奖励,鼓励AI活得久。死亡时给予-10惩罚。
  2. 动作空间离散化:将”旋转+列位置”编码为单一动作,简化策略输出。
  3. 回合长度限制:限制单回合最多1000步,防止陷入无意义的循环。
  4. 学习率衰减:训练后期降低学习率至初始值的1/10,稳定收敛。
  5. 多回合平均基线:使用最近100回合的平均回报作为动态基线,比单回合均值更稳定。

扩展方向

  • Actor-Critic:用值函数网络同时估计基线,避免等待回合结束才更新的问题,支持在线学习。
  • PPO(近端策略优化):通过裁剪目标函数限制每次策略更新的幅度,解决REINFORCE训练不稳定的问题。
  • CNN特征提取:使用卷积神经网络直接从原始网格提取特征,消除手工特征工程的需求。

总结

本文使用Java实现了基于REINFORCE策略梯度的俄罗斯方块AI。相比值函数方法,策略梯度直接优化动作概率,天然支持随机策略与连续动作空间。通过引入基线方差缩减技术,训练效率与稳定性得到显著提升。虽然手工特征限制了泛化能力,但为理解策略梯度原理提供了清晰的工程范例。