每日算法 — 使用java实现俄罗斯方块:N-tuple网络与TD学习评估

引言

俄罗斯方块(Tetris)的 AI 设计一直是算法领域的经典挑战。与完全信息博弈不同,俄罗斯方块具有随机性——玩家只能看到当前下落的方块,无法预知后续序列。因此,传统的 Minimax 搜索难以直接应用。本文介绍一种基于 N-tuple 网络(N-tuple Network)与 TD(Temporal Difference,时序差分)学习的评估函数训练方法,让 AI 在数百万局自对弈中自动提炼出优秀的盘面评估策略。

核心算法思想

1. N-tuple 网络

N-tuple 网络最初用于博弈评估,其核心思想是从棋盘上提取多个局部的 “N 格模式”,每个模式对应一个权重,最终评估值为所有模式激活值与其权重的点积:

$$V(s) = \sum_{i} w_i \cdot \phi_i(s)$$

对于俄罗斯方块,我们定义以下 N-tuple 特征:

  • 列高特征:每列的高度(共 10 列)
  • 列高差特征:相邻列的高度差(共 9 维)
  • 洞数特征:每列中位于方块上方空位的数量
  • 井深特征:连续凹陷列的深度
  • 行平整度:各行被填充的方块数

2. TD-Learning 更新

TD(0) 学习规则在每一步根据当前评估值与下一步评估值的差来更新权重:

$$w \leftarrow w + \alpha \cdot [r_{t+1} + \gamma V(s_{t+1}) – V(s_t)] \cdot \nabla_w V(s_t)$$

其中 $r_{t+1}$ 为即时奖励(如消行得分),$\gamma$ 为折扣因子,$\alpha$ 为学习率。

3. 动作搜索

对于每个下落的方块,枚举所有合法的旋转与平移落点,用训练好的 N-tuple 网络评估每个落点后的盘面,选择评估值最高的动作。

Java 实现

import java.util.*;

/**
 * 俄罗斯方块 AI — N-tuple 网络评估 + TD 学习训练
 * 特征提取:列高、列高差、洞数、井深、平整度
 * 训练方式:数百万局自对弈,TD(0) 在线更新权重
 */
public class TetrisNTupleAI {

    // 标准俄罗斯方块:宽10,高20(显示区),+2行隐藏缓冲
    private static final int WIDTH = 10;
    private static final int HEIGHT = 22;
    // 7种方块:I, O, T, S, Z, J, L
    private static final int[][][][] PIECES = {
        // I
        {{{0,0},{0,1},{0,2},{0,3}}, {{0,0},{1,0},{2,0},{3,0}}},
        // O
        {{{0,0},{0,1},{1,0},{1,1}}},
        // T
        {{{0,1},{1,0},{1,1},{1,2}}, {{0,0},{1,0},{1,1},{2,0}}, {{0,0},{0,1},{0,2},{1,1}}, {{0,1},{1,0},{1,1},{2,1}}},
        // S
        {{{0,1},{0,2},{1,0},{1,1}}, {{0,0},{1,0},{1,1},{2,1}}},
        // Z
        {{{0,0},{0,1},{1,1},{1,2}}, {{0,1},{1,0},{1,1},{2,0}}},
        // J
        {{{0,0},{1,0},{1,1},{1,2}}, {{0,0},{0,1},{1,0},{2,0}}, {{0,0},{0,1},{0,2},{1,2}}, {{0,1},{1,1},{2,0},{2,1}}},
        // L
        {{{0,2},{1,0},{1,1},{1,2}}, {{0,0},{1,0},{2,0},{2,1}}, {{0,0},{0,1},{0,2},{1,0}}, {{0,0},{0,1},{1,1},{2,1}}}
    };

    // N-tuple 权重向量
    private final double[] weights;
    private static final int FEATURE_DIM = 43; // 10列高 + 9列高差 + 10洞数 + 9井深 + 5平整度
    private final double alpha = 0.001;   // 学习率
    private final double gamma = 0.9;     // 折扣因子

    // 当前盘面:true 表示有方块
    private boolean[][] board;
    private Random rand;

    public TetrisNTupleAI() {
        this.weights = new double[FEATURE_DIM];
        // 初始化权重:小的随机值
        for (int i = 0; i < FEATURE_DIM; i++) {
            weights[i] = (Math.random() - 0.5) * 0.01;
        }
        this.board = new boolean[HEIGHT][WIDTH];
        this.rand = new Random();
    }

