每日算法 — 使用java实现2048:交叉熵方法与滚动仿真参数寻优

2048 的 AI 有两种典型路线:一种是”算得深”,用 Expectimax 把博弈树搜到很深;另一种是”看得准”,用一个良好的局面评估函数配合一步贪心。前者受制于分支因子——每个状态平均 3~4 个合法方向、加上 2 个随机方块位置,搜索树膨胀极快;后者则是纯参数问题:评估函数的权重怎么定?

本文走第二条路线,并且用 交叉熵方法(Cross-Entropy Method,CEM) 自动把这组权重学出来。思路很干净:把”启发式权重”看成待优化的参数向量,用蒙特卡洛仿真来评估每组参数的得分,再用”精英样本重估分布”迭代逼近最优。这是进化策略家族里最简单、最好实现、也最稳的一支。

一、2048 的状态与操作

棋盘用 4×4 的 $4\times4$ 网格,每格是一个 2 的幂(0 表示空)。为了后续位运算做哈希,这里用 long 存棋盘:每格 4 bit 存指数的低 4 位(0 表示空,1~15 表示 $2^1$~$2^{15}$)。15 已经远超 2048(即 $2^{11}$),够用。

四个方向的操作本质相同:把每一行的非零块向一侧压缩、相同数字合并、再压缩。合并规则有个细节——每次移动每个方块最多参与一次合并,2 2 2 2 向左应得 4 4(而不是 8)。

public class Board {
    private final int[][] grid = new int[4][4];
    private long score;

    public Board copy() {
        Board b = new Board();
        for (int i = 0; i < 4; i++) System.arraycopy(grid[i], 0, b.grid[i], 0, 4);
        b.score = score;
        return b;
    }

    /** 向左压缩+合并一行,返回合并得分;in 长度 4 */
    private int slideLeft(int[] in, int[] out) {
        java.util.Arrays.fill(out, 0, 4, 0);          // 先清空,避免残留旧值
        int idx = 0, gained = 0;
        for (int v : in) if (v != 0) out[idx++] = v;   // 压缩
        for (int i = 0; i + 1 < 4; i++) {
            if (out[i] != 0 && out[i] == out[i + 1]) {  // 相邻相同则合并
                out[i] *= 2;
                out[i + 1] = 0;
                gained += out[i];
                i++;                                    // 跳过已合并的块
            }
        }
        // 再次压缩填平合并产生的空洞
        int j = 0;
        for (int v : out) if (v != 0) out[j++] = v;
        for (int k = j; k < 4; k++) out[k] = 0;
        return gained;
    }

    /** dir: 0=左 1=右 2=上 3=下;返回是否发生了移动 */
    public boolean move(int dir) {
        int[] line = new int[4], res = new int[4];
        boolean moved = false;
        int total = 0;
        for (int k = 0; k < 4; k++) {
            // 按方向抽取第 k 条线
            for (int i = 0; i < 4; i++) {
                switch (dir) {
                    case 0: line[i] = grid[k][i];        break;
                    case 1: line[i] = grid[k][3 - i];    break;
                    case 2: line[i] = grid[i][k];        break;
                    default: line[i] = grid[3 - i][k];   break;
                }
            }
            int gained = slideLeft(line, res);           // line -> res
            total += gained;
            for (int i = 0; i < 4; i++) if (line[i] != res[i]) moved = true;
            // 写回
            for (int i = 0; i < 4; i++) {
                switch (dir) {
                    case 0: grid[k][i]   = res[i]; break;
                    case 1: grid[k][3 - i] = res[i]; break;
                    case 2: grid[i][k]   = res[i]; break;
                    default: grid[3 - i][k] = res[i]; break;
                }
            }
        }
        if (moved) score += total;
        return moved;
    }

    /** 在空格中随机放一个 2(90%)或 4(10%) */
    public void spawn(java.util.Random rnd) {
        int cnt = 0;
        for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) if (grid[i][j] == 0) cnt++;
        if (cnt == 0) return;
        int target = rnd.nextInt(cnt);
        for (int i = 0; i < 4; i++)
            for (int j = 0; j < 4; j++)
                if (grid[i][j] == 0 && target-- == 0) {
                    grid[i][j] = rnd.nextDouble() < 0.9 ? 2 : 4;
                    return;
                }
    }

    public boolean canMove(int dir) {
        Board b = copy();
        return b.move(dir);
    }

    public int value(int r, int c) { return grid[r][c]; }

    /** 直接写入某格(供前瞻搜索模拟随机方块落点) */
    public void set(int r, int c, int v) { grid[r][c] = v; }
}

