每日算法 — 使用java实现消消乐:Flood Fill连通检测与级联消除算法

游戏简介与算法目标

消消乐(Match-3)是一款家喻户晓的益智消除类游戏,玩家通过交换相邻的两个宝石,使三个或三个以上同色宝石连成一线即可消除得分。消除后上方的宝石受重力影响下落填补空缺,新的宝石从顶部生成,由此可能触发连续消除——即”级联消除”(Cascade),带来额外分数奖励。

本文的目标是剥离绚丽的界面特效,聚焦于消消乐的核心算法逻辑,用 Java 实现一个可在控制台运行的完整版本。读者将学到以下四种关键算法:

  1. Flood Fill 连通分量检测:通过 DFS 找出棋盘上所有同色连通的宝石簇,判定哪些满足消除条件(数量 ≥ 3)。
  2. 重力模拟算法:消除后让上方宝石逐格下落,并在顶部生成新宝石。
  3. 级联消除检测:循环执行”检测→消除→重力”,直到没有新的匹配产生。
  4. 贪心最优消除策略:评估所有合法的相邻交换,选择能触发最多消除或最高得分的步。

核心算法一:Flood Fill 连通分量检测

消消乐的第一步是判断”当前棋盘上有哪些宝石可以消除”。这本质上是一个连通分量(Connected Component)问题:将棋盘视为无向图,每个格子与其上下左右相邻且颜色相同的格子之间存在边。我们需要找出所有大小不小于 3 的连通分量。

Flood Fill(洪水填充)算法是解决此类问题的经典方案。它从每一个未访问的格子出发,使用深度优先搜索(DFS)或广度优先搜索(BFS)向四周”漫延”,标记所有颜色相同且可达的格子,从而得到该连通分量的全部成员。

算法步骤

  1. 创建一个与棋盘等大的 visited 布尔矩阵,初始全为 false
  2. 遍历棋盘的每一个格子 (r, c),若该格子未被访问:
  3. 以其为起点执行 DFS/BFS,收集所有同色连通格子。
  4. 若连通分量的大小 ≥ 3,则记录为一个”匹配”。
  5. 返回所有匹配列表。

该算法的时间复杂度为 O(ROWS × COLS),因为每个格子最多被访问一次。

核心算法二:重力模拟与级联消除

当一组宝石被消除后,它们所在的位置变成”空洞”。为了让游戏继续进行,需要模拟重力效果:每一列中,空洞下方的宝石应向上移动填补空缺,空缺位置则由顶部新随机生成的宝石填充。

重力模拟步骤

对每一列 c 独立处理:
1. 从底部向上遍历,将所有非空宝石按从下到上的顺序”压缩”到列底。
2. 列顶剩余的空位用随机颜色的新宝石填充。

级联消除

重力填充后,新落下的宝石可能与周围宝石形成新的匹配。因此需要循环执行:

while (存在匹配) {
    消除匹配宝石;
    应用重力;
    级联计数器++;
}

通常级联消除会伴随分数倍率奖励,例如第 n 次级联的得分为 消除数量 × n

核心算法三:贪心最优消除策略

在实现 AI 自动游玩时,我们需要一种策略来决定”下一步交换哪两个相邻宝石”。消消乐的状态空间巨大,精确求解属于 PSPACE-Complete 问题。实践中常采用贪心策略:枚举所有合法的相邻交换,模拟执行后的得分,选择得分最高的那一步。

贪心评估步骤

  1. 遍历所有水平相邻和垂直相邻的格子对。
  2. 对每一对,临时交换后调用 processMatches() 计算得分。
  3. 恢复棋盘状态(通过深拷贝备份实现)。
  4. 返回得分最高的交换动作。

虽然贪心策略无法保证全局最优,但在消消乐这类具有局部奖励结构的游戏中表现优异,且计算复杂度可控。

完整 Java 实现

以下代码是一个完整的、可直接编译运行的单文件 Java 项目。它包含棋盘管理、匹配检测、重力模拟、级联消除和贪心 AI 决策的全部逻辑。

import java.util.*;

/**
 * 消消乐(Match-3)核心算法实现
 * 
 * 核心算法:
 * 1. Flood Fill (DFS) 连通分量检测 —— 找出可消除的宝石簇
 * 2. 重力模拟 —— 消除后宝石下落、顶部生成新宝石
 * 3. 级联消除 —— 循环检测直至无新匹配
 * 4. 贪心策略 —— 评估所有合法交换,选择最优步
 */
