ARTICLE DETAIL

资讯详情

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

C语言迷宫求解实战:DFS与BFS算法对比及核心实现

C语言迷宫求解实战:DFS与BFS算法对比及核心实现 1. 迷宫求解程序的设计思路与算法选型1.1 为什么选迷宫求解作为C语言练手项目迷宫求解这个题目在C语言学习路径里属于那种“看起来简单、做起来全是坑”的经典项目。它不像冒泡排序那样十几行就能写完也不像链表操作那样纯粹考指针。它同时牵扯到二维数组操作、递归与栈、队列、路径记录、回溯逻辑是一个能把C语言核心知识点串起来的综合练习。我当初选这个题目是因为它有一个很实在的好处结果可视化程度高。你写排序算法跑完只能看控制台输出一串数字但迷宫求解跑完你能直接在终端里看到一条从入口到出口的路径被标记出来对错一眼就知道。这种即时反馈对调试和建立信心特别有帮助。这个程序适合谁如果你已经学完C语言基础语法知道数组、指针、函数、结构体怎么用但还没做过一个“完整的小项目”那迷宫求解是非常合适的切入点。它不需要图形库不需要额外依赖一个.c文件加一个终端就能跑起来。1.2 DFS和BFS到底该选哪个这是迷宫求解绕不开的第一个决策。两种算法都能找到路径但行为差别很大。DFS深度优先搜索的思路是“一条路走到黑撞墙再回头”。用递归或者显式栈实现代码短内存占用小。但它找到的路径不一定是最短的甚至可能绕很远。在迷宫里DFS容易沿着一个方向猛冲最后找到一条歪歪扭扭的路。BFS广度优先搜索的思路是“一层一层往外扩”用队列实现。它找到的路径一定是最短路径在无权图意义下。代价是需要存储更多状态内存占用比DFS大。我的建议是两个都实现。这不是为了炫技而是因为对比两者的输出结果能让你直观理解“搜索策略”对结果的影响。你可以先写DFS看到一条可行但很长的路径再写BFS看到一条明显更短的路。这种对比比看十遍教科书都管用。提示如果迷宫规模很大比如100×100以上DFS的递归深度可能爆栈这时候要么改显式栈要么换BFS。小迷宫20×20以内随便用递归都没问题。1.3 整体程序架构怎么搭我不喜欢一上来就写main函数然后所有逻辑堆在一起。迷宫求解这个项目我习惯分成四个模块迷宫数据结构用二维数组存地图0表示通路1表示墙另外用单独数组记录访问状态和路径来源。输入模块从文件或硬编码读取迷宫确定入口和出口坐标。搜索模块DFS和BFS各一个函数核心逻辑在这里。输出模块把求解后的路径用字符标记出来打印到终端。这样分的好处是搜索算法可以独立替换和测试。你今天想加个A*算法只需要新增一个搜索函数输入输出模块完全不用动。2. 核心数据结构与关键细节处理2.1 迷宫怎么存二维数组的几种玩法最直接的存法就是int maze[ROW][COL]0和1表示通路和墙。但实际写的时候有几个细节值得提前想清楚。边界处理我强烈建议在迷宫外围加一圈“虚拟墙”。比如实际迷宫是10×10你开数组开成12×12最外圈全填1。这样搜索的时候不用每次都判断x-1 0、x1 ROW代码会干净很多。这个技巧在写DFS递归的时候尤其省心少写四个边界判断少一堆bug。访问标记搜索过程中需要记录哪些格子已经走过。可以用单独的int visited[ROW][COL]也可以直接在maze数组上改值比如走过的格子标记为2。我倾向于用单独数组因为这样原始迷宫不会被破坏方便反复求解和对比不同算法。路径记录找到终点后要回溯出完整路径。两种常用方法一种是用parent[ROW][COL]记录每个格子的前驱坐标最后从终点倒推回起点另一种是在DFS递归返回时把路径压栈。前者对BFS更自然后者对DFS更自然。我一般统一用parent数组两种算法都能用同一套回溯逻辑。2.2 方向数组让代码少写一半新手写迷宫搜索最容易写成四个if判断上下左右。比如if (maze[x-1][y] 0 !visited[x-1][y]) { ... } if (maze[x1][y] 0 !visited[x1][y]) { ... } if (maze[x][y-1] 0 !visited[x][y-1]) { ... } if (maze[x][y1] 0 !visited[x][y1]) { ... }这样写没错但扩展到八个方向允许斜走就要写八个分支而且四个分支里逻辑重复度极高。更好的做法是用方向数组int dx[4] {-1, 1, 0, 0}; int dy[4] {0, 0, -1, 1};然后循环for (int d 0; d 4; d) { int nx x dx[d]; int ny y dy[d]; // 统一处理 nx, ny }这个写法不只是“看起来优雅”它实实在在减少了出错概率。你改方向、加方向、减方向只动数组就行循环体完全不用碰。我后来写任何网格搜索类问题第一件事就是先把方向数组定义好。2.3 队列和栈自己写还是用库C语言标准库没有现成的队列和栈。你可以用数组模拟也可以用链表。对于迷宫求解这种规模数组模拟完全够用而且更快、更简单。BFS的队列我一般开一个足够大的结构体数组typedef struct { int x, y; } Point; Point queue[ROW * COL]; int front 0, rear 0;入队就是queue[rear] p出队就是p queue[front]。注意这里没有循环利用空间因为BFS最多把每个格子入队一次ROW * COL的大小是够的。DFS如果用显式栈同理。但我更推荐DFS用递归写代码短得多可读性也好。只有在迷宫特别大、担心爆栈的时候才改成显式栈。注意队列数组大小要开ROW * COL不是ROW COL。我见过有人开小了导致越界程序跑着跑着就段错误查半天查不出来。3. 完整实操过程与核心代码实现3.1 迷宫数据的准备与读入我一般先用硬编码的方式测试算法逻辑确认没问题后再改成从文件读。硬编码的迷宫长这样#define ROW 12 #define COL 12 int maze[ROW][COL] { {1,1,1,1,1,1,1,1,1,1,1,1}, {1,0,0,0,1,0,0,0,0,0,0,1}, {1,0,1,0,1,0,1,1,1,1,0,1}, {1,0,1,0,0,0,0,0,0,1,0,1}, {1,0,1,1,1,1,1,1,0,1,0,1}, {1,0,0,0,0,0,0,1,0,0,0,1}, {1,1,1,1,1,1,0,1,1,1,1,1}, {1,0,0,0,0,1,0,0,0,0,0,1}, {1,0,1,1,0,1,1,1,1,1,0,1}, {1,0,1,0,0,0,0,0,0,0,0,1}, {1,0,0,0,1,1,1,1,1,1,0,1}, {1,1,1,1,1,1,1,1,1,1,1,1} };注意最外圈全是1这就是前面说的“虚拟墙”。入口设在(1,1)出口设在(10,10)。如果要从文件读格式可以很简单第一行是行数和列数后面每行是0和1的字符串。读的时候用fscanf或者fgets都行。我倾向于fgets逐行读然后逐字符转成整数这样对空格和换行的容错性更好。3.2 DFS递归实现短小但要注意细节DFS递归的核心代码大概长这样int dfs(int x, int y, int endX, int endY) { if (x endX y endY) { return 1; // 找到终点 } visited[x][y] 1; for (int d 0; d 4; d) { int nx x dx[d]; int ny y dy[d]; if (maze[nx][ny] 0 !visited[nx][ny]) { parent[nx][ny] x * COL y; // 记录前驱 if (dfs(nx, ny, endX, endY)) { return 1; } } } return 0; // 此路不通 }这里有几个细节值得说。前驱记录用一维编码parent[nx][ny] x * COL y把二维坐标压成一个整数存。回溯的时候再解出来px parent[x][y] / COL; py parent[x][y] % COL;。这样做的好处是parent数组可以是一维的省一点空间而且写起来也不麻烦。找到终点后直接返回注意if (dfs(...)) return 1;这一句。找到一条路径就立刻层层返回不再继续搜索其他分支。如果你想要所有路径就把这里改成收集路径然后继续循环但那样复杂度会高很多。visited标记的位置我在进入函数后立刻标记visited[x][y] 1而不是在循环里标记。这两种写法有细微差别。在函数入口标记更安全能防止同一格子被重复递归。3.3 BFS队列实现保证最短路径BFS的代码结构不同核心是队列int bfs(int startX, int startY, int endX, int endY) { int front 0, rear 0; queue[rear].x startX; queue[rear].y startY; rear; visited[startX][startY] 1; parent[startX][startY] -1; // 起点没有前驱 while (front rear) { Point cur queue[front]; if (cur.x endX cur.y endY) { return 1; } for (int d 0; d 4; d) { int nx cur.x dx[d]; int ny cur.y dy[d]; if (maze[nx][ny] 0 !visited[nx][ny]) { visited[nx][ny] 1; parent[nx][ny] cur.x * COL cur.y; queue[rear].x nx; queue[rear].y ny; rear; } } } return 0; }BFS和DFS最大的区别在于BFS在入队时就标记visitedDFS在递归入口标记。这个区别很关键。BFS如果等到出队才标记同一个格子可能被多次入队队列会膨胀甚至死循环。BFS找到终点后parent数组里存的是一条最短路径的前驱链。回溯的时候从终点一路倒推到起点把路径上的格子标记出来就行。3.4 路径回溯与结果打印不管DFS还是BFS回溯逻辑是一样的void printPath(int endX, int endY) { int x endX, y endY; while (x ! -1 y ! -1) { maze[x][y] 2; // 用2标记路径 int p parent[x][y]; if (p -1) break; x p / COL; y p % COL; } // 打印迷宫 for (int i 0; i ROW; i) { for (int j 0; j COL; j) { if (maze[i][j] 1) printf(#); else if (maze[i][j] 2) printf(*); else printf( ); } printf(\n); } }打印的时候用#表示墙*表示路径空格表示通路。这样终端里一眼就能看出路径走向。提示如果你先跑DFS再跑BFS记得在两次求解之间把visited和parent数组清零否则第二次搜索会受第一次结果影响。我当初就在这里踩过坑BFS跑出来路径不对查了半天才发现是visited没重置。4. 常见问题与排查技巧实录4.1 程序崩溃与段错误排查迷宫求解程序最常见的崩溃原因就三个数组越界、递归爆栈、队列溢出。数组越界通常发生在方向数组循环里。如果你没有加虚拟墙nx或ny可能变成-1或者等于ROW/COL访问maze[nx][ny]就崩了。解决办法就是前面说的加虚拟墙或者老老实实写边界判断。递归爆栈发生在DFS深度太大的时候。一个100×100的迷宫如果路径很长递归深度可能上千。默认栈大小一般能撑住几千层但再大就不行了。判断方法很简单程序跑到一半突然段错误而且迷宫规模很大基本就是爆栈。改成显式栈或者换BFS就行。队列溢出就是数组开小了。BFS队列大小必须是ROW * COL因为每个格子最多入队一次。如果你开成ROW COL大迷宫跑一半就崩。4.2 路径不对或找不到路径路径不对通常有几个原因visited重置遗漏多次求解之间没清零第二次搜索被第一次的visited挡住了。parent记录错误前驱编码或解码写错回溯出来的路径是乱的。方向数组写错比如dx和dy对应关系搞反了导致上下左右判断错位。找不到路径先确认迷宫确实有通路。你可以手动走一遍或者把迷宫打印出来看看入口出口是不是被墙堵住了。另外检查起点和终点的坐标有没有写反(x, y)和(row, col)搞混是新手常犯的错误。4.3 常见问题速查表问题现象可能原因排查方法解决方案程序段错误数组越界检查方向循环中nx/ny范围加虚拟墙或边界判断程序段错误递归爆栈迷宫规模大且用递归DFS改显式栈或换BFS路径明显绕远用了DFS对比BFS结果换BFS求最短路径第二次求解结果异常visited未重置检查求解前是否清零memset重置visited和parent队列相关崩溃队列数组太小检查队列大小是否为ROW*COL扩大队列数组路径回溯断裂parent编码错误打印parent数组检查统一用x*COLy编码4.4 几个我踩过的坑和对应技巧坑一递归DFS的返回值处理。我一开始写DFS在循环里递归调用后没有判断返回值导致找到终点后还在继续搜索最后返回了错误结果。正确做法是if (dfs(...)) return 1;找到就立刻层层返回。坑二BFS的visited标记时机。我试过在出队时标记visited结果同一个格子被多次入队队列爆炸程序跑得极慢。后来改成入队时标记问题立刻解决。这个细节很多教程不讲但实际写的时候非常关键。坑三打印路径时破坏了原迷宫。我一开始直接在maze数组上把路径改成2结果打印完想再跑一次BFS发现迷宫已经被改了。后来改成用单独的path数组记录路径或者每次求解前重新拷贝一份迷宫。坑四终端输出太密看不清。小迷宫还好大迷宫打印出来密密麻麻。我的做法是路径用*墙用#通路用空格并且在入口和出口位置分别用S和E标记。这样即使迷宫很大也能快速定位。4.5 性能优化的小技巧迷宫求解本身计算量不大但如果要跑很多次或者迷宫很大有几个优化点方向数组用static const避免每次循环重新初始化。visited用char而不是int省内存缓存友好。BFS队列用循环队列如果迷宫特别大可以复用空间不过一般没必要。DFS改迭代显式栈比递归快一点而且不怕爆栈。这些优化在小型迷宫上效果不明显但养成习惯没坏处。尤其是visited用char改起来就一行的事。5. 从迷宫求解延伸出去还能玩什么迷宫求解写完之后其实还有很多可以扩展的方向。这些扩展不是必须做但每一个都能让你对搜索算法和C语言的理解更深一层。加权重迷宫把格子里的值从0/1改成不同代价比如草地代价1沼泽代价5。这时候BFS就不够用了需要Dijkstra或者A*。你可以先实现Dijkstra感受一下优先队列的作用。多出口迷宫迷宫有多个出口要求找到最近的那个。BFS天然适合这种场景因为它是按距离一层层扩展的第一个遇到的出口就是最近的。动态迷宫迷宫里的墙会变化比如某些格子定时开关。这就涉及到重新规划路径的问题可以结合BFS和增量更新来做。迷宫生成反过来不求解而是生成随机迷宫。常用算法有递归回溯法和Prim算法。生成和求解结合起来就是一个完整的迷宫小系统。可视化如果不想停留在终端字符界面可以学一点简单的图形库把迷宫画成窗口。不过这就偏离C语言核心练习了看个人兴趣。我个人觉得把DFS和BFS两个版本都写一遍再对比它们的输出这个练习的价值就已经很大了。后面那些扩展挑一两个感兴趣的做就行不用全做。最后分享一个我调试时的小习惯在搜索函数里加一个计数器记录访问了多少个格子。跑完之后打印出来你能直观看到DFS和BFS的搜索规模差异。有时候DFS找到的路径更长但访问的格子反而更少BFS路径最短但访问的格子更多。这个对比数据比任何理论解释都直观。
返回列表