每日算法 — 使用java实现十滴水:泛洪填充与贪心策略优化

十滴水(又称 Flood It 或水滴扩散)是一款极简却极具策略深度的益智小游戏。玩家点击一个格子,该格子的水滴会向上下左右四个方向扩散,若相邻格子水滴数达到上限则连锁爆裂。本文将用 Java 实现完整的十滴水游戏 AI,核心讲解泛洪填充算法计算水滴扩散范围、贪心策略决策最优点击位置,以及连通块染色优化技巧。

一、游戏规则与状态建模

十滴水的核心规则可抽象如下:

  • 棋盘为 $N \times N$ 的方格,每个格子有 0-4 滴水
  • 点击某格子时,该格子水滴数 +1
  • 若某格子水滴数超过 4,则向四邻域各扩散 1 滴,自身归零,形成连锁反应
  • 目标通常是让棋盘全部归零,或使某个目标区域最大化清零
/**
 * 十滴水游戏状态
 */
public class FloodGame {
    public static final int SIZE = 6;      // 棋盘大小
    public static final int MAX_DROP = 4;  // 爆裂上限(四邻域格子)

    private int[][] board;
    private int steps; // 已用步数

    public FloodGame() {
        board = new int[SIZE][SIZE];
        steps = 0;
    }

    /** 深拷贝构造函数,用于搜索 */
    public FloodGame(FloodGame other) {
        this.board = new int[SIZE][SIZE];
        for (int i = 0; i < SIZE; i++)
            System.arraycopy(other.board[i], 0, this.board[i], 0, SIZE);
        this.steps = other.steps;
    }

    /** 随机初始化棋盘,每个格子 0-3 滴水 */
    public void randomInit() {
        java.util.Random rand = new java.util.Random();
        for (int i = 0; i < SIZE; i++)
            for (int j = 0; j < SIZE; j++)
                board[i][j] = rand.nextInt(MAX_DROP);
    }

    /** 获取格子水滴数 */
    public int get(int r, int c) {
        return board[r][c];
    }

    /** 设置格子水滴数 */
    public void set(int r, int c, int v) {
        board[r][c] = v;
    }

    public int getSteps() { return steps; }
}

二、泛洪填充:连锁反应模拟

点击一个格子后,连锁反应的模拟本质上是一个BFS 泛洪填充过程。我们使用队列处理所有待爆裂的格子,直到系统达到稳态。

/**
 * 四方向偏移量:上、下、左、右
 */
public class Directions {
    public static final int[][] DIR = {{-1,0},{1,0},{0,-1},{0,1}};
}

public class FloodSimulator {

    /**
     * 在指定位置点击,模拟连锁反应,返回最终状态
     * @param game 当前游戏状态
     * @param r 点击行
     * @param c 点击列
     * @return 新的游戏状态(深拷贝修改)
     */
    public static FloodGame click(FloodGame game, int r, int c) {
        FloodGame next = new FloodGame(game);
        next.set(r, c, next.get(r, c) + 1);

        // BFS 队列存储待爆裂的格子
        java.util.ArrayDeque<int[]> queue = new java.util.ArrayDeque<>();
        boolean[][] inQueue = new boolean[FloodGame.SIZE][FloodGame.SIZE];

        // 初始检查点击位置是否爆裂
        if (next.get(r, c) > FloodGame.MAX_DROP) {
            queue.add(new int[]{r, c});
            inQueue[r][c] = true;
        }

        while (!queue.isEmpty()) {
            int[] cell = queue.poll();
            int cr = cell[0], cc = cell[1];
            int drops = next.get(cr, cc);

            if (drops <= FloodGame.MAX_DROP) continue; // 已被其他连锁降低

            int overflow = drops; // 实际爆裂时全部释放
            next.set(cr, cc, 0);  // 自身归零

            // 向四邻域扩散
            for (int[] d : Directions.DIR) {
                int nr = cr + d[0], nc = cc + d[1];
                if (nr < 0 || nr >= FloodGame.SIZE || nc < 0 || nc >= FloodGame.SIZE)
                    continue;

                next.set(nr, nc, next.get(nr, nc) + 1);

                // 若邻域达到爆裂条件且未入队,加入队列
                if (next.get(nr, nc) > FloodGame.MAX_DROP && !inQueue[nr][nc]) {
                    queue.add(new int[]{nr, nc});
                    inQueue[nr][nc] = true;
                }
            }
        }

        return next;
    }
}

