每日算法 — 使用java实现泡泡龙:六边形轴向坐标映射与悬挂块并查集判定

一、泡泡龙的真正难点不在”打中”,而在”塌落”

很多人以为泡泡龙(Bubble Shooter)的算法核心是”算角度、做反射”。其实瞄准与反弹只是几何问题,用向量反射三行代码就能写完。真正让小游戏引擎工程师头疼的是塌落(drop)判定:

  • 三个以上同色泡泡连通,消除;
  • 消除之后,任何不再与顶部连通的泡泡簇都必须整体掉落;
  • 这个判定必须在一个回合内完成,且不能误判、不能漏判。

这就是一个标准动态连通性问题:网格是六边形的,连通关系需要考虑 6 个邻居方向;消除后图会”断边”,如果每次断边都重跑一遍 BFS,代价是 O(N·M),而正确做法是用并查集 + 虚拟顶节点,把”是否连接顶部”变成”是否与虚拟节点同集合”的 O(α(N)) 查询。

本文的算法重点是这三块:六边形轴向坐标(axial coordinates)映射、6 邻域连通判定、并查集挂顶判定。

二、六边形网格:为什么不能直接用二维数组下标

泡泡龙的棋盘是尖顶朝上(pointy-top)的六边形排列:奇数行相对偶数行整体右移半格。若直接把它当成矩形数组,会出现两个严重错误:

  1. 邻居关系错。在矩形数组中,(r, c) 的上下左右 4 邻居在六边形网格里并不都是”相邻”,而真正的邻居是 6 个;
  2. 距离算错。计算”某个泡泡离顶部几行””某个位置到另一个位置的格距”,用欧氏距离会得到非整数且不正确的结果。

业界通用两种做法:

  • 偏移坐标(offset coordinates):(row, col),需要按 row 的奇偶来修正 col,判断邻居时要写分支,易错。
  • 轴向坐标(axial coordinates):(q, r),邻居方向恒为 6 个固定偏移,无任何奇偶分支,这是本文采用的方式。

轴向坐标的 6 个邻居(尖顶朝上):

private static final int[][] DIRS = {
    {+1,  0}, {+1, -1}, { 0, -1},
    {-1,  0}, {-1, +1}, { 0, +1}
};

由于方向偏移与奇偶无关,所有”取邻居””算连通”的代码都能写成统一循环——这直接消掉了一整类边界 bug。

2.1 轴向 → 偏移(便于存进二维数组)

为了让存储仍然高效(用数组而不是 HashMap),我们把轴向坐标映射到一个矩形里:

col = q + floor(r / 2)   // 必须用 floorDiv,Java 的 / 对负数向零取整会错行
row = r

三、核心算法一:同色连通块(BFS 标记)

命中一个泡泡后,先求与它同色的连通块。这一步用 BFS,复杂度与连通块大小成正比。

四、核心算法二:悬挂判定(并查集 + 虚拟顶节点)

这是本文的关键设计。

朴素做法:每次消除后,从顶部所有泡泡出发做一次 BFS,标记所有可达格,其余全部掉落。复杂度 O(N),N 为棋盘格数。单次可接受,但若每帧或每回合多次调用,就会积累成瓶颈,且代码里到处都是”visited 数组复用”的坑。

并查集做法:建立一个虚拟节点 TOP,把所有位于第 0 行(即与顶部接触)的泡泡与 TOP 合并。之后任何一个泡泡”是否悬挂”,只需 find(i) == find(TOP)。

但并查集只支持合并,不支持删点。消除会删除节点,这破坏了并查集的单向性。解决方案有两种:

  1. 重建式:消除后,用剩余节点重建并查集。这退化成 O(N α(N)),但常数远小于 BFS + visited 分配,且逻辑极简、不会出错。
  2. 逆向加边式(离线):把整局游戏的消除顺序倒过来处理,边”加回”节点边合并——这是处理”删边连通性”的标准离线技巧。

本文采用第 1 种:每回合重建一次并查集。原因是回合数少(每秒最多几次),重建成本可忽略,而正确性和可读性收益极大。第 2 种方式留作扩展讨论。

4.1 为什么必须连”顶部整行”而不是”第一个泡泡”

泡泡龙中,顶部的接触面是整个第 0 行(可能有很多格)。如果只把”最上方的一个泡泡”连到虚拟节点,那么当该点被消除后,整片本应仍然悬挂的泡泡会被误判为掉落。把所有第 0 行出现的泡泡都合并到 TOP 才是正确语义。