public class Match3Game {

    // 棋盘配置
    private static final int ROWS = 8;      // 行数
    private static final int COLS = 8;      // 列数
    private static final int COLORS = 5;    // 宝石颜色种类数(0~4)
    private static final int MATCH_SIZE = 3; // 最少消除数量
    private static final int EMPTY = -1;    // 空位标记

    // 颜色对应的控制台显示字符(便于观察)
    private static final char[] COLOR_CHARS = {'R', 'G', 'B', 'Y', 'P'};

    private int[][] board;    // 当前棋盘
    private Random random;    // 随机数生成器
    private int totalScore;   // 累计得分

    public Match3Game() {
        this.random = new Random();
        this.board = new int[ROWS][COLS];
        this.totalScore = 0;
        initializeBoard();
    }

    /**
     * 初始化棋盘,确保没有任何初始匹配
     */
    private void initializeBoard() {
        for (int r = 0; r < ROWS; r++) {
            for (int c = 0; c < COLS; c++) {
                int color;
                do {
                    color = random.nextInt(COLORS);
                } while (wouldFormMatch(r, c, color));
                board[r][c] = color;
            }
        }
    }

    /**
     * 检查:如果在 (row, col) 放置指定颜色,是否会形成 >=3 的连线
     * 用于初始化时避免预设匹配
     */
    private boolean wouldFormMatch(int row, int col, int color) {
        // 水平方向计数
        int count = 1;
        for (int c = col - 1; c >= 0 && board[row][c] == color; c--) count++;
        for (int c = col + 1; c < COLS && board[row][c] == color; c++) count++;
        if (count >= MATCH_SIZE) return true;

        // 垂直方向计数
        count = 1;
        for (int r = row - 1; r >= 0 && board[r][col] == color; r--) count++;
        for (int r = row + 1; r < ROWS && board[r][col] == color; r++) count++;
        return count >= MATCH_SIZE;
    }

    /**
     * Flood Fill (DFS) 查找从 (r, c) 出发的连通分量
     * @return 连通分量中所有格子的坐标列表
     */
    private List<int[]> floodFill(int r, int c, boolean[][] visited) {
        List<int[]> component = new ArrayList<>();
        int color = board[r][c];
        Deque<int[]> stack = new ArrayDeque<>();
        stack.push(new int[]{r, c});
        visited[r][c] = true;

        // 四方向:上、下、左、右
        int[][] directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

        while (!stack.isEmpty()) {
            int[] curr = stack.pop();
            component.add(curr);

            for (int[] d : directions) {
                int nr = curr[0] + d[0];
                int nc = curr[1] + d[1];
                if (inBounds(nr, nc) && !visited[nr][nc] && board[nr][nc] == color) {
                    visited[nr][nc] = true;
                    stack.push(new int[]{nr, nc});
                }
            }
        }
        return component;
    }

    private boolean inBounds(int r, int c) {
        return r >= 0 && r < ROWS && c >= 0 && c < COLS;
    }

    /**
     * 查找棋盘上所有满足消除条件的匹配(连通分量大小 >= 3)
     */
    private List<List<int[]>> findAllMatches() {
        List<List<int[]>> matches = new ArrayList<>();
        boolean[][] visited = new boolean[ROWS][COLS];

        for (int r = 0; r < ROWS; r++) {
            for (int c = 0; c < COLS; c++) {
                if (board[r][c] != EMPTY && !visited[r][c]) {
                    List<int[]> component = floodFill(r, c, visited);
                    if (component.size() >= MATCH_SIZE) {
                        matches.add(component);
                    }
                }
            }
        }
        return matches;
    }

    /**
     * 将匹配列表中的宝石消除(标记为 EMPTY)
     * @return 本次消除的宝石总数
     */
    private int removeMatches(List<List<int[]>> matches) {
        int removed = 0;
        for (List<int[]> match : matches) {
            for (int[] pos : match) {
                board[pos[0]][pos[1]] = EMPTY;
                removed++;
            }
        }
        return removed;
    }

    /**
     * 重力模拟:每一列中,非空宝石下落到底部,顶部生成新宝石
     */
    private void applyGravity() {
        for (int c = 0; c < COLS; c++) {
            int writeRow = ROWS - 1;
            // 从下到上收集非空宝石
            for (int r = ROWS - 1; r >= 0; r--) {
                if (board[r][c] != EMPTY) {
                    board[writeRow][c] = board[r][c];
                    if (writeRow != r) {
                        board[r][c] = EMPTY;
                    }
                    writeRow--;
                }
            }
            // 顶部空缺填充新随机宝石
            for (int r = writeRow; r >= 0; r--) {
                board[r][c] = random.nextInt(COLORS);
            }
        }
    }

