每日算法 — 使用java实现迷宫生成与求解:元胞自动机与渗流连通性

随机生成的迷宫往往不是”迷宫”,而是一堆互不相连的碎片——你能看到通道,但走不到出口。这不是随机性不够,而是缺少连通性保证。

本文用一条完整的流水线解决这个问题:先用元胞自动机(Cellular Automata)把噪声平滑成有机的洞穴地形,再用渗流理论(Percolation)配合并查集只保留最大连通分量,最后用 A* 在八方向网格上求最短路径。三套算法各司其职,串起来就是一个可用的洞穴关卡生成器。

一、为什么纯随机生成的迷宫不可用

假设 40×22 的网格,每格 45% 概率为墙、55% 为空地。直觉上 55% 的空地足够连成一片,但实际情况是:

  • 空旷区域的形状毫无结构,看起来像电视雪花,不像洞穴;
  • 大量面积被切割成 1~3 格大小的孤立小洞,玩家进去就出不来;
  • 即使存在一条从左上到右下的通路,也往往细到只有一个格子宽,稍微调整参数就断了。

这个现象在统计物理里正是渗流(percolation)研究的对象。考虑一个无限大的网格,每格以概率 p 被”占据”,当 p 超过某个临界阈值 p_c 时,才首次出现贯穿整个系统的”无限簇”。对于二维方格点渗流,p_c ≈ 0.5927;对于八邻接的格点渗流,p_c ≈ 0.407。

这解释了为什么 45% 的初始占空比生成的洞穴会碎:八邻接的临界点约 0.4,我们在 0.45 附近,正好处于相变区——系统处在”要么大片连通、要么彻底碎裂”的临界带上,随机性稍微偏向哪一边,结果就完全不同。

元胞自动机的任务,就是把初始噪声推离这个临界区,让空地的空间相关性变强,形成有大块连通区域的有机形状。

二、元胞自动机:4-5 平滑规则

最经典的洞穴生成规则来自 Roguelike 开发社区,被称为 4-5 规则:

对每个格子,统计它自身加八邻共 9 格中有多少个是墙:

  • 墙数 ≥ 5 → 下一代变墙(填补空洞)
  • 墙数 ≤ 3 → 下一代变空地(削平凸起)
  • 墙数 = 4 → 状态保持不变(边界区域保持原样)

这条规则的本质是局部多数投票 + 死区(hysteresis)。多数投票让边界趋于平滑;4 这个”死区”让边界上的格子不会来回抖动,避免出现棋盘格振荡。

static final int W = 40, H = 22;
/** 邻居偏移:自身 + 八邻(下标 0 是自身) */
static final int[] DR9 = {0, -1, -1, -1, 0, 0, 1, 1, 1};
static final int[] DC9 = {0, -1, 0, 1, -1, 1, -1, 0, 1};

static int[][] grid;   // 1 = 墙,0 = 空地

/** 初始化:随机撒噪声,并将边界强制为墙 */
static void randomFill(double density, Random rnd) {
    grid = new int[H][W];
    for (int r = 0; r < H; r++)
        for (int c = 0; c < W; c++)
            grid[r][c] = rnd.nextDouble() < density ? 1 : 0;
    for (int r = 0; r < H; r++) { grid[r][0] = 1; grid[r][W - 1] = 1; }
    for (int c = 0; c < W; c++) { grid[0][c] = 1; grid[H - 1][c] = 1; }
}

/** 一次 CA 迭代:4-5 平滑规则 */
static void smooth() {
    int[][] next = new int[H][W];
    for (int r = 1; r < H - 1; r++) {
        for (int c = 1; c < W - 1; c++) {
            int walls = 0;
            for (int k = 0; k < 9; k++) walls += grid[r + DR9[k]][c + DC9[k]];
            if (walls >= 5) next[r][c] = 1;        // 周围墙多 -> 长成墙
            else if (walls <= 3) next[r][c] = 0;   // 周围空多 -> 保持空
            else next[r][c] = grid[r][c];          // 恰好 4 个墙 -> 状态不变
        }
    }
    // 边界仍然强制为墙
    for (int r = 0; r < H; r++) { next[r][0] = 1; next[r][W - 1] = 1; }
    for (int c = 0; c < W; c++) { next[0][c] = 1; next[H - 1][c] = 1; }
    grid = next;
}

必须用双缓冲(next 数组)。如果原地更新,同一轮里先算出的格子会影响后算的格子,结果依赖于遍历顺序,CA 的”同步演化”语义就丢了,生成的形状会带方向性条纹。

实测迭代过程中空地占比的变化(初始密度 45%):

=== 迭代 0(初始噪声)空地占比 46.6% ===
=== 迭代 1 空地占比 37.6% ===
=== 迭代 2 空地占比 32.8% ===
=== 迭代 3 空地占比 30.3% ===
=== 迭代 4 空地占比 28.3% ===
=== 迭代 5 空地占比 26.4% ===

