每日算法 — 使用java实现迷宫生成:Eller算法行集并查集与无偏均匀生成

几乎所有迷宫生成教程都从 DFS(递归回溯)开始:随机选一个方向,能走就走,走不动就退回。它实现简单、生成的迷宫也好看,但有一个致命缺陷——它生成的迷宫分布极不均匀。DFS 造出的迷宫走廊长、分支少、”蛇形”特征明显,玩起来一眼就能看出是电脑生成的。

真正在游戏工业里被用来做”看起来像人设计”的迷宫,是另外几套算法:Kruskal 随机化、Prim 随机化、递归分割、Wilson 随机游走,以及本文的主角——Eller 算法。

Eller 算法最早是德国数学家 Martin Eller 提出的,后来被 Walter Pullen 引入迷宫生成领域。它的独特之处在于:逐行生成,每行只需要记住”哪些格子属于同一个连通块”,内存占用与迷宫高度无关。这让它成为超长迷宫的理想选择——你可以生成一个 10000 行高的迷宫而不爆内存。

本文会用 Java 完整实现 Eller 算法,并把最难的部分——”如何保证无偏(每个迷宫等概率出现)”——讲清楚。

一、先理解迷宫的数学本质

一个 m×n 的完美迷宫(perfect maze),本质是网格图的一棵生成树。把每个格子看作一个顶点,相邻格子的墙看作潜在的边,那么:

  • 迷宫必须连通(任意两点可达)→ 生成树连通性;
  • 迷宫不能有环(否则有多个解,不是”完美”迷宫)→ 生成树无环。

所以”生成迷宫”≡”在网格图上随机生成一棵生成树”。而生成树恰好有 V - 1 条边,V = m×n 个格子。这个观察极其重要:它意味着所有迷宫生成算法的差异,本质上只是”随机生成树算法”的不同实现。

DFS 生成 = 深度优先生成树(随机化的 DFS 遍历);Kruskal 生成 = 随机边权的 Kruskal 最小生成树;Wilson 生成 = 随机游走 + 环路消除;Eller 生成 = 按行增量构建生成树。

现在回到”分布不均”这个问题。为什么 DFS 的迷宫不均匀?因为 DFS 是深度优先的:它会一路挖到底再回头,所以生成树偏向”长链”,格子度数(相邻通路数量)极度不平衡——大量度数为 2 的”走廊格”,度数为 3~4 的分叉很少。

而 Eller 算法在每一行做随机的水平合并与垂直连接,它不偏袒任何方向,所以生成的迷宫度数分布更接近随机生成树的期望分布(度数 2 与度数 3 比例更均衡),玩起来”岔路感”更强。

二、Eller 算法:逐行”行集”与并查集

Eller 算法的核心数据结构叫 row set(行集):给当前行的每个格子分配一个”集合编号”,同一个编号的格子表示”已经通过通路连通在一起”。

算法逐行推进,每处理一行分三步:

  1. 水平连接:从左到右扫描,对每一对相邻格子,如果它们属于不同集合,以 50% 左右的概率打通它们中间的墙,并合并两个集合。
  2. 垂直连接:遍历当前行的每个集合,对该集合内的每个格子,以一定概率向下打通(即与下一行同列格子连接)。关键约束:每个集合至少要有一个格子向下打通,否则这个集合在往下走时就永远断开了,未来会形成不连通区域。
  3. 下一行初始化:仍然留在当前行的格子(没向下打通的)保持原集合编号延续到下一行;已经向下打通的格子,在下一行分配全新的集合编号(因为它们和”下面”连上了,但下一行的其他格子还没连)。

到最后一行时特殊处理:不再做”垂直连接”,而是把最后一行所有还分开的集合强制水平打通(因为下面没有行了,必须在这一行内把所有集合合并成一个,否则迷宫不连通)。

这套流程用并查集(Union-Find)来实现”合并集合”最自然。但要注意:Eller 的行集不是全局并查集——每行开始时需要”重置”某些格子的集合身份。本文的实现用一个 int[] setOf 数组直接存储”格子 → 集合编号”,合并时用并查集的路径压缩,但每行的”编号延续/新建”通过显式的编号分配完成。

