每日算法 — 使用java实现俄罗斯方块:进化策略与协方差矩阵自适应评估

引言

俄罗斯方块(Tetris)是全球最经典的益智游戏之一,玩家需要控制下落的方块并合理摆放以消除完整行。设计一个高分的俄罗斯方块AI,核心在于评估每种落地方位的”局面价值”。本文采用进化策略(Evolution Strategy, ES)中的协方差矩阵自适应(CMA-ES)方法,自动优化局面评估函数的权重参数,让AI在进化中学会识别优质布局。

算法核心思想

传统俄罗斯方块AI通常依赖人工设计的评估特征(如高度、空洞数、平整度等)和固定权重。CMA-ES通过以下方式自动寻找最优权重:

  • 参数编码:将评估函数的各项权重编码为实数向量
  • 种群采样:根据多元正态分布采样多组权重参数
  • 选择更新:保留表现好的样本,更新分布的均值和协方差矩阵
  • 自适应步长:自动调整搜索步长,平衡探索与开发
  • 协方差学习:通过成功样本的分布学习参数间的相关性

核心数据结构

import java.util.*;

/**
 * CMA-ES 俄罗斯方块AI
 */
public class CMAESTetris {

    // 游戏面板:宽10高20
    private static final int WIDTH = 10;
    private static final int HEIGHT = 20;

