1. BISHI80走迷宫,为什么是这道题
如果你刷过算法题或者参与过信息学竞赛的基础训练,大概率碰过这类问题:给定一个由字符组成的迷宫矩阵,起点在左上角,终点在右下角,#代表墙壁,.代表空地,要求算出从起点到终点的最短步数,或者输出一条可行路径。BISHI80就是这类题中的一个典型代表,题目本身不复杂,但很多人第一次做的时候会发现,思路都懂,代码一写一堆bug,要么死循环,要么答案是错的。
我当年带过不少刚开始接触搜索算法的新手,发现他们卡住的点非常集中:不是不理解BFS的层序扩展,而是不知道代码层面怎么把"四个方向"这个动作写得干净利落。这时候方向数组就派上用场了。它不是一个算法,而是一种编码技巧,用两个一维数组配合循环,把"上、下、左、右"四个方向的尝试操作,从四段重复代码压缩成一段通用逻辑。理解了这个技巧,BFS的代码量能减少一半以上,而且几乎不会出现方向漏写的问题。
这篇文章不打算只贴一份AC代码然后让大家自己看,我想把这道题从头到尾拆开:从解题思路的选型,到方向数组的四种写法对比,再到完整代码的逐段解析,最后是调试经验和你可能在评测系统里遇到的坑。无论你是刚接触广度优先搜索的初学者,还是想把这题讲给学生听的带练者,这篇内容应该都能给到你想要的参考。我尽量把代码和讲解都写完整,你照着敲一遍,比干看十遍都管用。
2. 解题思路:为什么这题非BFS不可
2.1 最短路径问题和"层层扩散"的直觉
迷宫问题的本质是图上的最短路径搜索,只不过这个图的节点就是网格中的每一个坐标点,边就是相邻空地之间的移动关系。处理最短路径,BFS天然是首选,因为BFS的扩展顺序是按"层"来推进的:先把起点周围距离为1的所有节点全部入队,再处理距离为2的节点,依次类推。这意味着,当某个节点第一次被访问到时,它在队列中的层号就是从起点到它的最短距离。
我们用生活场景来类比:你在一栋楼里丢了一个包裹,公司组织大家一起找。BFS的做法是所有人先从会议室出发,把同楼层的每个房间都看一眼,然后统一去下一层看,而不是一个人先跑到十楼,另一个人又扎进地下室。这样能保证最先找到包裹的人,走过的路线一定是最短的。DFS则不同,它更像一个执着的人,顺着一条走廊走到黑,走到死胡同再折回来。在"只要可达"的迷宫问题里DFS够用,但在"求最短步数"的场景下,DFS必须要枚举所有路径才能知道哪条最短,效率低很多。
回到BISHI80这道题,题目明确要求输出从起点到终点的最少步数,所以方向几乎唯一,就是BFS。理解了BFS的层序扩散原理,你把队列想象成一个水波扩散的起源点,每一圈波纹对应一层步数,扩散到终点的那一刻,当前步数就是答案。
2.2 BFS的三个核心要素
写BFS代码之前,你需要确定三件事:用什么数据结构存待扩展的点、用什么结构记录已访问状态、用什么方式处理边界条件。
待扩展的点集合首选队列。一开始只有起点在队列中,然后循环执行"取出队头节点,尝试四个方向"的操作,把符合条件的邻居节点追加到队尾。这段逻辑里,队列天然地维持了"先进先出"的顺序,这正是BFS层序扩展的代码层保证。如果你手头只有数组,也可以把它当成循环队列模拟使用,不过在笔试题里直接用库里的队列最省事。
已访问状态的处理是很多初学者容易忽略的。BFS有一个重要原则:一个节点一旦被放入队列,就应该立刻标记为已访问,而不是等它出队时再标记。为什么?我们用一个简单例子说明:起点S出队时,它的下边和右边两个邻居节点都被加入队列,如果这两个邻居节点在后续扩展中都能走到同一个公共节点X,而X的已访问标记只有出队时才打上,那么X就可能被重复入队两次,产生大量冗余计算,极端情况下还会导致队列膨胀。正确的做法是在节点入队时立即标记,保证每个节点只进入队列一次。这道题体重低于100,矩阵规模也不大,重复入队可能不会让你超时,但养成好习惯总没错。
边界条件的处理相对直观,无非是检查下一个坐标是否超出矩阵范围,以及该位置是否是墙壁。把这些检查统一封装进一个判断逻辑里,代码的可读性会高很多。
2.3 DFS方案为什么在这个问题里失宠
这里我想多说两句DFS。不少人最初学搜索时先接触DFS,递归回溯那一套玩得挺溜,于是看到迷宫题也下意识写DFS。如果题目只要求"判断是否存在路径",DFS完全能胜任。但一旦要求"最短步数",DFS就力不从心了,因为它本质上是暴力枚举。你走完一条路径后,才能确认它的长度,只有把所有可行路径都遍历完,才能比较出最短的那条,这在最坏情况下是指数级的复杂度。
反观BFS,因为它按层扩展的特性,第一次扩展到终点时的步数一定是最短步数,中间不需要比较、不需要回溯,逻辑极其简洁。从这个角度说,BISHI80选择BFS不是因为它"更高级",而是在时间复杂度和代码复杂度上都更合理。理解这一点,比刷十道类似的题都重要。
3. 方向数组:代码里的"通用方向引擎"
3.1 方向数组的基本形态
方向数组解决的核心问题,是把"向四个方向移动"这样一个空间动作,翻译成计算机可以循环执行的整数操作。以常见的二维网格坐标系为例,x通常代表行号,y代表列号。四个方向的偏移量分别是:上(-1, 0)、下(1, 0)、左(0, -1)、右(0, 1)。
代码里最常见的写法是:
int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1};这里的dx和dy是配套使用的。dx[i]和dy[i]一起决定了第i个方向的横纵偏移量。当i=0时,偏移是(-1, 0),表示向上;i=1时是(1, 0),表示向下。遍历所有方向时,只需要一个for循环:
for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; // 检查 (nx, ny) 是否合法 }我之前见过有人用两个二维数组存方向,比如dir[4][2] = {{-1,0}, {1,0}, {0,-1}, {0,1}},然后通过nx = x + dir[i][0]来访问,这样也可以,只是可读性稍微差一点。双一维数组的写法在视觉上更直观,而且你不需要额外记忆"下标0是x偏移还是y偏移"这种细节。
需要注意的一点是,方向数组的顺序会影响代码的搜索结果。如果你的题目只求最短步数,顺序影响不大,因为BFS的层序扩展特性保证了首次到达终点的步数一定最短,与方向顺序无关。但如果题目要求输出路径,方向数组的顺序就会决定你最终找到的路径是哪一条。比如按"上、右、下、左"的顺序扩展,走出来的路线会偏向上方右侧;按"下、左、上、右"则偏爱向下延伸。绝大多数评测系统只校验路径的合法性,不校验唯一性,但如果你做可视化展示,可能需要调一下顺序让路径更"好看"。
3.2 为什么用方向数组而不是四段if
初学者最容易写的代码是这样:
// 向上 if (x - 1 >= 0 && grid[x-1][y] == '.') { q.push({x-1, y}); } // 向下 if (x + 1 < n && grid[x+1][y] == '.') { q.push({x+1, y}); } // 向左 if (y - 1 >= 0 && grid[x][y-1] == '.') { q.push({x, y-1}); } // 向右 if (y + 1 < m && grid[x][y+1] == '.') { q.push({x, y+1}); }这段代码在小规模迷宫上确实能跑通,但问题很明显:四个方向的逻辑大量重复,一旦你需要修改判断条件,得同时改四遍,漏一处就出bug。更糟的是,如果迷宫允许斜向移动,比如八个方向,这段代码会膨胀到八段重复逻辑,维护难度直线上升。方向数组的核心价值,就是把"尝试从当前点出发,走遍所有相邻方向"这个动作抽象成一个循环。你只需要改变方向数组的长度和内容,就能轻松适配四方向、八方向甚至马走日这类非规则的移动规则。代码量基本不变。
另外,方向数组还有一个隐性的好处:它天然地避免了方向漏写的问题。只要你的方向数组长度和循环次数一致,四个方向一个都跑不了。这对于考试时快速写题、减少低级错误来说,价值非常大。
3.3 方向数组的排布习惯与技巧
我见过不少有经验的竞赛选手会刻意把方向数组按照"顺时针"或"逆时针"顺序排列,这是有实际意义的。以四方向为例,顺时针顺序是:上(-1,0)、右(0,1)、下(1,0)、左(0,-1)。这样排布时,dx和dy分别为:
int dx[4] = {-1, 0, 1, 0}; int dy[4] = {0, 1, 0, -1};这样做的好处是,在某些需要输出路径、或者在网格上做螺旋遍历的场景下,方向的自然顺序会让逻辑更清晰。还有一个实用的技巧,如果你需要在代码中临时改变移动优先级,比如希望搜索优先向右走,只需要调整数组元素的顺序,不需要改循环逻辑。
对于八方向的问题,方向数组会变成八个元素,同样可以按顺时针排布:上、右上、右、右下、下、左下、左、左上。我个人的建议是,把方向数组的排布当成一种约定俗成的书写习惯固定下来,这样每次写搜索都不会因为临时想"方向顺序是什么"而打断思路。
4. BISHI80完整实现:从读入到答案逐段拆解
4.1 数据结构的选取和初始化
题目用的是字符矩阵,每个位置要么是空地,要么是墙壁。我习惯用vector<string>来存迷宫,因为每行字符串可以整体读入,访问某一行某一列的元素就用grid[x][y]。
记录步数的方式有两种。第一种是额外开一个二维数组dist,初始化成-1表示未访问,然后每进入一个新节点,就用前一个节点的步数加一。第二种是把步数直接封装在队列元素里,比如队列元素是一个结构体,包含x、y、step三个字段。两种方式各有优劣:dist数组在调试时可以直接观察每个位置的最短步数,但多占用一份内存;结构体方式代码简洁,不需要额外的数组。对于BISHI80这种小规模迷宫,两种方式差别不大,我倾向于用dist数组,因为后续如果要做可视化、或者想观察BFS扩散过程,它更方便。
还有一个细节是起点的坐标。题目中起点通常在左上角,也就是(0,0),终点在右下角(n-1,m-1)。但保险起见,代码里我会做一次检查,如果起点就是终点,直接输出0,避免边界情况的处理出现遗漏。
4.2 BFS主循环完整代码
直接给出一份可运行的C++代码,这道题的输入格式通常是第一行两个整数n和m,表示迷宫的行数和列数,接下来n行是迷宫地图,字符包括.(空地)、#(墙壁)、S(起点)和E(终点)。不同的题目起点终点符号可能不一样,但逻辑是通用的。
#include <bits/stdc++.h> using namespace std; const int MAXN = 100; int n, m; string grid[MAXN]; int dist[MAXN][MAXN]; int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; bool valid(int x, int y) { return x >= 0 && x < n && y >= 0 && y < m && grid[x][y] != '#' && dist[x][y] == -1; } int main() { cin >> n >> m; int sx = 0, sy = 0, ex = n - 1, ey = m - 1; for (int i = 0; i < n; i++) cin >> grid[i]; memset(dist, -1, sizeof(dist)); queue<pair<int, int>> q; q.push({sx, sy}); dist[sx][sy] = 0; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); if (x == ex && y == ey) break; for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; if (valid(nx, ny)) { dist[nx][ny] = dist[x][y] + 1; q.push({nx, ny}); } } } cout << dist[ex][ey] << endl; return 0; }valid函数是这道题的灵魂之一,它把越界检查、墙壁检查和访问状态检查全部收拢在一起。初学的时候不要觉得这个函数多余,把判断逻辑独立出来后,主循环里只剩三行操作,基本不会看错。BFS主循环里,取出队头节点后立刻判断是否是终点,如果是就跳出循环,这算是一个微小的优化。因为BFS的特性,此时dist[ex][ey]已经是终点的最短步数,不需要等待整个队列清空。
注意queue<pair<int, int>>的使用需要C++11标准,在代码里手动把{x, y}推入队列,是C++11的初始化列表写法。如果你用的编译器较老,改成q.push(make_pair(x, y))也行。
4.3 路径记录扩展:从步数到路径输出
很多走迷宫题不满足于只输出步数,还要输出路径本身。这时候BFS的代码需要做一些增强。核心思路是额外开一个parent数组,记录每个节点的前驱节点坐标。当BFS首次访问某个节点时,把它是从哪个方向走来的这个信息保存下来。等BFS结束,我们从终点逆着parent数组一路回溯到起点,得到的就是最短路径的倒序,再reverse一下就是正序路径。
方向数组在这个环节再次发挥作用。我们需要一个dirs数组,它不仅记录偏移量,还记录方向的字符标识,比如'U'、'D'、'L'、'R'。结构体写法:
struct Direction { int dx, dy; char ch; } dirs[4] = {{-1,0,'U'}, {1,0,'D'}, {0,-1,'L'}, {0,1,'R'}};在这个结构体数组里,方向和输出字符是一一对应的。遍历方向时,我们不仅更新坐标,还记录dirs[i].ch。如果你用多个独立数组,比如dx[]、dy[]和pathChar[],它们的下标对齐关系很容易在代码维护时被打破。结构体方案把这些信息绑定在一起,是更稳健的做法。
路径回溯时还有一个常见的坑:不要忘了路径中包含的节点数量是dist[ex][ey] + 1(包含起点),而方向字符的数量是dist[ex][ey]。如果你输出的是UDRL这样的移动序列,只用回溯步数次;如果你输出的是(x,y)坐标序列,别漏掉起点坐标。
4.4 从BISHI80到一类迷宫问题的模板化
走迷宫的题目换个符号、换条边界条件,核心逻辑基本不变。我自己平时刷题有一个习惯,把BFS迷宫题抽象成一个模板:一个valid函数、一个方向数组、两个二维数组(dist和parent)、一个主循环。之后遇到变种题目,先尝试往这个模板里套,套不进去的部分(也就是变种引入的新逻辑)单独打补丁。
举个例子,如果题目改为"在迷宫中找一条从起点到终点的路径,但是不允许朝右走",你只需要把方向数组里的右方向去掉,变成三个方向即可。如果题目改为"每个格子有一个体力消耗值,求消耗最小的路径",那BFS就不再适用,得换成带权最短路径算法,比如Dijkstra。但如果只是加一个"某些格子需要钥匙才能进入"的条件,BFS加一个状态维度也能搞定。这种"先套模板,再找差异"的方法,能让刷题效率提升不少。
5. 实战中的坑位与调试经验
5.1 死循环与队列爆炸的三大原因
我在实际调试中见过最多的问题,就是BFS跑不完,或者连简单样例都超时。归纳下来,主要原因有三个。
第一个原因最基础:忘记标记已访问。这里的忘记不是指完全没写,而是标记的时机不对。明确一点,标记必须在节点入队时立刻进行。如果等出队时才标记,你看小数据可能没问题,一旦迷宫变大,同一个节点会被反复压入队列,最坏情况下队列里同一层节点可能被重复处理几十次。排查方法是,在入队的地方加一个输出语句,观察入队节点是否出现重复坐标,如果重复,多半就是这个问题。
第二个原因是边界判断写反。比如if (x - 1 >= 0)写成if (x - 1 > 0),这会让最顶行无法向上移动。这类bug肉眼很难发现,我建议把valid函数单独拎出来打log,或者直接用几个特殊的测试用例去验证。比如起点周围全是空地,看看第一步能否扩展到四个方向。
第三个原因是坐标变量混乱。如果你用x、y作为当前节点坐标,又用nx、ny作为新节点坐标,一旦for循环里把赋值写成了x = x + dx[i], y = y + dy[i],当前坐标就被污染了,整个BFS必然陷入混乱。这种错误最隐蔽,因为单步看逻辑似乎正确,但实际运行时,同一层节点的扩展会互相影响。写代码时我给自己的规矩是:当前出队节点的坐标值绝不在循环内重新赋值,新节点一律用新变量接收。
5.2 方向数组越界的隐蔽现场
方向数组本身不易出错,但它的使用场景里藏着几个隐蔽的越界问题。比如你定义了dx[4],却在循环里写到i <= 4,这样循环的最后一次访问到dx[4],在C++中是不越界检查的,读出来的值是未知的内存数据,轻则搜索结果错乱,重则程序崩溃。我见过有人找了一整天bug,最后发现只是循环条件多了个等号。
另一个隐蔽的问题是memset(dist, -1, sizeof(dist))的使用。memset按字节填充,-1在补码表示下全为1,所以把int数组填成-1是安全的。但如果你的dist数组不是int类型,而是bool类型,把-1塞进去会导致所有位变为1,你就失去了"未访问"这个状态的表达能力。这个小坑在C++里特别容易踩,建议记住:memset只适合把int数组快速初始化为0或-1,其他值一律用fill来赋。
5.3 评测端常见的格式与边界坑
BISHI80这类题在评测系统上跑的版本,输入输出格式往往有严格约定。最常见的坑是输出没有换行符,或者多了空格。有些评测系统对空白字符容忍度很高,但大多数严格系统只要看到一个字符不一致就判WA。我的经验是,输出cout << dist[ex][ey] << endl;比cout << dist[ex][ey] << "\n";更保险?其实两者等效,我倒觉得"\n"就够了,但如果你不确定,用endl至少能强制刷新缓冲区,在程序异常中止时输出结果也不容易丢。
另有一个隐蔽的格式坑:迷宫地图行内可能有空格。比如某些题目的输入格式是"每个字符之间用空格隔开",这样你cin >> grid[i]读入的就不是一整行,而是单个字符。此时需要改用循环cin >> ch逐个字符读入。拿到题目先看清楚输入格式,这是经验之谈。我早期做题时很多次WA都是因为把带空格的地图当整行字符串读了,明明算法写得没有问题。
5.4 用"局部打印法"快速定位bug
调试BFS最有效的方法不是单步跟踪,而是打印关键信息。我这里分享一个我常用的"局部打印法":在BFS主循环中,每隔一层就打印当前队列的大小、队头坐标和dist数组的局部快照。观察dist数组时,重点看数值的分布是否符合"以起点为中心逐层递增"的模式。如果某一层的dist数值出现跳变,或者出现负数的未访问节点夹在中间却仍在队列里,那多半是入队标记时机有问题。
这个方法比断点调试高效得多,因为BFS的状态变化是数万次级别的,你不可能在断点下逐个确认。打印信息量不用大,每处理到新的层数时输出一行就够了。很多次我排查问题,就是靠看到"dist[3][4]本应为2,却变成了4"这一条输出,立刻定位到了坐标变量污染的问题。
5.5 一份可自测的边界用例清单
写完整份代码后,不要直接提交,先用几组边界用例自测。这里给出我常用的五组:
| 用例类型 | 迷宫 | 预期输出 |
|---|---|---|
| 最小规模 | 1x1,起点即终点 | 0 |
| 直线通路 | 1x5,全是空地 | 4 |
| 完全阻挡 | 起点被#围住 | -1 |
| 绕路场景 | 有墙但需绕行 | 对应步数 |
| 终点不可达 | 终点被墙围住 | -1 |
第一组和第三组经常被忽略。1x1的情况如果不加特判,程序会因为起点和终点坐标重合而出错或者输出0,但逻辑上BFS循环可以直接break。终点不可达时,dist[ex][ey]始终是-1,你直接输出-1即可。每道迷宫题的题目说明里都会写明不可达时应该输出什么,有的是-1,有的是"NO",你按题目要求来,但自测时用-1先验证程序逻辑没错。
6. 从BISHI80延伸出去的几个变种
6.1 带钥匙的迷宫:状态维度扩展
如果迷宫里的某些'#'门需要钥匙才能打开,而钥匙分布在地图各处,BFS就不只是"坐标"层面的搜索了。此时每个状态不再是一个坐标,而是"坐标+已收集钥匙集合"。这个集合可以用位运算压缩成整型,比如最多10把钥匙,就用一个10位的二进制数表示。visited数组也相应变成三维:vis[x][y][keyState]。BFS的框架不变,但在尝试进入一个表示门的格子时,额外判断当前keyState是否包含对应的钥匙位。
这类问题在信息学竞赛中很常见,看似复杂,本质上仍然是BFS,只是"状态"的含义变宽了。方向数组依然解决移动问题,valid函数里多加一个判断而已。理解了基础BFS,再理解带状态的BFS,一个下午就够了。
6.2 多起点或者多终点的最短逃生距离
另一种常见变种是:迷宫里有多个起点(比如多个人需要同时逃生),要求求所有起点到最近终点的最短距离。处理方法是把初始时所有起点一次性全部压入队列,且dist数组全部初始化为0。这样BFS扩散时,最先到达某个终点的节点,就代表了距离该终点最近的那个起点。这种思路叫"多源BFS",在很多场景下都有应用。方向数组在这里不需要任何改动,只是队列初始化的代码从压一个点变成压一堆点。
6.3 输出路径字典序最小
有些题目要求输出路径按字典序最小。这时方向数组的顺序就需要精心设计了。比如移动字符'D'、'L'、'R'、'U',字典序最小的是'D'(D在L之前),那么方向数组中应优先向下搜索,其次向左,然后向右,最后向上。你需要把方向数组按字符的字典序排列,这样第一个搜索到的路径,就是字典序最小的路径。注意这里BFS第一次到达终点时得到的路径,虽然是字典序最小的路径,但这并不影响它依然是最短的,因为BFS的层序特性不会因为方向顺序改变。
这类细节题目描述里会明确说,如果不仔细看题,自己默认方向顺序,很容易WA。所以拿到题目先看输出要求:是任一路径还是字典序最小的路径,这直接决定方向数组的排列方式。
7. 经验收尾:方向数组的"手感"是怎么练出来的
做了足够多搜索题之后,你会发现方向数组不只是一个技巧,它更像一种编程直觉。看到一道网格题,脑子里立刻浮现出dx和dy的数组内容,立刻意识到"这里用BFS"而不是DFS,立刻想到入队即标记。这些判断不是一蹴而就的,靠的是反复练习形成肌肉记忆。BISHI80作为一道经典的入门迷宫题,价值不在于题本身有多难,而在于它把BFS的骨架、方向数组的用法、visited标记的时机、边界条件的处理这些核心技能全部揉在了一起。
我建议你拿到这道题,至少用三种方式各写一遍:第一遍直接用我的模板AC掉;第二遍不看代码,自己从零实现,逼自己把每一个细节都想清楚;第三遍尝试加上路径输出功能,把parent数组和方向字符的映射写出来。三遍写完后,你基本上可以自信地面对所有同类迷宫题了。对我个人而言,这题的"手感"是从一次一次调试死循环、一次次看dist数组的打印输出中磨出来的。走迷宫看似简单,但它教会你的严谨习惯,会在之后的图论算法学习中反复受益。
最后分享一个小习惯:每次写完BFS,我会在代码尾部保留一行注释,写上// BFS: 入队即标记, 出队即扩展。这句注释看似简单,却能在下次复盘时帮我快速抓住算法的核心心法。希望你也能在实操中找到属于你自己的那一句"心法口诀"。