每日算法 — 使用java实现数字合并:滑动合并与启发式评估

引言

数字合并是一类经典的滑动益智游戏:玩家在网格上滑动数字方块,相同数字碰撞时合并为它们的和,目标是生成尽可能大的数字或填满特定目标值。本文用Java实现一个通用的数字合并引擎,重点讲解网格状态转移、滑动合并算法、空位生成策略以及基于蒙特卡洛模拟的启发式AI决策。

核心机制

游戏在 N × N 网格上进行,每次滑动(上/下/左/右)使所有方块向该方向移动并合并:
1. 移动:非零方块向滑动方向靠拢,中间不留空位。
2. 合并:相邻且值相同的方块碰撞时合并,得分增加合并后的值。每步中每个方块仅参与一次合并。
3. 生成:每次有效滑动后,在随机空位以给定概率生成新方块(通常为2或4)。

滑动合并算法

以向左滑动为例,对每一行执行:
1. 压缩:将非零元素左移,去除中间空位。
2. 合并:从左到右扫描,若相邻两元素相等则合并,右置零。
3. 再压缩:将合并产生的零位移除。

// 单行向左滑动示例
int[] line = {2, 0, 2, 4};
// 压缩: [2, 2, 4, 0]
// 合并: [4, 0, 4, 0]
// 再压缩: [4, 4, 0, 0]

Java实现

import java.util.*;

/**
 * 数字合并游戏引擎:滑动、合并、AI决策
 */
public class NumberMergeGame {
    private int size;
    private int[][] board;
    private Random random;
    private int score;
    private boolean moved; // 标记本轮是否发生移动

    public NumberMergeGame(int size) {
        this.size = size;
        this.board = new int[size][size];
        this.random = new Random();
        this.score = 0;
        spawnTile();
        spawnTile();
    }

    /**
     * 执行滑动操作
     * @param direction 0=左, 1=上, 2=右, 3=下
     * @return 是否成功移动
     */
    public boolean move(int direction) {
        moved = false;
        // 根据方向选择遍历策略
        switch (direction) {
            case 0: // 左
                for (int r = 0; r < size; r++) board[r] = slideAndMerge(board[r]);
                break;
            case 1: // 上
                for (int c = 0; c < size; c++) {
                    int[] col = extractColumn(c);
                    col = slideAndMerge(col);
                    setColumn(c, col);
                }
                break;
            case 2: // 右
                for (int r = 0; r < size; r++) {
                    int[] reversed = reverse(board[r]);
                    reversed = slideAndMerge(reversed);
                    board[r] = reverse(reversed);
                }
                break;
            case 3: // 下
                for (int c = 0; c < size; c++) {
                    int[] col = extractColumn(c);
                    col = reverse(col);
                    col = slideAndMerge(col);
                    col = reverse(col);
                    setColumn(c, col);
                }
                break;
        }
        if (moved) {
            spawnTile();
            return true;
        }
        return false;
    }

    /**
     * 单行/列的滑动与合并
     */
    private int[] slideAndMerge(int[] line) {
        int[] filtered = Arrays.stream(line).filter(v -> v != 0).toArray();
        int[] merged = new int[filtered.length];
        int idx = 0;
        for (int i = 0; i < filtered.length; i++) {
            if (i + 1 < filtered.length && filtered[i] == filtered[i + 1]) {
                merged[idx++] = filtered[i] * 2;
                score += filtered[i] * 2;
                i++; // 跳过下一个已合并元素
                moved = true;
            } else {
                merged[idx++] = filtered[i];
            }
        }
        // 检测是否发生位置变化
        int[] result = new int[size];
        for (int i = 0; i < idx; i++) result[i] = merged[i];
        for (int i = 0; i < size; i++) {
            if (result[i] != line[i]) moved = true;
        }
        return result;
    }

    private int[] extractColumn(int c) {
        int[] col = new int[size];
        for (int r = 0; r < size; r++) col[r] = board[r][c];
        return col;
    }

    private void setColumn(int c, int[] col) {
        for (int r = 0; r < size; r++) board[r][c] = col[r];
    }

    private int[] reverse(int[] arr) {
        int[] rev = new int[arr.length];
        for (int i = 0; i < arr.length; i++) rev[i] = arr[arr.length - 1 - i];
        return rev;
    }

    /**
     * 在随机空位生成新方块(90%概率为2,10%概率为4)
     */
    private void spawnTile() {
        List<int[]> empty = new ArrayList<>();
        for (int r = 0; r < size; r++)
            for (int c = 0; c < size; c++)
                if (board[r][c] == 0) empty.add(new int[]{r, c});
        if (empty.isEmpty()) return;
        int[] pos = empty.get(random.nextInt(empty.size()));
        board[pos[0]][pos[1]] = random.nextDouble() < 0.9 ? 2 : 4;
    }

