每日算法 — 使用java实现俄罗斯方块:蒙特卡洛树搜索与多落点UCT评估

引言

俄罗斯方块(Tetris)是1984年由阿列克谢·帕基特诺夫创造的经典下落式拼图游戏。玩家通过旋转和移动不同形状的方块(Tetromino),使其在底部形成完整的水平行以消除得分。游戏的速度随时间逐渐加快,要求玩家在极短时间内做出最优决策。本文将使用Java实现俄罗斯方块AI,采用蒙特卡洛树搜索(MCTS)算法评估每个下落方块的所有可能落点与旋转方案,使用UCT(Upper Confidence Bound applied to Trees)公式平衡探索与利用,选择期望得分最高的动作。

算法核心思想

蒙特卡洛树搜索(MCTS)

MCTS是一种通过随机模拟来评估决策的搜索算法,广泛应用于棋类AI(如围棋AlphaGo)。其核心包含四个步骤:

  1. 选择(Selection):从根节点开始,使用UCT公式选择最有价值的子节点,直到到达叶子节点
  2. 扩展(Expansion):为叶子节点添加一个或多个子节点
  3. 模拟(Simulation):从新节点出发进行随机对局(Rollout),直到游戏结束
  4. 回溯(Backpropagation):将模拟结果反向传播到路径上的所有节点

UCT公式

UCT用于平衡探索(未充分访问的节点)和利用(当前平均收益高的节点):

UCT = Q(child)/N(child) + C × √(ln(N(parent)) / N(child))
  • Q(child):子节点累积得分
  • N(child):子节点访问次数
  • C:探索常数,通常取√2

俄罗斯方块的MCTS应用

俄罗斯方块的决策空间包括:
– 7种不同的方块形状(I, J, L, O, S, T, Z)
– 每种方块最多4种旋转状态
– 水平位置(0到列数减宽度)

对于每个新出现的方块,MCTS评估所有合法的(旋转, 列位置)组合,通过随机模拟后续若干步来估计该动作的期望得分。

完整Java实现

import java.util.*;

/**
 * 俄罗斯方块AI - 蒙特卡洛树搜索与UCT评估
 */
public class TetrisMCTS {

    private static final int ROWS = 20;
    private static final int COLS = 10;
    private static final Random RAND = new Random();
    private static final double C = Math.sqrt(2); // UCT探索常数
    private static final int SIMULATIONS = 200; // 每动作模拟次数

    // 七种方块形状(4种旋转状态,每种用相对坐标表示)
    private static final int[][][][] PIECES = {
        // I
        {{{0,0},{0,1},{0,2},{0,3}}, {{0,0},{1,0},{2,0},{3,0}}, {{0,0},{0,1},{0,2},{0,3}}, {{0,0},{1,0},{2,0},{3,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}}},
        // O
        {{{0,0},{0,1},{1,0},{1,1}}, {{0,0},{0,1},{1,0},{1,1}}, {{0,0},{0,1},{1,0},{1,1}}, {{0,0},{0,1},{1,0},{1,1}}},
        // S
        {{{0,1},{0,2},{1,0},{1,1}}, {{0,0},{1,0},{1,1},{2,1}}, {{0,1},{0,2},{1,0},{1,1}}, {{0,0},{1,0},{1,1},{2,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}}},
        // Z
        {{{0,0},{0,1},{1,1},{1,2}}, {{0,1},{1,0},{1,1},{2,0}}, {{0,0},{0,1},{1,1},{1,2}}, {{0,1},{1,0},{1,1},{2,0}}}
    };
    private static final String[] PIECE_NAMES = {"I", "J", "L", "O", "S", "T", "Z"};

    private int[][] board; // 0为空,1为已固定方块
    private int score;
    private int linesCleared;

    public TetrisMCTS() {
        this.board = new int[ROWS][COLS];
        this.score = 0;
        this.linesCleared = 0;
    }

