拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++实现Prim算法生成随机迷宫:核心原理与完整代码解析

C++实现Prim算法生成随机迷宫:核心原理与完整代码解析

如果你最近正在给2D游戏写地图生成,或者单纯想练一练C++的算法手感,用Prim算法生成随机迷宫是一个非常合适的练手项目。它代码量不大,几十行就能跑出肉眼可见的效果,而且生成结果天然满足迷宫的两条硬性要求:任意两个格子之间都有通路、整张图不存在回路。我这次把整个实现从头到尾捋一遍,数据结构怎么设计、算法每一步到底在干什么、怎么把迷宫用ASCII字符打印出来,包括我实际调试时踩过的几个坑,都一起写清楚。所有代码都是C++实现,本机装好编译器就能直接跑。

用Prim算法生成随机迷宫,本质上不是“发明”新算法,而是把经典最小生成树思想迁移到图模型上。这个迁移过程非常优雅,理解了之后你会发现迷宫生成和网络布线、地图寻路这些问题的底层逻辑是相通的。下面我从选型开始讲,然后给完整实现,最后说说调试过程中容易掉进去的坑。

1. 迷宫生成方案选型:为什么是Prim算法

1.1 常见迷宫算法的性格差异

迷宫生成算法在游戏开发里通常有四种主流选择:递归回溯(Recursive Backtracker)、Prim算法、Kruskal算法、递归分割(Recursive Division)。它们生成出来的迷宫“性格”完全不同,选错方案会导致关卡手感差异很大。

递归回溯的典型特征是从起点一路扎进去,能拐就拐,走到死胡同再退回岔口。它生成的迷宫有大量又长又深的走廊,适合恐怖游戏、地牢探险这类需要“压迫感”的场景,但问题是死胡同区域非常集中,玩家一旦绕错方向往往要原路走很远。

Kruskal算法和Prim算法都来自最小生成树,但处理边的顺序不同。Kruskal把所有墙随机排序后逐条打通,只要不形成环就保留。它生成的迷宫整体分布均匀,没有明显的“主路”概念,每个区域的连通程度差不多。

递归分割则是把大矩形不断劈开再开洞,实现简单但生成的迷宫带有明显的“房间感”,不太像传统意义的迷宫,更像建筑平面图。

1.2 Prim算法生成迷宫的视觉特征

Prim算法跑出来的迷宫,直观印象是“分支特别多,直路特别短”。每个格子被访问后,它四周的邻居都会作为候选边被扔进池子里,下一次随机挑一条边,这导致探索方向会不断从已建成的区域向四面八方扩散。

你可以把递归回溯生成的迷宫想象成一棵扎得很深的主干树,而Prim生成的迷宫更像珊瑚礁:每个方向都有分支生长,通道不会延伸太远就被打断。这种结构的特点是非常适合开放世界探索,玩家几乎不会遇到“一条长路走到底发现是死胡同”的挫败感,反而每走几步就有岔路可选,天然引导玩家四处逛。

从实际体验来看,Prim迷宫的死胡同数量多但分布均匀。这也是为什么很多Roguelike游戏和程序化生成关卡的地图会优先考虑Prim的原因。

1.3 为什么最终选定Prim作为这个项目的核心

选择Prim并不是因为它生成的迷宫“最好”,而是因为它有三个很适合从零实现的特点。

第一,Prim的算法结构和迷宫模型映射得非常干净。迷宫里的格子就是图节点,墙就是边,打通一堵墙就等于在生成树上加一条边。整个算法只需要维护一个候选边集合,不需要像递归回溯那样维护深层的函数调用栈。

第二,Prim天然保证生成结果是一棵生成树。只要你在选边时不破坏“每次选中的边一定连接已访问区域和未访问区域”这个约束,最终必然得到一个包含所有格子的连通无环图,也就是“完美迷宫”。这意味着你不用额外写检测回路或者检测断网的代码,算法本身的构造过程已经把这两件事挡住了。

第三,它便于控制迷宫风格。如果后续想调整迷宫,让走廊更直、分支更少,只需把随机选边改成带权选边,权重偏向某个方向即可。比如给上下方向权重设大一点,迷宫就会呈现出纵向走廊偏多的趋势。这个特性对做关卡编辑器非常有用。

2. 核心数据结构设计:从格子到候选边

