BFS+方向数组求解迷宫最短路径的完整实操指南(含代码与避坑)

发布时间:2026/10/18 7:35:41
BFS+方向数组求解迷宫最短路径的完整实操指南(含代码与避坑)
最近帮一位同学调一个迷宫最短路径的程序卡点不在BFS逻辑而是在方向数组上——他把坐标的“行”和“列”搞反了导致本该往右走变成往下走。这个细节让我决定把“BFS方向数组”单独拿出来写一篇完整的实操笔记。不管你是刚学到图的遍历还是面试前想快速刷掉这类经典题这篇都值得存下来慢慢看。先说清楚这篇讲什么以一道常见的“走迷宫”题为例拆解如何用BFS广度优先搜索配合方向数组求解从起点到终点的最短路径。不仅给出可以直接跑的代码还会把方向数组的写法、坐标映射的坑、路径还原的方法讲透最后附上我自己踩过的几个典型问题排查。1. BFS为什么是走迷宫的“标准答案”很多第一次接触迷宫题的同学会想用DFS递归一条路走到黑不就行了吗确实DFS能找到一条路径但它不保证最短。想象你站在一个岔路口DFS的策略是“认准一条路走到底走不通再回头”这容易让你先钻进一条很长的死胡同虽然最终可能绕出来但路径未必是最短的。而BFS的策略是“把能走的下一步全部列出来一层一层向外扩展”像水波纹一样一圈圈往外推每一圈都是当前步数能到达的全部位置。正因为BFS是层层推进的所以当终点第一次被访问到时它所在的层数就是最短步数这也是BFS在无权图每一步代价相同中最核心的性质。拿走迷宫来说每一个格子就是图上的一个节点从当前格子能走到的上下左右四个格子就是它的邻居。整个迷宫就是一个无权图BFS天然适合处理这类“最少步数”“最短路径”问题。除了迷宫棋盘上骑士的最少移动步数、单词接龙的最小变换次数、社交网络里两个人之间最短的“经过几个人”等本质都是同一个套路BFS在无权图上找最短路径。理解了BFS的普适性你就会发现方向数组其实只是实现BFS时的一个“辅助工具”但它直接关系到代码的简洁性和正确性。下面重点说这个工具本身。1.1 方向数组四个方向的一维化表达方向数组的核心思想是把“上下左右”这四个方向的坐标变化量统一存到数组里然后用一个for循环遍历所有方向替代手写四段几乎相同的重复代码。常规写法是这样的// 方向数组右、下、左、上顺序可自定义 int dx[4] {0, 1, 0, -1}; int dy[4] {1, 0, -1, 0};这里的含义是对于当前位置(x, y)向右走一步是(x, y1)向下是(x1, y)向左是(x, y-1)向上是(x-1, y)。dx和dy分别对应行坐标和列坐标的变化量。为什么这么写因为二维数组的行下标x、列下标y在坐标系里恰好可以理解为“纵轴”和“横轴”你在纸上画一下就明白了(x-1, y) 上 (x, y-1) 左 右 (x, y1) (x1, y) 下有了方向数组扩展四个方向只需要一个循环for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; // 再对nx, ny做边界、墙壁、是否访问的判断 }如果没有方向数组你就得写四句几乎一模一样的判断代码。代码稍微多点不是大问题更关键的是容易漏改少写一个方向的判断、把某个方向的坐标写错这类bug常常很难一眼发现。方向数组把所有变化量放在同一个地方逻辑集中、容易检查这也是它在各种图论算法中被广泛使用的原因。1.2 方向顺序不是随便定的四个方向的顺序会影响什么最直接的影响是当多个方向都能走到下一步时访问邻居的先后顺序不同BFS队列里的节点顺序也不同。如果题目只要求输出最短步数顺序无所谓。但如果题目要求输出一条具体的路径且规定“字典序最小”或“优先向右”等规则就必须调整方向数组的顺序。比如有些题要求按照“D、L、R、U”下、左、右、上的字典序输出路径那么方向数组就应该写成int dx[4] {1, 0, 0, -1}; // 下、左、右、上 int dy[4] {0, -1, 1, 0};然后配合对应的方向字符数组char dir[4] {D, L, R, U};我个人的习惯是如果题目没有特殊要求就固定用“右、下、左、上”这个顺时针顺序因为这在直觉上最接近人走路时“先看看右边再看看下边”的自然习惯。不过如果你在处理需要回溯路径的题目务必先仔细读题看方向顺序是否会影响答案的判定。2. 从零到一实现BFS走迷宫这一部分我们具体写代码。先假设一个标准的输入格式第一行两个整数n、m表示迷宫行数和列数接下来n行每行m个字符字符只包含起点标记、终点标记、墙壁和空地。约束条件是所有空地都可以通行要求输出从起点到终点的最短步数。我将以C为例逐段实现因为C在各类题目里最常用而且能更清楚地看到队列和数组的底层操作。同时也给出Python版本方便对比。2.1 数据结构和变量准备#include iostream #include queue #include cstring using namespace std; const int MAXN 105; char maze[MAXN][MAXN]; bool vis[MAXN][MAXN]; // 访问标记 int step[MAXN][MAXN]; // 记录步数也可用结构体替代 int n, m; int startX, startY, endX, endY; int dx[4] {0, 1, 0, -1}; int dy[4] {1, 0, -1, 0};解释几个设计选择。访问标记vis数组是BFS必须的用来防止一个格子被重复入队。如果不加访问标记队列会无限膨胀陷入死循环——这在调试时会让你无比痛苦。step数组用来记录从起点到每个格子的最短步数也可以直接在队列里封装一个结构体带x、y、当前步数但单独开一个数组不仅访问方便而且最后如果想输出每个格子的步数分布图做调试也非常直观。起点和终点用全局变量存起来在主函数里读入时赋值。这里有一个特别容易踩的坑对二维数组来说行和列的下标起点都是0。也就是说如果起点位置在第2行第3列从1开始数对应的数组下标应该是maze[1][2]而不是maze[2][3]。很多人写惯了自然语言里的“第几行第几列”直接套到数组下标上导致起点定位错误。2.2 BFS主流程队列驱动的层序扩展void bfs() { queuepairint, int q; q.push({startX, startY}); vis[startX][startY] true; step[startX][startY] 0; while (!q.empty()) { int x q.front().first; int y q.front().second; q.pop(); // 如果当前格子是终点直接返回 if (x endX y endY) { return; } for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; // 越界检查 if (nx 0 || nx n || ny 0 || ny m) continue; // 墙壁检查 if (maze[nx][ny] #) continue; // 访问检查 if (vis[nx][ny]) continue; vis[nx][ny] true; step[nx][ny] step[x][y] 1; q.push({nx, ny}); } } }这段代码的流程可以拆成四步对应四个“检查”越界检查nx和ny必须在[0, n)和[0, m)之间否则直接跳过。这是最常见的错误来源写成nx 0 || nx n就已经很严谨了但有人会漏掉ny的判断导致程序访问到非法的列下标轻则越界读数据重则崩溃。墙壁检查迷宫里的墙不能走遇到井号直接跳过。访问检查如果这个格子已经在队列里处理过或正在等待处理就不要再次入队。这保证了每个格子最多入队一次BFS整体复杂度是O(n*m)非常高效。状态更新标记vis、更新step、入队。这里有个细节值得展开为什么每次扩展的步数要1而不是单独再做一个计数器因为BFS的层和层之间没有显式的分隔符如果只用一个全局计数器你无法知道当前这个格子到底在第几层。直接存step值是最直观的方案取队头元素时顺便取出它对应的步数然后新格子的步数就是当前格子的步数1逻辑清晰不容易出错。2.3 关于终点的处理方式如果你仔细看上面的代码会发现我在取出队头元素后先判断是不是终点然后return。这个位置有两种常见的替代写法写法A入队前判断在扩展邻居时如果邻居就是终点直接把步数算出来返回。这种写法稍微减少一点循环次数但代码可读性稍差因为终点判断被嵌在循环里。写法B出队时判断即上面代码的写法逻辑最清晰。由于BFS按层扩展当终点第一次出队时step值一定是最小的。而且在出队时统一判断代码结构最简单也不容易漏判断某个入口。我个人倾向用写法B原因是当题目扩展为“多个终点”或“终点不一定可达”时统一在出队处判断更好维护。注意如果终点根本不可达队列会正常变空函数执行完毕后返回主函数里再用step值判断是不是初始值即可。2.4 用pair还是结构体不同语言的选择C里用queuepairint,int最省事适合坐标题。但如果除了坐标你还需要记录方向字符、剩余血量、钥匙状态等额外信息pair就不够用了应该封装结构体struct Node { int x, y; int dist; char dir; // 记录从上一个格子走到当前格子的方向 };用结构体的代价是内存稍大一些但代码扩展性更好。我见过不少同学为了省事用pair结果题目一变复杂度就不得不重构。建议如果你的BFS里除了坐标以外还需要携带任何其他信息直接写结构体不要犹豫。2.5 Python版对比代码更清爽但底层的坑不变from collections import deque def bfs(maze, n, m, start, end): dirs [(0, 1), (1, 0), (0, -1), (-1, 0)] # 右、下、左、上 vis [[False] * m for _ in range(n)] step [[0] * m for _ in range(n)] q deque() q.append(start) vis[start[0]][start[1]] True while q: x, y q.popleft() if (x, y) end: return step[x][y] for dx, dy in dirs: nx, ny x dx, y dy if nx 0 or nx n or ny 0 or ny m: continue if maze[nx][ny] #: continue if vis[nx][ny]: continue vis[nx][ny] True step[nx][ny] step[x][y] 1 q.append((nx, ny)) return -1 # 不可达Python的元组解构让方向数组的遍历看起来更舒服但核心逻辑和C完全一致。唯一需要注意的是Python中二维列表的初始化方式[[False] * m for _ in range(n)]不能写成[[False] * m] * n后者会让每一行共享同一个列表对象改一个全变——这也是新手常踩的坑。3. 路径还原从“知道步数”到“输出完整路线”很多迷宫题不只是让你输出最短步数还要求输出具体路径比如“从起点到终点的移动序列”。这一节专门讲路径还原的两种常见方法。3.1 用前驱数组回溯思路很简单在BFS过程中当从(x, y)扩展到(nx, ny)时记录下(nx, ny)的前驱是(x, y)。等BFS结束后从终点开始沿着前驱一路倒推回起点再把路径反转成从起点到终点的顺序。pairint, int pre[MAXN][MAXN]; // 前驱坐标在扩展时加上一句pre[nx][ny] {x, y};最后从终点回溯vectorpairint, int path; pairint, int cur {endX, endY}; while (!(cur.first startX cur.second startY)) { path.push_back(cur); cur pre[cur.first][cur.second]; } path.push_back({startX, startY}); reverse(path.begin(), path.end());这种方法空间开销是O(n*m)对于大多数迷宫题完全够用。不需要存储整条路径只存储每个格子的前驱信息量最小但已足够重建路径。3.2 记录方向字符直接输出移动序列如果题目要求输出“RDLU”这样的移动序列不需要存完整坐标路径只需要在每个格子记录“我是从哪个方向到达这里的”。char pathDir[MAXN][MAXN]; // 记录到达当前格子的方向扩展时pathDir[nx][ny] dir[i]; // dir[i]对应方向数组的方向字符回溯时从终点一路取路径方向字符最后反转字符串即可。这种方式内存占用更小也更贴合“输出移动序列”的需求。这里有一个我在实际调试中遇到的注意点方向字符必须和方向数组一一对应而且顺序要一致。如果你方向数组用的是“右、下、左、上”字符数组必须是“R、D、L、U”一个错位整个路径还原出来就是乱的而且这种错误很难通过看日志发现因为步数是对的只是方向序列不对。3.3 递归逆序打印代码最短但小心递归深度也可以用递归的方式在回溯时直接打印路径void printPath(int x, int y) { if (x startX y startY) { cout ( x , y ); return; } printPath(pre[x][y].first, pre[x][y].second); cout - ( x , y ); }逻辑上更简洁但注意递归深度等于路径长度如果迷宫规模很大比如1000x1000的网格路径长度可能接近100w步递归会导致栈溢出。所以对于大规模迷宫我建议一律用迭代版本不要用递归。4. 常见问题与排查技巧实录下面集中列出我在实际写迷宫BFS时踩过、也帮别人排查过的典型问题每条都给出现象、原因和解决方案可以直接对照自查。编号现象原因解决方案1程序陷入死循环或队列无限增长没有给访问过的格子打标记同一个格子不断入队入队前设置vis[nx][ny] true而不是出队后再设置2结果比预期距离多出几倍方向数组的行列变化量写反比如把上下方向用了列坐标变化写一个“右下左上”的固定模板每次套用时确认dx对应行、dy对应列3起点位置读取错误读入时把行列搞混或没有按空间坐标映射到数组下标读入时先存startX i, startY j统一用0基下标4越界判断不完整只判断了nx的边界忘了ny的边界或者用了不严谨的比较每个方向都写四段判断nx 0、nx n、ny 0、ny m5输出步数总是0或1step数组初始化不正确或者在入队后没有更新step[ nx ][ ny ]确认step[nx][ny] step[x][y] 1这条语句在continue之后执行6迷宫不可达时返回了错误值BFS结束后没有额外判断直接用了step默认值0BFS函数返回-1表示不可达主函数根据返回值做判断7路径还原后顺序是反的回溯收集路径后忘了reverse先收集终点到起点的路径再reverse得到起点到终点8Python中vis矩阵所有行一起变用[[False] * m] * n初始化二维列表改用列表推导式[[False] * m for _ in range(n)]4.1 访问标记的“设置时机”为什么关键这是BFS代码里最精妙也最容易出错的地方值得单独说。正确的做法是在节点入队时标记vis而不是在出队时标记。设想一下如果在出队时才标记vis假设起点A能扩展到B和C两个格子A出队后访问标记才被设置为已访问。B和C入队时都未标记vis。接下来如果B扩展时发现邻居C还没被访问就会再次把C入队导致同一层内C被重复入队。虽然最终结果可能因为队列里多出重复节点而依然正确但时间复杂度会退化在某些图结构里甚至导致无限循环。反过来入队时立即标记vis就能保证每个节点入队一次、出队一次整个BFS的总复杂度严格是O(VE)。这一点你在写代码时应当当作一条铁律来执行。我自己的经验是每次写完BFS都检查一下vis赋值的位置如果是出队时才赋值就说明有隐患。4.2 坐标映射的“先验检查”习惯在迷宫题里启动坐标、终点坐标、方向变化量、step数组赋值这四个地方的坐标系必须完全一致。我推荐一个可以避免80%坐标错误的习惯读入迷宫后先手动打印一遍起点、终点、以及起点四周格子的值确认坐标没有偏移。具体做法是在主函数里临时加一段调试代码把起点周围的四个格子显式输出一遍看看是不是你设想的上下左右。这段代码看似浪费时间但效果极好。因为坐标错误在代码中通常不报错只是结果不正确而手动指认关键点可以在几分钟内暴露问题。4.3 输入格式细节空格和换行的坑迷宫题目的输入格式有时候不是干净的逐字符输入可能每行的字符之间用空格隔开形如. . # . # . . .这种情况下用cin maze[i][j]会直接把空格过滤掉一次读取一个字符正好得到正确的数组内容。但如果你用scanf(%c, maze[i][j])换行符和空格都会被读进去导致迷宫数组错位。一个简单的规避方法是用cin maze[i][j]或者读整行再过滤空格。另外如果题目给的迷宫有多个连续的空格、制表符混入建议读入每一行后用getline再手动处理。这里没有万能模板重点是你得明确自己用的输入方式对空白字符的处理逻辑否则迷宫读进去就是歪的后面的所有算法逻辑全部白搭。4.4 防止重复计算的剪枝思路在某些带权或带状态的变种题里单纯的vis二维数组不够用因为同一个格子可能以不同状态访问。经典的例子是“带钥匙的迷宫”你需要同时记录位置和钥匙状态也就是把vis扩展成三维数组vis[x][y][keyMask]。这个思路本质上是“状态BFS”的雏形——把位置和附加状态看作一个整体的状态节点。当你发现二维vis不够用时第一反应不应该是加各种奇怪的判断而是把状态空间想清楚。比如有4把钥匙钥匙状态用一个4位二进制数表示那么总状态数是nm16照样是有限的。只要状态总数有限BFS仍然可以保证找到最短路径。在我的经验里能想到“状态”这个维度你就比90%的初学者强了。5. 延伸应用从迷宫到更多BFS场景既然方向数组BFS能解决走迷宫最短路径那这套思路还能迁移到哪里这一节分享三个最直接的延伸方向。5.1 多起点BFS多个入口同时燃烧如果题目变成“从多个起点同时出发每个空地距离最近起点的最短距离”传统做法是跑多次BFS然后取最小值复杂度高。更优雅的做法是多源BFS把所有起点一次性全部初始入队然后统一向外扩展。因为队列天然保持层序每次扩展得到的就是所有起点到达该格子的最短距离。这个思路在实际刷题中非常常见比如“每个格子到最近的火源的距离”“多个快递员同时派送问题”。写法上和单源BFS唯一的区别就是起点入队从1个变成多个其他代码几乎一模一样。5.2 双向BFS缩短搜索空间如果迷宫规模很大单源BFS可能搜索大量无关区域。双向BFS的思路是从起点和终点同时开始BFS每次扩展当前层节点数较少的一端当两边访问到同一个节点时路径长度就是两端的步数之和。这能把搜索空间从指数级降到指数减半在状态搜索类题目里经常能救命。不过双向BFS的实现细节比常规BFS多一些关键点在于两边要用各自的vis数组和各自的队列每次扩展完比较两个vis数组是否有交集。迷宫中因为每个节点的方向只有四个这种优化带来的提升可能不会特别明显但在状态空间更大的搜索题里收益非常可观。建议学会常规BFS后再找两道双向BFS的题目练手。5.3 0-1 BFS当路径有权且只有0和1如果迷宫里的格子有些需要消耗体力代价为1有些不消耗代价为0那普通BFS就无法保证第一次到达终点就是最短路径了。这时候需要用0-1 BFS用双端队列替代普通队列代价为0的扩展插入队头代价为1的插入队尾。这个算法本质上是对Dijkstra的一种针对0/1权重的优化。我个人觉得理解0-1 BFS对理解BFS的局限性很有帮助它会让你意识到BFS之所以能找最短路径前提是“每一步代价相同”。一旦代价不同就不能简单用队列层序来保证了。这个认知上的升级比多刷十道模板题更有价值。最后再分享一个小技巧在我自己写迷宫BFS的这几年里最实用的一招是把方向数组的循环单独封装成一个函数或宏不要在每道题里重写。每次遇到新题先复用它然后集中精力处理这道题的输入格式、状态维度和输出要求。因为方向数组本身极其固定代码也没啥可“优化”的空间它的价值在于把容易出错的部分一次性写对。另外你去网上搜这道题的题解大概率会看到有人用“DFS回溯”写过也有人用“动态规划”写过但我的看法是只要题目要求的是“最短路径”BFS就是最稳妥的选择。方向数组这套写法配合正确入队时机和访问标记放到任何规模相似的题目里都能跑出正确答案。你先把这一套练熟再去看那些花哨的变种会发现很多看似复杂的题核心主结构依然是那个你熟悉的BFS循环。