1. 题目到底是什么:先看懂 P1605 在问什么
P1605 是洛谷(Luogu)上的一道经典搜索题,题号就叫“迷宫”。如果你刷过洛谷的题目列表,大概率在“普及-”这个难度档见过它。题目描述很简单:给定一个 N×M 的方格迷宫,里面有若干障碍物,给定起点和终点坐标,要求统计从起点到终点的路径总数,且路径不能重复经过同一个格子。
先别急着觉得这题太基础。这个题的“坑”恰恰藏在简单里。题目数据范围不算大(N、M 一般不超过 5~10,T 是障碍物个数),但如果你的 DFS 回溯写得不干净,或者边界判断漏了,WA 起来是一点脾气都没有。我当年第一次做这题的时候,就觉得“这不就是走格子吗”,结果连续三次提交全红,最后发现是起点坐标没标记为已访问——这种细节,题目描述里不会直接告诉你,只有踩过坑才记得住。
这道题适合谁?如果你刚开始学搜索算法,或者准备参加蓝桥杯、CSP-J/S 这类比赛,P1605 基本是绕不开的开胃菜。它帮你理解 DFS(深度优先搜索)和回溯到底是什么、怎么落地,也帮你搞明白“状态”这个概念在具体问题里长什么样。学会它,后面再遇到迷宫变种、岛屿数量、单词搜索这类题,都能顺不少。
顺便说一句,最近“方格迷宫生成器”这类工具挺火的,很多人拿它来生成随机迷宫玩。其实那类工具的底层,无非也是用 DFS 或者并查集来做生成,和 P1605 里“走迷宫”的方向刚好相反:一个是在没有路的地方挖路,一个是在有路的地方找路。如果你能把这道题的 DFS 真正搞透,再去看迷宫生成器的代码,会瞬间通透很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搜索思路拆解:为什么这题要用 DFS 而不是 BFS
2.1 DFS 和 BFS 的核心区别
很多初学者会问:走迷宫不都是找路吗,BFS 也能做,为什么通常 DFS 更自然?
关键在于“要什么”。BFS 擅长求最短路径,因为它是一层一层往外扩,第一次到达终点的路径就是最短的。而 P1605 要的是“从起点到终点的所有路径条数”,不是最短的那一条。BFS 要统计所有路径,得把所有状态全部铺开,队列里会积压大量重复状态,写起来也非常别扭。DFS 则天然适合“枚举所有可能”:从起点开始,能走就往下走,走不通就退回来换一条,递归天然帮我们保存了当前的路径状态。
打个比方:BFS 像你站在商场里问路,别人告诉你“往前走三个路口左转,再走两个路口右转”——它给你的是一条明确的最短指引;DFS 像你自己把商场每个通道都逛一遍,每条路都走到头,走不通就回头再试另一条——最终你掌握了所有能到达目的地的走法。P1605 要的就是后者。
2.2 回溯的核心作用
DFS 里最容易被忽略、又最重要的就是“回溯”。如果只递归不撤销状态,会出现什么情况?想象你走到一个死胡同,程序返回上一层,但刚才那个格子还标记为“已访问”,那别的路径想经过这个格子就不行了——这会导致漏掉大量合法路径。所以回溯就是“我试过这条路了,现在把状态恢复原样,换一条路再试”。
具体到代码里,就是做四件事:
- 当前格子标记为已访问;
- 向四个方向递归探索;
- 递归返回后,把当前格子恢复为未访问;
- 继续尝试下一个方向。
记住这个“标记—递归—撤销”三步曲,基本就掌握了 DFS 回溯的骨架。P1605 的障碍物是静态的,跟路径遍历状态不同,所以要分开维护:障碍物是静态的,用二维数组记录;格子是否被当前路径占用,用 visited 数组动态标记。
2.3 为什么四个方向都要试
题目没有规定移动方向,所以上下左右四个方向都要尝试。有些同学会想:能不能只走下和右?——那是极简版迷宫或者组合数学题的思路,P1605 里如果这样写,遇到需要绕路的路径就会漏掉,答案必然错误。
判断每个方向能不能走,要同时满足三个条件:
- 新坐标在 1~N、1~M 范围内;
- 新坐标不是障碍物;
- 新坐标在 visited 数组中未被标记。
三个条件缺一不可。尤其是“边界判断”,很多人喜欢先判断再访问,但顺序写反了就容易数组越界。先判范围,再查数组,这个顺序别乱。
3. 手把手实现:DFS 回溯模板与参数设计
3.1 全局变量怎么设计
这道题用全局变量记录公共状态,是最省事的写法。我自己常用的变量有这些:
cpp复制#include <iostream>
using namespace std;
int n, m, t; // 迷宫行数、列数、障碍物个数
int sx, sy, fx, fy; // 起点坐标、终点坐标
int ans = 0; // 路径总数
bool maze[15][15]; // true 表示障碍物
bool vis[15][15]; // true 表示当前路径已访问
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};
注意 maze 和 vis 我分开了。有人图省事会只用一个二维数组,在递归时临时改成障碍物再改回来,也能过,但逻辑上容易出 bug——如果输入里有重叠标记,或者某个初始化顺序出错,查起来很头疼。分两个数组更清晰:一个管“墙”,一个管“脚印”。
坐标数组 dx、dy 的顺序没有硬性要求,但建议固定成“下、上、右、左”或“上、下、左、右”,形成自己的编码习惯。以后写迷宫类题目时,这个方向数组基本可以复用。
3.2 输入处理与起点标记
读入部分有个小坑:一般题目给的行列编号从 1 开始,数组下标也从 1 开始用。障碍物坐标读进来后,直接在 maze[x][y] 标记 true。
起点标记这里,很多人第一次写容易漏:
cpp复制cin >> n >> m >> t;
cin >> sx >> sy >> fx >> fy;
for (int i = 0; i < t; i++) {
int x, y;
cin >> x >> y;
maze[x][y] = true;
}
vis[sx][sy] = true; // 起点必须标记为已访问,否则会来回走
dfs(sx, sy);
cout << ans << endl;
为什么起点必须标记?因为如果不标记,DFS 从起点出发往四个方向走,其中必然有一步会尝试“走回起点”。虽然某些情况下答案碰巧是对的,但路径集合里会出现起点被多次进入的非法情况,最终答案可能偏大。所以“进入状态时立刻标记”是铁律。
3.3 DFS 递归函数怎么写
递归函数的核心是先判断出口,再枚举四种可能:
cpp复制void dfs(int x, int y) {
// 到达终点,答案加一,返回
if (x == fx && y == fy) {
ans++;
return;
}
// 枚举四个方向
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;
// 标记、递归、撤销
vis[nx][ny] = true;
dfs(nx, ny);
vis[nx][ny] = false;
}
}
这里有几个值得讲的细节。
第一,“到达终点”这个分支在递归开头判断,意味着进入终点的这个动作本身就已经是一条合法路径的完成。你不需要在终点处再去枚举方向,因为题目只要求“到达终点”,而不是“从终点继续走出去”。有的写法会在枚举方向时判断下一步是不是终点再单独处理,也能工作,但不如这样统一。
第二,回溯发生在递归返回之后。你会发现“撤销”这行代码紧跟在 dfs(nx, ny) 后面。这保证了一条路走完(无论到达终点还是死路)之后,把当前格子让出来,让其他兄弟路径可以经过。如果你忘了写这行,你会得到比正确答案小很多的数字,因为很多路径被“走过”的状态堵死了。
第三,递归没有显式的“步数”参数,因为题目不关心路径长度。但如果你后续做类似“求最短路径长度”的变种,就需要在递归参数里加一个 dep 或 step 变量。这就是典型的状态设计:需要什么信息,就给递归函数加什么参数,不要一味照搬模板。
3.4 方向数组的两种常见写法
方向数组除了我这里写的 dx/dy 外,还有一种写法是写一个二维数组:
cpp复制int dir[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
两种写法没有优劣之分。用两个一维数组的好处是循环里写 nx = x + dx[i] 更直观;用二维数组的好处是方向数据整体打包,省得声明两个变量。新手我建议先用 dx/dy,因为这个模式在后续的 BFS 题里也一样,dx/dy 一换就是另一组移动方式,比如“马走日”。
4. 边界条件与常见错误:洛谷提交反复 WA 的真相
4.1 数组越界
N、M 最大一般不会太大,但如果你把数组开成 110,下标越界可能会访问到非法内存,可能不报错但结果乱掉。一个稳妥做法是统一把下标从 1 开始,数组多开两三个单位,例如 bool maze[15][15] 对应 N、M ≤ 10 的题目就足够。如果你不确定上限,直接开大 5~10 个单位,内存开销可忽略。
4.2 终点或起点在障碍物上怎么办
题目数据一般不会这么出,但如果是自己造的测试数据,建议提前判断:如果终点在障碍物上,直接输出 0;如果起点在障碍物上,同理。虽然正式数据通常不会这样,但这两个判断一两行代码就能加上,能增强代码鲁棒性。
4.3 起点终点相同
如果起点和终点坐标相同,应该输出多少?答案是 1。因为路径集合里有一条“长度为 0 的路径”——从起点出发就已经到终点了。我的模板里,先标记 vis[sx][sy],再调用 dfs(sx, sy),进来就直接命中 x==fx && y==fy,ans 加一,输出 1,逻辑天然正确。
4.4 回溯位置放错
回溯的撤销操作必须放在递归调用之后,并且放在 for 循环内。有些新手会把 vis[nx][ny] = false 写在 for 循环外面,或者写在 dfs 函数开头,那会导致方向之间状态互相污染,路径统计错误。
4.5 障碍物用错数组
如果你用一个数组同时表示障碍物和访问状态,撤销时会连带把“墙”撤销掉,导致重复走障碍物,答案偏大。这就是我坚持 maze 和 vis 分开的根本原因。踩过一次坑之后,你会发现所有“全局状态混乱”的问题,本质都是职责不清。
5. 用一道题吃透一类题:P1605 的变种与延伸
5.1 迷宫路径统计题的常见变种
P1605 的代码稍微改改就能解决很多平台上的“迷宫路径计数”题。比如:
- 求路径数量而不是有没有路;
- 加入时间/步数限制,路径必须满足步数 ≤ K;
- 加入多起点或多终点;
- 加入障碍物随时间变化。
这些变种通常只需要在 DFS 参数里加一两个变量,或者在剪枝条件里多加一个判断。比如“多终点”,就是把 if (x == fx && y == fy) 改成 if (x == fx1 || x == fx2 || x == fx3) 或者在终点集合里查找。好的递归设计,恰恰能让你以最小改动应付这些变化。
5.2 从计数到生成:方格迷宫生成器怎么用 DFS 反向工作
最近“方格迷宫生成器”这个热词挺多人搜。用 DFS 生成迷宫的原理其实非常有意思:不是去找路,而是“随机挖墙”。典型算法叫“递归回溯生成器”(Recursive Backtracker):
- 初始化一个所有格子都是墙的网格;
- 从某格子出发,随机选择一个相邻的未访问格子;
- 打通两格之间的墙,标记新格子为已访问;
- 继续随机走;
- 无路可走时回溯到上一个有未访问邻居的格子。
整个过程和 P1605 的 DFS 搜索方向相反,但核心机制完全一样:递归、回溯、visited 标记、方向数组。真把它跑起来,你会看到迷宫在屏幕上逐渐“生长”,像植物爬满网格一样,非常直观。所以别觉得 P1605 只是一道水题——它就是迷宫类算法的地基。
5.3 搜索算法的现实应用
理论上讲,凡是涉及“图的状态枚举”“所有可能路径”“组合爆炸搜索”的问题,几乎都可以用 DFS 的骨架解决。比如:
- 数独求解:每个空格是一个状态分支,DFS 枚举每个空格的 1~9,合法就继续;
- 八皇后:每一行选一列放皇后,冲突就回溯;
- 单词搜索:从一个格子出发上下左右匹配单词,和 P1605 几乎一模一样;
- 游戏寻路原型:虽然正式工程会用 A* 或 Dijkstra,但理解 DFS 能帮你建立状态空间和搜索树的直觉。
换句话说,P1605 是你和“状态搜索”这个概念之间的一座桥。你在这道题里建立的“标记—递归—撤销”心智模型,会反复在后续的递归、动态规划、回溯算法中出现。
6. 性能分析与优化:这题有必要剪枝吗
6.1 数据规模小,暴力可行
P1605 的原始数据范围很小,通常 N、M 在 5~10,障碍物个数 T ≤ 20,状态空间不超过 100 个格子,直接爆搜也能过。所以不少题解根本没有任何剪枝,只靠标准 DFS 就 AC 了。但理解“什么时候必须剪枝、什么时候不用剪枝”本身就是一种能力。
如果遇到 N、M 变成 20 甚至 50,路径数量会指数级爆炸,DFS 会跑很久甚至超时。这时可以加一些基础剪枝:
- 奇偶剪枝:有些迷宫题中,从某个点到终点的曼哈顿距离奇偶性如果和剩余步数不符,可以直接剪掉;
- 提前判断剩余步数是否够到达终点;
- 记忆化(严格来说变成带状态记忆的 DFS 或 DP)来避免重复子问题。
但在 P1605 原题里,这些剪枝都不必要。我建议你先把不加剪枝的版本写对、跑通,再思考哪些优化能加、哪些只是自我感动。过度优化是初学者的另一个坑,把时间花在正确性上,比花在玄学剪枝上重要得多。
6.2 递归深度与栈空间
N×M 最大也只有 100 个格子,递归深度最多也就是 100 层,远小于 C++ 默认栈限制,所以不用担心爆栈。Python 的话递归深度默认 1000,也够用,但有些评测平台会对递归栈做限制,如果题目规模变大,可以考虑改成迭代 DFS 或改用 sys.setrecursionlimit。
6.3 有没有更优的剪枝策略
如果未来你遇到 N、M 变大但障碍物较少的迷宫计数题,可以尝试用“可行性剪枝”:在进入某个格子前,先计算当前点到终点的曼哈顿距离,如果剩余可走步数已经小于这个距离,说明无论如何都到不了终点,直接跳过。这个剪枝在严格限制步数的变种题里非常有效,但对于 P1605 这种不限制步数的题目,反而会误伤合法路径(因为你不知道剩余步数到底是多少),所以要注意应用场景。
7. 实测一次完整提交:从本地调试到洛谷 AC
7.1 本地造几组测试数据
在正式提交前,我习惯自己先用几组手造数据验证正确性。比如:
输入:
cpp复制3 3 2
1 1 3 3
2 2
2 3
这个样例表示 3×3 迷宫,障碍物在 (2,2) 和 (2,3),起点左上角 (1,1),终点右下角 (3,3)。手数一下,合法路径只有两条:先下后右,先右后下再右再下。程序输出应该是 2。如果你的输出不是 2,优先检查起点标记和回溯位置。
另一组测试:
cpp复制1 2 0
1 1 1 2
只有一行两列的迷宫,起点在左,终点在右,答案应该是 1。这组数据专门测最小规模,能暴露数组初始化问题。
再比如说一个更刁钻的:
cpp复制2 2 0
1 1 2 2
2×2 没有障碍,起点 (1,1) 到终点 (2,2)。手动画一下,路径有两条:右下、下右。程序也应该输出 2。
7.2 提交时的几个经验
洛谷提交 C++ 代码时,注意选对语言标准。C++11 和 C++17 对这道题没有区别,但别选成 C(如果用 C 语法写 cpp 代码会编译报错)。另外,代码里尽量只用标准头文件,这道题用
7.3 如果答案不对,怎么调试
我调试 DFS 类题目的固定步骤是:
- 先在小数据规模下,把 dfs 入口处打印当前坐标;
- 检查每一步访问的坐标是否都在合法范围内;
- 检查是否出现了“重复进入已访问格子”;
- 检查回溯是否及时撤销;
- 最后用题目给的样例跑一遍。
如果你不想打日志,也可以直接写一个“暴力枚举所有路径”的程序作为对照,DFS 算出的结果和暴力结果一致,基本就稳了。
另外有一个非常实用的技巧:把 vis 数组的标记和撤销各打印一行,观察整个搜索顺序。你会发现 DFS 其实是在“一条路走到黑”,然后一步步退回来去试另一条分支。这个过程亲手看一遍,比看十篇博客都管用。
8. 搜索题之外的题外话:从 P1605 到迷宫算法热词
最近“西方世界的劫难7雷神迷宫怎么过”这种游戏攻略搜索也挺火。很多人找迷宫攻略找得头疼,其实就是因为游戏里的迷宫往往没有明确的路径提示,玩家只能靠枚举。如果你懂 DFS 的思路,你会发现在游戏里“手动 DFS”也是可行的:选定一个方向走到头,不行就退回上一个岔路口换方向。这和 P1605 的搜索逻辑本质上是一回事。
当然,游戏里的迷宫往往是“生成型”的,也就是地图是程序自动生成的。这类生成通常也依赖随机化 DFS 或 Prim 算法。所以你看,P1605 虽然是个 OJ 题,但它连接的却是游戏开发、路径规划、程序化生成这些听起来很酷的领域。
9. 踩坑复盘:我最初写这道题犯过的三个错误
9.1 错误一:忘记标记起点
我第一次写 P1605 时,起点没有标记 vis[sx][sy] = true,结果答案比预期大不少。当时的困惑在于:为什么路径会多出来?后来在纸上画了一下才发现,程序会把“回到起点”也当成一条合法路径的分支,导致路径集合里出现了重复经过起点的非法路径。
9.2 错误二:把回溯写成了“进入时撤销”
有一次我头脑一热,把 vis[nx][ny] = false 写在了 dfs(nx, ny) 的前面,等于“刚标记完就撤销”。结果每个方向都能走,路径搜索变成无限循环,程序直接跑飞。这个错误特别隐蔽,因为编译不报错,运行也不一定崩溃,只是答案完全不对。排查时最好回头看一眼自己的撤销语句到底在递归调用前还是后。
9.3 错误三:方向数组顺序不固定
我早期写方向数组时经常这次是上下左右,下次是右左下上,全靠临时心情。结果某次在一个变种题里,我把 dx/dy 的顺序弄反了,导致判题时某个特殊数据 WA。后来我固定写成“下、上、右、左”,并且所有迷宫题复用同一套,就再没出过这种问题。
10. 最后的实操建议
P1605 迷宫做下来,你会发现它真正教会你的不是“会走格子”,而是怎么把一个问题转化成“在状态空间里做搜索”的思维方式。题目本身不起眼,但它背后呼之欲出的,是一整套递归回溯的编程范式。
如果你做完这道题,我建议你顺手做几道同类变式巩固一下。比如 LeetCode 的 79 单词搜索、200 岛屿数量,或者洛谷的 P1219 八皇后。做完后你会发现,这些看似不同的题,核心的“标记—递归—撤销”节奏几乎一样。到那个时候,你再看 P1605,就不再是一道水题,而是一个让你从“看代码都懂”到“自己写得顺”的里程碑。
最后说一个我自己的小习惯:遇到这类搜索题,我会在纸上先把小规模迷宫的路径手动画一遍,再让程序输出对比。这个习惯帮我少走了很多弯路,也让我对递归执行顺序有了更扎实的感觉。希望这篇分享也能帮你把 P1605 稳稳拿下。
