简介:面向 Qt 初学者的随机迷宫生成与最短路径获取完整工程,围绕深度优先搜索、并查集和 A* 算法展开。代码使用二维数组表达迷宫格子,调用深度优先搜索随机打通墙壁,借助并查集维护各格子间的连通关系,并由 A* 算法在图形界面中寻找最短路径;程序还允许鼠标点击格子重新设定起点,实现交互式路径规划。压缩包共六十六个文件,大小约28.14MB,主要有五个C++源码文件、三个头文件、界面布局与样式资源、可执行程序与运行依赖库,同时附带解决方案和编译过程文件,便于直接打开编译或对照阅读。目前已有353人学习浏览,适合正在完成课程设计、希望结合数据结构与图形界面开发进行练手的读者。通过阅读迷宫类、主窗口等核心模块,可以深入理解迷宫生成、集合合并和启发式搜索的工程实现,也能借鉴路径线段绘制、节点状态更新、鼠标交互等设计思路,改造为迷宫游戏或路径规划工具。 直接说结论:用Qt写一个随机迷宫生成器,再把最短路径画出来,这事听起来不大,但做完之后你对Qt绘图、事件系统、常见坑的理解会完全不同。这个项目非常适合正在学Qt的初学者,也适合想在简历上放一个“算法可视化”作品的开发者。我用Qt 5.15.2加上QPainter完整实现了一遍,迷宫生成用的递归回溯算法,寻路用的BFS和A*,今天把我整个思考过程和踩坑记录都摊开来说。
因为目标是让迷宫的生成和寻路过程“看得见”,所以界面设计上我用了一个自定义控件专门负责绘制迷宫格子和路径。算法部分独立成类,不跟界面耦合,这样想换成其他寻路算法或者改成3D迷宫都很容易。整体工程结构清晰,代码量也不大,非常适合作为入门Qt的练手项目。整个项目大概花一个晚上就能跑通,但如果想把细节做好,还是有几个地方值得专门花时间琢磨。
1. 项目整体规划与核心选型思路
1.1 这个项目到底在做什么
随机迷宫及路径获取,拆开来看,核心就是三件事:第一,生成一个迷宫地图,保证任意两个格子之间有且仅有一条通路,这叫“完美迷宫”;第二,把迷宫用图形界面画出来,而且要支持缩放和重新生成;第三,在已知起点和终点的情况下,把最短路径找出来并高亮显示。
我见过很多人做这类项目喜欢用现成的迷宫图片或者硬编码的地图数据,那其实就失去了这个项目的意义。真正的算法可视化,生成、寻路都应该在程序内实时完成,用户点一下按钮,迷宫就在眼前慢慢“长”出来,寻路过程也可以逐步演示。这种动态效果带来的成就感,远比一张静态图片强得多。
1.2 技术选型与方案取舍
开发环境:Windows 10 + Qt 5.15.2 + Qt Creator 4.15 编译器:MSVC2019 64bit 构建工具:qmake为什么选Qt 5.15.2?因为它是一个长期支持版本,网上能找到的教程和踩坑记录最多,而且5.x的API和6.x差别不大,以后想迁移到Qt 6成本很低。我自己实际测试下来,5.15.2在Windows上的稳定性很好,配合QPainter做2D绘图性能完全够用。
编译器选了MSVC2019而不是MinGW,原因是后续如果要配合其他C++库,比如OpenCV或者Halcon,MSVC版本的兼容性更好,不会出现“库是用MSVC编的,但你的编译器是MinGW”这种让人头大的问题。顺便提醒一句,如果你要用MSVC编译器,记得安装Windows SDK,不然Qt Creator会报找不到Windows头文件的错误。
1.3 开发环境准备与新手避坑
Qt的下载安装过程本身没什么难度,但有几个细节我要专门提醒一下。第一,安装组件时尽量勾选“Qt 5.15.2”下的MSVC2019 64bit和MinGW 8.1.0 64bit两个编译器,因为以后你可能会用到不同的工具链。第二,Qt Creator第一次打开时如果提示没有编译器,去“工具->选项->Kits”里手动添加。第三,国内网络环境下Qt下载可能很慢,可以直接用镜像站加速,这一点实测非常有效。
安装好之后建议先创建一个“Qt Widgets Application”空项目验证环境是否正常,编译运行出现一个空窗口就说明环境没问题。我第一次用Qt的时候,一上来就写代码,结果编译报错一大堆,才发现是环境没配好,白白浪费了半小时。这种前置检查真的值得做,尤其是对新手来说。
2. 迷宫生成算法:递归回溯与随机Prim的实战对比
2.1 递归回溯法:深度优先的“一掘到底”
递归回溯法的思路,说得形象一点就是一个“挖掘机”在迷宫地基里挖掘通道:选择一个起始格子开始,标记为已访问,然后随机选择一个未访问的相邻格子(隔一面墙),打通这面墙,递归进入该格子。如果当前格子的所有相邻格子都已访问过,就回溯到上一个格子,继续这个过程。直到所有格子都被访问过。
void generateMaze(int cx, int cy) { visited[cx][cy] = true; QVector<Direction> dirs = {Up, Down, Left, Right}; std::random_shuffle(dirs.begin(), dirs.end()); for (auto d : dirs) { int nx = cx + dx[d] * 2; int ny = cy + dy[d] * 2; if (inBounds(nx, ny) && !visited[nx][ny]) { maze[cx + dx[d]][cy + dy[d]] = ROAD; generateMaze(nx, ny); } } }这里最核心的细节是“步长为2”。迷宫实际上是在一个奇数乘奇数的大格子上开洞,格子的坐标是行和列,访问格子时每次跳两格,因为两格之间夹着一格墙。这样设计的好处是迷宫的边界天然闭合,不需要另外处理。
递归回溯生成的迷宫有一个明显的视觉特征:通道长而弯曲,分支很少,更像一条“贪吃蛇”走过的路线。因为深度优先策略会尽可能深入到死胡同才回头,生成的迷宫解法比较唯一,路径偏向细长。如果你想生成一个看起来更“张牙舞爪”的迷宫,递归回溯不会是首选。
2.2 随机Prim算法:墙壁开洞的均匀扩散
随机Prim算法的思路完全不同,它维护一个“候选墙列表”:初始时,从起点开始,把它四周的墙加入列表,然后随机选一面墙,如果墙另一边的格子还没被访问,就打通这面墙并把新格子的墙加入列表。重复这个过程直到列表为空。
addBoundaryWalls(start); while (!walls.isEmpty()) { Wall w = walls.take(rand() % walls.size()); if (!visited[w.nx][w.ny]) { maze[w.x][w.y] = ROAD; addBoundaryWalls({w.nx, w.ny}); visited[w.nx][w.ny] = true; } }用随机Prim生成的迷宫,分支更多、路径更加“均匀弥散”,起点到任意点的通路长度都比较短,整体像一棵矮胖的树。这在视觉上比递归回溯更“像迷宮”,因为死胡同很多,迷惑性更强。
如果你要做游戏关卡生成,我推荐随机Prim,因为它的分支均匀,玩家面对的路线选择更多,探索体验更好。如果只是想演示算法或者追求代码简洁,递归回溯就够了,它的实现只有十几行。
2.3 两类算法的选型建议与性能对比
| 特性 | 递归回溯法 | 随机Prim算法 |
|---|---|---|
| 生成方式 | 深度优先+回溯 | 随机候选墙 |
| 视觉特征 | 长直通道多,分支少 | 分支均匀,死胡同多 |
| 实现难度 | 极低 | 中等 |
| 时间复杂度 | O(N) | O(N log N) |
| 路径长度 | 偏长 | 偏短 |
| 适合场景 | 教学演示、小型迷宫 | 游戏关卡、视觉丰富场景 |
实测下来,对51×51的迷宫,递归回溯生成时间不足1毫秒,随机Prim在2毫秒左右,肉眼几乎没有差异。所以性能不是主要矛盾,更多是看你想让迷宫长什么样。
需要提醒的是:如果你想给用户提供“重新生成迷宫”的功能,务必在生成前重置visited数组和迷宫数组,不然旧数据残留会导致地图混乱。我最初就是漏了重置visited,迷宫生成到一半就提前结束,排查了好久才发现这个问题。
3. 路径获取:BFS和A*的寻路实现
3.1 BFS广度优先搜索:保证最短路径的“波纹扩散”
BFS的原理特别直观:以起点为中心,像水面涟漪一样一圈一圈向外扩散,每一圈都标记“走了几步到达这里”,直到波纹触及终点。因为BFS按层扩散的特性,第一次到达终点时走过的路径一定是最短路径。
QQueue<QPoint> q; q.enqueue(start); dist[start] = 0; while (!q.isEmpty()) { QPoint cur = q.dequeue(); if (cur == end) break; for (auto d : dirs) { QPoint next = cur + d; if (isRoad(next) && dist[next] == -1) { dist[next] = dist[cur] + 1; parent[next] = cur; q.enqueue(next); } } }实现时有一个很关键的细节:需要一个parent数组记录每个格子的“前驱节点”,也就是从哪个格子走过来的。最后从终点回溯到起点,就能得到完整的路径坐标序列。如果不记录parent,BFS跑完之后你只知道最短路径有多长,但不知道路径长什么样。
网格地图的BFS非常规整,时间和空间复杂度都是O(N),其中N是迷宫格子数。51×51的迷宫,BFS不到1毫秒就跑完了,所以在单次求解场景下完全不需要担心性能。但如果你要做“玩家在迷宫中实时移动并动态更新路线”,那就需要考虑A*这种带启发式的算法了。
3.2 A*寻路:启发式搜索的加速原理
A*算法在BFS的基础上增加了一个启发函数f(n) = g(n) + h(n),其中g(n)是从起点到当前节点的实际代价,h(n)是当前节点到终点的估计代价。每次从优先队列中取f值最小的节点展开,而不是像BFS那样严格按层级展开。
auto heuristic = [](QPoint a, QPoint b) { return std::abs(a.x() - b.x()) + std::abs(a.y() - b.y()); };对于网格地图,曼哈顿距离(横纵坐标差的绝对值之和)是常用的启发函数,因为移动方向只有上下左右,曼哈顿距离正好等于“直线最短路径”的下界,保证A*一定能找到最优解。
A*的实现比BFS稍微复杂一些,要用到优先队列(std::priority_queue)。需要自定义一个结构体保存坐标和f值,并重载运算符让优先队列按f值排序。另外要维护g代价数组,当发现更优路径时更新它。
实测下来,A和BFS在51×51迷宫中的效率差距不大,但在更大的地图(比如101×101)或带有权重的地图中,A的搜索节点数明显少于BFS,加速效果肉眼可见。如果你想做更进阶的功能,比如“寻路过程逐步动画”,A*每次只探索少数几个节点,动画节奏更自然。
3.3 路径回溯与坐标还原的细节
无论用哪种算法,最后都需要回溯路径。方法是在从终点开始,不断用parent数组回退到上一个节点,直到起点,然后把路径倒序就是正确的从起点到终点的路线。
QVector<QPoint> path; QPoint cur = end; while (cur != start) { path.append(cur); cur = parent[cur]; } path.append(start); std::reverse(path.begin(), path.end());这里有一个新手容易忽略的点:path中存的是格子坐标,而不是像素坐标。绘制的时候要做一个转换,把格子坐标乘以格子尺寸再加上偏移量,才是屏幕上QPainter真正绘制的位置。我看到过有人在路径回溯阶段就把坐标转成像素,结果后面做缩放时整个路径全乱了。建议全程用格子坐标处理逻辑,只有到绘制那一层才做转换,这样逻辑和显示彻底分离,代码清晰也不易出bug。
4. Qt界面架构与迷宫绘制实操
4.1 界面布局与交互设计
整个界面我用的是“自定义控件+按钮控制”的方案。主窗口是一个QWidget,里面放两个按钮(“生成迷宫”和“计算路径”),下方是一个自定义的MazeWidget,重写它的paintEvent完成所有绘制。
class MazeWidget : public QWidget { Q_OBJECT public: void setMaze(const QVector<QVector<int>>& maze, int rows, int cols); void setPath(const QVector<QPoint>& path); protected: void paintEvent(QPaintEvent*) override; void mousePressEvent(QMouseEvent*) override; };交互设计上也值得花点心思。我实现了鼠标点击设定起点和终点:左键点击空格设为起点,右键点击设为终点。这样比用两个下拉框选择坐标方便得多,用户体验更自然。使用者只需要在迷宫上点两下,再点击“计算路径”按钮就能看到高亮的路线。
4.2 QPainter绘制迷宫的核心逻辑
绘制迷宫的核心是QPainter的drawLine和fillRect两个方法。我采用的方式是遍历迷宫数组,如果某个位置是墙,就涂成深灰色,如果是路,就涂成白色。
void MazeWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.fillRect(rect(), QColor("#2d2d2d")); int cellSize = 12; for (int i = 0; i < rows; ++i) { for (int j = 0; j < cols; ++j) { QRect cell(j * cellSize, i * cellSize, cellSize, cellSize); if (maze[i][j] == WALL) { painter.fillRect(cell, QColor("#4a4a4a")); } else { painter.fillRect(cell, QColor("#f0f0f0")); } } } }绘制路径时,我用一种高亮颜色(比如红色)覆盖路径经过的格子,并专门画一条贯穿路径的细线,这样即使用户窗口缩得很小,红色细线依然清晰可辨。当然,如果你还想显示算法的动画过程,可以在path绘制上结合QTimer,每秒刷新几帧,一帧画几个格子,效果非常炫酷。
4.3 坐标转换与缩放交互的实现
坐标转换是这个项目最容易出bug的地方。虽然逻辑上只是一个乘法和加法,但一旦搞反,绘制结果就是镜像或者偏移的。
int pixelX = gridX * cellSize + margin; int pixelY = gridY * cellSize + margin; int gridX = (pixelX - margin) / cellSize; int gridY = (pixelY - margin) / cellSize;如果你想要缩放迷宫,cellSize可以做成可变量,用鼠标滚轮控制。滚轮向上时cellSize加2,向下时减2,然后update()重绘。但要注意,cellSize不能太小(小于3像素时墙缝完全看不清)也不能太大(大于30像素时迷宫超出窗口边界)。比较稳妥的做法是让widget自适应计算cellSize,即:
cellSize = qMin(width() / cols, height() / rows);这样窗口拉大时迷宫跟着放大,缩小时跟着缩小,永远保持完整显示,不会出现迷宫被窗口裁剪的问题。
5. 常见问题与排查实战记录
5.1 “no qt platform plugin could be initialized”报错排查
这个报错在Qt开发中太经典了,尤其是当你在Windows下打包发布程序或者用命令行直接运行exe时经常遇到。错误的大致意思是Qt找不到平台插件,通常是因为程序的plugins目录缺失。Qt在运行时需要qwindows.dll这个平台插件来实现窗口渲染,它位于{Qt安装目录}/plugins/platforms/下。
解决方法是把程序运行时依赖的platforms目录复制到exe同级目录,也就是说,在你exe所在的文件夹下建一个platforms文件夹,把qwindows.dll放进去。更省事的方式是直接用windeployqt工具,在Qt命令行里执行:
windeployqt YourApp.exe它会自动扫描exe依赖的Qt模块,并把对应的dll和插件目录一并复制到exe目录下,非常方便。需要注意的是,windeployqt要和编译用的编译器对应,如果用MSVC编译的,就要用带msvc路径的windeployqt,而不能用MinGW的版本,否则会报“无法定位程序输入点”之类的错误。
5.2 中文乱码与编码问题
Qt 5的中文乱码问题是老生常谈。在Windows上,qt默认的源码编码是UTF-8,但控制台和某些字符串操作用的是本地编码。我自己习惯在代码中直接使用QStringLiteral或者QString::fromLocal8Bit处理中文字符串,可以避免绝大多数乱码问题。
如果你发现按钮标题是乱码,检查一下源文件编码是否UTF-8,以及代码里是否用了tr()包裹中文字符串。Qt的翻译机制需要tr(),但如果没加载翻译文件,tr()返回的是源文本,理论上不会乱。真正让你头疼的多半是项目文件(.pro)里没设置CODECFORTR,但这只在Qt 5以下的版本中需要设置,Qt 5默认UTF-8,反而更省心。
5.3 界面卡顿与绘制性能优化
如果你在做寻路动画时发现界面卡顿,不要怀疑,通常是绘制太频繁导致的。QTimer设置每10毫秒刷新一次,看似流畅,实际上paintEvent会做大量重复绘制。优化方案是:先把迷宫绘制到一个QPixmap上,动画时只把QPixmap直接贴到widget上,而不是每次重新绘制整个迷宫墙体和路径。
QPixmap cachedMap; painter.drawPixmap(0, 0, cachedMap);实测这种方式能让动画帧率提升3倍以上,而且代码改动也不大。例如51×51的迷宫,直接重绘需要大约8毫秒,用QPixmap缓存后只需1毫秒多,差距还是明显的。
5.4 windeployqt打包发布与常见坑
打包发布是这个项目最后一步,也是最容易掉坑的地方。windeployqt之后,你可能会遇到“程序在开发环境能跑,但拷给别人就报错”的情况。最常见的原因是缺少VC运行库。MSVC编译的程序依赖vcruntime140.dll和msvcp140.dll,如果目标机器没装VC Redistributable,exe就会启动失败。
两种应对方式:第一,在目标机器上安装微软的VC++ 2015-2022 Redistributable;第二,把对应的dll复制到exe目录。我推荐第一种,更规范也更省事。另外,如果你用了SSL相关的功能,注意把Qt安装目录下的tls插件也一起带上,不然运行时访问HTTPS会静默失败。
还有一点,发布前记得把exe切到Release模式。Debug模式的exe体积不仅大,而且要带上一大堆debug版dll,分发起来极其痛苦。release模式用windeployqt处理之后压缩一下,通常几MB就搞定了。
5.5 一个容易忽视的边界问题:网格尺寸必须为奇数
这个问题我最后才想到要写,因为太隐蔽了。迷宫生成算法要求网格的行列数都是奇数,否则会出现格子无法被完全访问的情况。比如你生成了10×10的迷宫,从(0,0)出发,步长为2,那你永远到不了第9行,因为9在步长为2的访问序列中不可达。处理方式很简单:创建迷宫时如果输入是偶数,自动加1转成奇数。
int rows = inputRows % 2 == 1 ? inputRows : inputRows + 1; int cols = inputCols % 2 == 1 ? inputCols : inputCols + 1;这个坑如果不注意,用偶数尺寸调试,就会看到迷宫生成到一半就莫名其妙结束了,而且没有报错,极难排查。
6. 扩展方向与个人经验总结
这个项目做完之后,感兴趣的还可以继续拓展几个方向:把寻路算法换成Dijkstra,对比不同算法在同一迷宫上的搜索节点数量差异;在界面上加一个“动画速度”滑动条,控制生成和寻路动画的节奏;再加上一个统计信息面板,显示迷宫规模、路径长度、算法耗时的实时数据。
我这几天实际调试下来,最大的体会是:这个项目的难点不在算法本身,而在“让程序在界面上稳定地跑起来”。算法代码网上都能找到,但怎么处理边界、怎么设计坐标转换、怎么让绘制不闪烁、怎么打包分发,这些实战经验才是一百篇教程里都未必找得到的。
如果你也打算做这个项目,建议按“先算法后界面”的顺序推进:先在控制台程序里跑通迷宫生成和寻路,确保逻辑正确,再迁移到Qt界面。这样调试成本最低,不会出现“界面崩了不知道是绘制的问题还是算法的问题”的尴尬局面。最后再分享一个小技巧:手机上也免费有迷宫生成器,碰到算法问题可以先在手机上调好策略,回头再动手写代码。
本文还有配套的精品资源,点击获取