    /**
     * 处理一轮完整的消除流程:检测匹配 -> 消除 -> 重力 -> 重复(级联)
     * @return 本轮总得分
     */
    public int processMatches() {
        int roundScore = 0;
        int cascade = 1; // 级联倍率

        while (true) {
            List<List<int[]>> matches = findAllMatches();
            if (matches.isEmpty()) break;

            int removed = removeMatches(matches);
            roundScore += removed * cascade; // 级联越高,倍率越大
            cascade++;

            applyGravity();
        }

        return roundScore;
    }

    /**
     * 判断交换 (r1,c1) 和 (r2,c2) 后是否能产生新的匹配
     * 仅允许相邻交换(上下左右)
     */
    public boolean isValidSwap(int r1, int c1, int r2, int c2) {
        if (Math.abs(r1 - r2) + Math.abs(c1 - c2) != 1) {
            return false; // 非相邻
        }

        // 临时交换
        int temp = board[r1][c1];
        board[r1][c1] = board[r2][c2];
        board[r2][c2] = temp;

        boolean valid = formsMatch(r1, c1) || formsMatch(r2, c2);

        // 还原
        temp = board[r1][c1];
        board[r1][c1] = board[r2][c2];
        board[r2][c2] = temp;

        return valid;
    }

    /**
     * 检查位置 (r, c) 是否在当前棋盘状态下形成了 >=3 的连线
     */
    private boolean formsMatch(int r, int c) {
        int color = board[r][c];
        if (color == EMPTY) return false;

        // 水平
        int count = 1;
        for (int nc = c - 1; nc >= 0 && board[r][nc] == color; nc--) count++;
        for (int nc = c + 1; nc < COLS && board[r][nc] == color; nc++) count++;
        if (count >= MATCH_SIZE) return true;

        // 垂直
        count = 1;
        for (int nr = r - 1; nr >= 0 && board[nr][c] == color; nr--) count++;
        for (int nr = r + 1; nr < ROWS && board[nr][c] == color; nr++) count++;
        return count >= MATCH_SIZE;
    }

    /**
     * 贪心策略:枚举所有合法交换,模拟执行后返回得分最高的动作
     */
    public Move findBestMove() {
        Move bestMove = null;
        int bestScore = -1;

        for (int r = 0; r < ROWS; r++) {
            for (int c = 0; c < COLS; c++) {
                // 尝试与右侧交换
                if (c + 1 < COLS && isValidSwap(r, c, r, c + 1)) {
                    int score = simulateSwapScore(r, c, r, c + 1);
                    if (score > bestScore) {
                        bestScore = score;
                        bestMove = new Move(r, c, r, c + 1, score);
                    }
                }
                // 尝试与下方交换
                if (r + 1 < ROWS && isValidSwap(r, c, r + 1, c)) {
                    int score = simulateSwapScore(r, c, r + 1, c);
                    if (score > bestScore) {
                        bestScore = score;
                        bestMove = new Move(r, c, r + 1, c, score);
                    }
                }
            }
        }
        return bestMove;
    }

    /**
     * 模拟一次交换并计算得分,模拟结束后恢复棋盘状态
     */
    private int simulateSwapScore(int r1, int c1, int r2, int c2) {
        // 备份当前棋盘
        int[][] backup = deepCopyBoard();

        // 执行交换
        int temp = board[r1][c1];
        board[r1][c1] = board[r2][c2];
        board[r2][c2] = temp;

        // 计算消除得分
        int score = processMatches();

        // 恢复棋盘
        restoreBoard(backup);

        return score;
    }

    /**
     * 深拷贝棋盘
     */
    private int[][] deepCopyBoard() {
        int[][] copy = new int[ROWS][COLS];
        for (int r = 0; r < ROWS; r++) {
            System.arraycopy(board[r], 0, copy[r], 0, COLS);
        }
        return copy;
    }

    /**
     * 从备份恢复棋盘
     */
    private void restoreBoard(int[][] backup) {
        for (int r = 0; r < ROWS; r++) {
            System.arraycopy(backup[r], 0, board[r], 0, COLS);
        }
    }

