免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

用C++实现随机Prim算法生成完美迷宫:从图论到游戏地图

用C++实现随机Prim算法生成完美迷宫:从图论到游戏地图 1. 项目概述为什么要用Prim算法生成随机迷宫生成随机迷宫这事儿乍一看像是某个课程设计的作业题但真做起来特别有意思。你可能在不少游戏里见过程序生成的迷宫地图比如Roguelike游戏里的地牢、某些解谜小游戏的关卡甚至是算法可视化网站上的演示动画。它们背后无一例外都是某种迷宫生成算法在跑而Prim算法是其中原理直观、实现省心、效果又很“匀称”的一种。这篇文章我打算用C把完整的随机迷宫生成过程拆开讲清楚。核心就一句话**用随机化后的Prim算法在一张网格图上不断“拆墙”最终生成一棵覆盖全图的生成树树上的边就是迷宫中可以走的路而没被拆掉的墙就构成了迷宫的分支与死胡同。**听起来有点绕但你跟着代码走一遍就明白了。适合谁来读我觉得三类人最合适一是C刚入门、想找个能练手又不枯燥的小项目的人二是游戏开发爱好者想在Unity、UE或者自己的小游戏里实现随机地图生成三是想理解图论算法在实际问题里怎么落地的同学。这篇文章不会只贴代码我会把每一步为什么这么做、数据按什么结构存、换一种做法会踩什么坑都讲一遍保证你看完能自己改、自己扩展而不是复制粘贴完就扔。先给个总览整篇文章会从迷宫建模方式说起接着回顾Prim算法的核心思想再给出完整的C实现从0到能跑出字符迷宫然后讲可视化方式、性能优化、后续玩法扩展最后整理一份我实际调试中踩过的坑清单。代码全部基于标准C11以上用VS或者VSCode配好编译器都能直接跑不需要额外的图形库依赖——我会额外给一个用Windows API实现的帧缓冲显示版本方便你看效果。2. 开始之前的两个关键选择网格建模和算法选型2.1 迷宫建模奇数行奇数列法写迷宫生成代码第一步不是写算法而是想清楚迷宫在程序里长什么样。我见过很多人上来就定义二维bool数组true表示路、false表示墙然后就开始写逻辑写到一半发现边界情况处理不过来。其实迷宫建模是有套路的最常用也最省心的方式叫奇数行奇数列建模法。具体是这样的定义一个二维数组maze[row][col]假设行数和列数都是奇数比如41行41列。那么所有行号、列号都为奇数的位置比如maze[1][1]、maze[3][3]、maze[5][7]我们把它当作迷宫的“房间”或者说“单元节点”而偶数坐标的位置当作“墙”。在初始化时把所有格子都置为墙接下来算法的任务就是在相邻的两个房间之间判断要不要拆掉它们之间夹着的那面墙。为什么非要用这种奇数坐标建模法因为它处理起来极其干净。你想想如果你随便定义0和1分别表示墙和路那么两个相邻房间之间的距离是不固定的你得自己维护一套“哪个格子属于哪个房间”的映射关系麻烦得很。而奇数坐标法天然保证了任意两个相邻房间之间恰好只隔一个墙格子拆墙就是把这个墙格子从墙改成路位置关系一目了然。当然也有另一种常用建模思路每个单元格是一个节点记录它四面墙的状态上、下、左、右各一个bool。这种结构在视觉上更接近真实迷宫但对C新手来说邻居关系、墙的共享、边界判断都要多绕一层代码量会明显增加。我的建议是第一次做迷宫生成老老实实用奇数行奇数列法等你把算法彻底吃透了再改造成四面墙结构去做带纹理、带房间的复杂地图也不迟。2.2 算法选型为什么我推荐随机Prim而不是深度优先搜索做随机迷宫社区里最常见的两个算法是深度优先搜索普通递归回溯和随机Prim。网上很多教程默认给你递归回溯因为它代码短从起点开始随机挑一个没访问过的邻居打墙过去走不动了就退回来。但递归回溯生成的迷宫有一个非常鲜明的特征——走廊特别长岔路特别少从起点到终点只有一条细细的通道两侧全是死胡同。这种迷宫玩起来容易腻因为玩家经常一跑就是一条直路探索感不强。随机Prim算法生成的迷宫风格完全不同它的分支非常丰富死胡同相对短小通道呈现一种均匀“分叉”的形态整体更像自然形成的洞穴网络。原因在于Prim的扩张方式是同时在多个“前沿节点”上并进而不是像DFS那样一条路走到黑。说个不太严谨但好记的类比DFS像一个固执的探险家非要顺着一个方向把地图舔干净才回头Prim像一群工兵在每个岔路口同时向前推进整个地图是“摊大饼”式地扩张出来的。从实现角度看随机Prim的代码量其实和递归回溯差不多甚至逻辑更直白——它不需要递归不需要系统栈只需要一个容器存“候选墙列表”用一个数组标记“节点是否已被访问”。这对我来说是很大的优势递归深了可能爆栈迭代写法完全没这个顾虑而且更容易做中途暂停、逐步可视化的效果。基于以上这些原因这个系列的第一篇我就选了Prim作为主角。2.3 回顾一下Prim算法的本质图论原版 vs 迷宫版的差别如果你以前学过数据结构应该见过Prim算法求最小生成树的标准版本给定一个带权连通图从一个顶点出发不断选择连接“已选顶点集合”和“未选顶点集合”的最小权值边把新顶点并入集合直到覆盖所有顶点。迷宫生成用的Prim是这个思想的随机化变体差别只有一点选边的时候不再比较权值大小而是完全随机地从所有候选边里抽一条。这个差别很微妙但效果完全不同。最小生成树版Prim生成的是确定性的、总权值最小的树随机版Prim生成的是一个随机形状的树它不追求任何最优性只追求“随机”和“连通”。但是由于算法框架完全一致随机版Prim天然继承了Prim的一个性质**它最终生成的边数一定是节点数减1也就是恰好形成一棵覆盖所有节点的树。**对应到迷宫上任意两个房间之间一定存在唯一的一条路径不多不少——这恰恰是“完美迷宫perfect maze”的定义没有环路所有房间连通且路径唯一。这点非常重要因为这意味着你不需要额外验证迷宫是否连通算法结构上就保证了。3. C实现详解数据结构、核心逻辑和控制台渲染3.1 数据结构设计从宏观到微观我建议把整个程序拆成三个层次底层是一个std::vectorstd::vectorchar或者std::vectorstd::vectorint构成的二维网格往上是一个管理“墙候选集合”的辅助容器再往上就是Prim算法的主循环。先写一个简单的常量定义#include iostream #include vector #include cstdlib #include ctime #include algorithm const int ROWS 21; // 行数建议奇数 const int COLS 21; // 列数建议奇数 const int WALL 1; const int ROAD 0; using Maze std::vectorstd::vectorint;这里ROWS和COLS取奇数的原因上文已经解释过了奇数坐标是房间偶数坐标是墙这样天然规整。初始化时整个迷宫全部填WALLMaze maze(ROWS, std::vectorint(COLS, WALL));候选墙集合我用std::vectorstd::pairint, int来存也就是一个坐标列表。为什么不用std::set或者std::priority_queue因为我们需要的是“随机取一个”而不是“取最小”或者“按序取”。vector配合“随机下标交换删除到末尾”的技巧能在O(1)时间内完成随机取出和删除这是最贴合场景的选择。这个技巧我们在下面的主循环里会看到。3.2 核心实现随机Prim主循环逐行讲解先把完整代码摆出来后面我一行一行解释。void generateMaze(Maze maze) { std::vectorstd::pairint, int wallList; // 1. 随机选择一个起点房间行列都为奇数 int startR (rand() % (ROWS / 2)) * 2 1; int startC (rand() % (COLS / 2)) * 2 1; maze[startR][startC] ROAD; // 2. 把这个房间四周的墙加入候选列表 addWallIfValid(maze, wallList, startR - 1, startC); addWallIfValid(maze, wallList, startR 1, startC); addWallIfValid(maze, wallList, startR, startC - 1); addWallIfValid(maze, wallList, startR, startC 1); // 3. 主循环 while (!wallList.empty()) { // 随机选一面候选墙 int idx rand() % wallList.size(); auto wall wallList[idx]; // 把选中的墙挪到末尾再弹出等效删除避免vector中间删除的O(n)开销 std::swap(wallList[idx], wallList.back()); wallList.pop_back(); int r wall.first; int c wall.second; // 4. 找到这面墙两侧的两个房间 // 如果墙是横向的左右两侧是房间墙是纵向的上下两侧是房间 if (r % 2 1) { // 横向墙行号是奇数列号是偶数房间在左右两边 int roomL r; int roomR r; int cellL c - 1; int cellR c 1; if (cellL 0 cellR COLS maze[roomL][cellL] ! maze[roomR][cellR]) { // 一个房间已经被访问过ROAD另一个还没访问WALL if ((maze[roomL][cellL] ROAD maze[roomR][cellR] WALL) || (maze[roomL][cellL] WALL maze[roomR][cellR] ROAD)) { // 拆墙 maze[r][c] ROAD; // 把新房间标记为路 if (maze[roomL][cellL] WALL) maze[roomL][cellL] ROAD; if (maze[roomR][cellR] WALL) maze[roomR][cellR] ROAD; // 找到那个新加入的房间把它的其他墙加入候选列表 int newR (maze[roomL][cellL] WALL) ? roomR : roomL; int newC (maze[roomL][cellL] WALL) ? cellR : cellL; // 实际上上面两行可读性较差下面会给更清晰的版本 } } } // 纵向墙同理... } }上面的代码为了说明“为什么这样判断”写得比较啰嗦。实际上在真正项目里我会写成更清爽的结构。这里我整理一段可读性优先版的addWallIfValid和主循环建议你直接参考这个版本void addWallIfValid(Maze maze, std::vectorstd::pairint, int wallList, int r, int c) { if (r 0 r ROWS - 1 c 0 c COLS - 1 maze[r][c] WALL) { wallList.push_back({r, c}); } } void generateMaze(Maze maze) { std::vectorstd::pairint, int wallList; // 随机起点 int startR (rand() % (ROWS / 2)) * 2 1; int startC (rand() % (COLS / 2)) * 2 1; maze[startR][startC] ROAD; // 起点的四面墙入候选 addWallIfValid(maze, wallList, startR - 1, startC); addWallIfValid(maze, wallList, startR 1, startC); addWallIfValid(maze, wallList, startR, startC - 1); addWallIfValid(maze, wallList, startR, startC 1); while (!wallList.empty()) { int idx rand() % wallList.size(); auto wall wallList[idx]; std::swap(wallList[idx], wallList.back()); wallList.pop_back(); int r wall.first; int c wall.second; // 确定墙两侧的房间坐标 int roomR1 r, roomC1 c, roomR2 r, roomC2 c; if (r % 2 1) { // 横向墙房间在左右 roomC1 c - 1; roomC2 c 1; } else { // 纵向墙房间在上下 roomR1 r - 1; roomR2 r 1; } // 检查是否越界 if (roomR1 0 || roomR1 ROWS || roomC1 0 || roomC1 COLS || roomR2 0 || roomR2 ROWS || roomC2 0 || roomC2 COLS) { continue; } // 关键判断两个房间状态必须是一路一墙 bool s1Road (maze[roomR1][roomC1] ROAD); bool s2Road (maze[roomR2][roomC2] ROAD); if (s1Road s2Road) continue; // 都是墙或都是路跳过 // 拆墙把未访问房间标记为路 maze[r][c] ROAD; if (!s1Road) maze[roomR1][roomC1] ROAD; if (!s2Road) maze[roomR2][roomC2] ROAD; // 找到新加入的房间把它周围的墙加入候选列表 int newR s1Road ? roomR2 : roomR1; int newC s1Road ? roomC2 : roomC1; addWallIfValid(maze, wallList, newR - 1, newC); addWallIfValid(maze, wallList, newR 1, newC); addWallIfValid(maze, wallList, newR, newC - 1); addWallIfValid(maze, wallList, newR, newC 1); } }这段代码里最重要的判断是if (s1Road s2Road) continue;。这句话是整个算法的灵魂。为什么因为墙两侧如果都已经是路说明这两个房间已经在生成树里连通了这时候再拆墙就会形成环迷宫就出现“捷径”了不再是完美迷宫如果两侧都还是墙说明两个房间都还没被访问过拆了这面墙等于凭空在荒野里开了一条路会破坏“从起点逐步扩张”的生成树结构后续可能出现孤立的房间。只有当一侧是路、一侧是墙时拆掉这面墙才等于把“已生成区域”往外拓展一步——这正是Prim的思想。3.3 控制台字符渲染两种风格随你挑算法跑完之后迷宫数据还只是0和1的二维数组得画出来才能看到效果。最简单的渲染方式就是用控制台逐个输出字符void printMazeConsole(const Maze maze) { for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { std::cout (maze[i][j] WALL ? ## : ); } std::cout \n; } }这里墙用##路用两个空格是为了让字符在控制台里看起来接近正方形。如果你只用单字符#和空格大部分终端下显示出来迷宫会被拉长比例不对。这个少量细节其实就是很多人说“输出迷宫怎么歪歪扭扭”的原因。如果你想要更直观的效果可以用Windows的API做成帧缓冲显示——把迷宫每帧绘制到控制台窗口的固定位置实现类似走迷宫游戏的动态效果。代码如下#ifdef _WIN32 #include windows.h void printMazeFrame(const Maze maze) { COORD cursorPos {0, 0}; HANDLE hConsole GetStdHandle(STD_OUTPUT_HANDLE); SetConsoleCursorPosition(hConsole, cursorPos); for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { if (maze[i][j] WALL) { std::cout ##; } else { std::cout ; } } std::cout \n; } } #endifSetConsoleCursorPosition每次把光标重置到(0,0)这样下一次打印就不会产生滚动看起来就像刷新了画面。这个函数配合循环可以在控制台里做简单的动画后续你可以把玩家位置、终点位置画进去就变成一个交互小游戏了。3.4 main函数测试入口int main() { srand((unsigned)time(nullptr)); Maze maze(ROWS, std::vectorint(COLS, WALL)); generateMaze(maze); printMazeConsole(maze); return 0; }这里有个老生常谈但依然有人犯的坑**死活用rand()但不调用srand或者srand里的种子写死成一个常量。**这样每次程序运行生成的迷宫一模一样随机效果就等于没有了。用time(nullptr)作为随机种子是为了让每次运行的种子不同。如果你对随机性有更高要求C11之后可以用random库里的std::default_random_engine和std::uniform_int_distribution不过对于迷宫这种场景rand()完全够用不必杀鸡用牛刀。4. 实测效果、性能表现与后续玩法扩展4.1 生成效果对比Prim迷宫长什么样我把同样的21x21网格分别用随机Prim和递归回溯各跑了一遍观察到的差异非常明显。递归回溯生成的迷宫从起点出去经常出现十几格长的直线通道转弯次数少整体像一根盘起来的线。Prim生成的迷宫则明显“碎”很多岔路口密集每个房间最多能通四个方向但大多数房间只有两三个方向死胡同普遍只有三到五格深。如果你把这种迷宫当游戏关卡玩家的体验是每走几步就要面临方向选择探索感强得多。需要提醒的是迷宫“好看”与否其实很主观。喜欢解谜向的玩家可能更吃递归回溯那种一条长路通到底的风格喜欢营造“洞穴感”的话Prim更合适。所以选哪种算法取决于你的游戏定位不是Prim就一定高人一等。这一点在我做过的几个小项目里体会很深——有一次给一个恐怖题材的Demo做地图用Prim生成的地下通道就比用DFS自然得多因为DFS那种笔直长走廊放在恐怖游戏里特别出戏什么追逐战、回头路都没法设计。4.2 复杂度与性能迷宫能不能实时生成分析一下复杂度。设房间数为N约等于ROWS/2 × COLS/2。每个房间被加入“已访问集合”一次每次加入时要把它四周的墙加入候选列表候选墙的总量是O(N)。随机选择墙用的是vector随机下标swap-pop单次操作是O(1)。所以总时间复杂度是O(N)线性于房间数。相比标准Prim用堆维护候选边是O(E log V)随机版因为不需要维护顺序快了一个量级。实际跑起来什么概念我测试过1001x1001这样的大尺寸迷宫约25万个房间在普通笔记本上生成只需要几十毫秒放到游戏里做实时地图生成绰绰有余。内存方面核心占用就是二维数组O(ROWS×COLS)加上候选墙列表O(N)。如果你要做超大尺寸比如1万x1万的迷宫二维数组就有点吃紧了——那种场景建议换成一维数组存储行索引换算成row * COLS col候选墙坐标也用一维索引表示操作上完全等价还能降低内存碎片。不过对绝大多数应用场景来说用二维vector的自然写法已经足够不必过早优化。4.3 从迷宫到关卡入口、终点和玩家移动生成迷宫只是第一步。要做成可玩的小项目至少还得补三样东西入口和终点最简单的方式是在最左上角的房间1,1标记为入口在最右下角的房间ROWS-2, COLS-2标记为终点。如果你想做得更讲究可以选两个随机房间然后用BFS计算它们之间的最短路径把路径长度作为一个关卡“难度”指标——路径越长横跨地图的探索范围越大。玩家移动控制台方案可以用键盘监听Windows下用_getch()读取方向键然后判断玩家要移动到的位置是不是ROAD是就更新玩家坐标不是就不动。这里要注意边界检查防止数组越界。胜利检测玩家坐标等于终点坐标就通关。如果你希望支持多关卡每关重新生成迷宫即可迷宫生成的随机性保证了每局地图都不一样天然自带重复可玩性。我能理解有些朋友会问那我怎么知道迷宫有没有解这个问题其实不用担心**随机Prim生成的迷宫一定连通且无环任意两个房间之间都有且仅有一条路径。**这是生成树的性质决定的不用额外做可达性检测。很多网上代码在生成后再跑一遍BFS检查连通性多半是算法实现出了问题比如“墙两侧状态判断写反了”或者“候选墙去重没做”导致的输出缺陷。4.4 扩展玩法房间、多路径与Moist迷宫接下来聊两个我觉得很值得做的扩展方向它们能直接把迷宫从“作业题”拉高到“游戏地图”的层次。第一个扩展是带房间的迷宫。思路很简单在Prim生成完迷宫之后再选几个矩形区域强制把区域里的墙挖空形成一个个开阔的“房间”。这在Roguelike地图生成里非常常见——你需要玩家有集合、整备、战斗的安全区域。实现时注意别把所有墙都挖了否则可能把迷宫路径形状破坏得太碎视觉上也会显得很突兀。通常做法是房间之间保持一定的间距等后续走廊把它们连接起来。如果你有兴趣可以回头看Prim的候选列表其实还能用“先用Prim生成骨架走廊再叠加大房间”的方式实现混合效果。第二个扩展是多通道迷宫多解迷宫。有时候游戏设计需要迷宫不是唯一解玩家可以走好几条路到达终点。做法是把Prim生成后的结果再随机挑一些墙拆掉。每次拆墙要确保墙的两侧都是ROAD拆了之后迷宫就多了一个环路径就不再唯一。你可以用这种方式调整关卡难度拆的墙越多玩家走错路的成本越低解谜感越弱动作感越强。这些扩展其实都是围绕同一条主线在做先把Prim这样基础的“完美迷宫生成器”跑通、跑明白再在这个基础上根据玩法需求去雕琢地图形态。这也是我建议你按这个顺序学习的原因——先理解“为什么这样生成是正确且随机的”再谈魔改。5. 环境配置、常见编译问题与排查技巧实录5.1 VSCode配置C环境的三分钟备忘写C小项目第一个绊倒新手的往往是环境而不是算法本身。如果你用的是VSCode我的建议是直接装MinGW-w64Windows下或直接用Linux自带的g然后配合VS Code的C/C扩展使用。这里有个常见的大坑——很多教程让你下载Code Runner插件然后按F5或右上角三角号去跑报了一堆配置错误大家一头雾水。我自己的经验是写算法小Demo根本不用配置复杂的launch.json和task.json。最简单的流程是安装MinGW-w64把g的路径加入系统环境变量PATH。在VSCode里打开项目文件夹新建main.cpp。打开终端快捷键Ctrl输入g -stdc11 main.cpp -o maze ./maze搞定。如果出现类似g 不是内部或外部命令的报错十有八九是MinGW没有正确加入PATH或者加完之后VSCode没重启使配置生效。不需要装额外的插件除非你想用调试功能。如果你在Windows上遇到error: microsoft visual c 14.0 or greater is required这个报错通常出现在用pip安装Python包时或者某些构建工具链需要MSVC编译器时。如果只是想编译C迷宫程序MinGW-w64就已经足够不需要安装庞大的Visual Studio。如果你想用MSVC编译比如配合Visual Studio的调试器那还是建议装VS Build Tools但这不是迷宫玩具必须的。5.2 逻辑错误的排查为什么我的迷宫是平的、堵死的或者全是墙这类问题我在调试时踩过好几次整理成一张速查表方便你对照排查。现象可能原因解决思路迷宫几乎全是墙只有起点附近是路主循环里的“一侧路一侧墙”判断反了或者候选墙没加全检查拆墙条件确认/两个房间状态必须不同/逻辑打印候选墙数量变化迷宫出现大面积连通但中间有几个孤立房间拆墙时把两个未访问房间之间的墙拆了严格遵守两侧状态一真一假才拆墙不要提前把房间标记为路边界处出现缺口候选墙越界检测只在添加时做了没有在拆墙时再做一次拆墙前务必重新检查房间坐标是否越界边界墙不要处理迷宫有环出现绕一圈能回起点的路径拆墙时两侧房间都已经访问过仍被拆开确认“两侧都是路”时直接continue每次运行结果一模一样srand没调用或种子固定用time(nullptr)做种子输出迷宫比例变形墙是细长条控制台字符本身高度大于宽度墙用两个字符##或[]显示路用两个空格排bug时有个特别管用的招**把候选墙列表的长度和当前已访问房间数打出来。**如果循环结束后发现已访问房间数不到总房间数说明有一部分房间从来没被加入候选列表那问题多半出在添加候选墙的越界条件上如果已访问房间数正确但迷宫有环问题必然出在拆墙判断上。我靠这套思路把一个藏在角落里的索引bug很快揪出来过——写roomC2 c 1时忘了检查COLS边界结果最右侧那列的房间全被漏掉了。5.3 另一个值得记录的教训递归回溯版为什么容易爆栈虽然这篇文章主推Prim但我还是想说一个扩展提醒——很多教程用递归回溯写DFS迷宫在迷宫尺寸稍微大一点时比如几百乘几百递归深度可能达到几万层直接把系统栈给爆了程序闪退连个错误提示都没有。随机Prim是纯迭代实现天然规避了这个问题。所以如果你之前用DFS遇到过莫名其妙的崩溃或者被网上的代码坑过换成Prim会省心很多。如果你确实喜欢DFS生成的“长走廊”风格又怕爆栈可以改成显式栈模拟递归把调用栈从系统栈搬到堆上的std::vector这样就能生成超大迷宫而不崩溃。这个改造往深里说其实就是把“递归深度换成了堆空间”原理不难但属于另一个话题了这里点到为止。6. 写在最后一个我很受用的调试技巧最后分享一个我在这个项目里反复用到的经验——**不要把迷宫当成“最终输出”而是把生成过程本身当成调试对象。**具体怎么做我在generateMaze里临时加了一个“步进模式”每拆一面墙把当前迷宫打印一帧然后用std::this_thread::sleep_for或_getch()暂停一下。这样一帧帧看过去算法每一步做了什么一目了然比你在脑子里模拟要直观得多。我第一次优化Prim实现的时候就是靠这个步进打印发现了问题——候选墙列表里有重复项同一面墙被反复加入导致拆墙时出现“两侧都是路”的情况结果迷宫零零散散有环。如果只看最终输出确实也能隐约察觉不对劲但很难定位是哪一步出的错一旦把过程摊开看错误源头马上现形。所以强烈建议你也在代码里留一个这样的调试入口一次性写完整个算法然后祈祷跑通在迷宫这种带有随机性的项目里并不现实。把这个Prim迷宫生成器写完你的下一步可以试着把它扩展成一个完整的“走迷宫”小游戏或者换一种算法比如递归回溯、Kruskal、Aldous-Broder做对比这些我后面也会找机会一篇篇写出来。先把这篇的代码跑起来多换几个尺寸看看效果你就能真切感受到“随机生成的迷宫”和“手写死的迷宫”在体验上的差距了。
返回列表