迷宫问题详解:DFS 回溯、BFS 最短路与路径还原

迷宫问题表面上是在二维网格中找路,本质上是图的遍历:每个可通行格子是顶点,相邻可达格子之间存在边。使用栈或递归进行深度优先搜索,可以寻找任意可行路径;使用队列进行广度优先搜索,则能在无权网格中找到步数最少的路径。

1. 把迷宫建模成图

设迷宫是 $m\times n$ 的网格:

  • 0 表示可通行;
  • 1 表示墙;
  • 每个可通行格子 (row, col) 是一个顶点;
  • 若两个格子上下左右相邻且都可通行,就在它们之间连边。

四个方向可以统一写成:

1
2
constexpr int DR[4] = {-1, 1, 0, 0};
constexpr int DC[4] = {0, 0, -1, 1};

考察邻居 (nr, nc) 时必须依次检查:

  1. 是否在边界内;
  2. 是否可通行;
  3. 是否已经访问。

先检查边界,再访问数组,否则会产生越界和未定义行为。

2. 为什么需要 visited

网格中的边通常是双向的:从 A 能走到 B,也能立刻从 B 返回 A。如果不记录访问状态,搜索可能在两个格子之间无限循环。

常见策略有:

  • 单独维护 visited[m][n];
  • 在允许修改输入时,把已访问格子标记成特殊值;
  • 维护“本次路径中是否出现”的标记,用于需要枚举所有简单路径的回溯问题。

“全局访问过”和“当前路径使用中”语义不同。只寻找一条路径或最短路时通常使用全局 visited;枚举全部路径时,回溯离开格子后往往要撤销当前路径标记。

3. DFS:沿一条路走到底

深度优先搜索(DFS)从当前位置选择一个未访问邻居继续深入;走到死路时退回最近的分叉点尝试其他方向。

递归版本的结构为:

1
2
3
4
5
6
7
dfs(current):
若 current 是出口,返回成功
标记 current 已访问
对每个合法且未访问的邻居 next:
记录 next 的父节点为 current
若 dfs(next) 成功,向上传递成功
返回失败

递归调用栈隐式保存了回退位置。也可以显式使用栈,以避免递归过深导致调用栈溢出。

3.1 DFS 能保证什么

DFS 能在线性时间内判断出口是否可达,并找到一条路径,但在普通无权迷宫中不保证路径最短。它得到哪条路径还会受到方向枚举顺序影响。

若网格共有 $V=mn$ 个格子,每个格子最多检查 4 条边,时间为 $O(V)$,访问数组与递归栈最坏需要 $O(V)$ 空间。

4. BFS:按距离逐层扩展

广度优先搜索(BFS)使用队列:先处理距离起点为 0 的顶点,再处理距离为 1、2、3……的顶点。

1
2
3
4
5
6
起点入队并标记访问
while 队列非空:
current 出队
对每个合法且未访问的邻居 next:
标记 next,记录父节点
next 入队

4.1 为什么 BFS 能找到最短路

在每条边代价相同的无权图中,队列保证按“从起点经过的边数”非递减顺序扩展节点。某个格子第一次被发现时,不可能存在一条更短但尚未处理的路径,因此首次到达终点的距离就是最短距离。

这个结论有严格前提:每步代价相同。如果进入草地、沙地等格子的代价不同,应使用 Dijkstra;边权只有 0 和 1 时可用 0-1 BFS;启发式搜索场景可考虑 A*。

5. 路径还原

仅记录“访问过”只能判断可达性。若要输出完整路径,应在首次发现节点时记录它来自哪个父节点:

1
parent[next] = current

到达终点后,从终点沿 parent 反向追溯至起点,再反转序列,即得到正向路径。

父节点应在节点入队时写入,同时标记访问。如果等到出队才标记,同一节点可能被多个前驱重复入队,增加开销并让父节点语义混乱。

6. 可运行的 BFS 最短路实现

下面的 C++17 程序支持任意起点和终点,返回包含两端点的最短路径;不存在路径时返回空数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#include <algorithm>
#include <array>
#include <cassert>
#include <cstddef>
#include <iostream>
#include <queue>
#include <utility>
#include <vector>

struct Point {
int row;
int col;
};

static bool operator==(Point a, Point b)
{
return a.row == b.row && a.col == b.col;
}

static bool inside(const std::vector<std::vector<int>>& maze, Point p)
{
return p.row >= 0 && p.col >= 0 &&
p.row < static_cast<int>(maze.size()) &&
p.col < static_cast<int>(maze[0].size());
}

