引言
俄罗斯方块(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 的消行上限。