简介:这份资源是面向计算机专业学生的数据结构课程设计完整方案,聚焦「老鼠走迷宫」这一经典图论应用场景,采用C++实现,适合正在学习数据结构与算法、需要完成课设或希望巩固搜索算法的初中级学习者。压缩包共27个文件、约148.59MB,除cpp源码与h头文件外,还包含sln工程文件与vcxproj配置便于直接打开工程,以及exe可执行程序、pdb与ilk调试符号、obj中间文件等编译产物,另有mp4操作录屏、png与jpg效果图、txt使用说明与log运行日志,构成从源码到演示的完整链路。资源围绕随机迷宫生成与路径搜索展开,涵盖用二维数组抽象迷宫格子状态、以深度优先搜索配合递归与栈完成遍历与回溯、标记墙体以保证迷宫连通且解唯一,并支持自定义迷宫规模与初始条件,帮助读者理解图、队列与栈在实际问题中的落地方式。目前已有1391人学习下载,可作为课设参考与算法入门的实践素材。
1. 老鼠走迷宫:一个把栈和队列用出区别的 C++ 课设
很多人学数据结构时,栈和队列就是两个背完定义就过的容器,直到用 C++ 手写一遍老鼠走迷宫才发现:同一个迷宫、同一套方向规则,把求解器里的栈换成队列,输出的路径立刻从「能走通但绕了三个弯」变成「最短的一条」。差别不在容器本身,而在「谁先被取出来」这条规则上。
这个课设项目做的事很具体:二维数组存迷宫,栈存当前路径做深度优先的回溯搜索,队列按层扩展做广度优先的最短路,解出来的路径再打回迷宫图上,用*标出来。代码量两个文件就装得下,但边界、标记时机、路径还原这几处特别容易翻车。
它适合三类人:正在赶数据结构课设、需要一个能跑能改的骨架的;想复习 DFS/BFS 边界处理的老手;以及要拿它当教学演示、往上加动画和多算法对比的。
2. 工程骨架:迷宫怎么存、位置怎么表示、栈和队列从哪来
动手之前先把文件摊开。这个项目是标准的 C++ 多文件结构,头文件放类型和声明,源文件放实现,地图单独一个目录,编译一条命令搞定。不搞 CMake,课设环境里 CMake 版本参差不齐,g++一行更省事。
2.1 目录结构与编译命令
maze-mouse/ ├── include/ │ ├── maze.h # 迷宫类型、坐标、方向常量 │ ├── solver.h # 栈式 DFS / 队列式 BFS 的声明 │ └── stack_queue.h # 手写顺序栈与循环队列(课设要求时用) ├── src/ │ ├── maze.cpp # 地图加载、随机生成、打印 │ ├── solver.cpp # 两种求解算法 │ └── main.cpp # 命令行入口 └── maps/ └── demo1.txt # 一张 9x9 的示例地图编译命令:
g++ -std=c++17 -O2 -Wall -Iinclude src/*.cpp -o maze ./maze maps/demo1.txt-Iinclude让#include "maze.h"能找到头文件,-Wall建议打开——我自己就被「有符号/无符号比较」的警告提前拦下过一次数组越界。用 C++17 是因为std::vector的嵌套初始化、constexpr数组这些写起来舒服,换成 C++11 也能跑,把结构化绑定去掉即可。
2.2 迷宫的数据模型:为什么不用 char 二维数组
地图的存储方式决定了后面所有代码好不好写。用char存'#'和'.'看着直观,但求解时要在「墙、通路、已访问、路径」四种状态之间切换,字符不够用,而且容易和输出格式绑死。我一般用int的 0/1 存原始地图,访问状态单独开一张表,路径用坐标列表存,三层职责分开。
// maze.h #pragma once #include <vector> #include <string> constexpr int WALL = 1; // 墙 constexpr int ROAD = 0; // 通路 struct Point { int r = 0; int c = 0; bool operator==(const Point& o) const { return r == o.r && c == o.c; } bool operator!=(const Point& o) const { return !(*this == o); } }; struct Maze { int rows = 0, cols = 0; std::vector<std::vector<int>> grid; // grid[r][c],r 是行,c 是列 Point start{1, 1}; Point goal{1, 1}; bool inBounds(const Point& p) const { return p.r >= 0 && p.r < rows && p.c >= 0 && p.c < cols; } // 注意:先判边界再取 grid,顺序反了就是越界访问 bool isRoad(const Point& p) const { return inBounds(p) && grid[p.r][p.c] == ROAD; } };inBounds和isRoad必须拆成两个函数,这是血泪经验:把边界判断和墙判断揉成一个函数,后面做随机迷宫生成、判断起点合法性时,你分不清失败到底是「出界了」还是「是墙」,排查起来只能打断点。
grid用vector<vector<int>>而不是int a[100][100],因为地图尺寸从文件读,编译期不知道;而且vector天然支持resize和随机生成。
2.3 地图文件格式与加载函数
地图用纯文本,第一行是行列数,第二行起点,第三行终点,坐标从 1 开始数,下面跟矩阵。用 1-based 是因为课设里的示例地图手写起来更自然,程序入口处一次性转成 0-based。
9 9 1 1 7 7 1 1 1 1 1 1 1 1 1 1 0 0 0 1 0 0 0 1 1 0 1 0 1 0 1 0 1 1 0 1 0 0 0 1 0 1 1 0 1 1 1 0 1 0 1 1 0 0 0 1 0 1 0 1 1 1 1 0 1 0 1 0 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1读取的代码不长,但有两个动作值得单独说:
// src/maze.cpp bool loadMaze(const std::string& path, Maze& m) { std::ifstream in(path); if (!in) return false; if (!(in >> m.rows >> m.cols) || m.rows <= 0 || m.cols <= 0) return false; // 文件里坐标从 1 开始,内存里统一 0-based in >> m.start.r >> m.start.c >> m.goal.r >> m.goal.c; --m.start.r; --m.start.c; --m.goal.r; --m.goal.c; m.grid.assign(m.rows, std::vector<int>(m.cols, WALL)); for (int r = 0; r < m.rows; ++r) for (int c = 0; c < m.cols; ++c) if (!(in >> m.grid[r][c])) return false; // 数据不够直接失败 // 入口校验:起点终点必须在界内且不是墙 return m.isRoad(m.start) && m.isRoad(m.goal); }逻辑说明:>>会自动跳过空白,所以矩阵里数字之间用空格还是换行分隔都能读。但读完后必须校验isRoad(start),否则一张把起点画在墙上的地图会让求解器返回「无解」,而你以为是自己算法写错了。
参数说明:path是地图文件路径,m是出参,失败时内容未定义,调用方只在返回true时使用。
2.4 方向数组与访问标记
四个方向用两个平行数组表示,比写四段if干净得多:
// 上、右、下、左,顺时针 constexpr int DR[4] = {-1, 0, 1, 0}; constexpr int DC[4] = {0, 1, 0, -1};方向顺序不影响正确性,但影响路径形态。按「上右下左」的顺序,DFS 解出来的路径会明显贴着上方和右方走;评审时如果被问「为什么这条路径这么怪」,答案是这个顺序固定导致的确定性结果,不是 bug。想换成别的形态,改这两行数组或者把方向下标随机打乱就行,随机化之后还能统计不同路径的分布。
访问标记单独开表,两种算法用的类型不一样:
std::vector<std::vector<char>> visited(rows, std::vector<char>(cols, 0)); // DFS std::vector<std::vector<int>> dist(rows, std::vector<int>(cols, -1)); // BFSDFS 只关心「来过没有」,char省内存;BFS 顺便把步数记在dist里,-1当「没访问」的哨兵,比再开一张visited表省一次判断。
2.5 栈和队列:手写还是用 STL
课设大纲里通常写着「要求自行实现栈和队列的基本操作」,所以项目里保留了手写版本,同时也给了 STL 版本方便调试。手写循环队列的核心是留一个空位区分队空和队满:
// include/stack_queue.h template <typename T> class SeqQueue { public: explicit SeqQueue(int cap) : buf_(cap + 1), head_(0), tail_(0) {} // 多留一格 bool empty() const { return head_ == tail_; } bool full() const { return (tail_ + 1) % (int)buf_.size() == head_; } bool push(const T& v) { if (full()) return false; // 容量不够就失败,调用方要判返回值 buf_[tail_] = v; tail_ = (tail_ + 1) % (int)buf_.size(); return true; } bool pop() { if (empty()) return false; head_ = (head_ + 1) % (int)buf_.size(); return true; } const T& front() const { return buf_[head_]; } private: std::vector<T> buf_; int head_, tail_; };逻辑说明:head_指向队首元素,tail_指向下一个空位。buf_的真实容量是cap + 1,浪费的那一格用来让「空」和「满」的判定不撞车——如果不留,head_ == tail_既可能是空也可能是满,得再加一个计数器。
参数说明:构造时cap传迷宫格子总数rows * cols最保险,BFS 里每个格子最多入队一次。容量给小了push返回false,如果调用方不检查,路径就会悄悄丢节点。
栈这边我不推荐用std::stack。它不提供迭代器,拿不到栈内元素,而 DFS 结束时路径就是栈的内容,用std::stack只能再倒腾一次。我一般直接用std::vector加push_back/pop_back/back(),语义一样,还能随时遍历打印,做动画时特别顺手。
3. DFS 求解:用栈把「走不通就退回来」写成循环
栈式 DFS 是整个课设里最像「数据结构课」的一段代码:没有递归,靠一个显式的栈记录当前路径,走到死胡同就弹出,退回上一个还有分支的格子。
3.1 把「回溯」翻译成栈操作
先把递归的思路摆出来:站在当前格,按方向顺序找一个能走的邻居,走进去,重复;四个方向都走不了,退回上一格。递归写法里「上一格」是函数调用栈自动保存的,改成迭代就必须自己维护。
栈里存的不是「待访问队列」,而是从起点到栈顶的完整当前路径。这个约定是整个实现的关键,它带来两个好处:找到终点时栈内容直接就是答案,不用再回溯;回溯时弹一个元素,路径自然缩短一格,状态永远一致。
每轮循环只做三件事:取栈顶看是不是终点;能走就压一个邻居进去;不能走就弹出。注意压完邻居后要回到循环开头重新取栈顶,而不是继续在for里往下试,否则栈的深度会一次涨好几个,路径就不连续了。
3.2 完整实现:solveByStack
// src/solver.cpp #include "solver.h" #include <vector> bool solveByStack(const Maze& m, std::vector<Point>& path) { path.clear(); if (!m.isRoad(m.start) || !m.isRoad(m.goal)) return false; if (m.start == m.goal) { path.push_back(m.start); return true; } std::vector<std::vector<char>> visited(m.rows, std::vector<char>(m.cols, 0)); std::vector<Point> stk; // 当栈用:back() 是栈顶 stk.push_back(m.start); visited[m.start.r][m.start.c] = 1; // 入栈即标记,别等到出栈 while (!stk.empty()) { Point cur = stk.back(); if (cur == m.goal) { path = stk; return true; } // 栈内容就是路径 bool advanced = false; for (int d = 0; d < 4; ++d) { Point nxt{cur.r + DR[d], cur.c + DC[d]}; if (!m.isRoad(nxt) || visited[nxt.r][nxt.c]) continue; visited[nxt.r][nxt.c] = 1; stk.push_back(nxt); advanced = true; break; // 一次只前进一步,回外层重新判终点 } if (!advanced) stk.pop_back(); // 四面碰壁,回溯 } return false; }逻辑说明:visited在入栈时标记、出栈时不撤销。这个取舍让每个格子最多入栈一次,循环一定会终止,栈深不超过rows * cols。代价是只能求出任意一条通路,求不出全部路径——想要全部解,就得在pop_back()时把标记清掉,同时靠stk本身查重,复杂度会上去一个量级。
参数说明:path是出参,成功时是从起点到终点的完整坐标序列(含两端),失败时被清空。返回值表示是否连通,和path.empty()是两回事——起点终点重合时path只有一个点,仍然算成功。
复杂度上,每个格子入栈一次、出栈一次,每次最多看四个方向,时间O(rows * cols),空间O(rows * cols)(visited表加最坏情况下的栈深)。
3.3 路径打印:把 * 打回迷宫
求解和渲染分开,中间隔一个画布。画布先照原图铺一遍,再把路径盖上去,最后用S/E覆盖两个端点,顺序反了端点就会被*吃掉。
// src/maze.cpp void printMaze(const Maze& m, const std::vector<Point>& path) { std::vector<std::vector<char>> canvas(m.rows, std::vector<char>(m.cols, '#')); for (int r = 0; r < m.rows; ++r) for (int c = 0; c < m.cols; ++c) canvas[r][c] = (m.grid[r][c] == WALL) ? '#' : '.'; for (const auto& p : path) // 先盖路径 if (m.inBounds(p)) canvas[p.r][p.c] = '*'; canvas[m.start.r][m.start.c] = 'S'; // 再盖端点 canvas[m.goal.r][m.goal.c] = 'E'; for (const auto& row : canvas) { for (char ch : row) std::cout << ch; std::cout << '\n'; } }参数说明:path传空 vector 就是只打印原始迷宫,做「求解前后对比图」很方便。canvas全部用 ASCII 字符是有意的,中文全角字符在终端里占两列宽,一旦混进去,行就对不齐了。
3.4 递归写法对照与栈溢出边界
递归版二十行就能写完,思路和上面完全一致:
static bool dfs(const Maze& m, Point cur, std::vector<std::vector<char>>& vis, std::vector<Point>& path) { if (!m.isRoad(cur) || vis[cur.r][cur.c]) return false; vis[cur.r][cur.c] = 1; path.push_back(cur); if (cur == m.goal) return true; // 找到了,path 一路保留 for (int d = 0; d < 4; ++d) { Point nxt{cur.r + DR[d], cur.c + DC[d]}; if (dfs(m, nxt, vis, path)) return true; } path.pop_back(); // 回溯:把当前点从路径里摘掉 return false; }差别在于「回溯」这个动作由谁完成。递归版里path.pop_back()是显式写的,容易漏,漏了路径里就会混入死胡同的点;迭代版里弹栈就是回溯,物理上不可能漏。
另一个差别是深度。递归深度等于路径长度,迷宫是 200x200 且蛇形走位时,深度能到几万层,默认 8MB 栈空间很容易撑爆,表现是程序直接崩、没有错误信息。迭代版的栈在堆上,vector自己扩容,稳当得多。这类玄学崩溃在本地小地图上根本复现不出来。
4. BFS 求解:队列凭什么保证第一次到终点就是最短路
栈式 DFS 给的是「一条能走通的路」,走到终点就算完事,中间绕多少弯它不管。要最短路就得换队列,按层一圈一圈往外扩。
4.1 分层扩展的不变式
BFS 的正确性靠一个不变式撑着:队列里的点,步数要么是d,要么是d + 1,且按步数非递减排列。每次从队首取出一个点,把它所有没访问过的邻居步数设成dist[cur] + 1再入队,队列的单调性就保住了。
由此推出关键结论:一个格子第一次被访问时,拿到的就是它的最短步数,因为更短的路径早就该在更早的层里把它标记掉了。所以终点第一次出队(或第一次被标记,两种写法等价)时,dist就是最短步数,沿着记录的前驱往回走就是最短路径。
这里的哨兵值要选对。dist初始化为-1表示没访问,别用0,否则起点和未访问分不开,第一次判断就会把起点当已访问跳过。
4.2 完整实现:solveByQueue 与 prev 回溯
// src/solver.cpp #include <deque> #include <algorithm> int solveByQueue(const Maze& m, std::vector<Point>& path) { path.clear(); if (!m.isRoad(m.start) || !m.isRoad(m.goal)) return -1; if (m.start == m.goal) { path.push_back(m.start); return 0; } std::vector<std::vector<int>> dist(m.rows, std::vector<int>(m.cols, -1)); std::vector<std::vector<Point>> prev(m.rows, std::vector<Point>(m.cols, Point{-1, -1})); // 哨兵:无前驱 std::deque<Point> q; // 或上面手写的 SeqQueue dist[m.start.r][m.start.c] = 0; q.push_back(m.start); while (!q.empty()) { Point cur = q.front(); q.pop_front(); if (cur == m.goal) { // 第一次到终点即最短 for (Point p = cur; p.r != -1; p = prev[p.r][p.c]) path.push_back(p); std::reverse(path.begin(), path.end()); // 回溯出来是倒序 return dist[cur.r][cur.c]; } for (int d = 0; d < 4; ++d) { Point nxt{cur.r + DR[d], cur.c + DC[d]}; if (!m.isRoad(nxt) || dist[nxt.r][nxt.c] != -1) continue; dist[nxt.r][nxt.c] = dist[cur.r][cur.c] + 1; prev[nxt.r][nxt.c] = cur; // 记录从哪来 q.push_back(nxt); } } return -1; // 队列空了还没到,不连通 }逻辑说明:prev表存的是每个格子的前驱坐标,回溯循环p = prev[p.r][p.c]一路走到起点,起点的prev是哨兵{-1, -1},循环条件p.r != -1靠它终止。这里最怕的是哨兵被初始化成{0, 0},那样回溯会绕回 (0,0) 然后原地打转,程序卡死不报错。
参数说明:返回值是步数,-1表示不连通,0表示起点即终点。path在成功时是正序的最短路径,失败时被清空。
复杂度:每个格子入队一次、出队一次,时间O(rows * cols);空间是dist、prev两张表和队列,都是O(rows * cols)。比起 DFS,多了一张prev表,但换来了最短性。
队列实现上有一点必须强调:如果用std::vector加erase(begin())当队列,每次出队都要搬移整个数组,100x100 的迷宫就会明显卡顿,1000x1000 直接跑不动。用std::deque或者 2.5 节的循环队列,出队只挪指针。
4.3 两种解法的取舍
| 维度 | 栈式 DFS | 队列式 BFS |
|---|---|---|
| 数据结构 | vector当栈,LIFO | 循环队列 /deque,FIFO |
| 路径性质 | 任意一条可行路 | 步数最少的那条 |
| 额外空间 | visited表 + 路径栈 | dist+prev+ 队列 |
| 终止时机 | 第一次栈顶等于终点 | 第一次队首等于终点 |
| 标记时机 | 入栈即标记,不撤销 | 入队即标记,不撤销 |
| 典型用途 | 判连通、找任意解、要回溯框架 | 最短路、步数统计、路径规划 |
选哪个取决于题目要什么。课设里常见的要求是「两种都实现并比较」,那就把两个函数都留着,用命令行参数切换,跑同一个地图看输出差异,这个对比图放在报告里比任何文字描述都有说服力。
4.4 随机迷宫生成:隔墙打通法
固定地图测几次就腻了,随机生成能测出很多边界问题。常见做法是把所有格子先填成墙,从(1,1)出发,每次朝四个方向隔一格「跳」过去,把中间那堵墙打通,跳到的格子继续递归。这样生成的迷宫一定是连通的,且任意两点间路径唯一。
// src/maze.cpp #include <random> static void carve(Maze& m, int r, int c, std::mt19937& rng) { const int dr[4] = {-2, 0, 2, 0}, dc[4] = {0, 2, 0, -2}; int order[4] = {0, 1, 2, 3}; std::shuffle(order, order + 4, rng); // 随机方向,避免迷宫规律化 for (int i = 0; i < 4; ++i) { int d = order[i]; int nr = r + dr[d], nc = c + dc[d]; // 目标格在界内且还是墙,才打通(奇数坐标是格子,偶数是墙) if (nr <= 0 || nr >= m.rows - 1 || nc <= 0 || nc >= m.cols - 1) continue; if (m.grid[nr][nc] != WALL) continue; m.grid[r + dr[d] / 2][c + dc[d] / 2] = ROAD; // 打通中间那面墙 m.grid[nr][nc] = ROAD; carve(m, nr, nc, rng); } } void generateMaze(Maze& m, int rows, int cols, unsigned seed) { if (rows % 2 == 0) ++rows; // 保证是奇数,四周边界才是墙 if (cols % 2 == 0) ++cols; m.rows = rows; m.cols = cols; m.grid.assign(rows, std::vector<int>(cols, WALL)); m.start = {1, 1}; m.goal = {rows - 2, cols - 2}; std::mt19937 rng(seed); m.grid[1][1] = ROAD; carve(m, 1, 1, rng); }参数说明:rows/cols传偶数会被自动加一,因为四周要留一圈墙、内部坐标是奇数,偶数尺寸会破坏这个规律。seed固定时同一张地图可复现,做回归测试必用。
carve用递归,深度是格子数的一半,101x101 的迷宫大概两千多层,一般没事;再大就改显式栈,套路和第 3 章一样。
5. 避坑与排查:五个把我卡住的现场
算法写对不难,难的是那些不报错、只是结果不对的时刻。下面五条都是我在这个项目里真实撞过的,按「现象 → 原因 → 解决」记下来,方便对照。
5.1 起点等于终点,程序输出空图或者直接崩
现象:把起点和终点设成同一格,跑 BFS 版本,输出的迷宫上没有任何路径,运气不好还会段错误。
原因:prev表的哨兵如果是{0, 0},回溯循环for (Point p = cur; p.r != -1; p = prev[p.r][p.c])会退到(0,0)再从(0,0)的prev走,永远出不来;而 DFS 版本里path = stk只有一个点,渲染没问题,但如果打印函数里假设path.size() >= 2去取「第二个点」,就越界了。
解决:两个求解器入口统一加if (m.start == m.goal) { path = {m.start}; return 0; },把退化情况提前拦掉;prev的哨兵统一用{-1, -1},并在printMaze里对空路径做特判。
5.2 路径里出现重复点,来回跳
现象:DFS 输出的路径上,同一格出现两次,画出来像打了个结。
原因:visited标记的时机放错了。一种是标记写在pop_back()之后(想着「退出来就还能再走」),另一种是for循环里不break,一次把四个方向的邻居全压进栈,栈就不再表示一条连续路径,而是一堆候选点。后一种最隐蔽,因为小地图上经常也能走到终点,只是路径乱七八糟。
解决:入栈前立刻标记,压一个就break回外层循环。判断标准很简单:任何时刻stk里相邻两个元素必须四邻接,不满足就说明压栈逻辑写错了。调试时可以把每次压栈/弹栈的坐标打出来,肉眼过一遍就能发现。
5.3 大迷宫跑得慢,出队像在搬砖
现象:9x9 地图秒出结果,随机生成 101x101 之后要等一会儿,301x301 干脆卡住。
原因:队列用std::vector<Point>存,出队写的是q.erase(q.begin())。erase会把后面所有元素往前挪一格,单次O(n),整体退化成O(n²),格子数一多就暴露。
解决:换成std::deque的pop_front(),或者用 2.5 节的循环队列,出队只挪head_指针。改完之后 301x301 的迷宫基本是毫秒级。这类问题在课设报告里不容易被看出来,但答辩现场用大图跑一次就露馅了。
5.4 地图加载成功,但求解器一律返回无解
现象:loadMaze返回true,printMaze也能打出正确的迷宫,可两个求解器都说找不到路。
原因:起点或终点落在墙上。示例地图里坐标是 1-based,手写的时候很容易把 8x8 的图写成终点(8, 8),而(8,8)正好是外圈那堵墙。loadMaze只做了inBounds检查,没做isRoad检查,于是程序带着非法起点进求解器,第一行if (!m.isRoad(m.start)) return false就返回了。
解决:loadMaze返回值里加上isRoad(start) && isRoad(goal),加载失败时打印具体原因(「起点越界」「起点是墙」分开报),别让调用方只拿到一个false猜半天。
5.5 终端里迷宫行行错位,右边多出一截空白
现象:同一份代码,在自己机器上打得整整齐齐,换台机器或者换个终端,每一行都往右偏一点。
原因:为了好看,把墙换成了全角方块或者中文符号。全角字符占两列宽,std::cout输出时按字符算,行与行之间的宽度对不齐;再叠加终端字体不是等宽字体,偏移会更明显。
解决:渲染层只用 ASCII 的#*.SE,想看彩色就上 ANSI 转义码改颜色,别改字符宽度。真需要方块效果,导成文本文件用别的工具渲染,不要在控制台里硬刚。
6. 二次开发:把它改成能上台演示的迷宫工具
课设交上去只是第一步,很多人后面还要拿它做答辩演示或者教学 demo。这时候最值得改的不是算法,而是入口和可观测性。我一般先动main.cpp,把硬编码的路径和算法选择做成参数:
| 参数 | 含义 | 示例 |
|---|---|---|
--algo=dfs|bfs | 选择求解算法 | --algo=bfs |
--map=路径 | 加载地图文件 | --map=maps/demo1.txt |
--gen=R,C | 随机生成 R 行 C 列迷宫 | --gen=31,41 |
--seed=N | 随机种子,固定后可复现 | --seed=20240501 |
--anim | 每步暂停,观察搜索过程 | --anim |
--anim的实现不需要改求解器,只要在solveByStack和solveByQueue的循环体里加一个回调:传入std::function<void(const std::vector<Point>&)>,每压栈/出队一次就调一次printMaze,中间std::this_thread::sleep_for(30ms)。求解逻辑和渲染彻底解耦,这也是为什么第 3、4 章坚持把打印单独拎出来——结构上给二次开发留了口子。
第二个值得加的是回归测试。不用引框架,几个assert就够:
void selfTest() { Maze m; assert(loadMaze("maps/demo1.txt", m)); std::vector<Point> p1, p2; bool ok1 = solveByStack(m, p1); int d2 = solveByQueue(m, p2); assert(ok1 && d2 >= 0); // BFS 的步数不该大于 DFS 路径长度减一 assert(d2 <= (int)p1.size() - 1); // 退化场景:起点即终点 m.start = m.goal; assert(solveByStack(m, p1) && p1.size() == 1); assert(solveByQueue(m, p2) == 0); // 不连通场景:终点四周全封死 Maze b = m; b.goal = {1, 1}; b.grid[1][1] = WALL; assert(!solveByStack(b, p1) && solveByQueue(b, p2) == -1); }这段测试里最有价值的是第二行断言:它把「BFS 求出的步数不能比 DFS 路径短」当成不变式来验,等于让两个算法互相校对。任何一边写错了,这条断言都会在改动后第一时间炸出来,比人工跑图靠谱得多。
最后说个习惯。从那以后我每次改完求解器,都会强制拿三张图走一遍:一张 9x9 的固定小地图、一张随机 101x101、一张起点终点重合的退化图,三张都过才继续往下写。小地图保证逻辑对,随机大图暴露性能和边界,退化图拦住那些只在特殊输入下才出现的崩溃。希望帮到你。
本文还有配套的精品资源,点击获取