1. 项目概述:为什么要用Prim算法生成随机迷宫
生成随机迷宫这事儿,乍一看像是某个课程设计的作业题,但真做起来特别有意思。你可能在不少游戏里见过程序生成的迷宫地图,比如Roguelike游戏里的地牢、某些解谜小游戏的关卡,甚至是算法可视化网站上的演示动画。它们背后无一例外都是某种迷宫生成算法在跑,而Prim算法是其中原理直观、实现省心、效果又很“匀称”的一种。
这篇文章我打算用C++把完整的随机迷宫生成过程拆开讲清楚。核心就一句话:**用随机化后的Prim算法在一张网格图上不断“拆墙”,最终生成一棵覆盖全图的生成树,树上的边就是迷宫中可以走的路,而没被拆掉的墙就构成了迷宫的分支与死胡同。**听起来有点绕,但你跟着代码走一遍就明白了。
适合谁来读?我觉得三类人最合适:一是C++刚入门、想找个能练手又不枯燥的小项目的人;二是游戏开发爱好者,想在Unity、UE或者自己的小游戏里实现随机地图生成;三是想理解图论算法在实际问题里怎么落地的同学。这篇文章不会只贴代码,我会把每一步为什么这么做、数据按什么结构存、换一种做法会踩什么坑都讲一遍,保证你看完能自己改、自己扩展,而不是复制粘贴完就扔。
先给个总览:整篇文章会从迷宫建模方式说起,接着回顾Prim算法的核心思想,再给出完整的C++实现(从0到能跑出字符迷宫),然后讲可视化方式、性能优化、后续玩法扩展,最后整理一份我实际调试中踩过的坑清单。代码全部基于标准C++11以上,用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::vector<std::vector<char>>或者std::vector<std::vector<int>>构成的二维网格,往上是一个管理“墙候选集合”的辅助容器,再往上就是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::vector<std::vector<int>>;这里ROWS和COLS取奇数的原因上文已经解释过了:奇数坐标是房间,偶数坐标是墙,这样天然规整。初始化时整个迷宫全部填WALL:
Maze maze(ROWS, std::vector<int>(COLS, WALL));候选墙集合我用std::vector<std::pair<int, int>>来存,也就是一个坐标列表。为什么不用std::set或者std::priority_queue?因为我们需要的是“随机取一个”,而不是“取最小”或者“按序取”。vector配合“随机下标+交换删除到末尾”的技巧,能在O(1)时间内完成随机取出和删除,这是最贴合场景的选择。这个技巧我们在下面的主循环里会看到。
3.2 核心实现:随机Prim主循环逐行讲解
先把完整代码摆出来,后面我一行一行解释。
void generateMaze(Maze& maze) { std::vector<std::pair<int, 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::vector<std::pair<int, 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::vector<std::pair<int, 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::vector<int>(COLS, WALL)); generateMaze(maze); printMazeConsole(maze); return 0; }这里有个老生常谈但依然有人犯的坑:**死活用rand()但不调用srand,或者srand里的种子写死成一个常量。**这样每次程序运行生成的迷宫一模一样,随机效果就等于没有了。用time(nullptr)作为随机种子,是为了让每次运行的种子不同。如果你对随机性有更高要求,C++11之后可以用<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-w64(Windows下)或直接用Linux自带的g++,然后配合VS Code的C/C++扩展使用。这里有个常见的大坑——很多教程让你下载Code Runner插件,然后按F5或右上角三角号去跑,报了一堆配置错误,大家一头雾水。
我自己的经验是,写算法小Demo根本不用配置复杂的launch.json和task.json。最简单的流程是:
- 安装MinGW-w64,把
g++的路径加入系统环境变量PATH。 - 在VSCode里打开项目文件夹,新建
main.cpp。 - 打开终端(快捷键
Ctrl+`),输入:
g++ -std=c++11 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)做对比,这些我后面也会找机会一篇篇写出来。先把这篇的代码跑起来,多换几个尺寸看看效果,你就能真切感受到“随机生成的迷宫”和“手写死的迷宫”在体验上的差距了。