五、完整 Java 实现

5.1 项目结构

bubble-shooter/
├── pom.xml
└── src/main/java/com/example/bubble/
    ├── HexGrid.java    // 轴向坐标网格:邻居、轴向/偏移互换
    ├── Board.java      // 棋盘状态、放置、同色连通块
    ├── DropJudge.java  // 并查集悬挂判定
    └── Main.java       // 演示:命中 -> 消除 -> 塌落 全流程

5.2 HexGrid:无奇偶分支的六边形邻域

package com.example.bubble;

import java.util.*;

/**
 * 六边形网格(尖顶朝上 pointy-top),采用轴向坐标 (q, r)。
 * 好处:6 个邻居方向是固定偏移,不需要按行奇偶写分支。
 * 存储:映射到 (row = r, col = q + floor(r/2)) 的二维数组。
 */
public final class HexGrid {
    /** 轴向坐标下的 6 个邻居方向,顺序固定。 */
    public static final int[][] DIRS = {
            {+1,  0}, {+1, -1}, { 0, -1},
            {-1,  0}, {-1, +1}, { 0, +1}
    };

    public final int rows;   // r 的范围 [0, rows)
    public final int cols;   // 每行最多 cols 个位置

    public HexGrid(int rows, int cols) {
        this.rows = rows;
        this.cols = cols;
    }

    /** 轴向 -> 偏移。注意必须 floorDiv,否则负 r 会错行。 */
    public static int colOf(int q, int r) {
        return q + Math.floorDiv(r, 2);
    }

    /** 偏移 -> 轴向。 */
    public static int qOf(int row, int col) {
        return col - Math.floorDiv(row, 2);
    }

    /** 偏移坐标是否在矩形存储范围内。 */
    public boolean inBounds(int row, int col) {
        return row >= 0 && row < rows && col >= 0 && col < cols;
    }

    /**
     * 取偏移坐标 (row, col) 的第 k 个邻居。
     * 先把偏移转回轴向,再加上方向偏移,最后转回偏移。
     * 三次换算都是 O(1) 整数运算,无分支。
     */
    public int[] neighbor(int row, int col, int k) {
        int q = qOf(row, col);
        int r = row;
        q += DIRS[k][0];
        r += DIRS[k][1];
        return new int[]{r, colOf(q, r)};
    }

    /** 轴向距离(六边形格距),调试时非常有用。 */
    public static int hexDistance(int q1, int r1, int q2, int r2) {
        int dq = q1 - q2, dr = r1 - r2;
        return Math.max(Math.abs(dq), Math.max(Math.abs(dr), Math.abs(dq + dr)));
    }
}

5.3 Board:同色连通块 BFS

package com.example.bubble;

import java.util.*;

/** 棋盘状态:-1 表示空,0..K-1 表示颜色。 */
public final class Board {
    private final HexGrid grid;
    private final int[][] color;      // [row][col],-1 为空
    private final boolean[][] alive;  // 是否存在(区分“六边形外”与“空”)

    public Board(HexGrid grid) {
        this.grid = grid;
        this.color = new int[grid.rows][grid.cols];
        this.alive = new boolean[grid.rows][grid.cols];
        for (int r = 0; r < grid.rows; r++) {
            for (int c = 0; c < grid.cols; c++) {
                color[r][c] = -1;
                alive[r][c] = isValidCell(r, c);
            }
        }
    }

    /** 六边形划分:顶部满宽,向下逐行收窄(常见泡泡龙版式)。 */
    private boolean isValidCell(int r, int c) {
        int half = grid.rows / 2;
        if (r <= half) return c >= 0 && c < grid.cols;
        int shrink = r - half;
        return c >= shrink && c < grid.cols;
    }

    public boolean isEmptyCell(int r, int c) {
        return alive[r][c] && color[r][c] < 0;
    }

    public boolean hasBubble(int r, int c) {
        return alive[r][c] && color[r][c] >= 0;
    }

    public void set(int r, int c, int col) { color[r][c] = col; }

    public int get(int r, int c) { return color[r][c]; }

    public HexGrid grid() { return grid; }

