DFS
深度优先遍历(Depth First Search),也有称为深度优先搜索,简称为 DFS。事实上,我们在树的遍历中早已涉及 DFS------前序遍历、中序遍历和后序遍历都属于深度优先遍历的方式,因为这些遍历方式本质上都归结于栈。
DFS 过程
- 在图中任选一个顶点 p 作为起点,遍历它并打上标记。
- 选择一个 p 的没有被遍历过的邻接点,遍历并访问。如果访问到一个顶点的邻接点全部都被遍历过了,就退回到上一个访问的节点。
- 如果退回到起点后还有顶点没有被访问,就再任选一个没有被访问过的顶点重复操作 2。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 以邻接矩阵存图为例
// 有 n 个点 m 条边的无权无向图
int n, m;
char v[105]; // 顶点数组
int g[105][105]; // 邻接矩阵,g[i][j] = g[j][i] = 1 表示有边
int flag[105]; // 标记某点有没有被走过
int Find(char x) {
int i = 0;
for (i = 1; i <= n; i++) {
if (v[i] == x) {
break;
}
}
return i;
}
// DFS
void DFS(int i) {
// 对 i 点进行深度优先遍历
printf("%c ", v[i]);
flag[i] = 1; // 打标记
for (int j = 1; j <= n; j++) {
// 找 i 的未被访问过的邻接点
if (g[i][j] == 1 && flag[j] == 0) {
DFS(j);
}
}
return;
}
int main() {
scanf("%d %d", &n, &m);
getchar();
for (int i = 1; i <= n; i++) {
scanf("%c", &v[i]);
}
char x, y;
int xi, yi;
for (int i = 1; i <= m; i++) {
getchar();
scanf("%c %c", &x, &y);
xi = Find(x);
yi = Find(y);
g[xi][yi] = g[yi][xi] = 1;
}
for (int i = 1; i <= n; i++) {
// 考虑到非连通图
if (flag[i] == 0) {
DFS(i);
}
}
return 0;
}
/*
输入样例:
9 16
ABCDEFGHI
A B
A F
B G
G F
B C
B I
C I
C D
I D
D G
D H
D E
G H
H E
E F
F G
*/
DFS 函数解析
c
void DFS(int i) {
printf("%c ", v[i]); // (1) 访问当前顶点
flag[i] = 1; // (2) 标记已访问
for (int j = 1; j <= n; j++) { // (3) 扫描所有顶点
if (g[i][j] == 1 && flag[j] == 0) { // (4) 找到未访问的邻接点
DFS(j); // (5) 递归深入
}
}
return; // (6) 回溯
}
逐行解读
| 行 | 作用 |
|---|---|
(1) |
访问顶点 i:输出当前顶点的值,表示"已到达"。 |
(2) |
标记已访问 :将 flag[i] 置 1,防止后续重复访问(避免死循环)。 |
(3) |
扫描邻接点:遍历 j = 1..n,逐一检查 i 是否与 j 相连。这是邻接矩阵存图的特点------即使 i 只有少数几个邻接点,也要遍历全部 n 个顶点。 |
(4) |
筛选条件 :g[i][j] == 1 表示 i 与 j 之间有边,flag[j] == 0 表示 j 尚未被访问过。只有同时满足这两个条件,j 才是"下一个要去的顶点"。 |
(5) |
递归深入 :对 j 调用 DFS(j),从 j 继续向下探索,不再理会 i 的其他邻接点。这正是"深度优先"的含义------一条路走到黑。 |
(6) |
回溯:当 for 循环结束(即 i 的所有邻接点都已访问或不存在),函数返回上一层调用,上一层的 DFS 会继续它的 for 循环,尝试下一个邻接点。 |
执行流程示例
以输入样例的图为例,从顶点 A 出发(DFS(1)):
scss
DFS(A)
├─ 访问 A,标记 A
├─ 扫描邻接点:找到 B(未访问)
│ └─ DFS(B)
│ ├─ 访问 B,标记 B
│ ├─ 扫描邻接点:找到 G(未访问)
│ │ └─ DFS(G)
│ │ ├─ 访问 G,标记 G
│ │ ├─ 扫描邻接点:找到 F(未访问)
│ │ │ └─ DFS(F)
│ │ │ ├─ 访问 F,标记 F
│ │ │ ├─ 扫描邻接点:全部已访问 → 回溯
│ │ │ └─ return
│ │ ├─ 继续扫描:找到 H(未访问)
│ │ │ └─ DFS(H)
│ │ │ ├─ 访问 H,标记 H
│ │ │ ├─ 扫描邻接点:找到 E(未访问)
│ │ │ │ └─ DFS(E)
│ │ │ │ ├─ 访问 E,标记 E
│ │ │ │ ├─ 扫描邻接点:全部已访问 → 回溯
│ │ │ │ └─ return
│ │ │ ├─ 继续扫描:全部已访问 → 回溯
│ │ │ └─ return
│ │ ├─ 继续扫描:全部已访问 → 回溯
│ │ └─ return
│ ├─ 继续扫描:找到 C(未访问)
│ │ └─ DFS(C) → ...
│ └─ ...
└─ ...
最终输出顺序为:A B G F H E C I D。
核心机制总结
- 递归实现栈 :函数调用的嵌套关系天然构成一个栈,调用链有多深,栈就有多高。每次
DFS(j)将新顶点入栈,return时出栈(回溯)。 - 标记防止兜圈 :无向图中若不加标记,A → B → A 会无限循环。
flag数组保证了每个顶点有且仅有一次被访问。 - 回溯即"回头":当某个顶点的所有邻接点都已访问完,for 循环自然结束,函数返回------这正是 DFS 过程中"退回到上一个访问的节点"的实现方式。
- 非连通图处理 :
main函数最后的循环逐个检查flag,确保每个连通分量都被遍历到。
时间复杂度分析
1. Find 函数
c
int Find(char x) {
int i = 0;
for (i = 1; i <= n; i++) {
if (v[i] == x) break;
}
return i;
}
- 每次调用遍历顶点数组
v[1..n],最坏情况下需扫描全部 n 个顶点。 - 时间复杂度:O(n)。
2. DFS 函数
c
void DFS(int i) {
printf("%c ", v[i]);
flag[i] = 1;
for (int j = 1; j <= n; j++) {
if (g[i][j] == 1 && flag[j] == 0) {
DFS(j);
}
}
}
-
每次调用
DFS(i)都要执行一个 n 次的循环,检查顶点 i 与所有其他顶点之间是否有边。 -
对于连通图,每个顶点恰好 被调用一次
DFS(因为一旦访问就会被标记,不会重复进入)。 -
因此所有
DFS调用的循环总次数为: \\sum_{i=1}\^{n} n = n\^2 -
时间复杂度:O(n²)。
3. main 函数整体
| 步骤 | 操作 | 复杂度 |
|---|---|---|
| 读入 n, m 和顶点数组 | 循环 n 次读入 v[i] |
O(n) |
| 读入 m 条边 | 每读一条边调用 2 次 Find,每次 O(n) |
O(m · n) |
| 遍历并调用 DFS | 最坏情况每个顶点出发一次 DFS,总计 O(n²) | O(n²) |
总时间复杂度:O(n² + n·m)。
- 当图较稠密(m 接近 n²)时,
Find的 O(n·m) 部分可能成为瓶颈 --- 甚至可以高达 O(n³); - 当图较稀疏(m 远小于 n²)时,主导项是 DFS 本身的 O(n²)。
4. 优化方向
上述复杂度分析基于当前邻接矩阵 + 线性查找顶点编号的实现。实际应用中可做如下优化:
| 优化方式 | 改进效果 |
|---|---|
| 用哈希表 或数组下标直接映射顶点编号 | Find 从 O(n) 降至 O(1),总复杂度变为 O(n² + m) |
| 改用邻接表存图 | DFS 只遍历实际存在的边,从 O(n²) 降至 O(n + m) |
采用上述两种优化后,DFS 的最优时间复杂度为 O(n + m),这也是图遍历的理论下界(每个顶点和每条边至少访问一次)。
空间复杂度
- 邻接矩阵
g[n+1][n+1]:O(n²) - 标记数组
flag[n+1]:O(n) - 顶点数组
v[n+1]:O(n) - 递归调用栈深度:最坏情况下 O(n)(图退化为一条链)
- 总空间复杂度:O(n²)
若改用邻接表,空间复杂度可降至 O(n + m)。