注意这个趋势:空地在持续减少。原因是”墙数 ≥ 5 变墙”的触发条件比”墙数 ≤ 3 变空”更容易被满足——空地边缘总是比墙边缘更容易被侵蚀。所以迭代越多,洞穴越小越”紧实”。这给了我们一个非常好用的参数:迭代次数直接控制洞穴的开放度。1~2 次迭代得到开阔的大厅,4~5 次迭代得到狭窄的蜂巢状废墟。

三、渗流清理:并查集只保留最大连通分量

CA 平滑之后,形状好看了,但仍会残留一些小洞穴。这些孤立区域对玩家毫无价值,却会让”从起点到终点的路径是否存在”这个判断变得复杂。

渗流理论给了一个干脆的答案:只保留最大连通分量,其余全部填成墙。这等价于判定”系统里是否已经形成了一个主导的连通簇”——正是渗流问题的核心关切。

实现用并查集(Union-Find),并且带两个标准优化:

static int[] parent, size;

/** 查找 + 路径压缩:把路径上所有节点直接挂到根上,后续查询接近 O(1) */
static int find(int x) {
    while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }
    return x;
}

/** 按大小合并:总是把小的树挂到大的树下,保证树高 O(log n) */
static void union(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return;
    if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }
    parent[rb] = ra;
    size[ra] += size[rb];
}

/**
 * 四邻接连通性合并,只保留最大簇。
 * 返回被填埋的孤立空地数量。
 */
static int percolateAndKeepLargest() {
    int n = H * W;
    parent = new int[n];
    size = new int[n];
    for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; }

    // 1) 只向右、向下两个方向合并,即可覆盖全部四邻接关系(避免重复)
    for (int r = 0; r < H; r++) {
        for (int c = 0; c < W; c++) {
            if (grid[r][c] == 1) continue;
            if (c + 1 < W && grid[r][c + 1] == 0) union(r * W + c, r * W + c + 1);
            if (r + 1 < H && grid[r + 1][c] == 0) union(r * W + c, (r + 1) * W + c);
        }
    }

    // 2) 找出 size 最大的根
    int largest = -1, largestRoot = -1;
    for (int r = 0; r < H; r++)
        for (int c = 0; c < W; c++)
            if (grid[r][c] == 0) {
                int rt = find(r * W + c);
                if (size[rt] > largest) { largest = size[rt]; largestRoot = rt; }
            }

    // 3) 不属于最大簇的空地全部填墙
    int filled = 0;
    for (int r = 0; r < H; r++)
        for (int c = 0; c < W; c++)
            if (grid[r][c] == 0 && find(r * W + c) != largestRoot) { grid[r][c] = 1; filled++; }
    return filled;
}

这里有一个容易写错的地方:合并时只需要检查右边和下边两个邻居。如果四个方向都检查,每对相邻格子会被合并两次——虽然并查集幂等不会出错,但白白多了一倍操作。只检查两个方向是并查集处理网格图的标准写法。

路径压缩用的是”路径减半”(path halving)变体:parent[x] = parent[parent[x]],一趟循环同时完成查找和压缩,比递归版省栈。

实测结果:

渗流清理:填埋了 62 个孤立小洞穴,剩余空地占比 19.3%

62 个碎洞被清掉,空地占比从 26.4% 降到 19.3%。这一步看似”浪费”了 7% 的可通行面积,但它换来一个宝贵性质:剩余空地的四邻接图是单连通的,任意两个空地之间一定存在路径。”有路吗”这个问题就此消失。

四、A* 在八方向网格上求解

网格迷宫既允许四方向移动,也允许对角移动,代价不同(正交 1,对角 √2)。这种”带权网格上的最短路”正是 A* 的经典场景。

static final int[] DR8 = {-1, -1, -1, 0, 0, 1, 1, 1};
static final int[] DC8 = {-1, 0, 1, -1, 1, -1, 0, 1};

static class Node implements Comparable<Node> {
    int r, c, g;
    double h;
    Node parent;
    Node(int r, int c, int g, double h, Node p) {
        this.r = r; this.c = c; this.g = g; this.h = h; this.parent = p;
    }
    double f() { return g + h; }
    @Override public int compareTo(Node o) { return Double.compare(f(), o.f()); }
}

