☰
BFS+方向数组求解迷宫最短路径的完整实操指南(含代码与避坑)
2026/10/18 7:35:35 网站建设 项目流程

最近帮一位同学调一个迷宫最短路径的程序,卡点不在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, y+1),向下是(x+1, y),向左是(x, y-1),向上是(x-1, y)。dx和dy分别对应行坐标和列坐标的变化量。为什么这么写?因为二维数组的行下标x、列下标y,在坐标系里恰好可以理解为“纵轴”和“横轴”,你在纸上画一下就明白了:

(x-1, y) 上 (x, y-1) 左 右 (x, y+1) (x+1, 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() { queue<pair<int, 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}); } } }

这段代码的流程可以拆成四步,对应四个“检查”:

  1. 越界检查:nx和ny必须在[0, n)和[0, m)之间,否则直接跳过。这是最常见的错误来源,写成nx < 0 || nx >= n就已经很严谨了,但有人会漏掉ny的判断,导致程序访问到非法的列下标,轻则越界读数据,重则崩溃。
  2. 墙壁检查:迷宫里的墙不能走,遇到井号直接跳过。
  3. 访问检查:如果这个格子已经在队列里处理过或正在等待处理,就不要再次入队。这保证了每个格子最多入队一次,BFS整体复杂度是O(n*m),非常高效。
  4. 状态更新:标记vis、更新step、入队。

这里有个细节值得展开:为什么每次扩展的步数要+1而不是单独再做一个计数器?因为BFS的层和层之间没有显式的分隔符,如果只用一个全局计数器,你无法知道当前这个格子到底在第几层。直接存step值是最直观的方案,取队头元素时顺便取出它对应的步数,然后新格子的步数就是当前格子的步数+1,逻辑清晰,不容易出错。

2.3 关于终点的处理方式

如果你仔细看上面的代码,会发现我在取出队头元素后先判断是不是终点,然后return。这个位置有两种常见的替代写法:

写法A(入队前判断):在扩展邻居时,如果邻居就是终点,直接把步数算出来返回。这种写法稍微减少一点循环次数,但代码可读性稍差,因为终点判断被嵌在循环里。

写法B(出队时判断,即上面代码的写法):逻辑最清晰。由于BFS按层扩展,当终点第一次出队时,step值一定是最小的。而且在出队时统一判断,代码结构最简单,也不容易漏判断某个入口。

我个人倾向用写法B,原因是当题目扩展为“多个终点”或“终点不一定可达”时,统一在出队处判断更好维护。注意如果终点根本不可达,队列会正常变空,函数执行完毕后返回,主函数里再用step值判断是不是初始值即可。

2.4 用pair还是结构体:不同语言的选择

C++里用queue<pair<int,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结束后,从终点开始沿着前驱一路倒推回起点,再把路径反转成从起点到终点的顺序。

pair<int, int> pre[MAXN][MAXN]; // 前驱坐标

在扩展时加上一句:

pre[nx][ny] = {x, y};

最后从终点回溯:

vector<pair<int, int>> path; pair<int, 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 >= m
5输出步数总是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(V+E)。这一点你在写代码时,应当当作一条铁律来执行。我自己的经验是,每次写完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循环。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询