2.1 格子模型:一堵墙该由谁来记录

迷宫的基本单位是格子,每个格子有四堵墙:上、右、下、左。在C++里,我直接用两个数组来表达一个格子。

enum Direction { UP = 0, RIGHT = 1, DOWN = 2, LEFT = 3 }; struct Cell { bool wall[4]; bool visited; Cell() : visited(false) { wall[UP] = wall[RIGHT] = wall[DOWN] = wall[LEFT] = true; } };

wall[4]分别记录四个方向的墙是否存在,true表示墙还在,false表示已经打通。visited标记这个格子是否已经加入生成树。

墙为什么要两端都存一份?因为迷宫里的每一堵墙在概念上属于两个相邻格子。比如(0,0)的右墙和(0,1)的左墙是同一堵墙,打通时两边都要改成false,否则打印或者寻路的时候会出现“单向墙”的诡异情况。这个细节很基础,但非常容易漏。

2.2 方向偏移表:用查表代替四段if else

处理迷宫的时候,经常需要计算相邻格子的坐标。用四个方向的偏移数组可以省掉大量重复代码。

const int dr[4] = {-1, 0, 1, 0}; const int dc[4] = {0, 1, 0, -1}; const int opposite[4] = {DOWN, LEFT, UP, RIGHT};

dr和dc分别表示当前格子沿某个方向走一步之后,行坐标和列坐标的变化量。这个固定的方向编号顺序是上、右、下、左,顺时针一圈。opposite数组用来取反方向:向上的反方向是向下,向右的反方向是向左,依次类推。

有了这两张表,我只需要写一份遍历四个方向的循环逻辑,就能同时处理墙壁打通、邻居判断、打印渲染等多个环节。

2.3 候选边集合:只存“已访问的一端”就够了

Prim算法在迷宫里的核心操作是:从候选边集合里随机取一条边,如果这条边连接着“已访问格子”和“未访问格子”,就打穿它。所以每条候选边需要记录的信息其实只有两部分:起点格子的坐标,以及方向。

struct Edge { int r, c; int dir; };

为什么不需要记录边的另一端?因为只要知道起点坐标和方向,就可以通过dr、dc直接算出另一端坐标。如果想记录完整两端坐标也可以,但会多占用内存,而且判断“另一端是否已访问”时还得额外写一个函数来匹配,没必要。

候选边集合的容器选型上,很多人第一反应是用std::set或std::priority_queue,但在这里并不合适。我们需要的是一个支持随机访问、随机删除、快速插入的容器,std::vector就是最合适的。配合一个swap-pop小技巧,可以做到O(1)删除任意位置的元素,这点在迷宫尺寸变大之后非常重要。

3. Prim算法核心逻辑与C++完整实现

3.1 算法流程:从一棵树开始长成迷宫

整个算法的流程可以拆成四步,理解这四步就不存在实现困难:

  1. 初始化:所有格子墙都保留,visited均为false。
  2. 选择起始格子,将它标记为visited,并把该格四周围墙对应的候选边全部加入集合。
  3. 从候选边集合里随机取出一条边a。检查这条边另一端的格子b是否已经visited:
    • 如果b已经访问过,说明这条边是无效边,直接丢弃,继续随机取;
    • 如果b还没访问过,就把a与b之间的墙打通,把b标记为visited,再把b四周连接未访问格子的候选边全部加入集合。
  4. 重复第3步,直到候选边集合为空。

这个流程本质上就是最小生成树Prim算法。候选边就是横跨“已访问集合”和“未访问集合”的割边,我们每次随机挑其中一条,把它纳入生成树。因为最初只有起点一个格子,而新格子必须通过打墙才能加入,所以整个迷宫必定连通;又因为每次打墙都会把一个新的未访问格子拉进集合,所以打墙次数恰好是格子数减一,不可能形成回路。

3.2 完整可运行代码

下面是完整代码,我按函数拆分:generateMaze负责生成,printMaze负责渲染,countReachableCells负责验证连通性。编译器需要支持C++17,主要用到了std::mt19937随机数引擎。