    /**
     * MCTS树节点
     */
    static class Node {
        int[][] boardState;
        int pieceType; // 当前处理的方块类型
        int rotation;  // 旋转状态
        int col;       // 放置列
        int visits;
        double totalScore;
        List<Node> children;
        Node parent;
        boolean isRoot;

        Node(int[][] bs, int pt, int rot, int c, Node p, boolean root) {
            this.boardState = bs;
            this.pieceType = pt;
            this.rotation = rot;
            this.col = c;
            this.visits = 0;
            this.totalScore = 0;
            this.children = new ArrayList<>();
            this.parent = p;
            this.isRoot = root;
        }

        double uct() {
            if (visits == 0) return Double.POSITIVE_INFINITY;
            double exploitation = totalScore / visits;
            double exploration = C * Math.sqrt(Math.log(parent.visits) / visits);
            return exploitation + exploration;
        }
    }

    /**
     * 获取方块的宽度和高度
     */
    private int[] getPieceBounds(int type, int rot) {
        int maxR = 0, maxC = 0;
        for (int[] p : PIECES[type][rot]) {
            maxR = Math.max(maxR, p[0]);
            maxC = Math.max(maxC, p[1]);
        }
        return new int[]{maxR + 1, maxC + 1};
    }

    /**
     * 检查方块能否放置在指定位置和旋转状态
     */
    private boolean canPlace(int[][] b, int type, int rot, int row, int col) {
        for (int[] p : PIECES[type][rot]) {
            int r = row + p[0];
            int c = col + p[1];
            if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return false;
            if (b[r][c] != 0) return false;
        }
        return true;
    }

    /**
     * 找到方块在指定列和旋转状态下的最低可放置行
     */
    private int findDropRow(int[][] b, int type, int rot, int col) {
        int row = 0;
        while (canPlace(b, type, rot, row + 1, col)) row++;
        return row;
    }

    /**
     * 放置方块到棋盘,返回新棋盘
     */
    private int[][] placePiece(int[][] b, int type, int rot, int row, int col) {
        int[][] nb = new int[ROWS][COLS];
        for (int i = 0; i < ROWS; i++) nb[i] = b[i].clone();
        for (int[] p : PIECES[type][rot]) {
            nb[row + p[0]][col + p[1]] = 1;
        }
        return nb;
    }

    /**
     * 清除完整行,返回清除行数
     */
    private int clearLines(int[][] b) {
        int cleared = 0;
        int writeRow = ROWS - 1;
        for (int r = ROWS - 1; r >= 0; r--) {
            boolean full = true;
            for (int c = 0; c < COLS; c++) {
                if (b[r][c] == 0) { full = false; break; }
            }
            if (!full) {
                b[writeRow] = b[r].clone();
                writeRow--;
            } else {
                cleared++;
            }
        }
        for (int r = writeRow; r >= 0; r--) {
            Arrays.fill(b[r], 0);
        }
        return cleared;
    }

    /**
     * 评估棋盘状态(启发式函数)
     */
    private double evaluateBoard(int[][] b) {
        // 1. 堆积高度惩罚
        int maxHeight = 0;
        int[] heights = new int[COLS];
        for (int c = 0; c < COLS; c++) {
            for (int r = 0; r < ROWS; r++) {
                if (b[r][c] != 0) {
                    heights[c] = ROWS - r;
                    maxHeight = Math.max(maxHeight, heights[c]);
                    break;
                }
            }
        }

        // 2. 高度差异惩罚(不平整)
        double bumpiness = 0;
        for (int c = 0; c < COLS - 1; c++) {
            bumpiness += Math.abs(heights[c] - heights[c + 1]);
        }

        // 3. 空洞惩罚
        int holes = 0;
        for (int c = 0; c < COLS; c++) {
            boolean found = false;
            for (int r = 0; r < ROWS; r++) {
                if (b[r][c] != 0) found = true;
                else if (found) holes++;
            }
        }

        // 4. 井深度惩罚
        int wellDepth = 0;
        for (int c = 0; c < COLS; c++) {
            int leftH = (c > 0) ? heights[c - 1] : ROWS;
            int rightH = (c < COLS - 1) ? heights[c + 1] : ROWS;
            int well = Math.min(leftH, rightH) - heights[c];
            if (well > 0) wellDepth += well;
        }

        return -0.51 * aggregateHeight(heights) - 0.18 * bumpiness 
               - 0.36 * holes - 0.76 * wellDepth;
    }

