迷宫生成算法:从生成树到均匀随机,再到工程化 Python 实现

目录

[1 引言:为什么"生成迷宫"不是画几堵墙那么简单](#1 引言:为什么“生成迷宫”不是画几堵墙那么简单)

[1.1 迷宫的"数学骨架":图、生成树与可解性](#1.1 迷宫的“数学骨架”:图、生成树与可解性)

[1.2 "好迷宫"的工程指标:结构风格、偏置、性能与可控性](#1.2 “好迷宫”的工程指标:结构风格、偏置、性能与可控性)

[2 表示法与统一框架:先把"迷宫这件事"表示清楚](#2 表示法与统一框架:先把“迷宫这件事”表示清楚)

[2.1 两种主流表示:格子墙(cell-walls)与像素栅格(odd-even grid)](#2.1 两种主流表示:格子墙(cell-walls)与像素栅格(odd-even grid))

[2.2 一个可复用的 Python 迷宫骨架:可插拔算法、可视化、可复现](#2.2 一个可复用的 Python 迷宫骨架:可插拔算法、可视化、可复现)

[3 随机深度优先(递归回溯):最常用、最"长走廊"的完美迷宫](#3 随机深度优先(递归回溯):最常用、最“长走廊”的完美迷宫)

[3.1 从 DFS 到迷宫纹理:为什么它总爱"走到黑"](#3.1 从 DFS 到迷宫纹理:为什么它总爱“走到黑”)

[3.2 递归实现与显式栈实现:避免 Python 递归深度问题](#3.2 递归实现与显式栈实现:避免 Python 递归深度问题)

[4 随机 Prim:像"边界起火"一样扩张的迷宫生长](#4 随机 Prim:像“边界起火”一样扩张的迷宫生长)

[4.1 从最小生成树到迷宫:为什么它更"碎"、岔路更多](#4.1 从最小生成树到迷宫:为什么它更“碎”、岔路更多)

[4.2 Python 实现:用"前沿边"而不是"前沿点"](#4.2 Python 实现:用“前沿边”而不是“前沿点”)

[5 随机 Kruskal:并查集驱动的"全局打乱后逐条连通"](#5 随机 Kruskal:并查集驱动的“全局打乱后逐条连通”)

[5.1 生成树的另一条路:先把所有墙打乱,再决定拆不拆](#5.1 生成树的另一条路:先把所有墙打乱,再决定拆不拆)

[5.2 并查集实现:路径压缩与按秩合并让大迷宫也能快](#5.2 并查集实现:路径压缩与按秩合并让大迷宫也能快)

[6 递归分割:先造房间再开门的"建筑师式"迷宫](#6 递归分割:先造房间再开门的“建筑师式”迷宫)

[6.1 它为什么看起来更"规整":长直墙与强几何感](#6.1 它为什么看起来更“规整”:长直墙与强几何感)

[6.2 Python 实现:在 cell-walls 上做"加墙等价变换"](#6.2 Python 实现:在 cell-walls 上做“加墙等价变换”)

[7 Sidewinder 与 Binary Tree:带偏置的极简算法与"可预测纹理"](#7 Sidewinder 与 Binary Tree:带偏置的极简算法与“可预测纹理”)

[7.1 偏置不是缺点:当你需要"方向性"与"可控风格"](#7.1 偏置不是缺点:当你需要“方向性”与“可控风格”)

[7.2 Sidewinder:一行一行地"横向跑团",偶尔往上打一个洞](#7.2 Sidewinder:一行一行地“横向跑团”,偶尔往上打一个洞)

[7.3 Binary Tree:每格只在两个方向里选一个,形成"斜向树根"](#7.3 Binary Tree:每格只在两个方向里选一个,形成“斜向树根”)

[8 Eller:只看一行也能生成无限高完美迷宫的"集合魔法"](#8 Eller:只看一行也能生成无限高完美迷宫的“集合魔法”)

[8.1 为什么它值得学习:流式生成、线性复杂度与行级状态](#8.1 为什么它值得学习:流式生成、线性复杂度与行级状态)

[8.2 Python 实现:可读性优先的 Eller(适合学习与改造)](#8.2 Python 实现:可读性优先的 Eller(适合学习与改造))

[9 Aldous--Broder 与 Wilson:走向"均匀生成树"的随机游走系算法](#9 Aldous–Broder 与 Wilson:走向“均匀生成树”的随机游走系算法)

[9.1 "无偏"到底是什么意思:均匀生成树与算法偏置](#9.1 “无偏”到底是什么意思:均匀生成树与算法偏置)

[9.2 Aldous--Broder:最简单的均匀生成树,但常被吐槽"慢得令人痛苦"](#9.2 Aldous–Broder:最简单的均匀生成树,但常被吐槽“慢得令人痛苦”)

[9.3 Wilson:循环擦除随机游走(LERW),更快、更优雅](#9.3 Wilson:循环擦除随机游走(LERW),更快、更优雅)

[10 让算法变成"可用系统":入口出口、加环、难度调参与验证](#10 让算法变成“可用系统”:入口出口、加环、难度调参与验证)

[10.1 入口出口并不是"开两个洞"这么简单](#10.1 入口出口并不是“开两个洞”这么简单)

[10.2 从"完美迷宫"到"更像现实的迷宫":加少量回路(braiding)](#10.2 从“完美迷宫”到“更像现实的迷宫”:加少量回路(braiding))

[10.3 难度与风格调参:你真正能控制的变量是什么](#10.3 难度与风格调参:你真正能控制的变量是什么)

[11 把一切串起来:一个可运行示例与输出](#11 把一切串起来:一个可运行示例与输出)

[11.1 最小示例:生成、开口、打印](#11.1 最小示例:生成、开口、打印)

[12 结语:选择算法,其实是在选择"随机性的哲学"](#12 结语:选择算法,其实是在选择“随机性的哲学”)


本文提供了多种迷宫生成算法,大家在设计迷宫游戏时可以将其中一种算法交给Claude,然后就能设计出窗体化的迷宫游戏或网页游戏。这种比较小的游戏设计用Claude Haiku 4.5足够。

1 引言:为什么"生成迷宫"不是画几堵墙那么简单

1.1 迷宫的"数学骨架":图、生成树与可解性

在程序里谈迷宫,最容易踩的坑就是把它当成"画图问题":随机画几条墙、留几条通道,看起来像迷宫就行。可一旦你希望迷宫"必定可解""难度可控""风格稳定""能复现同一随机种子",甚至希望它满足"只有唯一解"的性质时,迷宫立刻就变成一个严格的图论构造问题。经典二维格子迷宫通常把每个房间(cell)当作图的节点,把相邻房间之间"可以打通的墙"当作边。你要做的事,等价于从这张候选边组成的连通图里挑出一部分边作为通道,让整张图保持连通,同时又不要出现让人绕晕的多条等价路线。所谓"完美迷宫(perfect maze)",指任意两格之间恰好只有一条 通路,这在图论里就是"生成树":连通、无环、覆盖全部节点。很多迷宫生成算法本质上就是"随机生成树算法"的不同实现路线。(维基百科)

1.2 "好迷宫"的工程指标:结构风格、偏置、性能与可控性

同样是生成树,不同算法的迷宫观感会差很多:有的偏爱长走廊、分支少,像洞穴深处一条路走到黑;有的分叉密集、回头路多,像在树冠里穿梭;有的出现大段笔直隔墙,像建筑平面图;有的"无偏"(更严格地说是对生成树空间做均匀采样),让每一种可能的迷宫结构出现的概率相同,但代价是更慢、更难实现。Jamis Buck 的系列文章把这些算法当作"同一问题的不同审美答案"来讨论:递归回溯(深度优先)、Prim、Kruskal、Eller、Aldous--Broder、Wilson、递归分割、hunt-and-kill 等都各有"纹理"。(巴克博客) 你如果只把它们当作步骤列表,很快就会写出"能跑但难用"的代码;真正工程化的关键在于统一数据结构、统一接口、可视化验证、以及对随机性和偏置的认识。

2 表示法与统一框架:先把"迷宫这件事"表示清楚

2.1 两种主流表示:格子墙(cell-walls)与像素栅格(odd-even grid)

迷宫实现里最常见的两套表示法,各有利弊。第一种是"格子墙"模型:每个 cell 有四面墙(N/S/E/W),打通就是把相邻两格对应方向的墙置为打开。这种模型抽象层更高,算法表达自然,尤其适合 Wilson、Kruskal 这类"在 cell 图上做生成树"的算法。第二种是"像素栅格"模型:把最终输出当作由 0/1 组成的矩阵,1 表示路、0 表示墙,并用奇偶坐标把 cell 与墙分开(例如 cell 在奇数坐标,墙在偶数坐标)。这种模型非常利于打印、绘图与导出,也能让"挖墙"动作变成对矩阵的赋值。很多教学代码用 odd-even grid,就是因为它能把"拆墙"写成"把中间那一格也置 1"。这两套表示法并不冲突:工程上常见做法是内部用 cell-walls,输出时再渲染成矩阵或图像;或者内部直接用 odd-even grid,算法在奇数格上走、在偶数格上挖墙。

2.2 一个可复用的 Python 迷宫骨架:可插拔算法、可视化、可复现

下面给出一套偏工程化、但仍然易读的 Python 框架:用 cell-walls 表示迷宫;每种算法只负责"打开哪些墙";最后统一渲染为字符画或 numpy 矩阵。注意:这不是"把所有算法写成一堆函数然后复制粘贴",而是让算法实现只关注它们各自的核心逻辑,这样你扩展到六边形网格、三维迷宫、或加入"编织迷宫(weave)"时,改动会很小。

复制代码
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple, Iterable
import random

Dir = Tuple[int, int]
N, S, W, E = (0, -1), (0, 1), (-1, 0), (1, 0)
DIRS: List[Dir] = [N, S, W, E]
OPP: Dict[Dir, Dir] = {N: S, S: N, W: E, E: W}
DIR_NAME: Dict[Dir, str] = {N: "N", S: "S", W: "W", E: "E"}

@dataclass
class Cell:
    x: int
    y: int
    # True=墙存在, False=墙被打通
    walls: Dict[Dir, bool]

    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y
        self.walls = {d: True for d in DIRS}

class Maze:
    """
    cell-walls 表示法:width*height 个 cell,每个 cell 有四面墙。
    算法只负责调用 carve(a, b) 打通相邻 cell 之间的墙。
    """
    def __init__(self, width: int, height: int, seed: Optional[int] = None):
        if width <= 0 or height <= 0:
            raise ValueError("width/height must be positive")
        self.w = width
        self.h = height
        self.grid: List[List[Cell]] = [[Cell(x, y) for x in range(width)] for y in range(height)]
        self.rng = random.Random(seed)

    def cell(self, x: int, y: int) -> Cell:
        return self.grid[y][x]

    def in_bounds(self, x: int, y: int) -> bool:
        return 0 <= x < self.w and 0 <= y < self.h

    def neighbors(self, x: int, y: int) -> Iterable[Tuple[Dir, Cell]]:
        for d in DIRS:
            nx, ny = x + d[0], y + d[1]
            if self.in_bounds(nx, ny):
                yield d, self.cell(nx, ny)

    def carve(self, ax: int, ay: int, bx: int, by: int) -> None:
        """打通相邻两格之间的墙"""
        dx, dy = bx - ax, by - ay
        d = (dx, dy)
        if d not in DIRS:
            raise ValueError("cells are not adjacent")
        a = self.cell(ax, ay)
        b = self.cell(bx, by)
        a.walls[d] = False
        b.walls[OPP[d]] = False

    def to_ascii(self) -> str:
        """
        经典 ASCII 渲染:+---+ 框架。
        输出尺寸约为 (2*h+1) 行。
        """
        lines: List[str] = []
        # 顶边
        lines.append("+" + "---+" * self.w)
        for y in range(self.h):
            # 中间行(竖墙)
            row1 = ["|"]
            for x in range(self.w):
                row1.append("   ")
                row1.append("|" if self.cell(x, y).walls[E] else " ")
            lines.append("".join(row1))
            # 底边行(横墙)
            row2 = ["+"]
            for x in range(self.w):
                row2.append("---" if self.cell(x, y).walls[S] else "   ")
                row2.append("+")
            lines.append("".join(row2))
        return "\n".join(lines)

    def open_entrance_exit(self, entrance: Tuple[int,int], exit_: Tuple[int,int]) -> None:
        """
        在外边界开两个口。入口出口必须在边界上。
        """
        ex, ey = entrance
        ox, oy = exit_
        for (x, y) in [entrance, exit_]:
            if not self.in_bounds(x, y):
                raise ValueError("entrance/exit out of bounds")
            if x not in (0, self.w - 1) and y not in (0, self.h - 1):
                raise ValueError("entrance/exit must be on boundary")

        # 入口开口
        if ey == 0: self.cell(ex, ey).walls[N] = False
        elif ey == self.h - 1: self.cell(ex, ey).walls[S] = False
        elif ex == 0: self.cell(ex, ey).walls[W] = False
        else: self.cell(ex, ey).walls[E] = False

        # 出口开口
        if oy == 0: self.cell(ox, oy).walls[N] = False
        elif oy == self.h - 1: self.cell(ox, oy).walls[S] = False
        elif ox == 0: self.cell(ox, oy).walls[W] = False
        else: self.cell(ox, oy).walls[E] = False

这套骨架背后最重要的思想是:你把"迷宫"定义为一个 cell 图,把"生成迷宫"定义为在这张图上选择边,最终得到一个生成树或近似生成树的子图。Wikipedia 的总结非常直接:迷宫生成常被视作随机生成树问题;若你想引入回路,也是在生成树基础上再添加一些边。(维基百科) 下面所有算法都会围绕 carve() 这一个动作展开,但每个算法选择"下一条 carve 的边"的策略完全不同,于是迷宫呈现出完全不同的纹理。

3 随机深度优先(递归回溯):最常用、最"长走廊"的完美迷宫

3.1 从 DFS 到迷宫纹理:为什么它总爱"走到黑"

随机深度优先(randomized depth-first search)也叫递归回溯(recursive backtracker)。它的逻辑不是"同时在许多边界上扩张",而是从一个起点出发,沿着随机挑选的未访问邻居不断深入,直到走进死胡同才回退;回退到某个仍有未访问邻居的分叉点时,再挑一条新路继续深入。因为它倾向于在同一条路径上尽可能走远,才会形成肉眼可见的"长走廊、分叉少"的风格。Wikipedia 对这种风格也有明确描述:DFS 生成的迷宫分支因子低、长通道多。(维基百科) Jamis Buck 也把它当作最容易上手、最常被人当作"默认迷宫生成法"的算法之一。(巴克博客)

3.2 递归实现与显式栈实现:避免 Python 递归深度问题

递归写法最直观,但 Python 默认递归深度有限;网格稍大就可能触发 RecursionError。工程上更稳妥的做法是显式栈。两者生成分布相同,差异只是控制流形式。下面给出显式栈版本,它会在性能和稳定性上更可靠。

复制代码
from typing import Set, Tuple

def generate_dfs_backtracker(maze: Maze, start: Tuple[int,int] = (0,0)) -> None:
    rng = maze.rng
    sx, sy = start
    visited: Set[Tuple[int,int]] = set()
    stack: List[Tuple[int,int]] = [(sx, sy)]
    visited.add((sx, sy))

    while stack:
        x, y = stack[-1]

        # 收集未访问邻居
        cand: List[Tuple[int,int]] = []
        for d, nb in maze.neighbors(x, y):
            if (nb.x, nb.y) not in visited:
                cand.append((nb.x, nb.y))

        if not cand:
            stack.pop()
            continue

        nx, ny = rng.choice(cand)
        maze.carve(x, y, nx, ny)
        visited.add((nx, ny))
        stack.append((nx, ny))

如果你在 ASCII 输出里观察,会发现 DFS 生成的迷宫很"像树":从入口往里走,经常遇到很长的单通道,然后突然出现一个分叉点,分叉点后又各自延伸出长通道。这种纹理有时很讨喜,因为它对人类解迷宫来说更像"探洞",但如果你要的是"每走几步就要做选择"的高分叉迷宫,DFS 可能就显得太"线性"。

4 随机 Prim:像"边界起火"一样扩张的迷宫生长

4.1 从最小生成树到迷宫:为什么它更"碎"、岔路更多

Prim 算法本来用于最小生成树:从一个起点开始,不断从"已连通集合"到"未连通集合"的边界上挑一条最小权重边加入。随机 Prim 迷宫把"权重"替换成随机选择:它维护一组边界候选边(或候选墙),每次随机选一条,如果它能把一个未访问 cell 接入已访问集合,就打通它并把新 cell 的边界加入候选集。与 DFS 相比,Prim 并不执着于"一路走到黑",而是在不断扩张的边界上随机挑边,因此迷宫更容易出现很多短分支、岔路密集的感觉。Wikipedia 把它作为"图论方法"中典型示例之一。(维基百科)

4.2 Python 实现:用"前沿边"而不是"前沿点"

这里的关键是:前沿存的是"边(a->b)",不是 cell。这样每次取出一条边时,你能判断它是否连接了一个新 cell。实现里我们用列表当作简化版的随机队列,想更快可以换成 deque 或者用"随机索引删除"的技巧。

复制代码
from typing import Set, Tuple

def generate_random_prim(maze: Maze, start: Tuple[int,int] = (0,0)) -> None:
    rng = maze.rng
    sx, sy = start
    visited: Set[Tuple[int,int]] = set()
    visited.add((sx, sy))

    frontier: List[Tuple[int,int,int,int]] = []  # (ax,ay,bx,by)

    def add_frontier(x: int, y: int) -> None:
        for _, nb in maze.neighbors(x, y):
            if (nb.x, nb.y) not in visited:
                frontier.append((x, y, nb.x, nb.y))

    add_frontier(sx, sy)

    while frontier:
        i = rng.randrange(len(frontier))
        ax, ay, bx, by = frontier.pop(i)
        if (bx, by) in visited:
            continue
        maze.carve(ax, ay, bx, by)
        visited.add((bx, by))
        add_frontier(bx, by)

你会发现它生成的迷宫,整体更"颗粒化":因为前沿在整个已访问区域周围摆动,随机挑选会在空间上更均匀地扩张,这种感觉在视觉上接近"灌木丛式的分叉"。如果你做游戏关卡,希望玩家在较短时间内频繁做选择,随机 Prim 往往比 DFS 更合适。

5 随机 Kruskal:并查集驱动的"全局打乱后逐条连通"

5.1 生成树的另一条路:先把所有墙打乱,再决定拆不拆

Kruskal 的精神和 Prim 完全不同:Prim 是"从一个连通块长出去",Kruskal 是"全局把所有边随机排序,然后从前到后扫一遍,遇到能连通两个不同集合的边就加入"。迷宫语境下就是:列举所有相邻 cell 的墙(边),随机打乱;依次拿出来,如果这堵墙两侧 cell 还不在同一个连通分量里,就拆墙并合并集合,否则保留墙以避免产生环。Wikipedia 给了这种"带集合的迭代随机 Kruskal"描述,核心就是"不同集合才拆墙"。(维基百科)

5.2 并查集实现:路径压缩与按秩合并让大迷宫也能快

并查集(Union-Find)是这类算法的发动机。没有并查集也能写,但会慢到让你怀疑人生。下面是一个足够工程化、但仍然短小的实现。

复制代码
class UnionFind:
    def __init__(self, n: int):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, a: int) -> int:
        while self.parent[a] != a:
            self.parent[a] = self.parent[self.parent[a]]
            a = self.parent[a]
        return a

    def union(self, a: int, b: int) -> bool:
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        if self.rank[ra] < self.rank[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        if self.rank[ra] == self.rank[rb]:
            self.rank[ra] += 1
        return True

def generate_random_kruskal(maze: Maze) -> None:
    rng = maze.rng
    w, h = maze.w, maze.h
    uf = UnionFind(w * h)

    def idx(x: int, y: int) -> int:
        return y * w + x

    edges: List[Tuple[int,int,int,int]] = []
    for y in range(h):
        for x in range(w):
            if x + 1 < w:
                edges.append((x, y, x+1, y))
            if y + 1 < h:
                edges.append((x, y, x, y+1))

    rng.shuffle(edges)

    for ax, ay, bx, by in edges:
        if uf.union(idx(ax, ay), idx(bx, by)):
            maze.carve(ax, ay, bx, by)

随机 Kruskal 的迷宫通常会呈现一种"全局均匀随机"的气质:因为它不是从单点扩张,而是从全局随机边集合里筛边,所以结构分布更散。很多人会把 Prim 和 Kruskal 放在一起比较纹理差异;你如果做可视化动画,会发现 Prim 像"长出一片森林",Kruskal 像"给整块玻璃裂纹随机连通"。这类对比也经常出现在迷宫算法讨论中。(维基百科)

6 递归分割:先造房间再开门的"建筑师式"迷宫

6.1 它为什么看起来更"规整":长直墙与强几何感

如果说前面三类算法都是"在 cell 图上挑边",递归分割(recursive division)更像"在连续空间里画墙":先从一片空地开始,在某个方向上画一道横墙或竖墙,然后在墙上留一个或多个门洞,再把空间分成两个子区域,对每个子区域递归重复。Wikipedia 介绍这种方法时强调的是"从无墙开始,逐步加墙并留洞"。(维基百科) 它生成的迷宫往往具有明显的"建筑平面图"气质:长直墙很多,局部像房间与走廊的组合。某些游戏(尤其偏写实或偏解谜的室内场景)会更喜欢这种纹理,因为它比"树状洞穴"更像人造结构。

6.2 Python 实现:在 cell-walls 上做"加墙等价变换"

递归分割的传统实现通常在"像素栅格"上更直观,因为它是加墙;而我们目前的 Maze 类是"默认四周有墙,然后拆墙"。这两者看似矛盾,但可以转个视角:我们可以先把迷宫初始化为"全通"(把所有相邻墙都打开),然后递归地"关上某些墙",只在门洞处保持打开。为了不破坏框架,这里我给一个简洁做法:先全通,再用递归分割关闭。

复制代码
def make_all_open(maze: Maze) -> None:
    for y in range(maze.h):
        for x in range(maze.w):
            for d, nb in maze.neighbors(x, y):
                # 只向东南开,避免重复
                if (d == E) or (d == S):
                    maze.carve(x, y, nb.x, nb.y)

def generate_recursive_division(maze: Maze, min_room: int = 1) -> None:
    rng = maze.rng
    make_all_open(maze)

    def close_between(ax: int, ay: int, bx: int, by: int) -> None:
        dx, dy = bx - ax, by - ay
        d = (dx, dy)
        a, b = maze.cell(ax, ay), maze.cell(bx, by)
        a.walls[d] = True
        b.walls[OPP[d]] = True

    def divide(x0: int, y0: int, x1: int, y1: int) -> None:
        # 区域为 [x0,x1]×[y0,y1] 的 cell 坐标闭区间
        width = x1 - x0 + 1
        height = y1 - y0 + 1
        if width <= min_room or height <= min_room:
            return

        # 选择分割方向:尽量沿更长的一边切,也可以纯随机
        if width > height:
            cut_vertical = True
        elif height > width:
            cut_vertical = False
        else:
            cut_vertical = rng.choice([True, False])

        if cut_vertical:
            # 在 x = cut 处竖切:左区 [x0..cut] 右区 [cut+1..x1]
            cut = rng.randint(x0, x1 - 1)
            hole = rng.randint(y0, y1)
            # 关闭 cut 与 cut+1 之间的所有连接,唯独 hole 行留门
            for y in range(y0, y1 + 1):
                if y == hole:
                    continue
                close_between(cut, y, cut + 1, y)
            divide(x0, y0, cut, y1)
            divide(cut + 1, y0, x1, y1)
        else:
            # 在 y = cut 处横切:上区 [y0..cut] 下区 [cut+1..y1]
            cut = rng.randint(y0, y1 - 1)
            hole = rng.randint(x0, x1)
            for x in range(x0, x1 + 1):
                if x == hole:
                    continue
                close_between(x, cut, x, cut + 1)
            divide(x0, y0, x1, cut)
            divide(x0, cut + 1, x1, y1)

    divide(0, 0, maze.w - 1, maze.h - 1)

递归分割常被用来做"有房间感"的关卡雏形:你可以把门洞从"留一个"变成"留两个",就会出现更多回路与房间连通;你也可以对某些子区域停止分割,天然形成大房间。这就是它在工程上很实用的原因:它不仅生成迷宫,还天然生成"空间结构"。

7 Sidewinder 与 Binary Tree:带偏置的极简算法与"可预测纹理"

7.1 偏置不是缺点:当你需要"方向性"与"可控风格"

有些迷宫算法在 Wikipedia 的分类里被叫做"简单算法",比如 Sidewinder、Binary Tree。(维基百科) 它们之所以在生产上仍然常见,不是因为"学术上更高级",恰恰相反,是因为它们非常便宜、非常稳定,并且纹理偏置强到可以当作一种美术风格。你在做像素风游戏时可能就想要这种"倾斜的树状纹理",而不是高随机性的自然洞穴。更重要的是,这类算法非常适合"流式生成":只需要处理一行或局部,不必把全图状态都存起来。

7.2 Sidewinder:一行一行地"横向跑团",偶尔往上打一个洞

Sidewinder 的直觉是:第一行全开通道;从第二行开始,每行维护一个"run"(连续的横向通道段),你在这一行里不断向东打通,把 cell 加进 run;但你会在随机时刻"收束"这个 run,从 run 中随机挑一个 cell 向北打通,然后清空 run,继续新的 run。因为它总能向北连通到上一行,所以最终是完美迷宫;同时它天然带有"水平偏置"。Wikipedia 也提到它的行级生成方式和结构特性。(维基百科)

复制代码
def generate_sidewinder(maze: Maze) -> None:
    rng = maze.rng
    w, h = maze.w, maze.h

    # 第一行:一路向东打通
    for x in range(w - 1):
        maze.carve(x, 0, x + 1, 0)

    for y in range(1, h):
        run: List[int] = []
        for x in range(w):
            run.append(x)

            at_eastern_boundary = (x == w - 1)
            # 随机决定是否"收束 run"
            close_out = at_eastern_boundary or rng.choice([True, False])

            if close_out:
                carve_x = rng.choice(run)
                # 向北打通
                maze.carve(carve_x, y, carve_x, y - 1)
                run = []
            else:
                # 继续向东延伸 run
                maze.carve(x, y, x + 1, y)

7.3 Binary Tree:每格只在两个方向里选一个,形成"斜向树根"

Binary Tree 更极端:对每个 cell,只在"北"和"西"(或你选择的两方向)里随机打通一面墙,于是所有路径都会倾向某个角落,整体看起来像一棵朝某方向生长的树。Wikipedia 对它的描述强调了这种"每个 cell 只连上或左"的偏置特征。(维基百科)

复制代码
def generate_binary_tree(maze: Maze, bias: Tuple[Dir, Dir] = (N, W)) -> None:
    rng = maze.rng
    d1, d2 = bias

    for y in range(maze.h):
        for x in range(maze.w):
            options: List[Tuple[int,int]] = []
            for d in (d1, d2):
                nx, ny = x + d[0], y + d[1]
                if maze.in_bounds(nx, ny):
                    options.append((nx, ny))
            if options:
                nx, ny = rng.choice(options)
                maze.carve(x, y, nx, ny)

这类算法的价值在于:你几乎不用调参就能得到稳定风格,而且能非常快地产生大迷宫;缺点也同样明显:偏置强,会让迷宫某些方向"永远不可能出现死路",对解题者而言可被利用,难度上限受限。但如果你反过来需要"可被玩家学习和掌握的规律",那它反而是优点。

8 Eller:只看一行也能生成无限高完美迷宫的"集合魔法"

8.1 为什么它值得学习:流式生成、线性复杂度与行级状态

Eller 算法经常被称为"能流式生成的完美迷宫算法":它只需要维护当前行的集合信息,就能一行一行地往下生成,而且能做到"无限高度"的概念(当然现实里你还是会有高度)。Jamis Buck 对它的评价非常直白:它既疯狂又快,并且只需看一行。(巴克博客) Wikipedia 也提到它通过集合避免回路,并能仅存一行信息完成生成。(维基百科)

Eller 的核心在于"集合(set id)":同一集合表示这些 cell 在已经生成的部分里是连通的。你在一行内随机决定横向连接(合并集合),然后必须保证每个集合至少有一个 cell 向下打通,否则下一行会出现被隔绝的连通块,最终迷宫不连通。到最后一行时,你再强制把所有集合横向连通成一个集合,结束。

8.2 Python 实现:可读性优先的 Eller(适合学习与改造)

复制代码
def generate_eller(maze: Maze) -> None:
    rng = maze.rng
    w, h = maze.w, maze.h
    next_set_id = 1

    sets = [0] * w  # 当前行每列的 set id

    for y in range(h):
        # 为空的列分配新集合
        for x in range(w):
            if sets[x] == 0:
                sets[x] = next_set_id
                next_set_id += 1

        # 行内横向连接(除最后一行外随机,最后一行强制合并)
        for x in range(w - 1):
            if y == h - 1:
                # 最后一行:强制把所有不同集合连通
                if sets[x] != sets[x + 1]:
                    maze.carve(x, y, x + 1, y)
                    old = sets[x + 1]
                    new = sets[x]
                    for k in range(w):
                        if sets[k] == old:
                            sets[k] = new
            else:
                # 非最后一行:随机决定是否横向打通,且避免同集合产生环
                if sets[x] != sets[x + 1] and rng.choice([True, False]):
                    maze.carve(x, y, x + 1, y)
                    old = sets[x + 1]
                    new = sets[x]
                    for k in range(w):
                        if sets[k] == old:
                            sets[k] = new

        if y == h - 1:
            break

        # 决定向下开口:每个集合至少开一个
        new_sets = [0] * w
        # 按集合分组列索引
        groups: Dict[int, List[int]] = {}
        for x in range(w):
            groups.setdefault(sets[x], []).append(x)

        for set_id, xs in groups.items():
            # 该集合中随机选若干个向下打通,至少一个
            rng.shuffle(xs)
            open_count = rng.randint(1, len(xs))
            for x in xs[:open_count]:
                maze.carve(x, y, x, y + 1)
                new_sets[x] = set_id

        sets = new_sets

这段实现的好处是直观:你能清楚看到"行内合并集合"和"确保每集合至少一个向下连接"的两个关键约束。它也非常容易改造成"在线生成":每次生成一行就输出一行,尤其适合流式地图、无限跑酷、或者需要在内存极小环境下跑的场景。

9 Aldous--Broder 与 Wilson:走向"均匀生成树"的随机游走系算法

9.1 "无偏"到底是什么意思:均匀生成树与算法偏置

到目前为止,我们介绍的 DFS、Prim、Kruskal、Eller、Sidewinder 等,虽然都能生成完美迷宫,但它们对"哪一种生成树更容易被生成"都有偏好:有的更爱长走廊,有的更爱碎分叉,这种"偏置"是纹理来源,也是风格来源。而另一条路线是:我不想要风格偏置,我想在所有可能的生成树中做均匀采样 ,让每一棵生成树出现概率相同。Wikipedia 在介绍 Aldous--Broder 与 Wilson 时,直接把它们放在"能产生均匀生成树"的算法里。(维基百科) Jamis Buck 也强调 Aldous--Broder 的重要性质:它从所有生成树中等概率选择,但效率很差。(巴克博客)

这种"均匀生成树"的严肃数学背景来自随机游走与马尔可夫链理论。Aldous 的论文讨论了随机游走构造均匀生成树的性质,并给出"每棵生成树概率相同"的命题。(CMU计算机科学学院) 你不需要读完论文才能用算法,但知道"无偏"意味着什么,会让你在做内容生成时更清醒:你要的是"某种美术风格",还是要的是"统计意义上的公平随机"。

9.2 Aldous--Broder:最简单的均匀生成树,但常被吐槽"慢得令人痛苦"

Aldous--Broder 非常好理解:在图上做随机游走;当你第一次走到一个从未访问过的节点时,就把这一步的边加入生成树;直到所有节点都被访问。它慢的原因也直观:随机游走覆盖整个图的"覆盖时间"可能很大,尤其在大网格上会走很多无用步。Wikipedia 与 Jamis Buck 都提到它效率差。(维基百科)

复制代码
from typing import Set, Tuple

def generate_aldous_broder(maze: Maze, start: Optional[Tuple[int,int]] = None) -> None:
    rng = maze.rng
    if start is None:
        x, y = rng.randrange(maze.w), rng.randrange(maze.h)
    else:
        x, y = start

    visited: Set[Tuple[int,int]] = {(x, y)}
    total = maze.w * maze.h

    while len(visited) < total:
        # 随机走到一个邻居
        d, nb = rng.choice(list(maze.neighbors(x, y)))
        nx, ny = nb.x, nb.y
        if (nx, ny) not in visited:
            maze.carve(x, y, nx, ny)
            visited.add((nx, ny))
        x, y = nx, ny

9.3 Wilson:循环擦除随机游走(LERW),更快、更优雅

Wilson 算法同样生成均匀生成树,但通常比 Aldous--Broder 高效得多。它从一个已在树中的集合开始,然后对一个不在树中的起点做随机游走,直到碰到树;在游走过程中如果形成回路,就把回路"擦除"(loop-erased random walk),最后把擦除后的路径整体并入树。这个算法在很多可视化资源里都被当作"美学最强"的一种,因为它的循环擦除过程很有戏剧性。Mike Bostock 的说明简洁点出它的关键性质:Wilson 用循环擦除随机游走生成均匀生成树,很多其他算法不具备这种"无偏"性质。(Gist)

复制代码
from typing import Dict, Set, Tuple

def generate_wilson(maze: Maze, root: Optional[Tuple[int,int]] = None) -> None:
    rng = maze.rng
    all_cells = [(x, y) for y in range(maze.h) for x in range(maze.w)]
    if root is None:
        root = rng.choice(all_cells)

    in_tree: Set[Tuple[int,int]] = {root}

    # 便利:从 (x,y) 随机取邻居
    def rand_neighbor(x: int, y: int) -> Tuple[int,int]:
        _, nb = rng.choice(list(maze.neighbors(x, y)))
        return nb.x, nb.y

    for start in all_cells:
        if start in in_tree:
            continue

        # 随机游走并做 loop-erased:用 next 指针记录路径,若形成环就擦除
        path_next: Dict[Tuple[int,int], Tuple[int,int]] = {}
        x, y = start
        while (x, y) not in in_tree:
            nx, ny = rand_neighbor(x, y)
            path_next[(x, y)] = (nx, ny)
            x, y = nx, ny

        # 将 loop-erased 路径并入树:从 start 顺着 next 走到树
        x, y = start
        while (x, y) not in in_tree:
            nx, ny = path_next[(x, y)]
            maze.carve(x, y, nx, ny)
            in_tree.add((x, y))
            x, y = nx, ny
        in_tree.add((x, y))

Wilson 的实现细节比 DFS/Prim 更"绕",但它一旦写对,就会给你一个非常强的能力:你能在"纹理偏置"和"统计无偏"之间做明确选择。对于程序生成内容来说,这不是小事,因为偏置意味着某些结构更常出现,玩家会潜移默化地学会利用;而无偏意味着玩家更难凭经验投机取巧。

10 让算法变成"可用系统":入口出口、加环、难度调参与验证

10.1 入口出口并不是"开两个洞"这么简单

很多人生成完迷宫后随便在边上开两个洞就完事,但如果你做的是游戏关卡,你经常会希望入口与出口之间的主路径足够长,或者希望出口距离入口在图上的最短路超过某个阈值。做法并不复杂:生成迷宫后做一次 BFS/DFS 求最远点对(在树上就是找直径近似),然后把入口出口开在这对点上。即便你不做最远点对,至少也应当允许由外部指定入口出口位置,然后在外边界对应方向开口;上面 open_entrance_exit() 已经支持。Stack Overflow 上也有人讨论"如何选择入口出口点"的问题,本质就是把入口出口当作边界节点,并在生成后做路径长度控制。(Stack Overflow)

10.2 从"完美迷宫"到"更像现实的迷宫":加少量回路(braiding)

完美迷宫的特征是唯一解,但现实里很多迷宫并非如此。你可以在生成树基础上再随机拆掉少量"不会破坏外墙"的墙,让迷宫出现回路;这会显著改变体验:死胡同减少,路线选择增加,AI 寻路也更接近一般图。Wikipedia 也提到回路可通过"额外添加随机边"引入。(维基百科) 工程上常见做法是:统计所有死胡同(度为 1 的 cell),对其中一部分随机打通一面邻墙,把死胡同"编织"成回路。调参时你会发现,回路比例不是越高越好;太高会让迷宫失去"迷"的味道,变成普通网格通道。

10.3 难度与风格调参:你真正能控制的变量是什么

迷宫"难不难"并不只取决于大小。DFS 生成的长走廊,可能让玩家走很久但做选择很少;Prim 生成的碎分叉,可能让玩家频繁做选择但每次代价不大;递归分割生成的长直墙,会让玩家更依赖空间感与"房间逻辑"。你能控制的变量,往往是"算法选择 + 算法内随机策略 + 后处理(加环/加房间/加权)"。你如果想定量化难度,可以从图指标入手:平均分支度、死胡同比例、入口到出口的最短路长度、路径上的决策点数量、以及"错误路线的代价"(比如从岔路走到死胡同的平均距离)。这类指标一旦和算法绑定,你就能做"风格库":同样大小,同样入口出口,不同算法就是不同关卡味道。

11 把一切串起来:一个可运行示例与输出

11.1 最小示例:生成、开口、打印

复制代码
def demo():
    m = Maze(20, 10, seed=20251224)

    # 选择一种算法
    generate_dfs_backtracker(m, start=(0, 0))
    # generate_random_prim(m, start=(0, 0))
    # generate_random_kruskal(m)
    # generate_recursive_division(m)
    # generate_sidewinder(m)
    # generate_binary_tree(m, bias=(N, W))
    # generate_eller(m)
    # generate_aldous_broder(m)
    # generate_wilson(m)

    m.open_entrance_exit((0, 0), (m.w - 1, m.h - 1))
    print(m.to_ascii())

if __name__ == "__main__":
    demo()

你会得到一张 ASCII 迷宫。接下来你可以很自然地加上 matplotlib 或 pygame 做渲染;也可以把 walls 导出成 tilemap(例如 Tiled JSON),用于游戏引擎。只要你的内部表示足够干净,算法越多,你的系统反而越稳,因为每个算法都在用不同方式"折腾"同一套数据结构,这就是最好的回归测试。

12 结语:选择算法,其实是在选择"随机性的哲学"

迷宫生成算法的世界表面上像"十几种经典算法供你挑",但真正的分水岭其实就两条:一条是把迷宫当作随机生成树,接受算法偏置,把偏置当作风格;另一条是追求均匀生成树,把"无偏"当作目标,接受更复杂的实现与更高的成本。Wikipedia 的总述把这两条路线都讲得很清楚:迷宫生成可以视作生成树;回路可以后加;Aldous--Broder 与 Wilson 提供均匀生成树,而 DFS/Prim/Kruskal 更偏向工程常用。(维基百科) Jamis Buck 的系列则告诉你:不要只问"哪个最好",要问"我想要什么纹理"。(巴克博客)


相关推荐
Knight_AL2 小时前
Java 可变参数 Object... args 详解:原理、用法与实战场景
java·开发语言·python
深蓝海拓2 小时前
PySide6从0开始学习的笔记(十二) QProgressBar(进度条)
笔记·python·qt·学习·pyqt
醒过来摸鱼2 小时前
《线性空间》专栏写作计划(目录)
算法
C雨后彩虹2 小时前
幼儿园分班
java·数据结构·算法·华为·面试
Yupureki2 小时前
《算法竞赛从入门到国奖》算法基础:入门篇-二分算法
c语言·开发语言·数据结构·c++·算法·visual studio
xwill*2 小时前
Python 的类型提示(type hint)
开发语言·pytorch·python
汉堡go2 小时前
python_chapter3
开发语言·python
qq_463408422 小时前
React Native跨平台技术在开源鸿蒙中使用WebView来加载鸿蒙应用的网页版或通过一个WebView桥接本地代码与鸿蒙应用
javascript·算法·react native·react.js·开源·list·harmonyos
Jul1en_2 小时前
【算法】位运算
算法