目录
上一节我们学习了广度优先搜索 (BFS),它像水面的波纹一样,一层一层地向外探索。今天,我们要学习它的"兄弟"算法------深度优先搜索 (Depth-First Search, DFS),它体现了完全不同的探索哲学。
DFS的诞生------"不撞南墙不回头"
再次回到那个巨大的迷宫入口。上次,我们的策略是"由近及远"。但还有一种同样符合直觉的策略,那就是"一条路走到黑"。
-
你面前有多条路,随便选 一条 走下去。
-
在下一个路口,你再随便选一条路,继续 深入。
-
你就这样一直走,直到前面是死胡同(没有新的路可走),或者回到了之前走过的路口。
-
这时,你怎么办?你会 原路返回 到上一个路口,看看那个路口还有没有 其他没走过的路。
-
如果有,就选那条新路继续深入。如果也没有,就再退一步...
这种"勇往直前,走不通再退回来换条路"的探索策略,就是 深度优先搜索 (DFS)。"深度"指的就是它优先向"纵深方向"探索,直到无法再前进了,才回溯(backtrack)。
这个过程就像探险家在探索洞穴系统,他会沿着一个分支一直走到最深处,然后才返回来探索其他分支。
图 1️⃣**:DFS探索路径的直观感受**
cpp
(A) --1--> (B) --2--> (C) --3--> (D) <-- 到达死胡同,开始回溯
| ^ ^
| | '--5-- 回溯到C
| '--6-- 回溯到B
'--4--> (E) ...
1, 2, 3是深入探索的路径,4是回溯后尝试的新路径。
DFS的核心机制------如何实现"回溯"?
BFS的核心是队列,因为它要保存"先来的先处理"。那么DFS呢?我们来分析一下"回溯"这个行为。
当你从 A -> B -> C
,最后在 C
碰壁时,你需要返回到 B
。当 B
的所有路都探索完后,你需要返回到 A
。
这个返回的顺序是 C -> B -> A
。而你前进的顺序是 A -> B -> C
。
我们发现,这个路径记录有一个鲜明的特点:
最后记录的路径点,最先被拿出来用于回溯 (Last-In, First-Out)。这不就是 栈 (Stack) 的定义吗!
所以,栈就是实现深度优先搜索"深入"和"回溯"特性的核心数据结构。
💡一个更优雅的实现:递归 (Recursion)
虽然我们可以手动维护一个栈来实现DFS,但在编程中,有一个更简洁、更强大的工具天然就是用栈实现的------那就是函数调用。
当你调用一个函数 Func(A)
,它内部又调用 Func(B)
,Func(B)
内部又调用 Func(C)
时,计算机内部的"调用栈 (Call Stack)"会帮你记录下这个调用链。
-
Func(C)
执行完后,会自动返回到Func(B)
的断点处。 -
Func(B)
执行完后,会自动返回到Func(A)
的断点处。
这不就完美地模拟了我们想要的"回溯"行为吗?因此,递归是实现DFS最自然、最优雅的方式。我们可以把系统自带的函数调用栈当作我们的"回溯"工具。
DFS算法流程图解(递归版)
我们还是用上一节的图,这样你就可以清晰地对比BFS和DFS的差异。
cpp
(A) --------- (B)
| /
| /
| /
(D) (C) --------- (E)
| /
| /
'--------- (F)
我们同样用邻接表来表示它,并从顶点 A
开始遍历。
准备工作:
- visited数组:
[A:F, B:F, C:F, D:F, E:F, F:F]
(F代表False)
第1步: DFS(A)
-
主程序调用
DFS(A)
。 -
访问
A
,标记visited[A] = T
。 -
查看
A
的邻居:B
和D
。 -
选择第一个邻居
B
。B
未被访问。 -
递归调用
DFS(B)
。DFS(A)
在这里暂停,等待DFS(B)
返回。
状态:
-
访问顺序:
A
-
visited:
[A:T, B:F, ...]
-
调用栈:
[ main, DFS(A) ]
第2步: DFS(B)
-
DFS(B)
开始执行。 -
访问
B
,标记visited[B] = T
。 -
查看
B
的邻居:A
和C
。A
已被访问,跳过。 -
选择下一个邻居
C
。C
未被访问。 -
递归调用
DFS(C)
。DFS(B)
在此暂停。
状态:
-
访问顺序:
A, B
-
visited:
[A:T, B:T, C:F, ...]
-
调用栈:
[ main, DFS(A), DFS(B) ]
第3步: DFS(C)
-
DFS(C)
开始执行。 -
访问
C
,标记visited[C] = T
。 -
查看
C
的邻居:B
,E
,F
。B
已被访问,跳过。 -
选择下一个邻居
E
。E
未被访问。 -
递归调用
DFS(E)
。DFS(C)
在此暂停。
状态:
-
访问顺序:
A, B, C
-
visited:
[A:T, B:T, C:T, E:F, ...]
-
调用栈:
[ main, DFS(A), DFS(B), DFS(C) ]
第4步: DFS(E)
-
DFS(E)
开始执行。 -
访问
E
,标记visited[E] = T
。 -
查看
E
的邻居:C
,F
。C
已被访问。 -
选择下一个邻居
F
。F
未被访问。 -
递归调用
DFS(F)
。DFS(E)
在此暂停。
状态:
-
访问顺序:
A, B, C, E
-
visited:
[..., E:T, F:F]
-
调用栈:
[..., DFS(C), DFS(E) ]
第5步: DFS(F)
与回溯
-
DFS(F)
开始执行。 -
访问
F
,标记visited[F] = T
。 -
查看
F
的邻居:C
,E
。都已被访问。 -
DFS(F)
没有可递归的调用了,执行完毕**,**返回。 -
程序回到
DFS(E)
的暂停点。
状态:
-
访问顺序:
A, B, C, E, F
-
visited:
[..., E:T, F:T]
-
调用栈:
[..., DFS(C), DFS(E) ]
->[..., DFS(C) ]
(DFS(F)返回, DFS(E)恢复)
第6步: 继续回溯
-
DFS(E)
恢复执行。它已经检查完所有邻居,执行完毕,返回。 -
程序回到
DFS(C)
的暂停点。C
检查过E
,现在检查下一个邻居F
。F
已被访问。 -
DFS(C)
检查完所有邻居,执行完毕,返回。 -
程序回到
DFS(B)
的暂停点。B
检查完所有邻居,执行完毕,返回。 -
程序回到
DFS(A)
的暂停点。
状态:
- 调用栈:
[ main, DFS(A) ]
(一层层返回)
第7步: 探索新分支
-
DFS(A)
恢复执行。它上次探索了邻居B
。现在看下一个邻居D
。 -
D
未被访问。 -
递归调用
DFS(D)
。
状态:
-
访问顺序:
A, B, C, E, F, D
-
visited:
[..., D:T, ...]
-
调用栈:
[ main, DFS(A), DFS(D) ]
DFS(D)
检查其邻居 A
,已被访问。DFS(D)
返回。DFS(A)
检查完所有邻居,返回 main
。 所有顶点均被访问,遍历结束。
最终访问顺序: A -> B -> C -> E -> F -> D
。
对比BFS的 A -> B -> D -> C -> E -> F
,你会发现DFS的路径明显更"深"。
C/C++代码实现
我们将使用上一节的邻接表结构。
第一步: 核心递归函数 DFS
的框架
这个函数负责处理从单个顶点 v
开始的深度优先搜索。它需要知道整个图 g
,当前顶点 v
,以及一个全局的 visited
数组来共享访问状态。
cpp
// 假设 MAX_VERTICES 和 AdjListGraph 结构体已经定义
#include <stdio.h>
// v: 当前开始遍历的顶点
// visited: 访问标记数组
void DFS(AdjListGraph *g, int v, int visited[]) {
// 核心逻辑将在这里实现
}
第二步: 访问当前节点并探索邻居
DFS的第一件事就是访问当前节点,并标记它。然后,它需要遍历当前节点的所有邻居。
cpp
void DFS(AdjListGraph *g, int v, int visited[]) {
// 1. 访问并标记当前顶点
printf("访问顶点: %d\n", v);
visited[v] = 1; // 1 表示已访问
// 2. 遍历邻接链表,准备对邻居进行递归
EdgeNode *p = g->adj_list[v].first_edge;
while (p != NULL) {
int w = p->neighbor_index; // 获取邻居顶点w
// ... 对w进行处理 ...
p = p->next;
}
}
第三步: 加入递归调用
这是最关键的一步。在遍历邻居时,如果发现邻居 w
没有被访问过,就对它发起递归调用,深入探索。
cpp
void DFS(AdjListGraph *g, int v, int visited[]) {
printf("访问顶点: %d\n", v);
visited[v] = 1;
EdgeNode *p = g->adj_list[v].first_edge;
while (p != NULL) {
int w = p->neighbor_index;
// 核心:如果邻居w未被访问,就从w开始继续深度优先搜索
if (!visited[w]) {
DFS(g, w, visited);
}
p = p->next;
}
}
这个递归函数 DFS
已经完整了,非常简洁,因为它巧妙地利用了函数调用栈。
第四步: 处理非连通图的封装函数
DFSTraverse
和BFS一样,如果图不连通,我们需要一个外部循环来确保每个连通分量都被遍历到。
cpp
void DFSTraverse(AdjListGraph *g) {
// 1. 初始化访问标记数组
int visited[MAX_VERTICES];
for (int i = 0; i < g->num_vertices; i++) {
visited[i] = 0; // 0 表示未访问
}
// 2. 遍历所有顶点
for (int i = 0; i < g->num_vertices; i++) {
// 如果顶点i还没有在之前的搜索中被访问过
// 说明它属于一个新的连通分量,从它开始新的DFS
if (!visited[i]) {
DFS(g, i, visited);
}
}
}
这个 DFSTraverse
就是我们提供给用户调用的最终接口。
DFS的应用
DFS"不撞南墙不回头"的特性,使得它特别适合解决与 "路径"、"连通性"和"依赖关系" 相关的问题。
-
寻找路径 : DFS可以非常方便地找到从A到B的 一条 路径(但不一定是最短的)。它探索的过程本身就在构建一条路径。
-
检测环 (Cycle Detection): 在有向图中,如果在DFS的探索路径上(即在当前的递归调用栈中)遇到了一个已经访问过的顶点,那就说明发现了一个环。这是判断程序或任务是否存在循环依赖的关键算法。
-
拓扑排序 (Topological Sort): 对于一个有向无环图(比如课程的先修关系、项目的任务依赖),DFS可以输出一个线性的序列,保证所有的依赖关系都得到满足。
-
寻找连通分量 (Finding Connected Components) : 我们上面写的
DFSTraverse
实际上就在做这件事。每当外层循环启动一次新的DFS
调用,就意味着发现了一个新的连通分量。
DFS和BFS是图论算法的左膀右臂,掌握了它们,你就真正地入门了图的世界。