    // 七种方块形状(I, O, T, S, Z, J, L)
    private static final int[][][][] SHAPES = {
        // I
        {{{1,1,1,1}}},
        // O
        {{{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}}},
        // Z
        {{{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 final Random random;
    private boolean[][] board;

    // CMA-ES参数
    private final int lambda;      // 后代数量
    private final int mu;          // 父代选择数量
    private final int dim;         // 权重维度(评估特征数)

    public CMAESTetris() {
        this.random = new Random();
        this.board = new boolean[HEIGHT][WIDTH];
        this.dim = 8; // 8个评估特征
        this.lambda = 20 + (int)(3 * Math.log(dim)); // 种群大小
        this.mu = lambda / 2;
    }

    /**
     * 局面评估特征向量
     */
    static class Features {
        double[] values;
        Features(int n) { values = new double[n]; }
    }

    /**
     * CMA-ES 状态
     */
    static class CMAState {
        double[] xmean;      // 分布均值
        double[] sigma;      // 步长(标量,这里简化为每个维度独立)
        double[][] C;        // 协方差矩阵
        double[] pc;         // 进化路径(协方差)
        double[] ps;         // 进化路径(步长)

        CMAState(int dim) {
            xmean = new double[dim];
            sigma = new double[dim];
            C = new double[dim][dim];
            pc = new double[dim];
            ps = new double[dim];
            for (int i = 0; i < dim; i++) {
                sigma[i] = 1.0;
                C[i][i] = 1.0;
            }
        }
    }

局面评估特征提取

    /**
     * 提取局面的8维特征向量
     */
    private Features extractFeatures(boolean[][] b) {
        Features f = new Features(dim);
        int[] heights = new int[WIDTH];

        // 1. 各列高度
        for (int c = 0; c < WIDTH; c++) {
            for (int r = 0; r < HEIGHT; r++) {
                if (b[r][c]) {
                    heights[c] = HEIGHT - r;
                    break;
                }
            }
        }

        // 特征1: 总高度之和(越小越好)
        double sumHeight = 0;
        for (int h : heights) sumHeight += h;
        f.values[0] = sumHeight;

        // 特征2: 最大高度(越小越好)
        double maxHeight = 0;
        for (int h : heights) maxHeight = Math.max(maxHeight, h);
        f.values[1] = maxHeight;

        // 特征3: 高度标准差(越平整越好)
        double avgHeight = sumHeight / WIDTH;
        double varHeight = 0;
        for (int h : heights) varHeight += Math.pow(h - avgHeight, 2);
        f.values[2] = Math.sqrt(varHeight / WIDTH);

        // 特征4: 空洞数量(上方有方块但当前为空)
        int holes = 0;
        for (int c = 0; c < WIDTH; c++) {
            boolean foundBlock = false;
            for (int r = 0; r < HEIGHT; r++) {
                if (b[r][c]) foundBlock = true;
                else if (foundBlock) holes++;
            }
        }
        f.values[3] = holes;

        // 特征5: 凸度/凹陷惩罚(相邻列高度差的绝对值之和)
        double bumpiness = 0;
        for (int c = 0; c < WIDTH - 1; c++) {
            bumpiness += Math.abs(heights[c] - heights[c + 1]);
        }
        f.values[4] = bumpiness;

        // 特征6: 可消除行数
        int completeLines = 0;
        for (int r = 0; r < HEIGHT; r++) {
            boolean complete = true;
            for (int c = 0; c < WIDTH; c++) {
                if (!b[r][c]) { complete = false; break; }
            }
            if (complete) completeLines++;
        }
        f.values[5] = completeLines;

        // 特征7: 井的深度(两侧高中间低的列深度之和)
        double wellDepth = 0;
        for (int c = 0; c < WIDTH; c++) {
            int leftH = (c > 0) ? heights[c - 1] : HEIGHT;
            int rightH = (c < WIDTH - 1) ? heights[c + 1] : HEIGHT;
            int minNeighbor = Math.min(leftH, rightH);
            if (heights[c] < minNeighbor) {
                wellDepth += minNeighbor - heights[c];
            }
        }
        f.values[6] = wellDepth;

        // 特征8: 当前方块放置后的接触面(与已有方块和边界的接触点数)
        // 此特征在评估具体放置时计算,这里预留
        f.values[7] = 0;

        return f;
    }

    /**
     * 使用权重向量评估局面得分
     * 分数越高越好
     */
    private double evaluateBoard(boolean[][] b, double[] weights) {
        Features f = extractFeatures(b);
        double score = 0;
        for (int i = 0; i < dim; i++) {
            score += weights[i] * f.values[i];
        }
        return score;
    }

方块放置模拟与AI决策

    /**
     * 给定当前面板和下落方块,评估所有可能的放置位置
     * @return 最优放置后的新局面和对应的权重评分
     */
    private PlacementResult findBestPlacement(boolean[][] currentBoard, int shapeType, double[] weights) {
        int[][][] rotations = SHAPES[shapeType];
        double bestScore = Double.NEGATIVE_INFINITY;
        boolean[][] bestBoard = null;
        int bestClears = 0;

        for (int rot = 0; rot < rotations.length; rot++) {
            int[][] shape = rotations[rot];
            int sh = shape.length;
            int sw = shape[0].length;

            // 尝试所有水平位置
            for (int col = 0; col <= WIDTH - sw; col++) {
                // 从顶部下落,找到落点
                int row = findDropRow(currentBoard, shape, col);
                if (row < 0 || row + sh > HEIGHT) continue;

                // 模拟放置
                boolean[][] simulated = copyBoard(currentBoard);
                for (int r = 0; r < sh; r++) {
                    for (int c = 0; c < sw; c++) {
                        if (shape[r][c] == 1) {
                            simulated[row + r][col + c] = true;
                        }
                    }
                }

                // 消除完整行
                int clears = clearLines(simulated);

                // 计算评分(消除行给予额外奖励)
                double score = evaluateBoard(simulated, weights) + clears * clears * 100;

                if (score > bestScore) {
                    bestScore = score;
                    bestBoard = simulated;
                    bestClears = clears;
                }
            }
        }

        return new PlacementResult(bestBoard, bestScore, bestClears);
    }

    private int findDropRow(boolean[][] b, int[][] shape, int col) {
        int sh = shape.length;
        int sw = shape[0].length;

        for (int row = 0; row <= HEIGHT - sh; row++) {
            // 检查下一行是否碰撞
            if (row + sh >= HEIGHT) return row;
            boolean collision = false;
            for (int r = 0; r < sh && !collision; r++) {
                for (int c = 0; c < sw; c++) {
                    if (shape[r][c] == 1 && b[row + sh][col + c]) {
                        collision = true;
                        break;
                    }
                }
            }
            if (collision) return row;
        }
        return -1;
    }

    private int clearLines(boolean[][] b) {
        int clears = 0;
        for (int r = HEIGHT - 1; r >= 0; r--) {
            boolean complete = true;
            for (int c = 0; c < WIDTH; c++) {
                if (!b[r][c]) { complete = false; break; }
            }
            if (complete) {
                // 删除该行,上方整体下移
                for (int rr = r; rr > 0; rr--) {
                    b[rr] = b[rr - 1].clone();
                }
                b[0] = new boolean[WIDTH];
                clears++;
                r++; // 重新检查当前行
            }
        }
        return clears;
    }

    private boolean[][] copyBoard(boolean[][] b) {
        boolean[][] copy = new boolean[HEIGHT][WIDTH];
        for (int r = 0; r < HEIGHT; r++) copy[r] = b[r].clone();
        return copy;
    }

    static class PlacementResult {
        boolean[][] board;
        double score;
        int linesCleared;
        PlacementResult(boolean[][] b, double s, int l) {
            board = b; score = s; linesCleared = l;
        }
    }

CMA-ES 进化核心

    /**
     * 使用CMA-ES进化最优权重
     */
    public double[] evolveWeights(int generations) {
        CMAState cma = new CMAState(dim);

        // 初始均值在0附近
        for (int i = 0; i < dim; i++) cma.xmean[i] = random.nextGaussian() * 0.1;

        double bestFitness = Double.NEGATIVE_INFINITY;
        double[] bestWeights = null;

        for (int gen = 0; gen < generations; gen++) {
            // 采样lambda个后代
            double[][] samples = new double[lambda][dim];
            double[] fitnesses = new double[lambda];

            for (int i = 0; i < lambda; i++) {
                // 多元正态分布采样(简化版:各维度独立)
                for (int d = 0; d < dim; d++) {
                    samples[i][d] = cma.xmean[d] + cma.sigma[d] * Math.sqrt(cma.C[d][d]) * random.nextGaussian();
                }
                fitnesses[i] = evaluateWeights(samples[i]);

                if (fitnesses[i] > bestFitness) {
                    bestFitness = fitnesses[i];
                    bestWeights = samples[i].clone();
                }
            }

            // 按适应度排序,选择前mu个
            Integer[] indices = new Integer[lambda];
            for (int i = 0; i < lambda; i++) indices[i] = i;
            Arrays.sort(indices, (a, b) -> Double.compare(fitnesses[b], fitnesses[a]));

            // 更新均值
            double[] oldMean = cma.xmean.clone();
            for (int d = 0; d < dim; d++) {
                cma.xmean[d] = 0;
                for (int i = 0; i < mu; i++) {
                    cma.xmean[d] += samples[indices[i]][d] / mu;
                }
            }

            // 更新协方差矩阵(简化版)
            for (int d1 = 0; d1 < dim; d1++) {
                for (int d2 = 0; d2 < dim; d2++) {
                    double cov = 0;
                    for (int i = 0; i < mu; i++) {
                        double diff1 = samples[indices[i]][d1] - oldMean[d1];
                        double diff2 = samples[indices[i]][d2] - oldMean[d2];
                        cov += diff1 * diff2 / mu;
                    }
                    cma.C[d1][d2] = 0.9 * cma.C[d1][d2] + 0.1 * cov;
                }
            }

            // 更新步长(自适应)
            for (int d = 0; d < dim; d++) {
                double successRate = 0;
                for (int i = 0; i < mu; i++) {
                    if (fitnesses[indices[i]] > bestFitness * 0.95) successRate++;
                }
                successRate /= mu;
                if (successRate > 0.2) {
                    cma.sigma[d] *= 1.1; // 成功率高,扩大搜索
                } else {
                    cma.sigma[d] *= 0.9; // 成功率低,缩小搜索
                }
                cma.sigma[d] = Math.max(0.01, Math.min(cma.sigma[d], 10.0));
            }

            if (gen % 50 == 0) {
                System.out.println("Gen " + gen + ", Best Fitness: " + String.format("%.1f", bestFitness));
            }
        }

        return bestWeights;
    }

    /**
     * 评估一组权重:进行多局游戏取平均得分
     */
    private double evaluateWeights(double[] weights) {
        int games = 3;
        double totalScore = 0;

        for (int g = 0; g < games; g++) {
            boolean[][] gameBoard = new boolean[HEIGHT][WIDTH];
            int score = 0;
            int pieces = 0;
            int maxPieces = 500; // 单局最多落块数

            while (pieces < maxPieces) {
                int shapeType = random.nextInt(SHAPES.length);
                PlacementResult result = findBestPlacement(gameBoard, shapeType, weights);

                if (result.board == null) break; // 无法放置,游戏结束

                gameBoard = result.board;
                score += result.linesCleared * result.linesCleared * 100 + 1;
                pieces++;
            }
            totalScore += score;
        }
        return totalScore / games;
    }

完整游戏演示

    /**
     * 使用最优权重运行完整游戏并展示结果
     */
    public void playWithWeights(double[] weights, int maxPieces) {
        board = new boolean[HEIGHT][WIDTH];
        int totalScore = 0;
        int totalLines = 0;

        System.out.println("=== CMA-ES 俄罗斯方块AI ===");

        for (int p = 0; p < maxPieces; p++) {
            int shapeType = random.nextInt(SHAPES.length);
            PlacementResult result = findBestPlacement(board, shapeType, weights);

            if (result.board == null) {
                System.out.println("Game Over at piece " + p);
                break;
            }

            board = result.board;
            totalScore += result.linesCleared * result.linesCleared * 100;
            totalLines += result.linesCleared;

            if (p % 100 == 99) {
                System.out.println("Pieces: " + (p + 1) + ", Lines: " + totalLines + ", Score: " + totalScore);
                printBoard();
            }
        }

        System.out.println("\nFinal Score: " + totalScore + ", Total Lines: " + totalLines);
        printBoard();
    }

    private void printBoard() {
        System.out.println("+" + "-".repeat(WIDTH) + "+");
        for (int r = 0; r < HEIGHT; r++) {
            System.out.print("|");
            for (int c = 0; c < WIDTH; c++) {
                System.out.print(board[r][c] ? "#" : " ");
            }
            System.out.println("|");
        }
        System.out.println("+" + "-".repeat(WIDTH) + "+");
    }

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

        System.out.println("=== 阶段1:CMA-ES进化权重 ===");
        double[] weights = ai.evolveWeights(300);

        System.out.println("\n=== 进化后的权重 ===");
        String[] names = {"总高度","最大高度","平整度","空洞数","凹凸度","可消行","井深度","接触面"};
        for (int i = 0; i < weights.length; i++) {
            System.out.println(names[i] + ": " + String.format("%.3f", weights[i]));
        }

        System.out.println("\n=== 阶段2:使用最优权重游戏 ===");
        ai.playWithWeights(weights, 1000);
    }
}

算法复杂度分析

阶段 时间复杂度 空间复杂度
特征提取 O(W×H) O(1)
单步放置评估 O(R×W×H) O(W×H)
单局游戏 O(P×R×W×H) O(W×H)
权重评估 O(G×P×R×W×H) O(W×H)
CMA-ES单代 O(L×G×P×R×W×H + L²×D²) O(D² + L×D)
完整进化 O(E×L×G×P×R×W×H) O(D² + L×D)

其中 W=10, H=20, R=旋转数, P=落块数, L=lambda, D=维度, E=进化代数, G=评估局数。

总结

CMA-ES通过协方差矩阵自适应地调整搜索方向,能够高效地探索高维权重空间。在俄罗斯方块中,进化后的权重往往能学到”保持低高度、减少空洞、追求平整”等人类玩家凭直觉掌握的策略。相比手动调参,CMA-ES让AI自动发现最优评估策略,展现出进化计算的强大能力。