    /**
     * 执行一次真实的交换(用于 AI 自动运行或玩家操作)
     */
    public int executeSwap(int r1, int c1, int r2, int c2) {
        if (!isValidSwap(r1, c1, r2, c2)) {
            return 0;
        }
        int temp = board[r1][c1];
        board[r1][c1] = board[r2][c2];
        board[r2][c2] = temp;
        int score = processMatches();
        totalScore += score;
        return score;
    }

    /**
     * 控制台打印棋盘
     */
    public void printBoard() {
        System.out.println("  0 1 2 3 4 5 6 7");
        for (int r = 0; r < ROWS; r++) {
            System.out.print(r + " ");
            for (int c = 0; c < COLS; c++) {
                if (board[r][c] == EMPTY) {
                    System.out.print(". ");
                } else {
                    System.out.print(COLOR_CHARS[board[r][c]] + " ");
                }
            }
            System.out.println();
        }
        System.out.println("累计得分: " + totalScore);
        System.out.println();
    }

    public int getTotalScore() {
        return totalScore;
    }

    /**
     * 动作封装类:记录一次交换的坐标与预期得分
     */
    public static class Move {
        public final int r1, c1, r2, c2;
        public final int score;

        public Move(int r1, int c1, int r2, int c2, int score) {
            this.r1 = r1; this.c1 = c1;
            this.r2 = r2; this.c2 = c2;
            this.score = score;
        }

        @Override
        public String toString() {
            return String.format("交换 (%d,%d) 与 (%d,%d),预期得分: %d",
                    r1, c1, r2, c2, score);
        }
    }

    // ==================== 主程序入口 ====================

    public static void main(String[] args) {
        Match3Game game = new Match3Game();
        System.out.println("=== 消消乐核心算法演示 ===\n");
        System.out.println("初始棋盘:");
        game.printBoard();

        // 演示贪心 AI 自动运行 10 步
        for (int step = 1; step <= 10; step++) {
            Move best = game.findBestMove();
            if (best == null) {
                System.out.println("第 " + step + " 步:无可行移动,游戏结束。");
                break;
            }
            System.out.println("第 " + step + " 步 —— " + best);
            int score = game.executeSwap(best.r1, best.c1, best.r2, best.c2);
            System.out.println("实际得分: " + score);
            game.printBoard();
        }

        System.out.println("最终累计得分: " + game.getTotalScore());
    }
}

代码设计要点

模块 职责 关键算法
floodFill 连通分量检测 DFS 四方向搜索,O(n) 时间
findAllMatches 全局匹配扫描 遍历 + Flood Fill,O(ROWS×COLS)
applyGravity 重力模拟 列内双指针压缩,O(ROWS×COLS)
processMatches 级联消除 循环调用上述方法直至稳定
findBestMove 贪心决策 枚举所有合法交换并模拟评估
simulateSwapScore 模拟器 深拷贝备份 → 交换 → 计分 → 恢复

复杂度分析

操作 时间复杂度 空间复杂度 说明
初始化棋盘 O(ROWS × COLS × COLORS) O(ROWS × COLS) 最坏情况下反复尝试颜色
连通分量检测 O(ROWS × COLS) O(ROWS × COLS) 每个格子仅访问一次
重力模拟 O(ROWS × COLS) O(1) 列内原地操作
级联消除(一轮) O(k × ROWS × COLS) O(ROWS × COLS) k 为级联次数,通常 k ≤ 5
贪心最优步 O(ROWS × COLS × ROWS × COLS) O(ROWS × COLS) 约 2×ROWS×COLS 次交换模拟

对于标准的 8×8 棋盘,贪心策略每步评估约 112 种交换,每种交换需要一次级联消除(约 64×5 次操作),单步决策耗时在毫秒级,完全满足实时性要求。

总结

本文以消消乐为切入点,系统讲解了四种核心算法:Flood Fill 连通分量检测用于识别可消除区域,重力模拟实现消除后的状态更新,级联消除处理连锁反应,贪心策略为 AI 提供决策能力。通过完整的 Java 代码,读者可以直接编译运行,观察 AI 如何在无人工干预的情况下自动消除得分。

消消乐的算法思想具有很强的迁移价值:Flood Fill 广泛应用于图像填充、岛屿计数;连通分量检测是图论基础;贪心策略则是众多 AI 问题的首选启发式方法。掌握这些基础算法,将为后续学习更复杂的搜索与强化学习打下坚实基础。