备战蓝桥杯的C++选手,十个里有八个在DFS迷宫题上栽过跟头。不是不会写递归,也不是看不懂回溯,而是栽在一些看起来完全不起眼的“模板细节”上:方向数组写错了半个下标、访问标记提前了一行、读入字符时被换行符坑了一道。每次在OJ上跑出来WA或者RE,又死活查不出原因的时候,真的会怀疑人生。
这篇东西就是冲着“手写DFS迷宫模板”去的,专门拆解小白最容易踩的3个致命坑。我会把每个坑的错误现场、根因、修复方式、以及对应的调试验证过程都写出来,最后给出一份可以直接抄的完整模板。适合正在备赛蓝桥杯C++组、或者刚学完DFS却总在迷宫题上挂掉的选手,也适合拿模板背完却不理解为什么这么写的同学。只要你还在“按模板写代码”,这篇就能帮你少走很多弯路。
1. 为什么“会写DFS模板”和“能AC迷宫题”完全是两码事
先说一个很扎心的现象。
很多同学手里都有一份“DFS迷宫模板”,大概长这样:一个dfs()函数、一个vis[][]数组、一个方向数组、一个边界判断,然后递归进去,回溯出来。背得滚瓜烂熟,上课也能默写出来。但一上蓝桥杯的赛场,遇到稍微变个花样的迷宫题,就开始翻车。翻车的原因往往不是“不懂DFS”,而是模板里的那些细节从来就没真正搞明白过。
为什么背熟了模板还会错?因为模板是“死的”,题目是“活的”。
一份模板要能在考场上稳得住,至少得回答清楚这几个问题:
- 方向数组为什么要这么写?
dx/dy的四个组合到底代表什么? vis数组到底是在什么时候标记的?是在进入递归之前,还是在扩展邻居的时候?- 回溯时需不需要把
vis恢复成未访问?什么情况下必须恢复,什么情况下绝对不能恢复? - 边界条件是
0 <= x < n还是1 <= x <= n?这取决于你读入地图时的下标从几开始。 - 处理字符迷宫时,
cin >> ch和scanf(" %c", &ch)有什么本质区别?
这些问题看起来都“很小”,但每一个都可能让你的程序从“AC”变成“WA”或“RE”。而且最麻烦的是,有一些错误并不会直接崩溃,而是会静悄悄地给出一个错误答案,让你查半天都查不出来。
我在备赛期间就帮同学排查过大量类似的问题,其中三个坑出现的频率最高,几乎可以称为“小白三连”。
2. 致命坑一:方向数组与坐标映射的“看似正确,实则越界”
2.1 错误现场:答案错得莫名其妙,甚至干脆RE
先说第一个坑,也是新手写迷宫DFS时第一个就会碰上的坑:方向数组。
大多数模板里会这么写:
int dx[4] = {1, -1, 0, 0}; int dy[4] = {0, 0, 1, -1};这套写法表示四个方向分别是“下、上、右、左”,配合x表示行、y表示列的约定。看起来没毛病,但问题往往出在“你以为你写的是这个,实际上你写的是另一个”。
常见的错误版本包括但不限于:
// 错误示例1:dx和dy配错了位置 int dx[4] = {0, 0, 1, -1}; int dy[4] = {1, -1, 0, 0}; // 这写法本身没错,但你要清楚它代表“右、左、下、上” // 错误示例2:写成了四个方向但只覆盖了三个方向 int dx[4] = {1, 0, 0, -1}; int dy[4] = {0, 1, -1, 0}; // 看起来覆盖了,实际组合是{1,0},{0,1},{0,-1},{-1,0}这四种组合分别是“下、右、左、上”,其实覆盖全了。但如果你在循环里写的是for (int i = 0; i < 4; i++),而方向数组只给了三个值,那就直接从数组越界开始崩。
更隐蔽的是在二维数组的坐标使用上。
数组[x][y]中,x是行下标(第一维),y是列下标(第二维)。所以x的变化方向对应“上下”,y的变化方向对应“左右”。但很多同学写着写着就把x当成横坐标、y当成纵坐标,于是:
// 这样写,会让你在“左右移动”时改变的是行,而不是列 int nx = x + dir[i][0]; // 本该是y int ny = y + dir[i][1]; // 本该是x结果是什么?地图的访问范围完全错乱,明明眼前有路,程序却撞墙,甚至直接跳到地图外面造成RE。
2.2 根因分析:坐标维度的抽象混乱
要说清楚这个坑,得先把坐标系彻底捋一遍。
在迷宫题里,我们通常用一个二维字符数组存地图:
char maze[105][105];maze[x][y]的语义是“第x行、第y列的格子”。如果你把整个迷宫想象成一张表格,那么:
x是从上到下的行号,对应“纵向”。y是从左到右的列号,对应“横向”。
所以,当你从当前格子(x, y)出发,想去右边的格子,正确的操作是:
int nx = x; // 行号不变 int ny = y + 1; // 列号加1当你想去下面的格子,正确操作是:
int nx = x + 1; // 行号加1 int ny = y; // 列号不变只要把这两组关系理清楚,方向数组就不会写反。
我建议新手在写之前,先在自己的草稿纸上画一个3x3的格子,把每个格子的坐标标出来,然后再对着格子的上下左右写方向数组。这样写出来的代码往往一眼就能看出问题。
2.3 修复方案与防御性写法
最稳妥的写法,是把方向数组和坐标更新分开写,并且在注释里标明每个方向:
int dx[4] = {1, -1, 0, 0}; // 下、上、右、左 int dy[4] = {0, 0, 1, -1}; // 有些同学喜欢用pair或者结构体,也可以 // pair<int, int> dir[4] = {{1,0},{-1,0},{0,1},{0,-1}}; for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; // 然后判断边界 }还要养成一个习惯:在DFS函数开头加一个“合法性检查”而不是在扩展时才判断。这样即使方向数组写错了,程序最多是提前返回,不至于越界崩溃:
void dfs(int x, int y) { if (x < 0 || x >= n || y < 0 || y >= m) return; if (maze[x][y] == '#') return; // 墙 if (vis[x][y]) return; vis[x][y] = true; // 继续递归... }把边界判断放在函数开头,整个代码的可读性和健壮性都会好很多,这也是很多比赛选手推荐的方式。
3. 致命坑二:vis标记的时机错一个位置,结果差十万八千里
3.1 错误现场:要么死循环,要么漏路径
如果说方向数组是“第一道坎”,那么vis标记的时机就是“第二道坎”,而且这道坎更隐蔽。
vis数组的作用是防止DFS反复走进同一个格子而陷入死循环。但“什么时候标记”这个问题,不同写法的后果天差地别。
我先展示两种常见写法。
写法A:在进入DFS时标记(推荐)
void dfs(int x, int y) { vis[x][y] = true; // 一进函数就标记 // 处理当前格子... 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 && !vis[nx][ny] && maze[nx][ny] != '#') { dfs(nx, ny); } } }写法B:在扩展时标记(新手最爱犯,导致重复进入)
void dfs(int x, int y) { // 没有一进函数就标记! // 处理当前格子... for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; vis[nx][ny] = true; // 在邻居节点标记 if (边界 && !vis[nx][ny] && maze[nx][ny] != '#') { dfs(nx, ny); } } }写法B的问题很明显:你在if判断之前就把vis[nx][ny]设成了true,然后紧接着又在条件里判断!vis[nx][ny],这时它已经是true了,所以这个格子永远不会被递归进去。
反过来,如果有人在if判断里写的是:
if (边界 && maze[nx][ny] != '#') { vis[nx][ny] = true; dfs(nx, ny); }那就变成了“先判断再标记”,看起来对,但如果同一层循环里有两个方向都通向同一个格子,这个格子会被重复入队递归,可能造成重复计算甚至死循环。更麻烦的是,如果某条路径在递归中改了这个格子的访问状态,你很难追踪是哪里出的问题。
3.2 根因分析:标记时机决定的是“路径去重”还是“状态回溯”
要理解vis标记时机的本质,得先分清两种不同的需求。
第一类需求:只要判断“从起点能不能走到终点”,或者“能到达的格子有哪些”。
这种需求下,每个格子只需要访问一次,不需要撤销标记。因为你的目的是“覆盖所有可达格子”,而不是“枚举所有路径”。此时vis标记应该在“格子第一次被确认可以访问”时就设上,之后不会再撤销。
第二类需求:要统计“从起点到终点一共有多少条不同路径”,或者要“打印出每一条路径”。
这种需求下,你不能让一个格子永久处于已访问状态,因为不同路径允许经过同一个格子。这时vis不是“去重工具”,而是“当前路径上已占用格子的标记”。递归返回时必须撤销标记,也就是回溯。
所以,“回溯时到底要不要把vis[x][y]恢复成false”不是看模板,而是看题目问的是什么。
很多同学把两种混在一起,结果就是:
- 第一种需求下加了回溯撤销,导致一个格子被反复走,程序超时。
- 第二种需求下没加回溯撤销,导致路径数量被严重少算。
3.3 修复方案与判断标准
我自己的判断标准很简单:如果DFS的递归过程中,你希望两个不同的搜索分支能共享同一个格子,那这个格子的vis必须支持“撤销”;如果不希望,那就不要撤销。
具体落地时,可以这样处理:
如果是“可达性/连通块”类问题,采用“进入即标记且不回溯”:
void dfs(int x, int y) { vis[x][y] = true; // 扩展四个方向 for (...) { if (合法 && !vis[nx][ny]) { dfs(nx, ny); } } // 不需要vis[x][y] = false; }如果是“路径计数/打印路径”类问题,采用“进入即标记,返回前回溯”:
void dfs(int x, int y) { if (x == ex && y == ey) { ans++; return; } vis[x][y] = true; for (...) { if (合法 && !vis[nx][ny]) { dfs(nx, ny); } } vis[x][y] = false; // 回溯:撤销当前格子的占用状态 }这两段代码的区别只在最后一行,但适用场景完全不同。建议你把这两套写法都吃透,比赛时根据题目要求选择,而不是死背一份模板硬套。
4. 致命坑三:输入格式与边界条件的“经典阴间配置”
4.1 错误现场:WA得毫无头绪,样例却能过
第三个坑,不是DFS本身的问题,而是“地图都读错了,后面全是白干”。
蓝桥杯的迷宫题,输入格式非常喜欢挖坑。常见的坑有几种:
第一种:字符之间可能没有空格,直接一串字符串。
5 5 S#### ..... ##### ..... ####E这种没有空格的字符串,如果你用cin >> char循环读,其实也能读,因为cin >>会自动跳过空格和换行,把每个字符依次喂进来。但如果你用scanf("%c", &ch),就要小心了:%c不会跳过空白字符,它会连换行符一起读进来。于是你辛辛苦苦读进来的地图里,混进了一堆'\n',后面判断maze[x][y] == '#'时永远匹配不上,因为maze里存的是回车。
解决办法是用scanf(" %c", &ch),在%c前面加一个空格,告诉scanf先跳过所有空白字符再读一个字符。或者干脆用cin >>,它天生会跳过空白。
第二种:读入时下标从1开始还是从0开始,定了就别改。
很多同学在写边界条件时今天用x < n,明天用x <= n,或者读地图时从i = 1开始,但边界判断用的是0 <= x < n,导致第一行和第一列永远处于“不可达”状态。
我建议的做法是:下标统一从1开始读,边界判断也统一从1到n,这样最符合“第几行第几列”的自然语言直觉,也能避免很多边界混淆。
int n, m; cin >> n >> m; for (int i = 1; i <= n; i++) { cin >> (maze[i] + 1); // 如果迷宫是字符串,就把第0列留出来,从第1列开始存 }注意这句(maze[i] + 1),意思是把字符串从下标为1的位置开始存。前提是maze是二维char数组,且每行长度够大。这样maze[x][y]里的x范围就是1~n、y范围就是1~m。
第三种:起点终点藏在字符里,很多人只判断了墙,忘了判断起点终点。
蓝桥杯的迷宫题,起终点常用S和E表示。有些同学DFS扩展时只判断了maze[nx][ny] != '#',结果把E当成墙直接跳过,导致永远找不到终点。正确做法是:只要不是墙,就能走,起点终点只是普通可通行格子。
if (maze[nx][ny] != '#') { // 可通行,不管是S、E还是'.'都可以走 }或者更稳妥:把S和E在读入后统一改成.,然后在主函数里额外记录起终点坐标。这样DFS内部逻辑就只需要关心“这里是墙还是可以走”,不用纠结字符含义。
4.2 根因分析:你以为在读地图,其实在读了个寂寞
为什么输入格式能坑这么多小白?因为很多人把cin和scanf的空白处理机制完全搞混了。
cin >>在处理char、int、string时,默认会跳过所有空白字符(空格、换行、制表符)。所以cin >> ch连续读字符时,绝对不会读到换行符。
scanf就不一样。scanf("%c", &ch)会把换行符、空格原封不动读进来。很多人用scanf读整数没问题,因为%d也会跳过空白;但一到%c就翻车。
所以,如果你习惯用scanf读字符迷宫,千万记得%c前面加空格:
scanf(" %c", &ch);如果你用cin读整行字符串,需要注意getline会读取包括换行符在内的整行内容。在读完整数后面紧跟getline时,需要先把换行符“吃掉”:
int n, m; cin >> n >> m; // 读完后,末尾留下一个换行符 getchar(); // 将这个换行符消费掉 for (int i = 1; i <= n; i++) { cin.getline(maze[i] + 1, maxn); // 这样再读,就不会读到一个空行了 }4.3 修复方案与边界自检清单
分享一个我自己比赛时读迷宫题的固定套路,照着写基本不会出问题:
const int MAXN = 105; char maze[MAXN][MAXN]; bool vis[MAXN][MAXN]; int n, m, sx, sy, ex, ey; void findStartEnd() { for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (maze[i][j] == 'S') sx = i, sy = j, maze[i][j] = '.'; if (maze[i][j] == 'E') ex = i, ey = j, maze[i][j] = '.'; } } } int main() { cin >> n >> m; for (int i = 1; i <= n; i++) { cin >> (maze[i] + 1); // 下标从1开始 } findStartEnd(); // 提前处理起点终点,DFS里只关心墙和非墙 // 调用dfs(sx, sy),检查vis[ex][ey] return 0; }写完后,边界条件统一是:
if (nx < 1 || nx > n || ny < 1 || ny > m) continue; if (maze[nx][ny] == '#') continue; if (vis[nx][ny]) continue;这个“三连continue”把所有非法情况都挡在递归之外,逻辑非常清晰。
5. 一份能直接抄作业的DFS迷宫模板(含三种变体)
把前面三个坑都排掉之后,下面给出一份我自己现在还在用的模板。它不花哨,但足够稳,基本覆盖了蓝桥杯常见DFS迷宫题的三种玩法:问可达性、统计路径条数、打印路径。
5.1 基础模板:判断从起点能否到达终点
#include <bits/stdc++.h> using namespace std; const int MAXN = 105; char maze[MAXN][MAXN]; bool vis[MAXN][MAXN]; int n, m; int sx, sy, ex, ey; int dx[4] = {1, -1, 0, 0}; int dy[4] = {0, 0, 1, -1}; void dfs(int x, int y) { // 进入即标记 vis[x][y] = true; for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; // 边界 + 墙 + 访问状态,三个条件一个都不能少 if (nx < 1 || nx > n || ny < 1 || ny > m) continue; if (maze[nx][ny] == '#') continue; if (vis[nx][ny]) continue; dfs(nx, ny); } } int main() { ios::sync_with_stdio(false); cin.tie(0); cin >> n >> m; for (int i = 1; i <= n; i++) { cin >> (maze[i] + 1); } for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (maze[i][j] == 'S') { sx = i; sy = j; maze[i][j] = '.'; } if (maze[i][j] == 'E') { ex = i; ey = j; maze[i][j] = '.'; } } } dfs(sx, sy); if (vis[ex][ey]) cout << "Yes" << endl; else cout << "No" << endl; return 0; }这份模板的核心是:不管DFS内部怎么递归,最终只需要看终点格子有没有被打上vis标记。因为vis标记意味着“这个格子已经被访问并确认可通行”。如果终点被访问到了,说明起点和终点连通。
5.2 变体一:统计起点到终点的路径条数
这个变体需要用到回溯,因为同一条路径经过的格子集合是固定的,但不同路径之间可以共享格子。如果不回溯,DFS会认为某个格子已经被占用,导致路径计数偏少。
int ans = 0; void dfsCount(int x, int y) { if (x == ex && y == ey) { ans++; return; } vis[x][y] = true; for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; if (nx < 1 || nx > n || ny < 1 || ny > m) continue; if (maze[nx][ny] == '#') continue; if (vis[nx][ny]) continue; dfsCount(nx, ny); } vis[x][y] = false; // 关键:回溯撤销标记 }有两点要注意:
一是这个写法在到达终点时直接返回,没有把终点标记为已访问。这样如果有多条路径以不同顺序经过终点同层格子的前驱,依然能正常统计。
二是递归深度不要太大。如果迷宫是100x100,统计所有路径条数会非常爆炸,大概率超时。所以路径计数题通常数据范围很小,或者是要求“方案数模某个数”,才会用DFS。
5.3 变体二:打印一条可行路径
打印路径需要在DFS过程中记录走过的坐标序列。最简单的做法是用一个数组或vector<pair<int,int>>来维护“当前路径”。
vector<pair<int, int>> path; bool found = false; void dfsPath(int x, int y) { if (found) return; // 已找到一条路径,直接剪枝 if (x == ex && y == ey) { found = true; for (auto [px, py] : path) { cout << "(" << px << "," << py << ") -> "; } cout << "(" << x << "," << y << ")" << endl; return; } vis[x][y] = true; path.push_back({x, y}); for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; if (nx < 1 || nx > n || ny < 1 || ny > m) continue; if (maze[nx][ny] == '#') continue; if (vis[nx][ny]) continue; dfsPath(nx, ny); if (found) return; // 路径已找到,不再搜索其他分支 } path.pop_back(); // 回溯:从路径中移除当前格子 vis[x][y] = false; // 回溯:撤销标记 }注意path.pop_back();和vis[x][y] = false;必须成对出现。前者负责维护路径序列,后者保证其他路径分支也能走到这个格子。
这个模板在实际比赛里非常实用,尤其是“输出一条从S到E的路径”这种题,很多同学只会判断可达性,一要求输出路径就懵了。
6. 排查顺序与调试技巧:用完例造数据,别指望样例一次过
6.1 提交WA/RE之前的“一分钟自检清单”
很多同学在OJ上WA了之后第一反应是打开代码逐行看,效率很低。我建议按下面这个顺序排查,一分钟内能定位大部分问题。
第一步:检查输入格式。
先用最朴素的方式把你的地图打印出来:
for (int i = 1; i <= n; i++) { cout << (maze[i] + 1) << endl; }如果打印出来发现第一行是空行,或者字符错位,那问题出在读入环节,而不是DFS。此时检查你是不是用scanf("%c")读了字符、或者getline吃掉了换行。
第二步:检查起点和终点是否被正确记录。
在调用DFS之前打印一下sx, sy, ex, ey:
cout << "S: " << sx << " " << sy << endl; cout << "E: " << ex << " " << ey << endl;如果S和E都没有被识别出来,多半是地图读入的问题,或者你是否忘了把字符'S'和'E'处理好。
第三步:检查vis标记的时机。
如果程序出现无限递归导致栈溢出(RE),或者运行时间长得离谱(TLE),先看vis标记是不是在进入递归之前设置的。如果DFS在扩展邻居时才标记,而且标记时机不对,很容易造成重复访问。
一个很经典的调试技巧:在DFS函数开头加一行输出,观察坐标是否有重复:
void dfs(int x, int y) { // cout << "dfs: " << x << " " << y << endl; ... }把注释去掉跑一个小样例,如果同样的坐标被输出多次,那vis标记肯定有问题。
6.2 自己造数据的能力,才是决定比赛上限的关键
蓝桥杯的样例往往很弱,只靠样例AC就上考场,大概率是要挂的。我个人的习惯是,写完迷宫DFS后,一定会自己造几组边界用例来测。
最值得测的几组:
第一组:刚好2行2列的最小迷宫。
2 2 S. .E第二组:起点就是终点。
2 2 SE ..第三组:完全被墙挡住,没有通路。
3 3 S## ### ##E第四组:横着一条直线。
1 5 S...E注意,有些题目会故意把n=1或m=1的情况混进来。如果边界条件写得不严谨,这种数据分分钟WA。比如上面这组1 5,如果DFS里的方向数组仍然遍历四个方向,那“上”“下”两个方向会因为越界被continue掉,只有“左”“右”能用,其实没问题。但如果你边界判断写的是nx > n而不是nx > n的严格大于,就可能出现访问到s[1][-1]这种非法下标。
自己造数据还有一个好处:能顺便验证你对“题意的理解”是不是对的。有时候跑出来答案和你手算的不一致,不是代码问题,而是你根本没读懂题目想让你输出什么。
6.3 最后再分享一个我常用的“递归可视化”调试法
DFS很难调试,因为它是一个递归过程,普通的断点调试不好用。我自己最常用的方法,是做一个缩进输出,把递归过程“可视化”。
void dfs(int x, int y, int depth) { // 输出当前访问状态 for (int i = 0; i < depth; i++) cout << " "; cout << "(" << x << "," << y << ")" << endl; vis[x][y] = true; for (...) { ... dfs(nx, ny, depth + 1); } vis[x][y] = false; }运行输出会呈现一个树状结构,一眼就能看出DFS每一步走到了哪里、哪些分支提前返回了、哪些格子被重复访问了。这个方法在调试路径统计和输出路径的题目时特别管用,比对着看不出来问题的代码要高效得多。
我现在写迷宫DFS,已经完全不怕踩坑了。不是因为记得住模板,而是因为每次踩坑后都顺手把“为什么错”刻在脑子里:方向数组不对,先看坐标维度;vis出了问题,先想清楚到底要不要回溯;读入出了问题,先打印地图。这些习惯一养成,手写DFS就不再是玄学,而是一件非常稳的事情。