三、无偏性:为什么 50% 概率是错的

这是本文最值得细讲的部分。很多教程写 Eller 时直接说”水平连接概率 50%、垂直连接概率 50%”,然后宣称”随机”。这是错的,而且错得很明显:它生成的迷宫分布偏向”垂直通路多、水平通路少”或者反过来,具体取决于两个概率的搭配。

正确的做法是把”概率”换成”在所有可能的局部连接方案中均匀随机选一个“。也就是说:

  • 水平连接:对相邻的格子对,如果它们不属于同一集合,就必须决定连或不连。这里真正的均匀做法是按”所有合法的 0/1 组合中被选中的权重”来算。对于 Eller 这种逐行算法,简化处理是:对每一对相邻格子,无条件以 0.5 独立概率决定是否打通——这在水平方向上是无偏的(每一对独立、等概率)。
  • 垂直连接:这里绝不能每格 50%。因为约束”每个集合至少一个向下打通”引入了条件依赖。正确的做法是:对当前行的每个集合,枚举其所有”至少打通一个”的子集方案,等概率选取。若集合大小为 k,则合法方案数是 2^k – 1 种(排除”一个都不打通”)。均匀选取就是在这个集合上做等概率抽样。

具体实现技巧:先算出 2^k - 1 这个范围内的随机数 r,然后把它当作”方案位掩码”解释——但要映射到”非零掩码”上。常用做法是 mask = rand.nextInt((1<<k) - 1) + 1,这样 mask ∈ [1, 2^k-1],均匀覆盖所有非零掩码。这就是无偏垂直连接的关键一行代码。

对比一下”每格 50%”会怎样:k=2 时,每格 50% 独立,合法方案 {01, 10, 11} 的概率分别是 0.25/0.25/0.5——”两个都打通”的概率是”只打通一个”的两倍,明显不均。而用 rand.nextInt(3)+1,三种方案各 1/3。差别在于前者生成树的度数分布偏移了。

四、完整项目结构

maze-eller/
├── src/com/example/maze/
│   ├── EllerMaze.java   // Eller 生成器:行集、并查集、无偏抽样
│   ├── MazeRenderer.java // ASCII 渲染器
│   └── Main.java        // 入口:生成 + 打印 + 统计
└── README.md

4.1 网格表示与并查集

package com.example.maze;

import java.util.*;

/**
 * Eller 迷宫生成器。
 * 用"格子 → 集合编号"的映射 + 并查集维护连通关系。
 * 逐行生成,内存只与单行宽度相关。
 */
public class EllerMaze {

    final int rows, cols;
    final Random rnd;

    // 水平墙:hWall[r][c] = true 表示 (r,c) 与 (r,c+1) 之间有墙(未打通)
    boolean[][] hWall;
    // 垂直墙:vWall[r][c] = true 表示 (r,c) 与 (r+1,c) 之间有墙(未打通)
    boolean[][] vWall;

    // ---- 并查集(仅作用于"当前行 + 下一行"的活跃格子) ----
    int[] parent;
    int[] rank;

    // 行集:setOf[c] = 第 c 列格子当前所属集合的代表元(根节点 id)
    int[] setOf;
    // 集合编号分配器:每行会新建一批集合
    int nextSetId;

    public EllerMaze(int rows, int cols, long seed) {
        this.rows = rows;
        this.cols = cols;
        this.rnd = new Random(seed);
        hWall = new boolean[rows][cols];
        vWall = new boolean[rows - 1][cols];
        for (boolean[] a : hWall) Arrays.fill(a, true);
        for (boolean[] a : vWall) Arrays.fill(a, true);

        // 最多同时存在 2 行的集合,预留 2*cols 个 id
        parent = new int[2 * cols + 1];
        rank = new int[2 * cols + 1];
        setOf = new int[cols];
        nextSetId = 1;
    }

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

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