static List<int[]> solve(int sr, int sc, int tr, int tc) {
    double[][] best = new double[H][W];
    for (double[] row : best) Arrays.fill(row, Double.MAX_VALUE);
    PriorityQueue<Node> open = new PriorityQueue<>();
    open.add(new Node(sr, sc, 0, heuristic(sr, sc, tr, tc), null));
    best[sr][sc] = 0;

    while (!open.isEmpty()) {
        Node cur = open.poll();
        if (cur.r == tr && cur.c == tc) {
            List<int[]> path = new ArrayList<>();
            for (Node p = cur; p != null; p = p.parent) path.add(new int[]{p.r, p.c});
            Collections.reverse(path);
            return path;
        }
        for (int k = 0; k < 8; k++) {
            int nr = cur.r + DR8[k], nc = cur.c + DC8[k];
            if (nr < 0 || nr >= H || nc < 0 || nc >= W || grid[nr][nc] == 1) continue;

            // 禁止"穿墙角":斜走时两个相邻正交格必须都可通行
            if (DR8[k] != 0 && DC8[k] != 0) {
                if (grid[cur.r][nc] == 1 || grid[nr][cur.c] == 1) continue;
            }
            double cost = (DR8[k] != 0 && DC8[k] != 0) ? 1.41421356 : 1.0;
            int ng = cur.g + (int) Math.round(cost * 100);   // 放大 100 倍用整数累积,避免浮点误差
            if (ng < best[nr][nc]) {
                best[nr][nc] = ng;
                open.add(new Node(nr, nc, ng, heuristic(nr, nc, tr, tc), cur));
            }
        }
    }
    return null;
}

/** 八方向网格上,欧氏距离是 admissible 的启发函数 */
static double heuristic(int r, int c, int tr, int tc) {
    return Math.hypot(tr - r, tc - c) * 100;
}

三个技术点:

禁止穿墙角(no corner cutting)。如果不加这个检查,AI 会从两个对角摆放的墙之间”斜穿”过去,路径在视觉上穿透了墙体。检查方式很直接:斜走时,横向邻居 (cur.r, nc) 和纵向邻居 (nr, cur.c) 必须都可通行。

整数代价累积。1.41421356 这类对角代价在浮点累积几十步后会产生微小误差,导致 <= 比较出现”两条等价路径互相覆盖”的抖动。把所有代价放大 100 倍后四舍五入成整数,g 值就是精确的,比较变得可靠。

欧氏距离是 admissible 的。对角移动的实际代价是 √2≈1.414,而欧氏距离正是这个值——不会高估,所以 A 保证最优。如果用曼哈顿距离,在允许对角移动的网格上会高估*(因为曼哈顿假设只能走直角),启发式不再 admissible,可能返回次优路径。

五、完整项目结构与运行结果

maze/
├── MazeCA.java          // 单文件:CA 生成 + 渗流清理 + A* 求解
└── README.md

实测输出(40×22,初始密度 45%,5 次 CA 迭代):

########################################
########################################
####################S..#################
###################.*...################
#################..*.....###############
################..*.........############
###############..*..........############
#############....*##........############
#########.......*.##.....###############
########.......*.###....################
########.......*#####...################
#############..*######..##.#############
#############..*#######....#############
##############.*..######..##############
###############.*..#####################
################.*...###################
################..*...##################
################...*...#################
###########..#......**..###.############
###########...........**....############
##################..#...*E##############
########################################

最短路径长度 = 22 步(含起点),
起点(2,20) -> 终点(20,25)

注意生成的形状特征:洞穴由连续的弧形边界围成,没有棋盘格噪点,通道宽度大致均匀。这正是 CA 平滑的功劳——局部多数投票让边界获得了”表面张力”,曲线趋于最小周长。

路径从左上角的 (2,20) 一路斜向延伸到右下角的 (20,25),22 步走完 18 行的垂直距离——因为对角移动被允许且代价仅 1.414 倍,路径几乎是一条直线,只在洞穴边缘处做了必要的绕行。

六、参数调节与扩展

这套流水线的三个参数决定了关卡手感:

参数 影响 推荐区间
初始密度 太密(>0.5)洞穴太小,太疏(<0.4)几乎没有墙 0.42 ~ 0.48
CA 迭代次数 直接控制洞穴开放度,每多一轮空地减少约 2~3 个百分点 2 ~ 6
邻居规则阈值 5/3 是经典值;改成 5/2 会让洞穴更封闭,4/3 会更开放 保守用 5/3

三条例行的扩展方向:

  1. 保证起终点在同一簇。本文是先清理再求路径,如果最大簇里找不到合适的起终点,可以先标记起终点位置,再让并查集在合并时”避开”会把两者分到不同簇的情况(用带权并查集或反向随机删除墙)。
  2. 用 CA 做”侵蚀”后处理。生成完洞穴后,再跑几轮”只有墙数 ≤ 2 才变空”的规则,能磨掉尖锐的凸起,让洞穴更像被水流侵蚀过。这是很多程序化地牢生成器(如《矮人要塞》)的常用手法。
  3. 换成六边形网格。渗流阈值与 A* 的启发函数都需要重算(六边形上常用立方坐标 + 曼哈顿式距离),但整体架构完全不变——这正是把生成与求解解耦的好处。

元胞自动机最迷人的地方在于:规则只有一行,全局形态却完全无法从规则直接推导。而渗流与并查集则是把这种”涌现”拉回可用范围的工程手段——一个负责造出有趣的形状,一个负责保证形状能被走通。