    private int aggregateHeight(int[] heights) {
        int sum = 0;
        for (int h : heights) sum += h;
        return sum;
    }

    /**
     * 随机模拟(Rollout)从当前状态到游戏结束
     */
    private double rollout(int[][] startBoard, int startPiece) {
        int[][] sim = new int[ROWS][COLS];
        for (int i = 0; i < ROWS; i++) sim[i] = startBoard[i].clone();

        double totalScore = 0;
        int steps = 0;
        int currentPiece = startPiece;

        while (steps < 50) { // 限制模拟深度
            // 为当前方块找到最佳放置(贪心启发式)
            double bestVal = Double.NEGATIVE_INFINITY;
            int bestRot = 0, bestCol = 0;

            for (int rot = 0; rot < 4; rot++) {
                int[] bounds = getPieceBounds(currentPiece, rot);
                for (int col = 0; col <= COLS - bounds[1]; col++) {
                    int row = findDropRow(sim, currentPiece, rot, col);
                    if (row < 0) continue;
                    int[][] next = placePiece(sim, currentPiece, rot, row, col);
                    int cleared = clearLines(next);
                    double val = evaluateBoard(next) + cleared * 10;
                    if (val > bestVal) {
                        bestVal = val;
                        bestRot = rot;
                        bestCol = col;
                    }
                }
            }

            if (bestVal == Double.NEGATIVE_INFINITY) break; // 无法放置

            int row = findDropRow(sim, currentPiece, bestRot, bestCol);
            sim = placePiece(sim, currentPiece, bestRot, row, bestCol);
            int cleared = clearLines(sim);
            totalScore += cleared * cleared * 100 + bestVal;

            currentPiece = RAND.nextInt(7);
            steps++;
        }

        return totalScore;
    }

    /**
     * 为给定棋盘和当前方块执行MCTS搜索
     */
    public int[] findBestMove(int[][] currentBoard, int pieceType) {
        Node root = new Node(currentBoard, pieceType, 0, 0, null, true);

        // 生成所有合法动作作为根节点的子节点
        for (int rot = 0; rot < 4; rot++) {
            int[] bounds = getPieceBounds(pieceType, rot);
            for (int col = 0; col <= COLS - bounds[1]; col++) {
                int row = findDropRow(currentBoard, pieceType, rot, col);
                if (row < 0) continue;
                int[][] next = placePiece(currentBoard, pieceType, rot, row, col);
                clearLines(next);
                root.children.add(new Node(next, pieceType, rot, col, root, false));
            }
        }

        if (root.children.isEmpty()) return null;

        // MCTS主循环
        for (int sim = 0; sim < SIMULATIONS * root.children.size(); sim++) {
            // 1. 选择
            Node selected = select(root);

            // 2. 扩展(叶子节点直接模拟)
            // 3. 模拟
            double reward = rollout(selected.boardState, RAND.nextInt(7));

            // 4. 回溯
            backpropagate(selected, reward);
        }

        // 选择访问次数最多(或平均得分最高)的动作
        Node best = root.children.get(0);
        for (Node child : root.children) {
            if (child.visits > best.visits) best = child;
        }

        return new int[]{best.rotation, best.col};
    }

    /**
     * UCT选择
     */
    private Node select(Node node) {
        while (!node.children.isEmpty()) {
            Node best = node.children.get(0);
            double bestUct = best.uct();
            for (Node child : node.children) {
                double u = child.uct();
                if (u > bestUct) {
                    bestUct = u;
                    best = child;
                }
            }
            node = best;
        }
        return node;
    }

    /**
     * 回溯更新
     */
    private void backpropagate(Node node, double reward) {
        while (node != null) {
            node.visits++;
            node.totalScore += reward;
            node = node.parent;
        }
    }