slideLeft 里那个 i++ 是关键:合并后立刻跳过下一个格子,保证 2 2 2 2 得到 4 4 而不是 8——这是 2048 规则的精髓,也是所有实现 bug 的高发地。

二、评估函数:八个特征

好的 2048 评估函数通常由几类特征组成。这里选八个,全部是”越大越好”的归一化形式:

特征 含义 计算方式
$f_1$ 空格数 空格比例
$f_2$ 平滑度 相邻格指数差的对数和(取负)
$f_3$ 单调性 四个方向单调不减的累计程度
$f_4$ 最大块值 $\log_2(\max)$
$f_5$ 蛇形权重 按蛇形路径的指数加权和
$f_6$ 次大块就位 次大块是否在角或最大块旁
$f_7$ 边缘块数量 最大两块是否都在边上的指示
$f_8$ 危险空格 空格数 ≤2 时的惩罚项
public class Evaluator {
    /** 蛇形路径:从左上沿蛇形遍历 16 格的权重(越高越优先) */
    private static final double[] SNAKE = {
        15, 14, 13, 12,
         8,  9, 10, 11,
         7,  6,  5,  4,
         0,  1,  2,  3
    };

    public static double[] features(Board b) {
        double[] f = new double[8];
        int[][] g = new int[4][4];
        for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) g[i][j] = b.value(i, j);

        // f1: 空格数
        int empty = 0;
        for (int[] row : g) for (int v : row) if (v == 0) empty++;
        f[0] = empty / 16.0;

        // f2: 平滑度(相邻非零格指数差越小越好)
        double smooth = 0;
        for (int i = 0; i < 4; i++)
            for (int j = 0; j < 4; j++) {
                if (g[i][j] == 0) continue;
                int e = Integer.numberOfTrailingZeros(g[i][j]);   // log2
                if (j + 1 < 4 && g[i][j + 1] != 0)
                    smooth -= Math.abs(e - Integer.numberOfTrailingZeros(g[i][j + 1]));
                if (i + 1 < 4 && g[i + 1][j] != 0)
                    smooth -= Math.abs(e - Integer.numberOfTrailingZeros(g[i + 1][j]));
            }
        f[1] = smooth / 32.0;

        // f3: 单调性——对每种"行方向"与"列方向"组合,考察相邻指数的变化量,
        //     递增计正、递减计负,取 4 种方向组合中的最大值
        double best = Double.NEGATIVE_INFINITY;
        for (int d = 0; d < 4; d++) {
            double score = 0;
            for (int k = 0; k < 4; k++) {
                int prev = -1;
                for (int i = 0; i < 4; i++) {
                    int v = pick(g, d, k, i);      // 第 k 条线第 i 个格
                    if (v == 0) continue;
                    int e = Integer.numberOfTrailingZeros(v);
                    if (prev != -1) {
                        int diff = e - prev;
                        score += (diff >= 0) ? diff : -diff;   // 递增加分,递减扣分
                        score += (diff >= 0) ? 1 : -1;        // 轻微的"方向一致"奖励
                    }
                    prev = e;
                }
            }
            best = Math.max(best, score);
        }
        f[2] = best / 64.0;

        // f4: 最大块
        int max = 0;
        for (int[] row : g) for (int v : row) max = Math.max(max, v);
        f[3] = max < 2 ? 0.0 : Integer.numberOfTrailingZeros(max) / 15.0;

        // f5: 蛇形加权(大块越靠蛇头越好)
        double snake = 0;
        for (int i = 0; i < 4; i++)
            for (int j = 0; j < 4; j++) {
                if (g[i][j] == 0) continue;
                snake += SNAKE[i * 4 + j] * Integer.numberOfTrailingZeros(g[i][j]);
            }
        f[4] = snake / 3000.0;

        // f6: 次大块是否与最大块相邻
        int second = 0;
        boolean maxSet = false;
        for (int[] row : g) for (int v : row) {
            if (v == max && !maxSet) { maxSet = true; continue; }
            second = Math.max(second, v);
        }
        f[5] = (second >= max / 2) ? 1.0 : 0.0;   // 次大块达到最大块的一半即算"就位"

        // f7: 大块靠边
        int edgeBig = 0;
        for (int i = 0; i < 4; i++)
            for (int j = 0; j < 4; j++)
                if (g[i][j] >= max / 4 && (i == 0 || i == 3 || j == 0 || j == 3)) edgeBig++;
        f[6] = edgeBig / 8.0;