    /**
     * 提取 N-tuple 特征向量
     */
    public double[] extractFeatures(boolean[][] b) {
        double[] feat = new double[FEATURE_DIM];
        int idx = 0;

        // 1. 列高(10维)
        int[] colHeight = new int[WIDTH];
        for (int c = 0; c < WIDTH; c++) {
            for (int r = 0; r < HEIGHT; r++) {
                if (b[r][c]) { colHeight[c] = HEIGHT - r; break; }
            }
            feat[idx++] = colHeight[c];
        }

        // 2. 列高差(9维)
        for (int c = 0; c < WIDTH - 1; c++) {
            feat[idx++] = Math.abs(colHeight[c] - colHeight[c + 1]);
        }

        // 3. 洞数(10维)
        for (int c = 0; c < WIDTH; c++) {
            int holes = 0;
            boolean seen = false;
            for (int r = 0; r < HEIGHT; r++) {
                if (b[r][c]) seen = true;
                else if (seen) holes++;
            }
            feat[idx++] = holes;
        }

        // 4. 井深(9维):连续三列形成凹陷
        for (int c = 1; c < WIDTH - 1; c++) {
            int well = Math.max(0, Math.min(colHeight[c-1], colHeight[c+1]) - colHeight[c]);
            feat[idx++] = well;
        }
        // 边界井
        feat[idx++] = Math.max(0, colHeight[1] - colHeight[0]);
        feat[idx++] = Math.max(0, colHeight[WIDTH-2] - colHeight[WIDTH-1]);

        // 5. 平整度(5维):按行统计,分桶
        int[] rowFill = new int[HEIGHT];
        for (int r = 0; r < HEIGHT; r++) {
            for (int c = 0; c < WIDTH; c++) if (b[r][c]) rowFill[r]++;
        }
        int[] buckets = new int[5];
        for (int v : rowFill) {
            buckets[Math.min(v / 2, 4)]++;
        }
        for (int v : buckets) feat[idx++] = v;

        return feat;
    }

    /**
     * 评估盘面得分 = 权重与特征的点积
     */
    public double evaluate(boolean[][] b) {
        double[] feat = extractFeatures(b);
        double score = 0;
        for (int i = 0; i < FEATURE_DIM; i++) score += weights[i] * feat[i];
        return score;
    }

    /**
     * TD(0) 权重更新
     * @param currentFeat 当前状态特征
     * @param reward 即时奖励(消行数)
     * @param nextBoard 下一状态盘面
     */
    public void updateTD(double[] currentFeat, int reward, boolean[][] nextBoard) {
        double vCurr = 0, vNext = 0;
        for (int i = 0; i < FEATURE_DIM; i++) vCurr += weights[i] * currentFeat[i];
        double[] nextFeat = extractFeatures(nextBoard);
        for (int i = 0; i < FEATURE_DIM; i++) vNext += weights[i] * nextFeat[i];

        double delta = reward + gamma * vNext - vCurr;
        for (int i = 0; i < FEATURE_DIM; i++) {
            weights[i] += alpha * delta * currentFeat[i];
        }
    }

    /**
     * 对给定的当前方块,搜索最佳落点
     * @param pieceIdx 方块类型 0-6
     * @return 最佳动作: {旋转索引, 平移列数, 评估得分}
     */
    public int[] findBestPlacement(int pieceIdx) {
        int[][][] rotations = PIECES[pieceIdx];
        int bestRot = 0, bestCol = 0;
        double bestScore = Double.NEGATIVE_INFINITY;

        for (int rot = 0; rot < rotations.length; rot++) {
            int[][] shape = rotations[rot];
            // 计算形状宽度
            int minC = Integer.MAX_VALUE, maxC = Integer.MIN_VALUE;
            for (int[] cell : shape) {
                minC = Math.min(minC, cell[1]);
                maxC = Math.max(maxC, cell[1]);
            }
            int shapeW = maxC - minC + 1;

            // 枚举所有水平位置
            for (int col = -minC; col <= WIDTH - maxC - 1; col++) {
                boolean[][] sim = simulateDrop(board, shape, col);
                if (sim == null) continue; // 非法落点
                double score = evaluate(sim);
                if (score > bestScore) {
                    bestScore = score;
                    bestRot = rot;
                    bestCol = col;
                }
            }
        }
        return new int[]{bestRot, bestCol};
    }

    /**
     * 模拟方块从顶部下落到指定列位置后的盘面
     */
    private boolean[][] simulateDrop(boolean[][] base, int[][] shape, int colOffset) {
        boolean[][] result = new boolean[HEIGHT][WIDTH];
        for (int r = 0; r < HEIGHT; r++) result[r] = Arrays.copyOf(base[r], WIDTH);

        // 找到最低可行行
        int dropRow = -1;
        outer:
        for (int row = 0; row < HEIGHT; row++) {
            for (int[] cell : shape) {
                int tr = row + cell[0];
                int tc = colOffset + cell[1];
                if (tr >= HEIGHT) continue outer; // 越界,尝试更上一行
                if (tr >= 0 && result[tr][tc]) {
                    // 碰撞,落点为上一行
                    dropRow = row - 1;
                    break outer;
                }
            }
        }
        if (dropRow < 0) dropRow = HEIGHT - 1; // 未碰撞,落到最底部

        // 放置方块
        for (int[] cell : shape) {
            int tr = dropRow + cell[0];
            int tc = colOffset + cell[1];
            if (tr < 0 || tr >= HEIGHT || tc < 0 || tc >= WIDTH) return null;
            if (result[tr][tc]) return null; // 重叠
            result[tr][tc] = true;
        }

        // 消行
        return clearLines(result);
    }