static std::vector<Point> shortest_path(
const std::vector<std::vector<int>>& maze, Point start, Point goal)
{
if (maze.empty() || maze[0].empty()) {
return {};
}
const std::size_t cols = maze[0].size();
for (const auto& row : maze) {
if (row.size() != cols) {
return {}; // 拒绝不规则网格
}
}
if (!inside(maze, start) || !inside(maze, goal) ||
maze[start.row][start.col] != 0 || maze[goal.row][goal.col] != 0) {
return {};
}

const int rows = static_cast<int>(maze.size());
const Point none{-1, -1};
std::vector<std::vector<bool>> visited(rows, std::vector<bool>(cols, false));
std::vector<std::vector<Point>> parent(rows, std::vector<Point>(cols, none));
std::queue<Point> pending;
constexpr std::array<Point, 4> directions{{
{-1, 0}, {1, 0}, {0, -1}, {0, 1}
}};

visited[start.row][start.col] = true;
pending.push(start);

while (!pending.empty()) {
Point current = pending.front();
pending.pop();
if (current == goal) {
break;
}
for (Point direction : directions) {
Point next{current.row + direction.row, current.col + direction.col};
if (inside(maze, next) && maze[next.row][next.col] == 0 &&
!visited[next.row][next.col]) {
visited[next.row][next.col] = true;
parent[next.row][next.col] = current;
pending.push(next);
}
}
}

if (!visited[goal.row][goal.col]) {
return {};
}
std::vector<Point> path;
for (Point p = goal;; p = parent[p.row][p.col]) {
path.push_back(p);
if (p == start) {
break;
}
}
std::reverse(path.begin(), path.end());
return path;
}

int main()
{
const std::vector<std::vector<int>> maze{
{0, 1, 0, 0, 0},
{0, 1, 0, 1, 0},
{0, 0, 0, 1, 0},
{1, 1, 0, 1, 0},
{0, 0, 0, 0, 0}
};
const std::vector<Point> path = shortest_path(maze, {0, 0}, {4, 4});
assert(!path.empty());
assert((path.front() == Point{0, 0}));
assert((path.back() == Point{4, 4}));
assert(path.size() == 9); // 8 步,9 个格子
for (Point p : path) {
std::cout << '(' << p.row << ',' << p.col << ") ";
}
std::cout << '\n';
}

这里使用 path.size() - 1 表示步数,因为路径数组同时包含起点和终点。

7. 使用显式栈的 DFS

递归 DFS 写法简洁,但大迷宫可能导致调用栈过深。显式栈版本可以这样组织:

1
2
3
4
5
起点入栈并标记
while 栈非空:
current = 栈顶并弹出
若 current 是终点则结束
将未访问邻居标记、记录父节点并压栈

如果只想模拟“走一步,死路再退一步”的可视过程,可以让栈保存当前路径:查看栈顶邻居,找到可走方向就压栈;没有邻居时弹栈回退。若只寻找一条路径,到达终点时整个栈就是路径。

8. 枚举所有路径

若要输出起点到终点的所有简单路径,DFS 需要在递归返回时撤销状态:

1
2
3
4
5
标记 current 在当前路径中
把 current 加入 path
递归探索每个未在当前路径中的邻居
从 path 删除 current
取消 current 的当前路径标记

枚举所有路径的数量可能呈指数增长,因此算法时间不再是简单的 $O(mn)$。输出本身可能就非常大,应设置规模限制或只统计、只找前若干条路径。

9. 带权迷宫与更复杂状态

9.1 不同地形代价

如果每个格子的进入代价不同,普通 BFS 不再保证总代价最小:

  • 非负权:Dijkstra;
  • 只有 0/1 权:0-1 BFS;
  • 有合理启发函数并希望更快到达目标:A*。

9.2 钥匙、门与传送点

当“能否进入格子”取决于已经拿到的钥匙,顶点就不能只用坐标表示。状态应扩展为:

1
(row, col, key_mask)

同一个格子携带不同钥匙集合,是图中的不同状态。visited 也必须包含完整状态,否则会错误剪枝。

9.3 多源 BFS

若要求每个格子到最近出口或最近障碍的距离,可以把所有源点同时入队并设距离为 0,再进行一次 BFS。这比从每个格子分别搜索高效得多。

10. 常见错误

  • 访问 maze[nr][nc] 后才检查边界;
  • 没有 visited,在环中无限往返;
  • 认为 DFS 找到的一定是最短路径;
  • BFS 到出队时才标记访问,造成重复入队;
  • 没有验证起点、终点是否越界或位于墙上;
  • 输入网格各行长度不一致;
  • 修改迷宫作为访问标记,却忘记调用者仍需要原始网格;
  • 只记录距离没有记录父节点,最后无法还原路径;
  • 在带权迷宫中仍使用普通 BFS;
  • 把路径包含的格子数误当作移动步数。

11. 测试清单

至少覆盖以下情况:

  1. 起点等于终点;
  2. 单行、单列和 $1\times1$ 网格;
  3. 起点或终点是墙;
  4. 完全无路;
  5. 存在多条等长最短路;
  6. 有环和大面积开放区域;
  7. 路径贴着四条边界;
  8. 非矩形输入;
  9. 大网格下的时间与空间;
  10. 返回路径的每对相邻格子是否真正相邻且可通行。

迷宫题最重要的迁移能力,是把画面抽象为“状态 + 合法转移”。确定图是无权还是带权、需要任意路径还是最短路径,再选择 DFS、BFS 或最短路算法,问题就会从“绕迷宫”转化为标准的图搜索。