    /**
     * 启发式评估函数:综合空位数、单调性、平滑度、最大值位置
     */
    public double evaluate() {
        int empty = countEmpty();
        double smoothness = calculateSmoothness();
        double monotonicity = calculateMonotonicity();
        double maxCorner = maxInCorner();
        return empty * 100 + smoothness * 10 + monotonicity * 50 + maxCorner * 1000;
    }

    private int countEmpty() {
        int count = 0;
        for (int[] row : board)
            for (int v : row)
                if (v == 0) count++;
        return count;
    }

    private double calculateSmoothness() {
        double smooth = 0;
        for (int r = 0; r < size; r++) {
            for (int c = 0; c < size - 1; c++) {
                smooth -= Math.abs(board[r][c] - board[r][c + 1]);
            }
        }
        for (int c = 0; c < size; c++) {
            for (int r = 0; r < size - 1; r++) {
                smooth -= Math.abs(board[r][c] - board[r + 1][c]);
            }
        }
        return smooth;
    }

    private double calculateMonotonicity() {
        // 优先保持行/列单调递减(大数在一角)
        double mono = 0;
        for (int r = 0; r < size; r++) {
            for (int c = 0; c < size - 1; c++) {
                if (board[r][c] >= board[r][c + 1]) mono++;
            }
        }
        return mono;
    }

    private double maxInCorner() {
        int max = 0;
        for (int[] row : board)
            for (int v : row)
                max = Math.max(max, v);
        if (board[0][0] == max || board[0][size-1] == max
                || board[size-1][0] == max || board[size-1][size-1] == max)
            return 1;
        return 0;
    }

    /**
     * 蒙特卡洛模拟:对每个合法移动模拟若干随机对局,选平均分最高的
     */
    public int monteCarloMove(int simulations) {
        double[] scores = new double[4];
        for (int dir = 0; dir < 4; dir++) {
            int[][] backup = copyBoard();
            if (!move(dir)) {
                board = backup;
                scores[dir] = Double.NEGATIVE_INFINITY;
                continue;
            }
            double total = evaluate();
            for (int sim = 0; sim < simulations; sim++) {
                total += randomPlayout();
                board = copyBoard(); // 恢复状态
            }
            scores[dir] = total / (simulations + 1);
            board = backup;
        }
        int bestDir = 0;
        for (int i = 1; i < 4; i++)
            if (scores[i] > scores[bestDir]) bestDir = i;
        return bestDir;
    }

    /**
     * 随机对局直到结束,返回最终得分
     */
    private double randomPlayout() {
        int steps = 0;
        while (steps < 50) {
            List<Integer> valid = new ArrayList<>();
            for (int d = 0; d < 4; d++) {
                int[][] backup = copyBoard();
                if (move(d)) valid.add(d);
                board = backup;
            }
            if (valid.isEmpty()) break;
            move(valid.get(random.nextInt(valid.size())));
            steps++;
        }
        return evaluate();
    }

    private int[][] copyBoard() {
        int[][] copy = new int[size][size];
        for (int r = 0; r < size; r++)
            System.arraycopy(board[r], 0, copy[r], 0, size);
        return copy;
    }

    public boolean canMove() {
        for (int d = 0; d < 4; d++) {
            int[][] backup = copyBoard();
            boolean ok = move(d);
            board = backup;
            if (ok) return true;
        }
        return false;
    }

    public void printBoard() {
        System.out.println("Score: " + score);
        for (int[] row : board) {
            for (int v : row) {
                System.out.printf("%6d", v);
            }
            System.out.println();
        }
    }

    public static void main(String[] args) {
        NumberMergeGame game = new NumberMergeGame(4);
        while (game.canMove()) {
            game.printBoard();
            int dir = game.monteCarloMove(20);
            String[] names = {"左", "上", "右", "下"};
            System.out.println("AI选择: " + names[dir]);
            game.move(dir);
            System.out.println();
        }
        game.printBoard();
        System.out.println("游戏结束!");
    }
}

启发式评估详解

数字合并的AI性能高度依赖评估函数。上述实现采用四因子加权:
1. 空位数:保留更多操作空间,权重最高。
2. 平滑度:相邻格子差值越小越好,利于未来合并。
3. 单调性:行/列呈单调递减,使大数向角落聚集。
4. 最大值在角落:防止大数被锁死在中部。

蒙特卡洛模拟在此基础上进行短局 rollout,对随机生成的不确定性进行统计平均,显著提升决策稳定性。

扩展方向

  • ** 期望Minimax**:将随机生成建模为期望值节点,构建深度为3-4的博弈树。
  • ** 神经网络评估**:用TD-Learning训练浅层网络替代手工评估函数。
  • ** 变体规则**:支持三向合并、障碍格、目标值挑战等扩展模式。