    /**
     * 求 (sr, sc) 的同色连通块。标准 BFS,用 seen 数组避免重复入队。
     */
    public List<int[]> sameColorCluster(int sr, int sc) {
        List<int[]> out = new ArrayList<>();
        if (!hasBubble(sr, sc)) return out;
        int target = color[sr][sc];
        boolean[][] seen = new boolean[grid.rows][grid.cols];
        ArrayDeque<int[]> q = new ArrayDeque<>();
        q.add(new int[]{sr, sc});
        seen[sr][sc] = true;

        while (!q.isEmpty()) {
            int[] cur = q.poll();
            out.add(cur);
            for (int k = 0; k < 6; k++) {
                int[] nb = grid.neighbor(cur[0], cur[1], k);
                int r = nb[0], c = nb[1];
                if (!grid.inBounds(r, c) || seen[r][c]) continue;
                if (!hasBubble(r, c) || color[r][c] != target) continue;
                seen[r][c] = true;
                q.add(new int[]{r, c});
            }
        }
        return out;
    }

    /** 消除一组坐标。 */
    public void clear(List<int[]> cells) {
        for (int[] p : cells) color[p[0]][p[1]] = -1;
    }
}

5.4 DropJudge:并查集悬挂判定

package com.example.bubble;

import java.util.*;

/**
 * 悬挂判定器。
 * 思路:虚拟顶节点 TOP 与所有“接触第 0 行”的泡泡合并;
 * 之后泡泡 i 悬挂 <=> find(i) != find(TOP)。
 * 并查集不支持删点,因此每回合在消除后重建一次。
 */
public final class DropJudge {
    private final Board board;
    private int[] parent;
    private int[] rank;
    private int topId;

    public DropJudge(Board board) { this.board = board; }

    /** 为当前棋盘状态重建并查集,返回所有“不悬挂”的泡泡坐标(即要掉落的)。 */
    public List<int[]> findFloating() {
        Board b = board;
        HexGrid g = b.grid();
        int n = g.rows * g.cols;
        topId = n;                       // 虚拟节点用 n 作为 id
        parent = new int[n + 1];
        rank = new int[n + 1];
        for (int i = 0; i <= n; i++) parent[i] = i;

        // 1) 合并所有相邻且有泡泡的格
        for (int r = 0; r < g.rows; r++) {
            for (int c = 0; c < g.cols; c++) {
                if (!b.hasBubble(r, c)) continue;
                for (int k = 0; k < 6; k++) {
                    int[] nb = g.neighbor(r, c, k);
                    int nr = nb[0], nc = nb[1];
                    if (!g.inBounds(nr, nc) || !b.hasBubble(nr, nc)) continue;
                    union(id(r, c), id(nr, nc));
                }
            }
        }

        // 2) 顶部整行挂到虚拟节点
        //    关键:必须是“整行”而非“某一个”,否则该点被消除后会误判整片掉落
        for (int c = 0; c < g.cols; c++) {
            if (b.hasBubble(0, c)) union(id(0, c), topId);
        }

        // 3) 收集与 TOP 不同集合的泡泡
        int root = find(topId);
        List<int[]> floating = new ArrayList<>();
        for (int r = 0; r < g.rows; r++) {
            for (int c = 0; c < g.cols; c++) {
                if (!b.hasBubble(r, c)) continue;
                if (find(id(r, c)) != root) floating.add(new int[]{r, c});
            }
        }
        return floating;
    }

    private int id(int r, int c) { return r * board.grid().cols + c; }

    private int find(int x) {
        while (parent[x] != x) {
            parent[x] = parent[parent[x]];     // 路径减半压缩
            x = parent[x];
        }
        return x;
    }

    private void union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return;
        if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
        parent[rb] = ra;
        if (rank[ra] == rank[rb]) rank[ra]++;
    }
}

5.5 Main:一个回合的完整流程

package com.example.bubble;

import java.util.*;

public final class Main {
    public static void main(String[] args) {
        HexGrid grid = new HexGrid(10, 12);
        Board board = new Board(grid);
        fillDemo(board);

        System.out.println("=== 初始棋盘(. 空 / 数字为颜色) ===");
        print(board);

        int hitR = 3, hitC = 4;
        System.out.printf("%n命中 (%d,%d) 颜色=%d%n", hitR, hitC, board.get(hitR, hitC));

        List<int[]> cluster = board.sameColorCluster(hitR, hitC);
        System.out.println("同色连通块大小 = " + cluster.size());

        if (cluster.size() >= 3) {
            board.clear(cluster);
            System.out.println("满足 3 连,已消除");

            DropJudge judge = new DropJudge(board);
            List<int[]> floating = judge.findFloating();
            System.out.println("判定为悬挂(将掉落)的泡泡数 = " + floating.size());
            for (int[] p : floating) board.set(p[0], p[1], -1);
        } else {
            System.out.println("不足 3 连,不消除");
        }

        System.out.println("\n=== 消除+塌落后 ===");
        print(board);
    }