三、贪心策略:单步最优决策

对于大型棋盘,完整的状态空间搜索(BFS/DFS)会遭遇组合爆炸。我们采用贪心策略:在当前状态下,评估每个可点击位置的”收益”,选择收益最大的动作。

收益函数设计需综合考虑以下因素:

  1. 清零格数:连锁反应后棋盘上有多少格子归零
  2. 中心化程度:爆裂是否发生在棋盘中心(中心爆裂波及范围更大)
  3. 后续潜力:是否存在即将爆裂的”临界”格子(值为 4)
public class GreedyStrategy {

    /**
     * 评估点击 (r,c) 的收益得分
     * @param before 点击前的状态
     * @param after 点击后的状态
     * @param r 点击行
     * @param c 点击列
     * @return 收益得分(越高越好)
     */
    public static double evaluate(FloodGame before, FloodGame after, int r, int c) {
        double score = 0;
        int size = FloodGame.SIZE;

        // 1. 清零格子奖励
        int cleared = 0;
        int beforeSum = 0, afterSum = 0;
        for (int i = 0; i < size; i++) {
            for (int j = 0; j < size; j++) {
                if (before.get(i,j) > 0 && after.get(i,j) == 0) cleared++;
                beforeSum += before.get(i,j);
                afterSum += after.get(i,j);
            }
        }
        score += cleared * 10.0;

        // 2. 总水滴数减少奖励
        score += (beforeSum - afterSum) * 2.0;

        // 3. 中心化奖励:中心区域连锁收益更高
        double centerDist = Math.abs(r - size/2.0) + Math.abs(c - size/2.0);
        score += Math.max(0, (size - centerDist)) * 1.5;

        // 4. 制造临界格子(值为4)的惩罚——避免给对手创造机会
        int criticalAfter = 0;
        for (int i = 0; i < size; i++)
            for (int j = 0; j < size; j++)
                if (after.get(i,j) == FloodGame.MAX_DROP) criticalAfter++;
        score -= criticalAfter * 3.0;

        // 5. 边缘点击惩罚(边缘格子扩散范围受限)
        boolean isEdge = (r == 0 || r == size-1 || c == 0 || c == size-1);
        if (isEdge) score -= 2.0;

        return score;
    }

    /**
     * 贪心选择最优点击位置
     * @param game 当前状态
     * @return [row, col] 最优位置,若全部归零则返回 null
     */
    public static int[] chooseBestMove(FloodGame game) {
        int size = FloodGame.SIZE;
        double bestScore = Double.NEGATIVE_INFINITY;
        int[] bestMove = null;

        for (int r = 0; r < size; r++) {
            for (int c = 0; c < size; c++) {
                // 跳过已清零的格子(通常不可点击,或点击无意义)
                if (game.get(r, c) == 0) continue;

                FloodGame after = FloodSimulator.click(game, r, c);
                double score = evaluate(game, after, r, c);

                if (score > bestScore) {
                    bestScore = score;
                    bestMove = new int[]{r, c};
                }
            }
        }
        return bestMove;
    }
}

四、连通块染色优化

高级策略需要识别棋盘上的连通块——由相同水滴数或相邻关系构成的区域。通过连通块分析,可以预判一次点击能波及的最大范围。

/**
 * 连通块分析工具
 */
public class ConnectedComponents {