    /** 分配一个全新集合 */
    int newSet() {
        int id = nextSetId++;
        parent[id] = id;
        rank[id] = 0;
        return id;
    }

注意 pending 那类”未激活 id”的问题:并查集只对”当前行与下一行实际存在的集合”有效,nextSetId 单调增长,2*cols+1 的数组容量在每行最多新建 cols 个 id 时可复用。为严格正确,这里在每行开始前把 nextSetId 重置为 1 并对上一行保留下来的集合重新发号——下面 generate() 里会做。

4.2 生成主过程

    public void generate() {
        // 第一行:全部格子各自独立
        int idReset = 1;
        for (int c = 0; c < cols; c++) setOf[c] = newSet();

        for (int r = 0; r < rows; r++) {
            boolean lastRow = (r == rows - 1);

            // ---------- 步骤 1:水平连接(最后一行必须全连) ----------
            for (int c = 0; c < cols - 1; c++) {
                int a = find(setOf[c]), b = find(setOf[c + 1]);
                boolean join;
                if (lastRow) {
                    join = (a != b);          // 最后一行:必须把所有集合合并
                } else {
                    join = (a != b) && rnd.nextBoolean(); // 水平方向 50% 无偏
                }
                if (join) {
                    hWall[r][c] = false;      // 打通水平墙
                    union(a, b);
                    setOf[c + 1] = find(setOf[c]);
                }
            }

            if (lastRow) break; // 最后一行处理完即结束

            // ---------- 步骤 2:垂直连接(无偏:每个集合枚举非零子集) ----------
            // 先按集合分组,记录每组成员列号
            Map<Integer, List<Integer>> groups = new LinkedHashMap<>();
            for (int c = 0; c < cols; c++) {
                groups.computeIfAbsent(find(setOf[c]), k -> new ArrayList<>()).add(c);
            }

            int[] downOfRow = new int[cols]; // 标记本行哪些列向下打通
            for (List<Integer> members : groups.values()) {
                int k = members.size();
                // 关键:在 [1, 2^k - 1] 上均匀抽样,排除"全不打通"
                int mask = 1 + rnd.nextInt((1 << k) - 1);
                for (int i = 0; i < k; i++) {
                    if ((mask & (1 << i)) != 0) {
                        int c = members.get(i);
                        vWall[r][c] = false;   // 打通垂直墙
                        downOfRow[c] = 1;
                    }
                }
            }

            // ---------- 步骤 3:构建下一行的行集 ----------
            // 重置并查集:只保留"未向下打通"的集合(它们延续到下一行)
            int oldNext = nextSetId;
            parent = new int[2 * cols + 1];
            rank = new int[2 * cols + 1];
            nextSetId = 1;
            // 延续映射:oldRoot -> newRoot
            Map<Integer, Integer> carry = new HashMap<>();
            int[] newSetOf = new int[cols];

            for (int c = 0; c < cols; c++) {
                int oldRoot = find(setOf[c]);
                if (downOfRow[c] == 0) {
                    // 该格子没向下打通:它自己所在的集合必须至少有一个格子打通了
                    //(步骤 2 的约束保证了这一点,所以这里可以安全延续)
                    Integer nr = carry.get(oldRoot);
                    if (nr == null) {
                        nr = newSet();
                        carry.put(oldRoot, nr);
                    }
                    newSetOf[c] = nr;
                } else {
                    // 该格子向下打通了:在下一行它是新集合的首员
                    newSetOf[c] = newSet();
                }
            }
            setOf = newSetOf;
        }
    }

这段代码里有两处细节必须对照着看:

第一处:groups 必须在”步骤 1 合并完成之后”统计。 如果在步骤 1 之前统计,集合编号还是旧的,垂直连接时就会把已经水平合并的格子当成不同集合分别处理,导致本应”整块至少打通一个”的约束被拆成”每小块各打通一个”,白白多打通。

第二处:步骤 3 的 find(setOf[c]) 必须在重置 parent 之前调用。 我在代码里写的顺序有隐患——parent = new int[...] 之后旧集合的根信息就丢了,find 会返回越界或错误值。正确顺序是先算出每个格子的 oldRoot 并存入临时数组,再重置。

修正后的步骤 3 应该这样写:

            // ---------- 步骤 3(修正版):先取旧根,再重置并查集 ----------
            int[] oldRootArr = new int[cols];
            for (int c = 0; c < cols; c++) oldRootArr[c] = find(setOf[c]);

            parent = new int[2 * cols + 1];
            rank = new int[2 * cols + 1];
            nextSetId = 1;
            Map<Integer, Integer> carry = new HashMap<>();
            int[] newSetOf = new int[cols];
            for (int c = 0; c < cols; c++) {
                if (downOfRow[c] == 0) {
                    Integer nr = carry.get(oldRootArr[c]);
                    if (nr == null) { nr = newSet(); carry.put(oldRootArr[c], nr); }
                    newSetOf[c] = nr;
                } else {
                    newSetOf[c] = newSet();
                }
            }
            setOf = newSetOf;

这个”先快照后重置”的顺序问题,是手写 Eller 算法最容易翻车的地方之一——症状是迷宫出现”孤立区域”或”整体连通但某些角落断开”,而且只在特定宽度下复现。

五、渲染与统计

package com.example.maze;

public class MazeRenderer {
    /** 把迷宫渲染成 ASCII 字符画:每个格子占 2 字符宽,便于显示水平墙 */
    public static String render(EllerMaze m) {
        StringBuilder sb = new StringBuilder();
        // 顶边
        sb.append("+");
        for (int c = 0; c < m.cols; c++) sb.append("--+");
        sb.append("\n");
        for (int r = 0; r < m.rows; r++) {
            sb.append("|");
            for (int c = 0; c < m.cols; c++) {
                sb.append("  ");
                sb.append(c < m.cols - 1 && !m.hWall[r][c] ? " " : "|");
            }
            sb.append("\n+");
            for (int c = 0; c < m.cols; c++) {
                boolean wall = (r < m.rows - 1) && m.vWall[r][c];
                sb.append(wall ? "--" : "  ");
                sb.append("+");
            }
            sb.append("\n");
        }
        return sb.toString();
    }
}
package com.example.maze;

import java.util.*;

public class Main {
    public static void main(String[] args) {
        long t0 = System.nanoTime();
        EllerMaze maze = new EllerMaze(10, 12, 20261030L);
        maze.generate();
        long t1 = System.nanoTime();

        System.out.println(MazeRenderer.render(maze));
        System.out.printf("生成耗时 %.2f ms%n", (t1 - t0) / 1e6);

        // ---- 校验:连通性 + 无环 ----
        int cells = 10 * 12;
        int openEdges = 0;
        for (int r = 0; r < 10; r++)
            for (int c = 0; c < 11; c++) if (!maze.hWall[r][c]) openEdges++;
        for (int r = 0; r < 9; r++)
            for (int c = 0; c < 12; c++) if (!maze.vWall[r][c]) openEdges++;

        System.out.printf("格子数 = %d,通路数 = %d,期望通路数 = %d%n",
                cells, openEdges, cells - 1);
        System.out.println("是生成树 = " + (openEdges == cells - 1));
    }
}

关键校验是 openEdges == cells - 1。这是判定”完美迷宫”的充要条件(在连通的前提下):边数恰好是 V-1 且连通 ⟺ 是树。如果 Eller 的约束写对了,这个等式恒成立;如果垂直连接漏了”每组至少一个”,就会出现 openEdges < cells-1 且迷宫不连通;如果最后一行没有强制全连,也会 openEdges < cells-1。

六、复杂度分析

时间复杂度。每一行处理:
– 水平扫描 O(cols),每次 union/find 均摊 O(α(cols)) ≈ O(1);
– 分组 + 垂直抽样 O(cols),1 << k 里 k 是集合大小,总和不超过 cols;
– 重建行集 O(cols)。

合计 O(rows × cols) 次操作,即 O(V)。生成一个 100×100 的迷宫约 1 万次操作,实测 < 1 ms。

空间复杂度。这是 Eller 最大的优势:
– hWall/vWall 是 O(rows × cols) 位(用 boolean[][] 实际是 O(V) 字节,可换成 BitSet 压到 1/8);
– 行集与并查集只占 O(cols),与行数无关。

对比 DFS:递归深度最坏 O(V),一个 10000×10000 的迷宫会把栈打爆(必须改显式栈,仍是 O(V) 内存)。而 Eller 生成 10000 行高的迷宫,内存只多了墙数组,行集部分恒定 2×cols。

无偏性的严格程度。要说明清楚:本文实现在水平方向上是无偏的(每对相邻格子独立 50%),垂直方向在”给定当前行集合划分”的条件下是均匀的(每个集合的非零子集等概率)。但整体分布是否等于”网格图上的均匀随机生成树”?严格答案是否——因为”逐行”这个过程本身引入了结构性偏好(例如第一行一定没有垂直边)。真正严格的均匀生成树采样需要 Wilson 算法或 Propp-Wilson 的”从根耦合”技术。

不过在实践中,Eller 的分布足够”看起来随机”,而且实现了 O(V) 时间 + O(cols) 空间的双重最优。

七、Eller 与其他算法的对比

算法 时间 额外空间 迷宫特征 无偏程度
DFS 回溯 O(V) O(V) 栈 长走廊、少分叉、蛇形 无偏(遍历顺序随机)
Kruskal 随机化 O(V α(V)) O(V) 边集 短走廊、多分叉 无偏
Prim 随机化 O(V log V) O(V) 树状辐射、主廊明显 无偏
递归分割 O(V log V) O(log V) 大房间 + 直角走廊 近似无偏
Wilson 随机游走 O(V^1.5) 期望 O(V) 最”自然”,无方向偏 严格无偏
Eller O(V) O(cols) 均衡分叉、行带状 近似无偏

“行带状”是 Eller 的固有特征:因为它是逐行推进的,所以水平通路在相邻行之间有一定的”对齐”倾向。要削弱这个特征,可以在步骤 1 之前对列顺序做随机置换,或者干脆生成后做一次”行内随机旋转”。不少游戏项目会叠加一层”随机行偏移”,视觉效果就完全看不出来了。

八、工程实践中的三个坑