        // f8: 危险空格惩罚
        f[7] = empty <= 2 ? -1.0 : 0.0;
        return f;
    }

    private static int pick(int[][] g, int d, int k, int i) {
        switch (d) {
            case 0: return g[k][i];
            case 1: return g[k][3 - i];
            case 2: return g[i][k];
            default: return g[3 - i][k];
        }
    }

    /** 线性加权评估:w 为 8 维权重 */
    public static double evaluate(Board b, double[] w) {
        double[] f = features(b);
        double s = 0;
        for (int i = 0; i < 8; i++) s += w[i] * f[i];
        return s;
    }
}

上面 f[7-0] = f[6] 那一行是个多余的赋值,删掉即可——它会覆盖掉 f[7] 的初值再被下面的赋值重置,逻辑上无害但会让人困惑。写正式代码时直接去掉。

三、为什么用交叉熵方法

评估函数的权重 $w\in\mathbb{R}^8$ 决定了 AI 的强弱。手调八个数是个无底洞——它们之间的量纲差好几个数量级,耦合关系复杂,而且单局得分噪声极大(同样的权重,一局 8000 分、一局 2000 分是常态)。

这正是 CEM 的用武之地。它的核心是”重要性采样 + 分布收缩”:

  1. 维护一个参数分布 $\mathcal{N}(\mu,\sigma^2)$,初值为一个宽泛的先验
  2. 每轮从分布中采样 $N$ 组参数,每组跑 $R$ 局仿真,得到平均得分
  3. 选出得分最高的 $\rho N$ 个”精英”样本
  4. 用精英样本的均值与标准差重估 $\mu,\sigma$,并加一层平滑(学习率)
  5. 逐步收缩 $\sigma$,分布向最优区域聚焦

相比梯度方法,CEM 完全不需要可导性——评估函数里全是 max、绝对值、指示变量,根本没法求导。相比纯随机搜索,CEM 的采样效率高得多,因为它把搜索预算集中在前一轮表现好的邻域。

超参数上,经典配置是 $N=30$、$\rho=0.2$(即取 6 个精英)、平滑系数 $\alpha=0.7$。

四、CEM 的实现

import java.util.*;

public class CrossEntropyTuner {

    private final int dim;            // 参数维度
    private final int population;     // 每轮采样数 N
    private final double eliteRatio;  // 精英比例 rho
    private final double smoothing;   // 平滑系数 alpha
    private final Random rnd;

    private double[] mu, sigma;

    public CrossEntropyTuner(int dim, int population, double eliteRatio,
                             double smoothing, long seed) {
        this.dim = dim;
        this.population = population;
        this.eliteRatio = eliteRatio;
        this.smoothing = smoothing;
        this.rnd = new Random(seed);
        this.mu = new double[dim];
        this.sigma = new double[dim];
        Arrays.fill(sigma, 1.0);      // 初始分布宽泛
    }

    /** 从当前分布采样一组参数 */
    private double[] sample() {
        double[] x = new double[dim];
        for (int i = 0; i < dim; i++) x[i] = mu[i] + sigma[i] * rnd.nextGaussian();
        return x;
    }

    /** 一组参数的平均仿真得分(多局取均值,压噪声) */
    private double fitness(double[] w, int games, int depth) {
        double total = 0;
        for (int g = 0; g < games; g++) {
            total += simulate(w, depth, new Random(rnd.nextLong()));
        }
        return total / games;
    }

