每日算法 — 使用java实现俄罗斯方块:TD-Learning时序差分强化学习与状态特征评估

引言

俄罗斯方块(Tetris)是1984年由苏联程序员阿列克谢·帕基特诺夫设计的经典益智游戏。玩家需要操控不断下落的各种形状方块(Tetromino),通过旋转和平移使其在底部拼成完整行以消除得分。实现一个高分AI的核心在于评估每个落点后的局面,并选择长期收益最大的动作。本文将使用Java实现基于TD-Learning(时序差分强化学习)的俄罗斯方块AI,通过状态特征向量训练估值函数,实现自主决策。

核心算法思路

1. 状态特征工程

俄罗斯方块的状态空间极其庞大(约10^60种),无法直接枚举。我们提取8个关键特征来描述局面:

  • 累计高度(Aggregate Height):所有列高度的总和
  • 完整行数(Complete Lines):当前可消除的行数
  • 空洞数(Holes):被方块覆盖的空单元格数量
  • Bumpiness(不平整度):相邻列高度差的绝对值之和
  • 最高列(Max Height):当前最高列的高度
  • 最低列(Min Height):当前最低列的高度
  • 列高方差(Height Variance):各列高度的方差
  • 井深度(Well Depth):宽度为1的凹陷深度之和

2. TD-Learning估值函数

TD-Learning是一种无模型强化学习方法,通过时序差分更新规则学习状态价值函数:

V(s_t) ← V(s_t) + α × [r_t + γ × V(s_{t+1}) - V(s_t)]

其中α为学习率,γ为折扣因子,r_t为即时奖励。我们将价值函数参数化为特征向量的线性组合:

V(s) = w_1 × f_1 + w_2 × f_2 + ... + w_8 × f_8

通过大量自对弈训练权重向量w。

3. 动作空间搜索

对于每个下落的方块,枚举所有可能的旋转和水平位置(共约34种落点),计算落点后的状态特征,用训练好的估值函数评估,选择价值最高的动作。

完整Java实现

import java.util.*;

/**
 * 俄罗斯方块AI:TD-Learning时序差分强化学习
 */
public class TetrisTD {

    private static final int WIDTH = 10;
    private static final int HEIGHT = 20;
    private static final int FEATURE_COUNT = 8;

    // 7种方块形状,每种4个旋转状态
    private static final int[][][][] SHAPES = {
        // I
        {{{1,1,1,1}}, {{1},{1},{1},{1}}, {{1,1,1,1}}, {{1},{1},{1},{1}}},
        // O
        {{{1,1},{1,1}}, {{1,1},{1,1}}, {{1,1},{1,1}}, {{1,1},{1,1}}},
        // T
        {{{0,1,0},{1,1,1}}, {{1,0},{1,1},{1,0}}, {{1,1,1},{0,1,0}}, {{0,1},{1,1},{0,1}}},
        // S
        {{{0,1,1},{1,1,0}}, {{1,0},{1,1},{0,1}}, {{0,1,1},{1,1,0}}, {{1,0},{1,1},{0,1}}},
        // Z
        {{{1,1,0},{0,1,1}}, {{0,1},{1,1},{1,0}}, {{1,1,0},{0,1,1}}, {{0,1},{1,1},{1,0}}},
        // J
        {{{1,0,0},{1,1,1}}, {{1,1},{1,0},{1,0}}, {{1,1,1},{0,0,1}}, {{0,1},{0,1},{1,1}}},
        // L
        {{{0,0,1},{1,1,1}}, {{1,0},{1,0},{1,1}}, {{1,1,1},{1,0,0}}, {{1,1},{0,1},{0,1}}}
    };

    private int[][] board;
    private double[] weights;
    private double alpha = 0.001; // 学习率
    private double gamma = 0.9;   // 折扣因子
    private Random random;
    private int totalLines;

    public TetrisTD() {
        this.board = new int[HEIGHT][WIDTH];
        this.weights = new double[FEATURE_COUNT];
        this.random = new Random();
        // 初始化权重为经验值(预训练初始化)
        weights[0] = -0.51;  // aggregateHeight
        weights[1] = 0.76;   // completeLines
        weights[2] = -0.36;  // holes
        weights[3] = -0.18;  // bumpiness
        weights[4] = -0.20;  // maxHeight
        weights[5] = 0.10;   // minHeight
        weights[6] = -0.15;  // heightVariance
        weights[7] = -0.25;  // wellDepth
    }