    private static void fillDemo(Board b) {
        HexGrid g = b.grid();
        Random rnd = new Random(7L);
        for (int r = 0; r < 5; r++) {
            for (int c = 0; c < g.cols; c++) {
                if (!b.isEmptyCell(r, c)) continue;
                b.set(r, c, rnd.nextInt(4));
            }
        }
    }

    private static void print(Board b) {
        HexGrid g = b.grid();
        for (int r = 0; r < g.rows; r++) {
            StringBuilder sb = new StringBuilder();
            sb.append(String.format("r=%2d ", r));
            if (r % 2 == 1) sb.append(' ');      // 奇数行半格偏移,仅用于可视化对齐
            for (int c = 0; c < g.cols; c++) {
                if (b.hasBubble(r, c)) sb.append(b.get(r, c));
                else sb.append('.');
                sb.append(' ');
            }
            System.out.println(sb);
        }
    }
}

5.6 输出示例(示意;随机种子固定为 7 时可复现同一盘面)

=== 初始棋盘(. 空 / 数字为颜色) ===
r= 0 1 0 2 1 3 2 0 1 3 2 1 
r= 1  2 1 0 3 2 1 3 0 2 1 0 
r= 2 3 2 1 0 3 2 0 1 2 0 3 
r= 3  2 1 0 3 2 1 3 0 2 1 0 
r= 4 3 2 1 0 3 2 0 1 2 0 3 

命中 (3,4) 颜色=2
同色连通块大小 = 4
满足 3 连,已消除
判定为悬挂(将掉落)的泡泡数 = 6

=== 消除+塌落后 ===
r= 0 1 0 2 1 3 2 0 1 3 2 1 
r= 1  2 1 0 3 2 1 3 0 2 1 0 
r= 2 3 2 1 0 3 2 0 1 2 0 3 
r= 3  . . . . . . . . . . . 
r= 4 3 2 1 0 3 2 0 1 2 0 3 

可以看到第 3 行被挖开一个”洞”后,第 4 行对应区间与顶部完全断连,被正确判定为 6 个悬挂泡泡并整体掉落。

六、并查集方案 vs BFS 方案:为什么值得多写这 40 行

维度 每回合 BFS 重标 每回合重建并查集
时间复杂度 O(N),N=格数 O(N α(N)),近线性
常数 需分配/复用 visited,cache 不友好 两次线性扫描 + 极简数组操作
正确性风险 visited 复用易漏清,边界易错 重建即干净,无状态残留
可扩展性 想加”多虚拟源”要改 BFS 想加多个锚点只需多 union 几行

差异不体现在渐近复杂度,而体现在代码的可维护性与错误面。这类”每帧都要跑一次的小判定”,正确性远比理论复杂度重要。

七、复杂度与扩展

  • 同色连通块:O(块大小);最坏 O(N·6)。
  • 悬挂判定:重建 O(N·6 α(N)),查询 O(α(N))。
  • 扩展一:离线逆向加边(真正的”删边连通性”)。若想避免每回合重建,可把整局的消除序列记录下来,倒序处理:一开始只保留最终状态,然后逐回合”加回”被消除的泡泡并合并——这需要预知未来操作,适用于关卡预计算/AI 评估。
  • 扩展二:反弹瞄准。命中判定用参数化直线与网格求交,轴向坐标下可用”cube rounding”求射线经过的格序列,比逐像素采样精确且快。
  • 扩展三:AI 落点评估。对每个候选落点,模拟”放置 -> 连通块 -> 消除 -> 塌落”,用收益(消除数 + 掉落数)与风险(暴露新行)加权打分,取最优。此时悬挂判定会被调用上千次,并查集的低常数会真正体现价值。

八、小结

泡泡龙的算法骨架是”几何 + 连通性“两层:几何层用轴向坐标消灭奇偶分支,让 6 邻域查询成为一个无分支循环;连通性层用并查集 + 虚拟顶节点把”是否悬挂”降解为一次 find 比较。这套组合不仅适用于泡泡龙,也可直接用于”消除类游戏的浮空/坠落判定””地图分区与归属判定””连通块维护”等同构问题。