
第二十六课:图的遍历------DFS深度优先搜索
------从"树的先序遍历"走进真正的图世界
上一课我们解决了一个重要问题:
图到底怎样存进计算机?
我们学习了两种方法:
图
│
├── 邻接矩阵
│
└── 邻接表
但是把图存起来还不够。
假如我们有这样一张"城市地图":
A
/ \
B C
| |
D---E
\
F
现在老师问:
"从 A 出发,把所有城市都访问一遍,而且每个城市只能访问一次,应该怎么办?"
这就是今天要学习的:
DFS------深度优先搜索(Depth First Search)
你会发现:
DFS其实和我们之前学过的二叉树先序遍历非常像。
这也是本课最重要的连接点。
我们把"从图的某个顶点出发,访问所有顶点,并使每个顶点仅被访问一次"称为图的遍历,并把遍历分为深度优先遍历和广度优先遍历;其中 DFS 的基本思想就是"仿树的先序遍历"。
一、什么叫"遍历"?
先不要急着学 DFS。
我们先理解:
遍历到底是什么意思?
比如班里有10个同学:
小明
小红
小刚
小丽
......
老师说:
"请每个同学都点名一次,而且不能重复点名。"
这个过程就是:
遍历。
图也是一样。
假设:
A ------ B ------ C
| |
D E
从A出发,我们希望:
A
B
C
D
E
每个点都访问一次。
这就是:
图的遍历
二、为什么图的遍历比树麻烦?
我们以前学习二叉树:
A
/ \
B C
/ \
D E
树有一个特点:
不会绕回来形成环。
但是图可能有环:
A ------ B
| |
D ------ C
如果从A开始:
A → B → C → D → A → B → C → ...
糟糕!
死循环了!
所以图的遍历必须解决一个非常重要的问题:
这个顶点以前来过没有?
这就是:
visited数组
三、DFS的核心思想:一条路走到底
假设我们来到一个迷宫:
入口
↓
+---+
| |
| +---+
| |
+-------+
你走迷宫的时候,如果采用一种策略:
看到一条路,就一直往里面走。
走到不能继续了:
退回来,再换一条路。
这就是:
深度优先
"深度"两个字非常重要。
它不是:
先把附近的人全部访问完。
而是:
先沿着一条路尽可能走深。
四、给小学生一个最形象的比喻
把图想象成一栋巨大的城堡。
你从:
A房间
出发。
A有两个门:
A
/ \
B C
你选择B。
B又有门:
A
/ \
B C
/
D
你继续:
A → B → D
D没有新的房间可以走了。
怎么办?
回退!
回到B。
B没有新的路了,再回到A。
然后:
A → C
这就是 DFS。
五、DFS最重要的四个字
我特别建议学生们把:
"访问、深入、回退、换路"
记下来。
完整过程:
访问当前点
↓
找一个没有访问过的邻居
↓
走过去
↓
继续深入
↓
走不动了
↓
回退
↓
换另一条路
六、先从一个最简单的图开始
我们看:
A ------ B ------ D
|
C
从A开始。
假设邻接点按照字母顺序访问。
那么:
A
先访问A。
然后发现:
A → B
于是访问B:
A → B
B发现:
B → D
于是:
A → B → D
D走不动了。
回退到B。
B没有其他没访问过的点。
回退到A。
A还有C。
于是:
A → B → D → C
最终 DFS 顺序:
A,B,D,C
七、为什么要有 visited?
再看一个有环的图:
A ------ B
| |
D ------ C
如果没有 visited:
A
↓
B
↓
C
↓
D
↓
A
↓
B
↓
C
↓
...
永远走不完。
所以我们需要:
bool visited[100];
它就像每个城市门口的一块牌子:
A:来过 ✓
B:来过 ✓
C:还没来过
D:还没来过
八、访问一个点时,第一件事情是什么?
假设我们来到:
v
第一件事情:
visited[v] = true;
意思:
"我已经来过v了。"
然后:
cout << v;
表示:
"访问v。"
所以最基本的 DFS 框架是:
void dfs(int v)
{
visited[v] = true;
cout << v << " ";
// 再访问邻居
}
九、接下来找邻居
如果使用邻接矩阵:
int g[100][100];
那么:
g[v][i] == 1
表示:
v和i之间有边。
所以我们可以:
for (int i = 0; i < n; i++)
{
if (g[v][i] == 1)
{
// i是v的邻居
}
}
十、但是邻居不能乱走
我们需要判断:
if (g[v][i] == 1 && !visited[i])
意思是:
v和i有边,而且i以前没有访问过。
那么:
dfs(i);
进入i继续搜索。
于是完整代码:
void dfs(int v)
{
visited[v] = true;
cout << v << " ";
for (int i = 0; i < n; i++)
{
if (g[v][i] == 1 && !visited[i])
{
dfs(i);
}
}
}
这就是我们今天最重要的一段代码。
十一、把代码解析一下
这一段:
void dfs(int v)
意思:
"从v这个点出发,进行一次深度优先搜索。"
这一句:
visited[v] = true;
意思:
"我来过v了,打个标记。"
这一句:
cout << v << " ";
意思:
"访问v。"
这一段:
for (int i = 0; i < n; i++)
意思:
"把v的所有可能邻居检查一遍。"
这一句:
if (g[v][i] == 1 && !visited[i])
意思:
"如果i是v的邻居,而且我还没去过i......"
最后:
dfs(i);
意思:
"那我就走到i那里,继续往深处探索!"
十二、这就是"递归"
同学们注意:
这里出现了:
dfs(i);
而这个函数本身叫:
dfs
也就是说:
DFS使用了递归!
这和我们之前学习的二叉树遍历产生了非常漂亮的联系。
十三、还记得二叉树先序遍历吗?
以前我们写:
void preorder(Node* root)
{
if (root == nullptr)
return;
cout << root->value << " ";
preorder(root->left);
preorder(root->right);
}
顺序:
访问根
↓
左边深入
↓
右边深入
也就是:
根 → 左 → 右
十四、DFS和二叉树先序遍历有什么关系?
看二叉树:
A
/ \
B C
/ \
D E
先序:
A → B → D → E → C
DFS也是:
访问当前点,然后选择一个没有访问过的邻居继续深入。
所以我们特别强调:
DFS的基本思想是"仿树的先序遍历过程"。
这其实是非常重要的学习桥梁:
递归
↓
二叉树先序遍历
↓
DFS
十五、但是DFS和二叉树遍历有一个关键区别
二叉树中:
root
├── left
└── right
最多两个孩子。
而图中:
A
├── B
├── C
├── D
├── E
└── F
一个顶点可能有很多邻居。
所以:
for (...)
就非常重要。
也就是说:
二叉树:递归访问左右孩子
图:循环寻找所有邻居,再递归访问
十六、用一个完整例子手算DFS
图:
0
/ \
1 2
/ \ \
3 4---5
我们规定:
邻居按照编号从小到大访问。
从0开始。
第一步:访问0
0
visited:
0 ✓
1
2
3
4
5
第二步:0找到1
0 → 1
访问:
0 1
第三步:1找到3
0 → 1 → 3
访问:
0 1 3
3没有新的邻居。
于是:
回退到1
第四步:1找到4
0 → 1 → 4
现在:
0 1 3 4
第五步:4找到5
0 → 1 → 4 → 5
得到:
0 1 3 4 5
第六步:5附近有什么?
5连接:
2
4
4已经访问过。
2还没访问。
所以:
0 → 1 → 4 → 5 → 2
最终:
0,1,3,4,5,2
这就是 DFS 的一种遍历结果。
十七、为什么说"DFS结果可能不唯一"?
这是初赛中很值得注意的一个问题。
假设:
A
/ \
B C
从A开始。
你可以先走:
A → B → C
也可能先走:
A → C → B
如果题目没有规定:
邻接点按照编号从小到大访问
那么 DFS 的具体访问顺序可能不同。
所以做程序阅读题时,一定要注意:
"邻居的访问顺序"是否被题目规定。
一般来说,不同起点下,会得到不同的 DFS 搜索结果。
十八、邻接矩阵版DFS
这是初学者适合掌握的版本:
#include <iostream>
using namespace std;
const int N = 100;
int n;
int g[N][N];
bool visited[N];
void dfs(int v)
{
visited[v] = true;
cout << v << " ";
for (int i = 0; i < n; i++)
{
if (g[v][i] == 1 && !visited[i])
{
dfs(i);
}
}
}
int main()
{
cin >> n;
// 读入图
// ...
dfs(0);
return 0;
}
十九、如果使用邻接表呢?
上一课我们学习了:
0:1 2
1:0 3 4
2:0
3:1
4:1
C++中可以写:
vector<int> g[N];
那么 DFS 就变成:
void dfs(int v)
{
visited[v] = true;
cout << v << " ";
for (int u : g[v])
{
if (!visited[u])
{
dfs(u);
}
}
}
是不是比邻接矩阵更自然?
因为:
for (int u : g[v])
直接就是:
"遍历v的所有邻居。"
二十、邻接矩阵版和邻接表版的区别
邻接矩阵
for (int i = 0; i < n; i++)
{
if (g[v][i] && !visited[i])
dfs(i);
}
含义:
把所有顶点都检查一遍,看看谁是v的邻居。
邻接表
for (int u : g[v])
{
if (!visited[u])
dfs(u);
}
含义:
只看v真正存在的邻居。
所以在实际图论算法中:
邻接表 + DFS
是非常常见的组合。
二十一、DFS为什么必须"标记访问"?
这是初学者容易忘记的一步。
错误代码:
void dfs(int v)
{
cout << v << " ";
for (int i = 0; i < n; i++)
{
if (g[v][i] == 1)
{
dfs(i);
}
}
}
看起来好像没问题。
但是:
A ------ B
从A:
A → B
然后B又发现A:
B → A
然后:
A → B → A → B → A → B → ...
无限递归!
所以:
visited[v] = true;
是DFS的生命线。
二十二、一定要先标记,再递归
正确:
void dfs(int v)
{
visited[v] = true;
for (int u : g[v])
{
if (!visited[u])
{
dfs(u);
}
}
}
为什么?
因为:
我一到达v
↓
马上宣布"v来过了"
↓
以后别人再遇到v
↓
就知道不能再进去
二十三、DFS的"递归栈"到底在干什么?
这部分可以和之前课的"函数调用栈"连接起来。
还是:
0
|
1
|
3
执行:
dfs(0);
然后:
dfs(1);
然后:
dfs(3);
此时函数栈大概是:
┌─────────┐
│ dfs(3) │ ← 当前
├─────────┤
│ dfs(1) │
├─────────┤
│ dfs(0) │
└─────────┘
3没有路了:
dfs(3) return;
于是:
┌─────────┐
│ dfs(1) │ ← 回来了
├─────────┤
│ dfs(0) │
└─────────┘
这就是:
DFS中的"回退"
所以我们之前学习函数调用栈,并不是白学的。
现在它真正派上用场了。
二十四、DFS = 走进去 + 递归 + 回退
可以画成:
dfs(0)
│
↓
dfs(1)
│
↓
dfs(3)
│
无路可走
│
↓
return
│
↓
dfs(1)
│
继续找路
│
↓
dfs(4)
所以:
DFS的"深度"是递归造成的
不是说:
DFS一定要使用递归。
实际上DFS也可以用栈模拟。
但是对于初学者:
递归版DFS是最容易理解的。
二十五、DFS与"栈"的关系
这一点非常重要。
我们已经学过:
栈 Stack
特点:
后进先出
DFS的回退过程:
先进入:
A
B
D
回退:
D
B
A
也是:
后进去的先回来。
因此:
DFS天然具有栈的特点。
递归程序的函数调用栈,实际上就在帮助我们完成这件事。
以后如果学习"非递归DFS",就可以直接使用:
stack<int>
来模拟。
二十六、DFS可以解决什么问题?
DFS不是只为了"输出一个遍历顺序"。
它非常强大。
例如:
1. 判断两个点是否连通
A ------ B ------ C
从A DFS。
如果能访问到C:
A和C连通。
2. 统计连通块数量
A---B---C D---E F
有:
3个连通块
我们可以:
从A DFS → 第一块
从D DFS → 第二块
从F DFS → 第三块
后面我们会专门学习。
3. 迷宫搜索
S . . #
# . . #
# . . T
DFS可以尝试寻找:
S能不能到T?
4. Flood Fill
我们学习过泛洪算法:
一块区域
↓
不断寻找相邻位置
↓
把整个连通区域找出来
本质上也是 DFS/BFS。
所以:
Flood Fill其实就是DFS/BFS思想在网格上的应用。
二十七、DFS最重要的程序模板
这个模板,同学们逐渐要做到"看到就认识"。
void dfs(int v)
{
visited[v] = true;
// 访问 v
cout << v << " ";
for (int u : graph[v])
{
if (!visited[u])
{
dfs(u);
}
}
}
把它解析下:
进入一个点
↓
标记"来过"
↓
处理这个点
↓
寻找所有邻居
↓
如果邻居没来过
↓
递归进入邻居
二十八、DFS的万能思维框架
以后看到DFS题,不要背代码。
先问自己4个问题:
问题1:从哪里开始?
dfs(start)
问题2:怎么表示"来过"?
visited[v]
问题3:一个点的邻居是谁?
可能是:
邻接矩阵
也可能是:
邻接表
问题4:没访问过的邻居怎么办?
dfs(u);
只要这4个问题想清楚:
DFS就已经掌握了一大半。
二十九、CSP-J初赛常见考法
考法1:判断DFS顺序
给一张图:
A ------ B
| |
C ------ D
告诉你:
从A开始,邻接点按照字母顺序访问。
让你写DFS序列。
一定要:
-
标记A
-
找A的第一个未访问邻居
-
一路深入
-
无路后回退
-
再找其他邻居
三十、考法2:问某个点什么时候会被访问
重点观察:
DFS是不是已经深入到另一条分支。
千万不要把DFS理解成:
A
B
C
D
简单从小到大扫描。
它不是普通循环。
它的核心是:
"先深入,再回退。"
三十一、考法3:程序阅读
例如:
void dfs(int v)
{
visited[v] = true;
cout << v << " ";
for (int u : g[v])
{
if (!visited[u])
dfs(u);
}
}
题目问:
输出的是什么?
马上识别:
DFS遍历序列
三十二、考法4:为什么不会死循环?
看到:
visited[v] = true;
以及:
if (!visited[u])
就应该想到:
防止图中存在环导致无限递归。
三十三、一个特别重要的陷阱
"访问过"不等于"只调用一次函数"吗?
正确理解:
一个顶点:
visited[v] = true
之后,其他地方再发现它:
if (!visited[v])
条件不成立。
所以不会再次进入:
dfs(v)
因此:
每个顶点最多真正进入DFS一次。
这也与" 每个顶点仅被访问一次 "的遍历定义相对应。
三十四、如果图不是连通图怎么办?
例如:
A ------ B ------ C
D ------ E
F
从A开始:
dfs(A);
只能访问:
A B C
因为:
D E
和:
F
根本到不了。
所以:
一次DFS不一定能访问整个图。
如果题目要求:
"访问整个图中的所有顶点"
就可以:
for (int i = 0; i < n; i++)
{
if (!visited[i])
{
dfs(i);
}
}
这段代码非常重要。
三十五、它实际上在干什么?
第一次:
dfs(0);
找到第一块连通区域。
然后:
for
继续寻找:
有没有哪个点还没访问?
如果找到:
D
就:
dfs(D);
找到第二块。
再找到:
F
再:
dfs(F);
于是:
整个图
↓
一个一个连通块
↓
DFS全部扫完
这就是学习:
连通分量
的基础。
三十六、今天的知识地图
图的遍历
│
┌─────────┴─────────┐
│ │
DFS BFS
│ │
深度优先 广度优先
│ │
仿先序遍历 仿层序遍历
│
递归 / 栈
│
┌────┼─────┐
│ │ │
visited 邻居 回退
│
防止重复访问
而整个知识链条是:
二叉树
↓
先序遍历
↓
递归
↓
函数调用栈
↓
DFS
↓
图的遍历
↓
连通性 / 连通块 / 迷宫 / Flood Fill
三十七、课堂练习
练习1:判断DFS
图:
0
/ \
1 2
/ \
3 4
从0开始,邻接点按照编号从小到大访问。
问:
DFS顺序是什么?
答案:
0,1,3,4,2
练习2:找出错误
下面DFS有什么问题?
void dfs(int v)
{
cout << v << " ";
for (int u : g[v])
{
dfs(u);
}
}
答案:
没有visited数组。
图中有环时可能无限递归。
练习3:填空
void dfs(int v)
{
____________;
cout << v << " ";
for (int u : g[v])
{
if (____________)
{
dfs(u);
}
}
}
答案:
visited[v] = true;
以及:
!visited[u]
练习4:判断
下面说法正确的是:
A. DFS一定按照编号从小到大访问
B. DFS的核心思想是尽可能向深处搜索
C. DFS不需要visited数组
D. DFS只能用于树
答案:
B
三十八、第28课"必背知识卡"
① DFS是什么?
深度优先搜索(Depth First Search)
② DFS的核心思想
一条路尽可能走深,走不通再回退。
③ DFS与什么树遍历相似?
二叉树的先序遍历
④ DFS最重要的数据
bool visited[N];
⑤ 为什么需要visited?
防止重复访问,避免图中有环时无限递归。
⑥ DFS的基本代码
void dfs(int v)
{
visited[v] = true;
for (int u : g[v])
{
if (!visited[u])
{
dfs(u);
}
}
}
⑦ DFS和什么数据结构有关?
栈
递归调用本质上利用了:
函数调用栈。
⑧ DFS的口诀
标记 → 访问 → 找邻居 → 没访问 → 递归 → 回退
三十九、在脑中留下一个重要的画面
让脑子里留下这个画面:
A
/ \
B C
/ \
D E
DFS不是:
A
B
C
D
E
这样机械地扫。
而是:
A
↓
B
↓
D
↑
B
↓
E
↑
B
↑
A
↓
C
一句话:
"先往深处走,走不动了就回来,再换一条路。"
这就是 DFS。
下一课 图的遍历------BFS广度优先搜索,正好与DFS形成最重要的一组对比:
DFS BFS
深度优先 广度优先
一条路走到底 一层一层扩散
递归 / 栈 队列
像树的先序 像树的层序
先深入 先横向
学完这两个以后,再进入连通分量、最短路、迷宫、Flood Fill,大家会真正开始理解"图算法"为什么如此强大。