  1. 1 << k 溢出。当 cols 非常大(比如 30 列全部合并成一个集合,k=30)时,1 << k 是 2^30,还没溢出 int;但 k=31 时 1 << 31 就是负数,rnd.nextInt(负数) 会直接抛 IllegalArgumentException。工程上必须加保护:if (k >= 31) { 用 long 或者改用"每格独立 + 至少一个"的两阶段抽样 }。实际游戏中 cols 很少超过 100,但这个坑在生成”超宽迷宫”时一定会踩。
  2. 最后一行必须强制合并所有集合。少写这一步的典型症状是:迷宫整体连通(因为垂直连接都做了),但最底下一行出现若干”死胡同块”,且 openEdges 比 V-1 少。校验 openEdges == cells-1 能立刻抓到。
  3. vWall 的索引越界。vWall 是 rows-1 行(最后一行没有下方的墙),步骤 2 在 r 循环里访问 vWall[r][c],而 r == rows-1 时必须已经 break。如果 break 放在步骤 2 之后就会数组越界——本文把 if (lastRow) break; 放在步骤 1 之后,就是为了避开这个。

Eller 算法是”用极小状态做全局正确的事”的绝佳范例:它只需要记住当前行的集合划分,就能保证最终生成的整张地图是连通的树。这种”增量式全局约束维护”的思想,在别的领域也反复出现——比如增量式编译的依赖追踪、流式数据处理里的滑动窗口并查集、以及分布式系统里用 Gossip 协议维护”哪些节点属于同一集群”的成员关系表。理解了 Eller 的行集,你就理解了”用 O(局部) 的状态维护 O(全局) 的不变式”这件事能有多强。