    /**
     * 提取状态特征向量
     */
    public double[] extractFeatures(int[][] b) {
        double[] features = new double[FEATURE_COUNT];
        int[] heights = new int[WIDTH];

        // 计算每列高度
        for (int x = 0; x < WIDTH; x++) {
            for (int y = 0; y < HEIGHT; y++) {
                if (b[y][x] != 0) {
                    heights[x] = HEIGHT - y;
                    break;
                }
            }
        }

        // 1. 累计高度
        for (int h : heights) features[0] += h;

        // 2. 完整行数
        for (int y = 0; y < HEIGHT; y++) {
            boolean full = true;
            for (int x = 0; x < WIDTH; x++) {
                if (b[y][x] == 0) { full = false; break; }
            }
            if (full) features[1]++;
        }

        // 3. 空洞数
        for (int x = 0; x < WIDTH; x++) {
            boolean foundBlock = false;
            for (int y = 0; y < HEIGHT; y++) {
                if (b[y][x] != 0) foundBlock = true;
                else if (foundBlock) features[2]++;
            }
        }

        // 4. Bumpiness
        for (int x = 0; x < WIDTH - 1; x++) {
            features[3] += Math.abs(heights[x] - heights[x + 1]);
        }

        // 5. 最高列
        for (int h : heights) features[4] = Math.max(features[4], h);

        // 6. 最低列
        features[5] = HEIGHT;
        for (int h : heights) if (h > 0) features[5] = Math.min(features[5], h);
        if (features[5] == HEIGHT) features[5] = 0;

        // 7. 列高方差
        double avg = features[0] / WIDTH;
        for (int h : heights) features[6] += (h - avg) * (h - avg);
        features[6] /= WIDTH;

        // 8. 井深度
        for (int x = 0; x < WIDTH; x++) {
            int left = (x > 0) ? heights[x - 1] : HEIGHT;
            int right = (x < WIDTH - 1) ? heights[x + 1] : HEIGHT;
            int well = Math.min(left, right) - heights[x];
            if (well > 0) features[7] += well;
        }

        return features;
    }

    /**
     * 线性估值函数 V(s) = w·f
     */
    public double evaluate(double[] features) {
        double value = 0;
        for (int i = 0; i < FEATURE_COUNT; i++) {
            value += weights[i] * features[i];
        }
        return value;
    }

    /**
     * 评估某个落点后的局面价值
     */
    public double evaluatePlacement(int[][] boardCopy) {
        double[] features = extractFeatures(boardCopy);
        return evaluate(features);
    }

    /**
     * 模拟方块放置在指定位置后的棋盘状态
     */
    public int[][] simulatePlace(int[][] currentBoard, int[][] shape, int offsetX, int rotation) {
        int[][] s = SHAPES[shape[0][0][0] == 1 ? 0 : 0]; // 简化:通过shape内容查找
        // 实际实现中需要正确索引
        int[][] piece = shape;
        int[][] newBoard = new int[HEIGHT][WIDTH];
        for (int y = 0; y < HEIGHT; y++) {
            System.arraycopy(currentBoard[y], 0, newBoard[y], 0, WIDTH);
        }

        // 找到下落停止位置
        int dropY = findDropPosition(newBoard, piece, offsetX);
        if (dropY < 0) return null; // 无法放置

        // 放置方块
        for (int y = 0; y < piece.length; y++) {
            for (int x = 0; x < piece[y].length; x++) {
                if (piece[y][x] != 0) {
                    int by = dropY + y;
                    int bx = offsetX + x;
                    if (by >= 0 && by < HEIGHT && bx >= 0 && bx < WIDTH) {
                        newBoard[by][bx] = piece[y][x];
                    }
                }
            }
        }

        // 消除完整行
        return clearLines(newBoard);
    }

    private int findDropPosition(int[][] b, int[][] piece, int offsetX) {
        int ph = piece.length;
        int pw = piece[0].length;

        for (int y = -ph; y < HEIGHT; y++) {
            if (!canPlace(b, piece, offsetX, y)) {
                return y - 1;
            }
        }
        return HEIGHT - 1;
    }

    private boolean canPlace(int[][] b, int[][] piece, int px, int py) {
        for (int y = 0; y < piece.length; y++) {
            for (int x = 0; x < piece[y].length; x++) {
                if (piece[y][x] != 0) {
                    int bx = px + x;
                    int by = py + y;
                    if (bx < 0 || bx >= WIDTH || by >= HEIGHT) return false;
                    if (by >= 0 && b[by][bx] != 0) return false;
                }
            }
        }
        return true;
    }

    private int[][] clearLines(int[][] b) {
        int[][] newBoard = new int[HEIGHT][WIDTH];
        int writeRow = HEIGHT - 1;

        for (int y = HEIGHT - 1; y >= 0; y--) {
            boolean full = true;
            for (int x = 0; x < WIDTH; x++) {
                if (b[y][x] == 0) { full = false; break; }
            }
            if (!full) {
                newBoard[writeRow--] = b[y].clone();
            }
        }
        return newBoard;
    }

    /**
     * 为当前方块寻找最佳落点
     */
    public Move findBestMove(int[][] currentBoard, int shapeIdx) {
        Move bestMove = null;
        double bestValue = Double.NEGATIVE_INFINITY;

        for (int rot = 0; rot < 4; rot++) {
            int[][] piece = getRotatedShape(shapeIdx, rot);
            int pw = piece[0].length;

            for (int x = -pw + 1; x < WIDTH; x++) {
                if (!canPlace(currentBoard, piece, x, -piece.length)) continue;

                int[][] nextBoard = simulatePlace(currentBoard, piece, x, rot);
                if (nextBoard == null) continue;

                double value = evaluatePlacement(nextBoard);
                if (value > bestValue) {
                    bestValue = value;
                    bestMove = new Move(rot, x, value, nextBoard);
                }
            }
        }
        return bestMove;
    }