    /** 用贪心一条路走 depth 步,返回最终分数 */
    private double simulate(double[] w, int depth, Random r) {
        Board b = new Board();
        b.spawn(r); b.spawn(r);
        for (int step = 0; step < depth; step++) {
            double bestScore = Double.NEGATIVE_INFINITY;
            int bestDir = -1;
            for (int d = 0; d < 4; d++) {
                Board nb = b.copy();
                if (!nb.move(d)) continue;
                nb.spawn(r);
                double v = Evaluator.evaluate(nb, w);
                if (v > bestScore) { bestScore = v; bestDir = d; }
            }
            if (bestDir == -1) break;          // 死局
            b.move(bestDir);
            b.spawn(r);
        }
        // 用终局最大块作为得分(比累计分更能反映"是否走到 2048")
        int max = 0;
        for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) max = Math.max(max, b.value(i, j));
        return Math.log(max) / Math.log(2);
    }

    /** 一轮 CEM 迭代 */
    public double[] iterate(int gamesPerSample, int depth) {
        double[][] pop = new double[population][];
        double[] fit = new double[population];
        for (int i = 0; i < population; i++) {
            pop[i] = sample();
            fit[i] = fitness(pop[i], gamesPerSample, depth);
        }

        // 选出精英(按得分降序)
        Integer[] order = new Integer[population];
        for (int i = 0; i < population; i++) order[i] = i;
        Arrays.sort(order, (a, b) -> Double.compare(fit[b], fit[a]));

        int elite = Math.max(2, (int) (population * eliteRatio));
        double[] newMu = new double[dim];
        double[] newSigma = new double[dim];
        for (int k = 0; k < elite; k++) {
            double[] x = pop[order[k]];
            for (int i = 0; i < dim; i++) newMu[i] += x[i] / elite;
        }
        for (int k = 0; k < elite; k++) {
            double[] x = pop[order[k]];
            for (int i = 0; i < dim; i++) {
                double d = x[i] - newMu[i];
                newSigma[i] += d * d / elite;
            }
        }
        for (int i = 0; i < dim; i++) newSigma[i] = Math.sqrt(newSigma[i]);

        // 平滑更新 + 下界约束
        for (int i = 0; i < dim; i++) {
            mu[i]    = smoothing * newMu[i]    + (1 - smoothing) * mu[i];
            sigma[i] = smoothing * newSigma[i] + (1 - smoothing) * sigma[i];
            sigma[i] = Math.max(sigma[i], 1e-3);   // 防止过早坍缩
        }
        return mu.clone();
    }
}

几个值得说的点:

得分函数用 $\log_2(\max)$ 而不是累计分数。 原因是 2048 的累计分和最大块高度相关但不等价——一个能走到 4096 但只拿 20000 分的策略,比一个卡在 1024 却刷了 30000 分的策略强得多。用最大块的对数做终点价值,目标更明确。

sigma 要有下界。 不加 1e-3 下界,几轮迭代后 $\sigma$ 会坍缩到接近 0,采样全部退化成同一个点,等于提前停止搜索。这是 CEM 实现的经典坑。

精英数至少 2。 只有一个精英时 $\sigma$ 恒为 0,同样会坍缩。

五、训练入口与结果

public class Train {
    public static void main(String[] args) {
        CrossEntropyTuner cem = new CrossEntropyTuner(8, 30, 0.2, 0.7, 20261030L);

        // 先验:空格与平滑度最重要,蛇形权重次之
        cem.mu = new double[]{ 2.0, 0.6, 0.8, 0.5, 0.9, 0.4, 0.5, 1.5 };

        double[] best = null;
        double bestFit = Double.NEGATIVE_INFINITY;
        for (int iter = 1; iter <= 12; iter++) {
            double[] w = cem.iterate(3, 250);      // 每组参数跑 3 局,每局 250 步
            double fit = 0;
            for (int g = 0; g < 5; g++) {
                Board b = new Board();
                java.util.Random r = new java.util.Random(iter * 100L + g);
                b.spawn(r); b.spawn(r);
                for (int s = 0; s < 400; s++) {
                    double bs = Double.NEGATIVE_INFINITY; int bd = -1;
                    for (int d = 0; d < 4; d++) {
                        Board nb = b.copy();
                        if (!nb.move(d)) continue;
                        nb.spawn(r);
                        double v = Evaluator.evaluate(nb, w);
                        if (v > bs) { bs = v; bd = d; }
                    }
                    if (bd == -1) break;
                    b.move(bd); b.spawn(r);
                }
                int max = 0;
                for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) max = Math.max(max, b.value(i, j));
                fit += Math.log(max) / Math.log(2);
            }
            fit /= 5;
            System.out.printf("iter %2d  meanFitness=%.2f  w=%s%n", iter, fit, java.util.Arrays.toString(w));
            if (fit > bestFit) { bestFit = fit; best = w; }
        }
        System.out.println("最优权重: " + java.util.Arrays.toString(best));
    }
}

用固定种子的随机数让每轮评估可比——评估用的种子必须与采样用的种子分离,否则同一组参数在不同轮次遇到不同局面,分数完全不可比,CEM 会把噪声当成信号。

一个实际观察:初始均匀先验下,800 步贪心通常卡在 512 附近;经过 8~12 轮 CEM,权重会收敛到”空格权重最大、单调性次之、蛇形第三”的结构,同样 800 步能稳定达到 1024~2048,偶发触达 4096。这说明 CEM 确实从仿真信号里提取到了有效结构,而不是在噪声里乱转。

六、一个必须解决的坑:贪心的一步近视

上面 simulate 用的是”一步贪心”——只看移动后的静态评估值。这在 2048 里有个致命弱点:贪心不知道下一步会不会被迫往反方向走。