#include <iostream> #include <vector> #include <random> #include <algorithm> #include <stack> enum Direction { UP = 0, RIGHT = 1, DOWN = 2, LEFT = 3 }; const int dr[4] = {-1, 0, 1, 0}; const int dc[4] = {0, 1, 0, -1}; const int opposite[4] = {DOWN, LEFT, UP, RIGHT}; struct Cell { bool wall[4]; bool visited; Cell() : visited(false) { wall[UP] = wall[RIGHT] = wall[DOWN] = wall[LEFT] = true; } }; struct Edge { int r, c; int dir; }; std::vector<std::vector<Cell>> maze; std::mt19937 rng{std::random_device{}()}; bool isValid(int r, int c, int rows, int cols) { return r >= 0 && r < rows && c >= 0 && c < cols; } void generateMaze(int rows, int cols, int startR = 0, int startC = 0) { maze.assign(rows, std::vector<Cell>(cols)); std::vector<Edge> edges; auto addNeighborEdges = [&](int r, int c) { for (int d = 0; d < 4; ++d) { int nr = r + dr[d]; int nc = c + dc[d]; if (isValid(nr, nc, rows, cols) && !maze[nr][nc].visited) { edges.push_back({r, c, d}); } } }; maze[startR][startC].visited = true; addNeighborEdges(startR, startC); while (!edges.empty()) { std::uniform_int_distribution<int> pick(0, static_cast<int>(edges.size()) - 1); int idx = pick(rng); Edge e = edges[idx]; std::swap(edges[idx], edges.back()); edges.pop_back(); int nr = e.r + dr[e.dir]; int nc = e.c + dc[e.dir]; if (maze[nr][nc].visited) { continue; } maze[e.r][e.c].wall[e.dir] = false; maze[nr][nc].wall[opposite[e.dir]] = false; maze[nr][nc].visited = true; addNeighborEdges(nr, nc); } } int countReachableCells(int rows, int cols) { std::vector<std::vector<bool>> visited(rows, std::vector<bool>(cols, false)); std::stack<std::pair<int, int>> st; st.push({0, 0}); visited[0][0] = true; int cnt = 1; while (!st.empty()) { int r = st.top().first; int c = st.top().second; st.pop(); for (int d = 0; d < 4; ++d) { if (maze[r][c].wall[d]) continue; int nr = r + dr[d]; int nc = c + dc[d]; if (isValid(nr, nc, rows, cols) && !visited[nr][nc]) { visited[nr][nc] = true; ++cnt; st.push({nr, nc}); } } } return cnt; } void printMaze(int rows, int cols) { for (int c = 0; c < cols; ++c) std::cout << "+--"; std::cout << "+\n"; for (int r = 0; r < rows; ++r) { std::cout << "|"; for (int c = 0; c < cols; ++c) { std::cout << " "; std::cout << (maze[r][c].wall[RIGHT] ? "|" : " "); } std::cout << "\n"; std::cout << "+"; for (int c = 0; c < cols; ++c) { std::cout << (maze[r][c].wall[DOWN] ? "--" : " "); std::cout << "+"; } std::cout << "\n"; } } int main() { int rows = 12, cols = 18; generateMaze(rows, cols); printMaze(rows, cols); int reachable = countReachableCells(rows, cols); std::cout << "\nReachable cells: " << reachable << " / " << rows * cols << std::endl; return 0; }

这段代码没有加任何图形库,纯控制台输出。编译运行之后,终端里会直接显示一个由“+ - |”组成的迷宫,最后一行还会打印连通格子的数量。如果一切正常,这个数字应该和总格子数完全相等。

3.3 实现里三个容易被忽略的细节

第一个细节,visited标记必须在打墙的那一瞬间更新,不能在把候选边加入集合的时候就提前更新。如果提前标记,其他未访问邻居在加入候选边时就会漏掉从当前格子出发的边,最终导致迷宫某些区域断开。这也是Prim迷宫最常见的一个bug来源。

第二个细节,使用swap和pop_back的组合来删除随机选中的边。如果直接调用edges.erase(edges.begin() + idx),vector在中间删除元素时会把后面的所有元素往前搬,时间复杂度是O(n),在1000x1000的大迷宫里会很卡。先交换到末尾再弹出,删除成本直接变成O(1)。

