news 2026/10/18 7:32:02

小学生C++信息学竞赛课程----算法选择与思维训练(10、第四章:深度优先搜索DFS 第一单元:深度优先搜索 DFS 入门——走进迷宫,探索每一条岔路!)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
小学生C++信息学竞赛课程----算法选择与思维训练(10、第四章:深度优先搜索DFS 第一单元:深度优先搜索 DFS 入门——走进迷宫,探索每一条岔路!)


第四章 深度优先搜索DFS

第一单元:深度优先搜索 DFS 入门——走进迷宫,探索每一条岔路!

同学们,准备好了吗?

今天,我们要走进一座神秘的魔法迷宫。迷宫里有岔路、有宝箱、有机关,还有一扇通往终点的大门。

我们的任务是:

让计算机像一名勇敢的小探险家一样,沿着一条路不断向前探索;走不通了,就退回来,换一条路继续探索。

这就是今天要学习的算法——深度优先搜索,英文简称 DFS(Depth-First Search)。

它是信息学竞赛中非常重要的一种搜索算法。学会 DFS 后,我们就能解决迷宫寻路、排列组合、选取方案、图的遍历、八皇后等许多问题。

本单元将按照下面的路线展开:


第一站:认识 DFS

什么是深度优先搜索?它为什么要先走到底?



第二站:图示推演

一步一步走迷宫,学会前进、回退和搜索顺序。



第三站:基础代码与万能模板

从递归函数到 visited 数组,搭建自己的 DFS 工具箱。



第四站:典型例题与变式训练

从数数、走迷宫,到统计路径和枚举所有方案。



第五站:复杂度分析与自测

学会估算搜索规模,发现 DFS 最容易出现的错误。


第一站:什么是深度优先搜索?

1. 魔法迷宫里的探险家

很久以前,魔法王国里有一座迷宫。探险家从入口出发,要寻找藏在迷宫深处的宝箱。

他来到第一个岔路口,发现前面有两条路。

  • 左边通道通向一个新的岔路口。

  • 右边通道也通向一个新的岔路口。

应该先走哪边呢?

假设我们规定:有多条路可以走时,先走左边;沿着左边继续走,直到走不动了,再回来探索还没走过的路。


于是,探险家会这样行动:

  1. 选择一条尚未探索的道路。

  2. 沿着这条道路继续深入。

  3. 如果遇到新的岔路,继续向深处探索。

  4. 如果所有可走的道路都已经探索过,就退回上一个岔路口。

  5. 再探索其他尚未走过的道路。

这就是 DFS 的核心思想。



2. 为什么叫“深度优先”?

我们先比较两种探索方式。


深度优先搜索 DFS

先沿着一条路尽可能深入,走不通再回退。

关键词:一条路走到底,再回来。



广度优先搜索 BFS

先探索离起点近的一层,再探索下一层。

关键词:一层一层向外扩展。

今天我们专注学习 DFS。等以后学习 BFS 时,就会发现:两种搜索像两位性格不同的探险家,一位喜欢深入,一位喜欢先探索附近。


3. DFS 的三个关键动作

请记住这三个动作:

这三个动作看起来简单,却是很多 DFS 程序的共同基础。


第二站:图示推演——亲眼看见 DFS 如何搜索

例题一:探索一棵魔法树

迷宫可以画成道路图。为了先理解 DFS,我们把它简化成一棵树。

假设树上的房间连接关系如下:

这棵树的关系是:

  • A 连接 B、C;

  • B 连接 D、E;

  • C 连接 F、G。


从 A 开始,DFS 会如何访问?

第一步:从 A 出发

A 有两个孩子:B 和 C。

先走左边的 B。

访问顺序目前是:

A → B


第二步:继续深入 B

B 还有两个孩子:D 和 E。

先走 D。D 没有孩子了,于是回退到 B。

访问顺序:

A → B → D

注意:回退不是把 D 从访问记录中删除,而是让探险家返回上一个岔路口。


第三步:探索 B 的另一条路

回到 B 后,发现 E 还没有探索,于是进入 E。

E 没有孩子,继续回退。

访问顺序:

A → B → D → E


第四步:回到 A,探索 C

B 的所有分支都已经探索完毕,回到 A,进入 C。

再依次探索 F、G。

最终访问顺序为:

A → B → D → E → C → F → G