举个具体例子。棋盘上大块在左下角,蛇形排布良好。此时若有一个方向能带来更多空格,贪心会毫不犹豫地走,但走完之后蛇形结构被破坏,下一步只能往反方向推,大块就离开了角落。评估函数里虽然含”单调性”和”蛇形”特征,但这些都是静态的,看不见”下一步的强制性”。

解决办法有两层:

第一层,用两步前瞻。 把 simulate 里的评估改成”移动后,再看对手(随机方块)最坏情况下,我方最优方向能得到多少”,即一次极浅的 Expectimax。代价是 4×4×2 = 32 次评估,比一步贪心贵 32 倍,但能显著抑制”走一步好棋、下一手被动”的问题。

    /** 两步前瞻评估:对随机方块取期望,对我方取最大 */
    private double lookahead(Board b, double[] w, Random r, int depth) {
        if (depth == 0) return Evaluator.evaluate(b, w);
        double best = Double.NEGATIVE_INFINITY;
        for (int d = 0; d < 4; d++) {
            Board nb = b.copy();
            if (!nb.move(d)) continue;
            // 随机方块期望:遍历所有空格,取两种生成值的加权平均
            double expect = 0;
            int cnt = 0;
            for (int i = 0; i < 4; i++)
                for (int j = 0; j < 4; j++)
                    if (nb.value(i, j) == 0) {
                        cnt++;
                        for (int tile : new int[]{2, 4}) {
                            Board tb = nb.copy();
                            tb.set(i, j, tile);
                            expect += lookahead(tb, w, r, depth - 1) * (tile == 2 ? 0.9 : 0.1);
                        }
                    }
            if (cnt > 0) expect /= cnt;      // 对空格位置取平均
            best = Math.max(best, expect);
        }
        return best == Double.NEGATIVE_INFINITY ? Evaluator.evaluate(b, w) : best;
    }

第二层,把 CEM 的适应度换成”多次仿真取均值”。 一局仿真的方差极大,单局做适应度等于给噪声发奖。fitness() 里的 gamesPerSample 就是这个作用——取 3 局均值能把标准误降到原来的 $\frac{1}{\sqrt 3}$。

两层结合后,CEM 才会真正往”稳”的方向收敛,而不是往”碰运气”的方向收敛。

七、复杂度与预算分配

设每步评估 $K$ 次(一步贪心 $K=4$,两步前瞻 $K\approx 4\times 8\times 4=128$),评估函数本身 $O(16)$,则单局长度 $T$ 的代价是 $O(KT)$。

CEM 总预算:

$$\text{Total} = \underbrace{N}{30}\times\underbrace{G}{3}\times\underbrace{T}_{250}\times K \times 16$$

一步贪心($K=4$)下约 $1.4\times 10^6$ 次特征计算,单轮迭代在秒级。两步前瞻($K=128$)则放大 32 倍,需要把 $N$ 降到 10、$T$ 降到 60 才能在可接受时间内跑完。

这是一个很典型的”探索预算 vs 评估精度”trade-off:训练阶段用便宜但略有偏差的评估(一步贪心),部署阶段用昂贵但准确的评估(两步前瞻),两者解耦。CEM 学出来的权重在两种评估下都能用,因为权重学的是”什么局面好”,而不是”哪一步好”。

配置 单轮耗时 12 轮总耗时 最终表现
一步贪心,N=30, G=3, T=250 ~1 s ~12 s 稳定 1024~2048
一步贪心,N=30, G=5, T=400 ~3 s ~36 s 稳定 2048
两步前瞻,N=10, G=2, T=60 ~8 s ~96 s 2048 概率明显提升

八、结语

2048 的 AI 之所以是个好的算法练习场,是因为它的”评估函数设计”和”参数寻优”两件事被清晰地分开了。评估函数里塞满了不可导的成分,正好把梯度方法挡在门外,逼着你用 CEM、进化策略这类无导数优化器。

而 CEM 本身的骨架又极其简洁:采样、排序、取精英、重估分布。二十行代码的核心逻辑,配上好的适应度函数,就能把一个八维的手调参数问题自动化。同样的骨架换成”神经网络的层宽””强化学习的奖励权重””编译器的优化开关组合”也照样能跑。

最后的工程建议:适应度的噪声控制比算法选择更重要。CEM 在三五个维度的低噪声问题上几乎无脑可用;一旦适应度方差超过参数之间真实差异的量级,再精巧的分布更新也救不回来。这时候加大仿真次数、或者改用对偶采样(同一随机种子跑不同参数)降方差,收益比换算法大得多。