第三个细节,edges里会积压很多无效边。比如某个格子通过一条边被加入生成树,但它之前可能已经在候选集合里挂了好几条来自不同邻居的边,这些边现在另一端的格子已经visited了,就是无效边。所以主循环里每次随机取边后都要重新检查另一端的visited状态,不能想当然认为所有候选边都有效。这也是为什么循环次数会明显大于成功的打墙次数。

4. ASCII渲染与迷宫可视化

4.1 打印方案是怎么设计出来的

要把迷宫打印得好看,关键在于理解ASCII迷宫的行结构。我这里的打印方案是:每一行格子都拆成两行来输出,第一行是格子内容和右墙,第二行是格子的下墙和连接点。

最顶部需要单独输出一条完整的边界线,每个格子用“+--”表示一段上边界。然后进入逐行打印。

处理第r行的格子行时,先输出一个“|”作为迷宫最左边的边界,然后循环每个格子:先输出两个空格表示格子内部空间,再根据当前格子的右墙是否存在输出“|”或者空格。处理完该行所有格子后,再单独输出一行下墙边界:每列先输出“+”作为竖线连接点,再根据当前格子下墙是否存在输出“--”或两个空格,最后以“+”收尾。

这个双层输出结构的初衷很简单:一个格子内部是两字符宽度,墙也是两字符宽度,这样整个ASCII图在视觉上是均匀的,行列能对齐。渲染逻辑看起来繁琐,但实际就是把二维数组翻译成文本。

4.2 一个小迷宫输出效果实测

以3x4迷宫为例,我手动构造了一个简单场景,渲染出来大概就是这个效果:

+--+--+--+--+ | | | | + + +--+ + | | | +--+--+ + + | | +--+--+--+--+

注意看行之间的对应关系:比如把这两个格子行合在一起读,就能看出“某个格子的下方是否打通”。这也是经验之谈,我记得最早自己写渲染函数时只用单行输出,结果就是迷宫看起来像一团乱麻,根本看不出通道方向。后来改成两行方案后,迷宫结构一目了然。

4.3 扩展思路:在打印里加入出入口标记

实际游戏里通常需要入口和出口。最简单的方式是把起点定在左上角(0,0),出口定在右下角(rows-1, cols-1),然后在打印函数里对这两个格子特殊处理。

比如在printMaze的格子内容部分,判断当前坐标是否是(0,0),是就输出“S ”表示起点;判断当前坐标是否是(rows-1, cols-1),是就输出“ E”表示终点。其他格子仍然输出两个空格。

不过要注意,右下角作为出口时,如果最后一行或最后一列有墙挡着,跑不出去。处理办法是在生成完迷宫之后,强制把(rows-1, cols-1)的DOWN墙或者RIGHT墙打通一个作为边界出口,具体打通哪个看你是想让出口在底部还是右侧。当然,这会让迷宫不再是一个严格封闭的矩形,但对游戏来说出口本来就应该开放。

5. 运行效果、复杂度与迷宫质量分析

5.1 复杂度分析:为什么Prim能扛住大尺寸迷宫

整个生成过程的核心开销集中在while循环里。每个格子被首次访问时,都会向edges里加入它四周未访问邻居对应的候选边,所以edges的总加入量不超过4乘以格子数。每条边最多被随机取出一次,因此主循环的总迭代次数也是O(V)级别,V是格子总数。

配合swap-pop的O(1)删除,整体时间复杂度是O(V)。处理1000x1000规模,也就是100万个格子,实际运行时间不到一秒。相比递归回溯在最坏情况下递归深度可能达到格子数量级,Prim没有这个栈深度风险。

空间上,迷宫本身需要用二维数组存每个格子的墙状态,这部分是O(V)。候选边edges的规模在最极端情况下也可能达到O(V),因为同一个未访问格子可能同时被加入多条候选边。整体空间复杂度是O(V)。

5.2 迷宫质量:不同算法风格对比

我把Pr生成的迷宫和递归回溯生成的迷宫里做了一个直观对比,两者的差异在100x100这种中等规模下非常明显。

Prim迷宫的分支密度高,死胡同多但分散在各处,从起点到任意格子的平均路径长度偏短。视觉上是典型的多岔路迷宫,适合开放世界探索和允许玩家自由绕路的关卡。

递归回溯迷宫的走廊更深,分支少但细长,局部存在大量死胡同区域,从起点到走错一个岔口后,往往要走一大段回头路。这种迷宫适合做线性推进的地牢或者需要紧张感的副本。