    /**
     * 消除满行并返回新盘面
     */
    private boolean[][] clearLines(boolean[][] b) {
        boolean[][] nb = new boolean[HEIGHT][WIDTH];
        int writeRow = HEIGHT - 1;
        for (int r = HEIGHT - 1; r >= 0; r--) {
            boolean full = true;
            for (int c = 0; c < WIDTH; c++) if (!b[r][c]) { full = false; break; }
            if (!full) {
                nb[writeRow--] = Arrays.copyOf(b[r], WIDTH);
            }
        }
        return nb;
    }

    /**
     * 训练一局:随机生成方块序列,AI 自对弈并 TD 更新
     * @return 本局消行总数
     */
    public int trainEpisode() {
        boolean[][] state = new boolean[HEIGHT][WIDTH];
        int totalLines = 0;
        double[] prevFeat = null;

        while (true) {
            int piece = rand.nextInt(7);
            int[] action = findBestPlacement(piece);
            int[][][] rotations = PIECES[piece];
            boolean[][] nextState = simulateDrop(state, rotations[action[0]], action[1]);
            if (nextState == null) break; // 游戏结束

            // 计算消行奖励
            int lines = countLinesCleared(state, nextState);
            totalLines += lines;

            double[] currFeat = extractFeatures(state);
            if (prevFeat != null) {
                // 用上一状态更新(标准TD(0))
                updateTD(prevFeat, lines, nextState);
            }
            prevFeat = currFeat;
            state = nextState;

            // 检查顶部溢出
            for (int c = 0; c < WIDTH; c++) if (state[0][c]) return totalLines;
        }
        return totalLines;
    }

    private int countLinesCleared(boolean[][] before, boolean[][] after) {
        int filledBefore = 0;
        for (int r = 0; r < HEIGHT; r++) {
            boolean full = true;
            for (int c = 0; c < WIDTH; c++) if (!before[r][c]) { full = false; break; }
            if (full) filledBefore++;
        }
        int filledAfter = 0;
        for (int r = 0; r < HEIGHT; r++) {
            boolean full = true;
            for (int c = 0; c < WIDTH; c++) if (!after[r][c]) { full = false; break; }
            if (full) filledAfter++;
        }
        return filledBefore - filledAfter; // 实际上消行前before没有满行,这里简化计算
    }

    /**
     * 批量训练
     */
    public void train(int episodes) {
        int sumLines = 0;
        for (int ep = 1; ep <= episodes; ep++) {
            int lines = trainEpisode();
            sumLines += lines;
            if (ep % 1000 == 0) {
                System.out.println("Episode " + ep + ", avg lines: " + (sumLines / 1000.0));
                sumLines = 0;
            }
        }
    }

    public static void main(String[] args) {
        TetrisNTupleAI ai = new TetrisNTupleAI();
        System.out.println("开始训练...");
        ai.train(50000);
        System.out.println("训练完成。演示一局:");
        int lines = ai.trainEpisode();
        System.out.println("本局消行:" + lines);
    }
}

复杂度分析

模块 时间复杂度 空间复杂度
特征提取 $O(W \cdot H)$ $O(F)$
单方块落点搜索 $O(R \cdot W \cdot W \cdot H)$ $O(W \cdot H)$
TD 更新 $O(F)$ $O(F)$
完整训练(N 局) $O(N \cdot W^2 \cdot H \cdot R)$ $O(W \cdot H)$

其中 $W=10, H=22, R$ 为旋转数(最多 4),$F=43$ 为特征维度。单局训练在毫秒级完成,百万局训练约需数小时。

总结

N-tuple 网络以紧凑的线性模型捕获了俄罗斯方块盘面的关键几何特征,TD 学习则让 AI 在无需人工标注的情况下自动优化评估策略。相比于 Pierre Dellacherie 等手工设计的启发式函数,N-tuple + TD 的优势在于可扩展性——只需增加新的特征模式或调整网络结构,即可迁移到不同尺寸的变体游戏。读者可以尝试引入更丰富的特征(如洞的形状、塔状结构)或升级为非线性神经网络(如小型 MLP),进一步提升 AI 的消行上限。