这是在“先左后右”规则下的 DFS 访问顺序。

如果我们规定先右后左,访问顺序就会改变。因此,DFS 的具体访问顺序与邻居的检查顺序有关,但它“深入后回退”的核心思想不变。


第三站:让计算机学会 DFS——递归是怎样工作的?

现在有一个关键问题:

探险家走不通时,为什么知道该回到哪个岔路口?

在程序里,我们可以用递归来实现这种探索过程。

1. 先理解递归

递归就是:一个函数在执行过程中,再次调用它自己。

例如:

void dfs(int x) { // 处理当前房间 // 继续探索下一个房间 dfs(下一个房间); }

这段代码只是示意,不能直接运行,因为“下一个房间”还没有具体定义。

我们先把递归想象成一支探险队:

  • 进入一个房间,就开启一层新的探索任务;

  • 新任务探索完毕,返回上一层;

  • 上一层接着寻找其他还没探索的道路。


2. 递归调用像一摞盘子

比如:

dfs(A);

程序进入 A 后,调用:

dfs(B);

程序进入 B 后,又调用:

dfs(D);

当 D 没有可探索的房间时,dfs(D)执行结束,程序返回dfs(B)里尚未执行完的地方。

这就像把盘子一层层放上去,再一层层拿下来。

递归的调用过程不断深入,函数返回的过程就是回退。


3. 递归必须有停止条件

如果一个函数永远调用自己,会发生什么?

void dfs(int x) { dfs(x); }

这个函数没有停止条件,会不断调用自己,最终可能导致程序崩溃。

所以,每个递归搜索都必须有合理的结束条件。例如:

if (x == target) { return; }

这表示:如果已经到达目标,就结束当前这次探索。

不过,在真实的迷宫问题中,还需要处理边界、墙壁、访问记录等条件。接下来,我们逐步补齐这些内容。


第四站:DFS 基础代码与模板——建立自己的搜索工具箱

前面我们通过故事和图示认识了 DFS,现在正式进入代码世界。

初学时,不要急着背很多不同的 DFS 程序。先掌握一个基本结构,再根据题目逐步添加判断条件。


1. 最简单的递归模板

假设我们要从房间x出发,探索它的所有相邻房间。

void dfs(int x) { // 1. 处理当前房间 // 2. 枚举所有相邻房间 // 3. 判断能不能前往 // 4. 满足条件就继续 dfs(下一个房间) }

这四步是许多 DFS 程序的共同骨架。

不过,真实搜索中经常会出现循环道路。比如 A 连着 B,B 又连回 A。如果不记录哪些房间已经访问过,程序可能永远在 A 和 B 之间来回探索。

所以,我们需要认识一个重要工具:visited数组。


2.visited数组:给探索过的房间盖章

我们可以用一个数组记录房间是否访问过。

bool visited[1005];

它就像探险家的印章本:

  • visited[x] == false:房间 x 还没有探索过。

  • visited[x] == true:房间 x 已经探索过。

每次进入一个新房间,先盖章,再探索它的邻居。

void dfs(int x) { visited[x] = true; // 处理当前房间 x // 对每个与 x 相邻的房间 y: // 如果 y 没有访问过,就继续 dfs(y) }

为什么要先盖章,再往下探索?

因为如果进入 B 后还没有给 B 盖章,就继续进入 A;A 又可能进入 B。这样,程序就可能不停重复。

记忆口诀:先标记,再深入;没访问,才进入。


3. 图的 DFS 通用模板

当题目给出一张图时,可以使用下面的模板。这里假设图的邻接表g[x]已经建好。

#include <iostream> #include <vector> using namespace std; const int N = 1005; vector<int> g[N]; bool visited[N]; void dfs(int x) { visited[x] = true; cout << x << " "; for (int i = 0; i < (int)g[x].size(); i++) { int y = g[x][i]; if (!visited[y]) { dfs(y); } } } int main() { int n, m; cin >> n >> m; for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; // 无向图:两个方向都要记录 g[u].push_back(v); g[v].push_back(u); } int start; cin >> start; dfs(start); cout << endl; return 0; }

例如输入:

5 4 1 2 1 3 2 4 3 5 1

表示有 5 个顶点、4 条无向边,从顶点 1 开始搜索。

在邻接表按输入顺序存储邻居的情况下,输出:

1 2 4 3 5

搜索过程是:

  • 从 1 进入 2;

  • 从 2 进入 4,4 没有未访问的邻居,返回 2;

  • 返回 1,继续进入 3;

  • 从 3 进入 5,之后结束。

这就是 DFS 在图上的完整探索过程。


4. DFS 模板的四个关键位置

代码位置

作用

visited[x] = true;

标记当前顶点已经访问

cout << x;

处理当前顶点,本例是输出

for (...)

依次检查相邻顶点

if (!visited[y]) dfs(y);

只进入尚未访问的顶点

要注意:邻接表模板适用于图的遍历。二维迷宫虽然也可以用 DFS,但需要另外检查上下左右四个方向,以及地图边界和墙壁。


第五站:典型例题一——迷宫探险,能不能找到出口?

这是 DFS 最经典的入门应用。

题目背景

小勇士进入了一座 6 * 6 的迷宫。

  • S表示起点;

  • E表示终点;

  • #表示墙壁;

  • .表示可以通行的道路。

勇士每次只能向上、下、左、右移动一格,不能穿过墙壁。

请判断:能否从起点到达终点?


1. 先把迷宫变成二维数组

我们用字符数组保存地图:

char maze[105][105];

例如:

S...# ##..# ...## .#..E .....

每个字符就是一个格子。

如果当前位置是(x, y),那么四个方向可以表示为:

方向

新行号

新列号

上

x - 1

y

下

x + 1

y

左

x

y - 1

右

x

y + 1

我们把方向变化放进两个数组中:

int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1};

当i从 0 到 3 时:

nx = x + dx[i]; ny = y + dy[i];

就可以依次得到上、下、左、右四个相邻格子。

这是一种很常用的网格搜索写法。


2. 迷宫 DFS 的思考步骤

每次进入一个格子,我们要做四件事:

  1. 如果已经到达终点,说明找到出口。

  2. 标记当前格子已经探索过。

  3. 枚举上、下、左、右四个方向。

  4. 只进入没有越界、不是墙壁、尚未访问过的格子。

为什么还需要访问标记?

因为道路可能连成环。例如走上两步、右两步后,又可能通过另一条路返回原来的位置。如果不记录访问状态,搜索可能反复绕圈。


3. 完整 C++参考程序

输入格式:

  • 第一行输入n m;

  • 接下来输入n行迷宫;

  • 每行包含m个字符;

  • 地图中有一个S和一个E。

#include <iostream> using namespace std; const int N = 105; int n, m; char maze[N][N]; bool visited[N][N]; int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; int sx, sy, ex, ey; bool found = false; void dfs(int x, int y) { if (x == ex && y == ey) { found = true; return; } visited[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) { continue; } // 墙壁不能通过 if (maze[nx][ny] == '#') { continue; } // 已经访问过,不重复进入 if (visited[nx][ny]) { continue; } dfs(nx, ny); } } int main() { cin >> n >> m; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { cin >> maze[i][j]; if (maze[i][j] == 'S') { sx = i; sy = j; } if (maze[i][j] == 'E') { ex = i; ey = j; } } } dfs(sx, sy); if (found) { cout << "YES" << endl; } else { cout << "NO" << endl; } return 0; }

这里使用从0开始的行号和列号。


4. 代码里的continue是什么意思?

在迷宫中,我们经常发现某个方向不能走。

if (maze[nx][ny] == '#') { continue; }

continue的意思是:跳过本次循环,继续检查下一个方向。

例如,当前位置的上方是墙壁,就不再探索上方,直接检查下方。

它不会结束整个 DFS,只是放弃当前这个方向。


5. 一个重要的细节:找到终点后,为什么还会返回?

在这份程序中,找到终点时:

found = true; return;

只是结束当前这一层dfs函数。

如果更深层的函数已经返回了,上一层函数可能还会继续检查其他方向。对于本题而言,这不影响最终的YES或NO。

如果想找到终点后立即结束全部搜索,可以在递归调用后增加判断:

dfs(nx, ny); if (found) { return; }

这样,一旦找到终点,就会逐层返回,不再探索其他分支。


第六站:典型例题二——数一数,从起点能到达多少个格子

刚才的任务只需要判断能否找到出口。

现在,我们把任务升级一下。

题目背景

还是一座由道路和墙壁组成的迷宫。现在不需要寻找出口,而是要计算:

从起点出发,不穿过墙壁,最多能到达多少个格子?

起点本身也算一个格子。

1. 算法思路

这次不需要found变量,因为我们不关心是否到达某个终点。

每当 DFS 第一次进入一个可通行的格子:

  • 把它标记为访问过;

  • 计数器加 1;

  • 继续探索四个方向。

注意,计数必须放在标记访问之后,而且每个格子只能计数一次。


2. 参考程序

输入格式与上一题相同,地图中使用S表示起点、#表示墙壁、.表示道路。

#include <iostream> using namespace std; const int N = 105; int n, m; char maze[N][N]; bool visited[N][N]; int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; int sx, sy; int cnt = 0; void dfs(int x, int y) { visited[x][y] = true; cnt++; 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 (visited[nx][ny]) { continue; } dfs(nx, ny); } } int main() { cin >> n >> m; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { cin >> maze[i][j]; if (maze[i][j] == 'S') { sx = i; sy = j; } } } dfs(sx, sy); cout << cnt << endl; return 0; }

3. 和上一题有什么区别?

内容

迷宫找出口

统计可到达格子

主要目标

判断能否到达终点

统计可到达的格子数量

计数变量

不需要

需要cnt

到达终点后

可以停止搜索

还要继续探索

DFS 的核心

标记、检查、深入

标记、计数、深入

你会发现,DFS 的主体结构几乎不变,改变的往往只是“进入一个新位置后,要完成什么任务”。

这就是算法根据实际需要的变化。


第七站:典型例题三——统计迷宫中有多少个独立区域

题目背景:魔法岛屿地图

魔法王国有一张岛屿地图:

  • .表示陆地;

  • #表示海洋;

  • 上、下、左、右相邻的陆地属于同一块区域;

  • 斜对角相邻不算连接。

请统计地图中有多少块互相分离的陆地区域。

例如:

..##. ..##. ####. .#... .#..#

1. 从一块陆地出发,探索整个区域

我们从左上角的陆地出发,使用 DFS 把所有与它连通的陆地都访问一遍。

完成后,这一整块区域就处理完了。

然后继续从左到右、从上到下扫描地图。

如果遇到一块还没有访问过的陆地,说明我们发现了一个新的区域:

  • 区域数量加 1;

  • 从这块陆地开始 DFS;

  • 标记它能够到达的所有陆地。

重复这个过程,直到扫描完整张地图。


2. 算法关键点

与前两题相比,这次多了一层循环:

for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { // 发现没有访问过的陆地 // 区域数加 1 // 从这里启动 DFS } }

外层循环负责发现新的区域,DFS 负责把一个区域完整探索出来。


3. 参考程序

#include <iostream> using namespace std; const int N = 105; int n, m; char a[N][N]; bool visited[N][N]; int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; void dfs(int x, int y) { visited[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) { continue; } if (a[nx][ny] == '#') { continue; } if (visited[nx][ny]) { continue; } dfs(nx, ny); } } int main() { cin >> n >> m; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { cin >> a[i][j]; } } int regions = 0; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (a[i][j] == '.' && !visited[i][j]) { regions++; dfs(i, j); } } } cout << regions << endl; return 0; }

对于上面给出的地图,程序输出:

3

4. 这道题带给我们的新知识

这是一类非常重要的图论问题:连通区域计数。

以后我们遇到:

  • 数地图上的岛屿;

  • 统计森林中相互分离的林地;

  • 统计图中有多少个连通分量;

  • 统计棋盘上互相连接的同色区域;

都可以考虑类似的 DFS 思路。

不过,要注意“连接”的定义:本题只允许上下左右四个方向。如果题目允许斜对角连接,就需要把方向数组扩展为八个方向。


第八站:典型例题四——寻找从起点到终点的一条路径

前面我们知道了“能不能到达终点”,但如果题目要求输出具体的路线,该怎么办?

这次,我们要让探险家留下脚印。

1. 核心想法:记录每一步走到了哪里

我们可以用两个数组记录当前路径:

int pathX[10005]; int pathY[10005]; int pathLen = 0;

每次进入一个新格子,就把坐标放入路径数组。

如果成功到达终点,数组里保存的就是一条找到的路线。

如果当前方向走不通,需要回退,就把刚才的格子从当前路径中移除。

这里要特别区分:

  • visited:记录哪些格子已经探索过,避免重复搜索。

  • pathX、pathY:记录当前这条路线经过了哪些格子。

两者不是一回事。


2. 为什么回退时要恢复路径?

假设当前路线是:

A → B → D

后来发现 D 走不通,就要退回 B,尝试另一条路:

A → B → E

这时,当前路径应该删除 D,再加入 E。

如果不删除 D,路径记录就会变成错误的:

A → B → D → E

但探险家实际上并没有从 D 直接走到 E。

这就是搜索中非常重要的思想:当前路线改变时,路线记录也要跟着改变。


3. 参考程序:找到一条迷宫路径

仍然使用S、E、.、#表示迷宫。

#include <iostream> using namespace std; const int N = 105; int n, m; char maze[N][N]; bool visited[N][N]; int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; int sx, sy, ex, ey; int pathX[N * N], pathY[N * N]; int pathLen = 0; bool found = false; void dfs(int x, int y) { visited[x][y] = true; // 把当前格子加入路径 pathX[pathLen] = x; pathY[pathLen] = y; pathLen++; if (x == ex && y == ey) { found = true; 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 (visited[nx][ny]) { continue; } dfs(nx, ny); if (found) { return; } } // 当前格子无法通向终点: // 从当前路径中撤销它 pathLen--; } int main() { cin >> n >> m; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { cin >> maze[i][j]; if (maze[i][j] == 'S') { sx = i; sy = j; } if (maze[i][j] == 'E') { ex = i; ey = j; } } } dfs(sx, sy); if (!found) { cout << "NO" << endl; } else { cout << "YES" << endl; for (int i = 0; i < pathLen; i++) { cout << "(" << pathX[i] << "," << pathY[i] << ")" << endl; } } return 0; }

这里需要特别说明的细节:

这份程序在失败回退时,只恢复pathLen,并没有把visited恢复为false。这是因为它采用的是“每个格子只探索一次”的搜索方式,目标是找到一条可行路线,而不是枚举所有路线。

对于普通的无权迷宫,这种写法能够找到一条可行路径,但不能保证找到步数最少的路径。如果题目要求最短路,通常应该考虑 BFS。

如果题目要求枚举所有不同的简单路径,或者路径中的某个位置允许在不同路线里反复尝试,那么访问标记的恢复方式就需要重新设计。我们后面会专门学习这种“回溯”。


第九站:典型例题五——DFS 还能用来生成所有排列吗?

现在,我们从迷宫转向另一个经典问题。

故事背景:三位勇士的出场顺序

王国要举行庆典,A、B、C 三位勇士需要依次出场。

每位勇士只能出场一次。请输出所有可能的出场顺序。

例如:

  • ABC

  • ACB

  • BAC

还会有哪些呢?


1. 为什么这也是 DFS?

我们可以把每一次选择想象成走进一个新的岔路口。

第一步:选择第一位出场的勇士。

有 A、B、C 三种选择。

如果第一位选择 A,第二位只能从 B、C 中选择。

如果第二位选择 B,第三位就只能选择 C。

这样就得到 ABC。

之后回退到第二位的位置,尝试 C,就得到 ACB。

当第一位的所有方案都尝试完后,再回退到第一位,选择 B。

这就是 DFS 搜索树的思想。



2. 用used数组避免重复使用

bool used[4]; char person[4] = {'A', 'B', 'C'}; char ans[4];

used[i]表示第i位勇士是否已经被安排出场。

当我们选择一位勇士时:

used[i] = true;

完成后回退:

used[i] = false;

注意,这里与之前迷宫的visited不完全一样。

在排列问题中,同一个勇士可以出现在不同的排列里,只是不能在同一个排列里重复出场。因此,回退时必须把used[i]恢复为false。


3. 完整 C++ 参考程序

#include <iostream> using namespace std; char person[3] = {'A', 'B', 'C'}; char ans[3]; bool used[3]; void dfs(int step) { // 三个位置都安排好了 if (step == 3) { for (int i = 0; i < 3; i++) { cout << ans[i]; } cout << endl; return; } // 尝试把每一位勇士放到当前的位置 for (int i = 0; i < 3; i++) { if (used[i]) { continue; } ans[step] = person[i]; used[i] = true; dfs(step + 1); // 回退:把勇士还回去 used[i] = false; } } int main() { dfs(0); return 0; }

输出:

ABC ACB BAC BCA CAB CBA

一共 6 种排列。

为什么是 6 种?

第一位有 3 种选择,第二位有 2 种,第三位有 1 种,所以:

3 * 2 * 1 = 6

也就是:

3! = 6


4. 这道题教会我们的重要知识

DFS 不只是走迷宫,还可以枚举所有可能的方案。

排列问题的通用思路是:

  1. 确定当前要填写的位置。

  2. 枚举所有可以放进去的元素。

  3. 标记元素已经使用。

  4. 递归填写下一个位置。

  5. 返回时撤销标记,尝试其他元素。

这就是回溯搜索的基本形态。后面的 DFS 单元会继续深入学习如何保存状态、撤销选择以及剪枝。


第十站:典型例题六——从 1 走到 n,一共有多少条不同的路径?

这道题用来练习 DFS 的递归过程与计数。

题目背景

有一条简单的数字通道,起点是 1。每一步只能从当前数字走到:

  • 当前数字加 1;

  • 当前数字加 2。

例如,从 1 走到 4,可以有:

  • 1 → 2 → 3 → 4

  • 1 → 2 → 4

  • 1 → 3 → 4

请问,从 1 走到n一共有多少种走法?

这里的路径只看经过的数字序列。


1. DFS 思路

假设当前在数字x:

  • 如果x == n,说明找到了一条完整路径,答案加 1。

  • 如果x > n,说明走过头了,这条路线无效。

  • 否则尝试走到x + 1。

  • 再尝试走到x + 2。

每条完整路线都会被枚举一次。


2. 参考程序

#include <iostream> using namespace std; int n; long long ans = 0; void dfs(int x) { if (x == n) { ans++; return; } if (x > n) { return; } dfs(x + 1); dfs(x + 2); } int main() { cin >> n; dfs(1); cout << ans << endl; return 0; }

输入:

4

输出:

3

3. 为什么这题不需要visited?

因为我们不是在一张有环的地图里反复访问顶点,而是在一个不断增大的数字序列中搜索。

每一步都从x走向更大的数字,因此不可能回到之前的数字,也不会形成循环。

不过,这份程序枚举了所有走法,随着n增大,搜索量会迅速增加。更大的数据通常适合用动态规划求解。

这也是算法学习的重要一环:DFS 可以帮助我们理解所有方案,但当方案数量太多时,就要考虑更高效的方法。


第十一站:变式训练——同一个 DFS,换一个任务

现在我们来做几道变式题。它们不一定都需要完整的复杂代码,重点是训练你从题目中识别 DFS 的核心结构。

变式一:数一数能走到多少个格子

在一个二维地图中,.表示道路,#表示墙壁。给定起点,统计起点所在连通区域的大小。

提示:使用visited数组;每次首次进入一个道路格子时,计数加 1。


变式二:统计独立岛屿

地图中1表示陆地,0表示海洋。上下左右相连的陆地属于同一座岛屿,求岛屿数量。

提示:扫描整个地图,发现一个未访问的1,就启动一次 DFS,岛屿数量加 1。


变式三:输出所有二进制串

给定整数n,输出所有长度为n的二进制串。例如n = 2时,输出00、01、10、11。

提示:每个位置有两种选择:填写 0 或填写 1。填完所有位置就输出。


变式四:从 1 到 n,只能加 1 或加 3

每一步只能把当前数字增加 1 或增加 3,问从 1 到 n 有多少种走法。

提示:递归尝试dfs(x + 1)和dfs(x + 3);到达 n 时计数,超过 n 时返回。


变式五:三位密码

用数字 1、2、3 组成三位密码,每个数字最多使用一次,输出所有可能的密码。

提示:与三位勇士排列问题相同,使用used数组和递归位置step。

这些题目的故事不同,但它们有一个共同点:每一步做出一个选择,然后继续探索剩下的可能性。


第十二站:复杂度分析——DFS 会不会搜索到天荒地老?

DFS 的优点是思路清晰,代码结构也很自然。但它并不总是快。

搜索速度主要取决于两个因素:

  1. 搜索过程中会访问多少个状态或顶点?

  2. 每个状态需要检查多少个后续选择?


1. 图的 DFS:每个顶点访问一次

对于使用邻接表存储的图,如果从一个起点开始搜索,且每个顶点最多访问一次:

  • 每个顶点最多被访问一次;

  • 每条边最多被检查常数次。

因此,完整遍历图的时间复杂度为:

O(V+E)

其中:

  • V 表示顶点数量;

  • E 表示边的数量。

例如,一张有 100 个顶点、200 条边的图,DFS 只需要处理这些顶点和边,不需要枚举所有顶点的排列。


2. 二维网格 DFS:每个格子访问一次

如果地图有 n 行、m 列,每个格子最多访问一次,每次最多检查四个方向,那么时间复杂度是:

O(nm)

因为总共有 n * m 个格子。


3. 排列问题:搜索量可能非常大

如果我们要生成 n 个不同元素的所有排列,那么方案数为:

n!

仅输出所有排列,就需要与 n! 成正比的时间。若每个排列都要输出 n 个元素,总时间通常为:

O(n * n!)

当 n 变大时,排列数量会迅速增长。

例如:

元素个数 \(n\)

排列数量 \(n!\)

3

6

5

120

8

40,320

10

3,628,800

12

479,001,600

所以,枚举排列适合较小规模的数据。对于大规模搜索,我们往往需要剪枝、动态规划或其他优化。


4. DFS 的空间复杂度

DFS 还会使用递归调用栈。

如果搜索的最大递归深度是 d ,递归栈通常需要 O(d) 的空间。

此外,程序还可能保存visited数组、地图、邻接表或当前路径。这些数据也要计入总空间复杂度。

例如:

  • 图的邻接表与访问数组:通常需要 O(V+E) 空间;

  • 二维地图与访问数组:通常需要 O(nm)空间;

  • 排列搜索的递归深度:通常为 O(n) ,另有用于记录答案的数组。

特别提醒: 即使时间复杂度允许,递归深度过大也可能导致栈溢出。对于很深的图或长链,有时需要使用显式栈来模拟 DFS。


第十三站:DFS 初学者最容易犯的六个错误

同学们,很多时候,DFS 的思路并不难,真正让程序出错的是一些小细节。

我们来看看六个常见陷阱。

错误一:没有设置递归结束条件

函数一直调用自己,可能导致栈溢出。

改进:明确什么时候到达目标、什么时候走不通,以及什么时候应该返回。


错误二:忘记标记已经访问的顶点

在有环的图中,程序可能反复访问同一批顶点。

改进:普通图遍历中,进入顶点后及时标记visited[x] = true。


错误三:把越界判断放得太晚

访问maze[nx][ny]之前,没有确认坐标合法。

改进:先检查边界,再访问数组。


错误四:把visited和当前路径混为一谈

它们分别记录“已经探索过什么”和“当前路线经过什么”。在排列、组合等回溯问题中,通常需要撤销当前选择。

改进:根据题目判断,回退时应该恢复哪些状态。


错误五:以为 DFS 找到的路径一定最短

DFS 优先深入,不保证先找到的路径最短。

改进:无权图的最短路通常优先考虑 BFS。


错误六:搜索规模太大,仍然直接枚举

例如生成 15 个元素的所有排列,方案数已经非常庞大。

改进:估算搜索规模,必要时使用剪枝、动态规划或其他算法。


第十四站:课堂自测——看看你是否真正理解了 DFS

1. 深度优先搜索最重要的特点是什么?

先访问离起点最近的所有顶点

沿着一条分支不断深入,必要时回退

每次选择数字最大的顶点

答案:B

DFS 的核心是深入一条分支,无法继续时再回退。


2. 在普通图 DFS 中,visited 数组的主要作用是什么?

记录顶点的权重

记录顶点是否已经访问,避免重复探索

记录所有最短路径

答案:B

它能避免在有环的图中反复进入同一顶点。


3. 一个有 4 个顶点的树,从根开始按左子树优先 DFS。根的左孩子为 B、右孩子为 C,B 的孩子为 D、E。访问顺序应为?

A B D E C

A B C D E

A C B E D

答案:A

先访问根 A,再深入左子树 B、D、E,最后访问右子树 C。


4. 为什么排列 DFS 在回退时要把 used[i] 恢复为 false?

为了让其他分支也能使用这个元素

为了让程序提前结束

为了让排序更快

答案:A

一个元素不能在同一个排列中重复,但可以出现在其他排列里。


5. 对一个使用邻接表存储的图进行完整 DFS,时间复杂度通常是多少?

O(V+E)

O(V²) 必然成立

O(V!)

答案:A

每个顶点访问一次,每条边被检查常数次


编程作业:魔法迷宫挑战

请同学们完成下面三道递进题目。

作业一:能不能走出去?

给定一个迷宫,S是起点,E是终点,#是墙,.是道路。每次只能上下左右移动,判断能否到达终点。

考查:二维数组、方向数组、DFS、访问标记。


作业二:统计魔法岛屿

地图中1表示陆地,0表示海洋。上下左右相邻的陆地属于同一个岛屿。请统计岛屿数量。

考查:双重循环、连通区域、重复调用 DFS。


作业三:勇士密码排列

给定数字1、2、3、4,每个数字最多使用一次,输出所有四位排列。

考查:递归层数、used 数组、回退恢复、搜索树。


本单元总结:带走你的 DFS 探险工具箱

今天,我们从魔法迷宫出发,学习了 DFS 的核心思想,并通过多道例题认识了它的不同用途。

DFS 基础模板

void dfs(int x) { // 1. 判断是否到达目标 // 2. 标记当前状态 // 3. 枚举下一步的所有选择 // 4. 检查选择是否合法 // 5. 递归探索 // 6. 必要时撤销当前选择 }

这是思路模板,不是可以直接运行的完整程序。不同题目需要补充不同的状态、条件与回退操作。


本单元的六个核心知识点

  1. DFS:沿着一条分支深入,无法继续时回退。

  2. 递归:函数调用自己,返回时回到上一层。

  3. visited:记录已经探索过的状态,避免重复访问。

  4. 方向数组:用dx、dy统一枚举上下左右。

  5. 搜索树:把每一次选择看作进入一个新的分支。

  6. 回溯:撤销当前选择,让其他分支也能尝试。


最后,请同学们记住这句口诀:

先判断,再标记;枚举选择,递归深入;走不通时回退,换条道路再探索。

下一单元,我们将继续深入 DFS:学习搜索树与递归的完整推演、如何记录当前路径,以及如何枚举所有方案。届时,我们会从简单排列逐步走向组合、子集和更复杂的回溯问题,让 DFS 真正成为你解决算法题的利器。


版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 7:31:08

Linux find命令深度解析:原理、避坑与生产级实战

1. 为什么在Linux里“找文件”这件事&#xff0c;比你想象中更值得花时间搞懂很多人刚接触Linux时&#xff0c;第一反应是&#xff1a;不就是个命令行嘛&#xff0c;ls看看目录&#xff0c;cd切切路径&#xff0c;顶多加个ll——找文件&#xff1f;点开图形界面的搜索框不就完了…

作者头像 李华
网站建设 2026/10/18 7:31:05

音视频开发核心链路实战:采集、编码、传输、解码与渲染全解析

不知道你有没有过这种经历&#xff1a;本想自己动手搞一个视频工具或直播应用&#xff0c;结果一搜“音视频开发”&#xff0c;铺天盖地全是抽象名词——H.264、PTS、GOP、硬解软解……人直接看懵。更别说真正上手时&#xff0c;采集出来画面是花的&#xff0c;推流连麦谁先说话…

作者头像 李华
网站建设 2026/10/18 7:30:54

2026年国内API聚合平台综合评测:版本时效、协议兼容与企业治理避坑指南

很多开发者选API聚合平台时&#xff0c;眼睛盯着价格&#xff0c;实际踩坑最多的却是另外三件事&#xff1a;模型版本滞后、协议兼容性不过关、企业治理能力缺位。这三件事平日不起眼&#xff0c;一旦进入生产环境就会集中爆发。本文围绕模型覆盖广度、协议支持完整度、稳定性、…

作者头像 李华
网站建设 2026/10/18 7:29:56

SQL注入入门:从原理到实战,数字型与字符型详解(2)

摘要&#xff1a;本文以 SQL 注入为主题&#xff0c;先回顾数字型注入与字符型注入的区别&#xff0c;再重点讲解两个关键探测手段&#xff1a;order by 用于判断查询结果的列数&#xff0c;union 用于把自定义查询结果拼接到原查询后面。文章结合靶场实例&#xff0c;详细演示…

作者头像 李华