    /**
     * 标记所有连通块,返回每个格子所属连通块编号
     * 这里以"格子是否非零"作为连通条件(四连通)
     */
    public static int[][] labelComponents(FloodGame game) {
        int size = FloodGame.SIZE;
        int[][] labels = new int[size][size];
        for (int i = 0; i < size; i++)
            java.util.Arrays.fill(labels[i], -1);

        int label = 0;
        for (int r = 0; r < size; r++) {
            for (int c = 0; c < size; c++) {
                if (game.get(r, c) > 0 && labels[r][c] == -1) {
                    // BFS 标记整个连通块
                    java.util.ArrayDeque<int[]> q = new java.util.ArrayDeque<>();
                    q.add(new int[]{r, c});
                    labels[r][c] = label;
                    int count = 0;

                    while (!q.isEmpty()) {
                        int[] cell = q.poll();
                        count++;
                        int cr = cell[0], cc = cell[1];

                        for (int[] d : Directions.DIR) {
                            int nr = cr + d[0], nc = cc + d[1];
                            if (nr >= 0 && nr < size && nc >= 0 && nc < size
                                && game.get(nr, nc) > 0 && labels[nr][nc] == -1) {
                                labels[nr][nc] = label;
                                q.add(new int[]{nr, nc});
                            }
                        }
                    }
                    label++;
                }
            }
        }
        return labels;
    }

    /**
     * 计算连通块大小分布
     */
    public static java.util.Map<Integer, Integer> componentSizes(int[][] labels) {
        java.util.Map<Integer, Integer> sizes = new java.util.HashMap<>();
        for (int[] row : labels) {
            for (int l : row) {
                if (l >= 0) sizes.merge(l, 1, Integer::sum);
            }
        }
        return sizes;
    }
}

五、完整游戏引擎与自动求解

public class FloodGameEngine {
    private FloodGame game;

    public FloodGameEngine() {
        game = new FloodGame();
        game.randomInit();
    }

    public void printBoard() {
        System.out.println("  0 1 2 3 4 5");
        for (int r = 0; r < FloodGame.SIZE; r++) {
            System.out.print(r + " ");
            for (int c = 0; c < FloodGame.SIZE; c++) {
                System.out.print(game.get(r, c) + " ");
            }
            System.out.println();
        }
        System.out.println();
    }

    /** 检查是否全部清零 */
    public boolean isCleared() {
        for (int r = 0; r < FloodGame.SIZE; r++)
            for (int c = 0; c < FloodGame.SIZE; c++)
                if (game.get(r, c) > 0) return false;
        return true;
    }

    /** 执行一步贪心点击 */
    public boolean step() {
        if (isCleared()) return false;
        int[] move = GreedyStrategy.chooseBestMove(game);
        if (move == null) return false;

        System.out.printf("Step: click (%d, %d)%n", move[0], move[1]);
        game = FloodSimulator.click(game, move[0], move[1]);
        printBoard();
        return true;
    }

    /** 自动求解直到清零或无法继续 */
    public void autoSolve() {
        System.out.println("Initial board:");
        printBoard();
        int maxSteps = 50;
        for (int i = 0; i < maxSteps && step(); i++) {
            if (isCleared()) {
                System.out.println("Cleared in " + (i+1) + " steps!");
                return;
            }
        }
        System.out.println("Could not clear completely.");
    }

    public static void main(String[] args) {
        FloodGameEngine engine = new FloodGameEngine();
        engine.autoSolve();
    }
}

六、复杂度分析

模块 时间复杂度 空间复杂度 说明
单次点击模拟 O(N²) O(N²) BFS 最坏遍历全棋盘
贪心单步决策 O(N⁴) O(N²) 需评估 N² 个位置,每个模拟 O(N²)
连通块标记 O(N²) O(N²) 一次 BFS/DFS 遍历
完整贪心求解 O(K·N⁴) O(N²) K 为实际步数

对于 $6 \times 6$ 的棋盘,贪心策略的每步决策约需 $6^4 = 1296$ 次基本操作,在毫秒级完成。若棋盘扩大到 $10 \times 10$,则每步约 $10^4 = 10000$ 次操作,仍可接受。

七、策略升级方向

  1. 蒙特卡洛树搜索(MCTS):每步模拟数千次随机 rollout,用 UCB 公式平衡探索与利用,显著提升胜率。
  2. 值函数近似:用神经网络评估盘面状态,替代手工设计的收益函数。
  3. 最小步数求解:将问题建模为最短路径问题,用 A* 搜索配合启发式函数(如剩余水滴总数)寻找最优解。

十滴水的魅力在于极简规则下蕴含的复杂动态。泛洪填充模拟连锁反应、贪心策略快速决策、连通块分析预判局势——这套算法组合拳不仅适用于水滴游戏,也是处理级联故障、病毒传播模拟等现实问题的经典范式。