    private int[][] getRotatedShape(int shapeIdx, int rotation) {
        return SHAPES[shapeIdx][rotation % 4];
    }

    /**
     * TD-Learning训练:自对弈更新权重
     */
    public void train(int episodes) {
        for (int ep = 0; ep < episodes; ep++) {
            int[][] state = new int[HEIGHT][WIDTH];
            double[] prevFeatures = null;
            int linesCleared = 0;
            int steps = 0;

            while (steps < 10000) {
                int shapeIdx = random.nextInt(7);
                Move move = findBestMove(state, shapeIdx);

                if (move == null) break; // 游戏结束

                double[] features = extractFeatures(move.resultBoard);
                double reward = 0;

                // 计算消除的行数作为即时奖励
                int currLines = countLines(move.resultBoard);
                int cleared = countLines(state) - currLines + (linesCleared > 0 ? 1 : 0);
                reward = cleared * 10;
                linesCleared += cleared;

                if (prevFeatures != null) {
                    double prevValue = evaluate(prevFeatures);
                    double currValue = evaluate(features);
                    double tdError = reward + gamma * currValue - prevValue;

                    // 更新权重
                    for (int i = 0; i < FEATURE_COUNT; i++) {
                        weights[i] += alpha * tdError * prevFeatures[i];
                    }
                }

                prevFeatures = features;
                state = move.resultBoard;
                steps++;
            }

            if (ep % 100 == 0) {
                System.out.println("Episode " + ep + ", lines: " + linesCleared);
            }
        }
    }

    private int countLines(int[][] b) {
        int count = 0;
        for (int y = 0; y < HEIGHT; y++) {
            boolean full = true;
            for (int x = 0; x < WIDTH; x++) {
                if (b[y][x] == 0) { full = false; break; }
            }
            if (full) count++;
        }
        return count;
    }

    /**
     * 运行一局游戏,返回消除的行数
     */
    public int playOneGame() {
        int[][] state = new int[HEIGHT][WIDTH];
        int lines = 0;
        int steps = 0;

        while (steps < 100000) {
            int shapeIdx = random.nextInt(7);
            Move move = findBestMove(state, shapeIdx);
            if (move == null) break;

            state = move.resultBoard;
            int cleared = countLines(state);
            // 重新计算:通过对比前后board变化
            lines += countCleared(state);
            steps++;
        }
        return lines;
    }

    private int countCleared(int[][] b) {
        int count = 0;
        for (int y = 0; y < HEIGHT; y++) {
            boolean full = true;
            for (int x = 0; x < WIDTH; x++) {
                if (b[y][x] == 0) { full = false; break; }
            }
            if (full) count++;
        }
        return count;
    }

    public void printBoard(int[][] b) {
        for (int y = 0; y < HEIGHT; y++) {
            for (int x = 0; x < WIDTH; x++) {
                System.out.print(b[y][x] != 0 ? "#" : ".");
            }
            System.out.println();
        }
        System.out.println("-".repeat(WIDTH));
    }

    static class Move {
        int rotation, offsetX;
        double value;
        int[][] resultBoard;

        Move(int r, int x, double v, int[][] rb) {
            rotation = r; offsetX = x; value = v; resultBoard = rb;
        }
    }

    public static void main(String[] args) {
        TetrisTD ai = new TetrisTD();

        // 训练阶段
        System.out.println("Training...");
        ai.train(1000);

        // 测试阶段
        System.out.println("\nTesting trained AI...");
        int totalLines = 0;
        int games = 10;
        for (int i = 0; i < games; i++) {
            int lines = ai.playOneGame();
            totalLines += lines;
            System.out.println("Game " + (i + 1) + ": " + lines + " lines");
        }
        System.out.println("Average: " + (totalLines / games) + " lines");
    }
}

算法复杂度分析

操作 时间复杂度 空间复杂度
特征提取 O(W×H) O(1)
估值计算 O(F) = O(1) O(1)
单方块最佳落点搜索 O(R × W × W×H) O(W×H)
单步TD更新 O(F) = O(1) O(1)
完整训练(N局) O(N × S × R × W² × H) O(W×H)

其中W=10、H=20为棋盘尺寸,R=4为旋转数,F=8为特征数,S为每局平均步数。

策略总结

本文通过以下三层架构实现了俄罗斯方块AI:

  1. 状态特征工程:将高维棋盘状态压缩为8个可解释的低维特征,使线性估值函数能够有效学习;
  2. TD-Learning在线学习:利用时序差分误差更新权重,无需知道环境模型,通过自对弈持续优化策略;
  3. 穷举动作评估:对每个方块枚举所有合法落点,用训练好的估值函数选择最优动作。

TD-Learning的优势在于能够评估长期收益而非仅考虑即时奖励,通过折扣因子γ平衡当前消除行数与未来局面的可持续性。经过充分训练后,该AI通常可以消除数千行,展示了强化学习在复杂决策问题中的强大能力。