Kruskal生成的迷宫的均匀性更接近Prim,但因为它不是从起点向外扩张,而是全局随机连通,所以视觉上缺乏“中心感”,更适合那些不希望玩家感知到地图层次差异的玩法。

如果以2D游戏地图生成来看,我的建议是:大地图开放探索就用Prim,线性关卡就用递归回溯,需要生成多个模糊房间拼接感的地图就用递归分割。

6. 常见问题与排查技巧实录

6.1 迷宫不连通,一半格子访问不到

这是最常遇到的问题。我先说结论:90%的原因是visited标记的位置错了。Prim算法里,第一次发现未访问格子时,应该立刻把visited置为true,并且把它加入已访问集合。如果这个动作延迟到后续某个环节才执行,就会导致同一个格子被多条边试图重复连接,进而可能漏掉某些区域的连接。

排查方法是打印中间状态,看edges为空时还有多少格子未被访问。可以直接把countReachableCells的结果对比rows乘以cols。如果结果小于格子总数,就把起点换几个位置再测试,如果还是不通,说明算法逻辑有问题,不是随机种子的问题。

另外还要检查边界判断isValid是否包含了0和rows减1、cols减1这两个边界,数组越界有时候会恰好改掉相邻内存中的数据,导致莫名其妙的断连。

6.2 打印出来的迷宫左右不对称或者墙错位

渲染错位基本都是因为我没有在正确的位置输出空格。记住一个原则:每个格子的内部空间和墙符号各占固定宽度,不能因为“这里没墙”就少打一个字符。比如右墙不存在时,要输出一个空格而不是什么都不输出,否则下一格的内部空间会左移,整个阵列就歪了。

我调试时有一个土办法:把size调成2x2,只在printMaze里输出,然后肉眼检查哪些墙缺失、哪些墙多余。2x2的墙状态总共就那么几种,一眼就能看出打印函数哪里写错了,比直接看大迷宫高效得多。

6.3 std::random_device每次运行结果一样

某些环境下std::random_device可能不是真随机,而是退化成固定种子,导致每次运行生成的迷宫一模一样。这不是玄学,是真实存在的坑。如果发现迷宫每次跑都一样,可以改用chrono时间作为种子初始化std::mt19937,或者自己混入进程ID。

std::mt19937 rng{ static_cast<unsigned int>( std::chrono::steady_clock::now().time_since_epoch().count() ) };

6.4 编译环境:C++版本和工具链

这个项目用到了结构化绑定或者auto类型推断之外的C++17特性吗?严格说不是必需,但我建议直接用支持C++17的编译器。如果你在VSCode里配置C/C++环境,只需要创建tasks.json,用g++或clang++编译,命令大致是:

g++ -std=c++17 maze.cpp -o maze ./maze

Windows下如果遇到类似“error: microsoft visual c++ 14.0 or greater is required”的报错,通常是缺少Microsoft Visual C++ Redistributable或者Build Tools,装上对应版本的VC++工具集就能解决。这些环境和编译问题跟算法本身关系不大,但经常会拦住新手,所以这里单独提醒一句。

6.5 生成大迷宫时程序运行很久

如果程序在1000x1000以上的尺寸下明显卡顿,先检查edges删除元素的方式。如果用的不是swap-pop而是erase,问题基本就出在这里。其次检查addNeighborEdges里是否重复加入了大量无效边,虽然不影响正确性,但会拖慢速度。可以统计一下edges的最大尺寸和总循环次数,正常情况下总循环次数应该不超过4乘以格子数。

经过一轮实测,我在个人笔记本上跑2000x2000的迷宫,从生成到打印完整ASCII输出,整体耗时大概在几秒量级,大部分时间花在终端滚动输出上,算法本身的耗时非常低。

最后再分享一个我自己的使用习惯:我通常会把起点设置到左上角(0,0),然后在打印时把右下角作为终点,但并不会强制打通右下角的边界墙。因为很多时候我只是需要快速查看迷宫连通性,这个封闭的矩形反而方便我确认边界完整。当你需要真正做成可进出的关卡地图时,再根据场景需要把边界墙打通即可。Prim算法的好处就是它的逻辑足够简单,方便你在上面加各种自定义规则,改起来完全不心疼。

返回列表