    /**
     * 执行AI移动
     */
    public boolean aiMove() {
        int pieceType = RAND.nextInt(7);
        int[] move = findBestMove(board, pieceType);
        if (move == null) return false;

        int rot = move[0], col = move[1];
        int row = findDropRow(board, pieceType, rot, col);
        if (row < 0) return false;

        board = placePiece(board, pieceType, rot, row, col);
        int cleared = clearLines(board);
        linesCleared += cleared;
        score += cleared * cleared * 100;

        return true;
    }

    /**
     * 打印棋盘
     */
    public void printBoard() {
        System.out.println("Score: " + score + ", Lines: " + linesCleared);
        for (int r = 0; r < ROWS; r++) {
            System.out.print("|");
            for (int c = 0; c < COLS; c++) {
                System.out.print(board[r][c] == 0 ? " " : "#");
            }
            System.out.println("|");
        }
        System.out.println("+----------+");
        System.out.println();
    }

    /**
     * 检查游戏是否结束(顶部有方块)
     */
    public boolean isGameOver() {
        for (int c = 0; c < COLS; c++)
            if (board[0][c] != 0) return true;
        return false;
    }

    /**
     * 运行AI游戏
     */
    public void runAI() {
        board = new int[ROWS][COLS];
        score = 0;
        linesCleared = 0;

        printBoard();
        int pieces = 0;

        while (!isGameOver() && pieces < 1000) {
            if (!aiMove()) break;
            pieces++;
            if (pieces % 50 == 0) printBoard();
        }

        System.out.println("Game Over! Pieces: " + pieces + ", Lines: " + linesCleared + ", Score: " + score);
        printBoard();
    }

    public static void main(String[] args) {
        TetrisMCTS game = new TetrisMCTS();
        game.runAI();
    }
}

关键算法解析

1. MCTS四阶段循环

代码完整实现了MCTS的四个核心阶段:
– 选择阶段:从根节点开始,使用UCT公式递归选择子节点,直到叶子
– 扩展阶段:每个合法动作(旋转×列位置)预先生成为根节点的子节点
– 模拟阶段:rollout函数从当前状态出发,使用贪心启发式快速模拟后续50步
– 回溯阶段:将模拟得分累加到路径上所有节点的totalScore和visits

2. UCT探索-利用平衡

UCT公式中的第一项Q/N是利用项(选择当前平均得分高的动作),第二项C×√(ln(N_parent)/N_child)是探索项(鼓励访问次数少的动作)。代码中C=√2是理论最优值,确保在无限次模拟下收敛到最优策略。

3. 棋盘评估启发式

俄罗斯方块的评估函数参考了经典Tetris AI(如Pierre Dellacherie的算法):
– 堆积高度惩罚:防止方块堆得太高
– 不平整惩罚:鼓励保持顶部平坦
– 空洞惩罚:避免在内部产生无法填补的空隙
– 井深度惩罚:”井”(两侧高、中间低的凹槽)难以用特定方块填充

复杂度分析

指标 复杂度 说明
动作空间 O(4×10) 最多4种旋转×10列位置
MCTS迭代 O(K) K为模拟次数(通常几百到几千)
单次模拟 O(D×R×C) D为模拟深度,R×C为棋盘大小
总决策时间 O(K×D×R×C) 单次方块放置的完整评估

通过限制模拟深度为50步,AI能在几百毫秒内完成决策,满足实时性要求。

总结

本文实现了基于蒙特卡洛树搜索的俄罗斯方块AI,通过UCT公式在探索新动作和利用已知高价值动作之间取得平衡。MCTS的强大之处在于不需要精确的领域知识——仅通过随机模拟就能学习到有效的策略。结合经典的堆积高度、不平整、空洞和井深度四项启发式评估,该AI能够在标准20×10棋盘上稳定消除数百行。读者可以尝试调整UCT常数C、增加模拟次数,或引入更复杂的模拟策略(如多步前瞻)来进一步提升AI表现。