首页/数据结构/05-graph/深度优先遍历(DFS) 🔗 在 Obsidian 中打开
数据结构 · 05-graph

深度优先遍历(DFS, Depth-First Search)

难度 ★★★重要度 ★★ 考查频率 低题型 选择 / 综合应用 数据结构/图深度优先图遍历递归408
速查
DFS 借助栈(递归调用栈)尽可能深地探索分支,走到死胡同再回溯。邻接矩阵实现为 O(|V|²),邻接表实现为 O(|V|+|E|),空间 O(|V|)

核心概念

深度优先搜索(DFS)是一种用于遍历或搜索图/树的算法。其核心思想是:

尽可能深地搜索图的分支,当某个分支搜索完毕后,回溯到上一个分叉点,继续搜索其他未访问的分支。

直观理解

  • DFS 就像走迷宫:一直往前走,遇到死胡同就退回到上一个分叉口,换一条路继续走。
  • 使用(或递归调用栈)来记住回溯的位置。
  • 连通图只需调用一次 DFS;对非连通图需要对每个连通分量调用一次。

与 BFS 的对比

特性DFSBFS
数据结构栈(递归栈或显式栈)队列
搜索策略尽可能深尽可能广
空间复杂度$O(h)$ 或 $O(n)$$O(n)$
适用场景连通性、拓扑排序、回溯最短路径(无权图)

存储结构的影响

邻接矩阵实现 DFS:

  • 查找某顶点的邻接点需要 $O(|V|)$ 时间
  • 总时间复杂度 $O(|V|^2)$

邻接表实现 DFS:

  • 查找某顶点的邻接点需要 $O(\deg(v))$ 时间
  • 总时间复杂度 $O(|V| + |E|)$
为什么差这么多邻接矩阵每次找邻接点都要扫一整行($|V|$ 个格子,多数是 0);邻接表只走真实存在的边,稀疏图上优势巨大。

算法步骤

邻接矩阵版 DFS

设图 G 的顶点集为 {v₁, v₂, ..., vₙ}:

1. 初始化 visited[] 数组,全部为 false
2. 选择起始顶点 vᵢ,执行 DFS_Visit(G, vᵢ)
3. 若还有未访问的顶点,选择下一个未访问顶点继续 DFS

DFS_Visit(G, v):
1. 访问 v,visited[v] = true
2. 对 v 的每个邻接点 w(按编号从小到大):
   2.1 若 w 未被访问(visited[w] == false):
       递归调用 DFS_Visit(G, w)

邻接表版 DFS

与邻接矩阵版类似,区别在于:
- 邻接矩阵:遍历 adjmatrix[v][0..n-1] 查找邻接点
- 邻接表:遍历 v 的邻接链表查找邻接点
- 邻接表中邻接点的顺序取决于建表时的插入顺序

DFS 序

DFS 过程中访问结点的顺序称为 DFS 序。同一个图,从不同起点开始或邻接点遍历顺序不同时,DFS 序可能不同。

DFS 生成树 / 生成森林

  • 连通图执行 DFS 会得到一棵 DFS 生成树
  • 非连通图执行 DFS 会得到 DFS 生成森林
  • DFS 过程中,通过递归调用访问到的边组成树边(Tree Edge)。
  • 其他边为回边(Back Edge)。

代码实现(C 语言)

邻接矩阵版

#define MaxVertexNum 100

typedef struct {
    char vex[MaxVertexNum];                    // 顶点表
    int edge[MaxVertexNum][MaxVertexNum];      // 邻接矩阵
    int vexnum, arcnum;                        // 顶点数、边数
} MGraph;

bool visited[MaxVertexNum];                    // 访问标记数组

// 从顶点 v 出发,对图 G 进行 DFS
void DFS(MGraph G, int v) {
    visit(v);                   // 访问顶点 v
    visited[v] = true;          // 标记已访问
    for (int w = 0; w < G.vexnum; w++) {    // 遍历所有可能的邻接点
        if (G.edge[v][w] != 0 && !visited[w]) {  // 有边且未访问
            DFS(G, w);          // 递归访问
        }
    }
}

// 对整个图进行 DFS(处理非连通图)
void DFSTraverse(MGraph G) {
    for (int i = 0; i < G.vexnum; i++)
        visited[i] = false;     // 初始化
    for (int i = 0; i < G.vexnum; i++) {
        if (!visited[i])        // 从每个未访问的顶点开始
            DFS(G, i);
    }
}

邻接表版

// 邻接表结点定义
typedef struct ArcNode {
    int adjvex;                 // 该弧所指向的顶点位置
    struct ArcNode *nextarc;    // 指向下一条弧的指针
} ArcNode;

typedef struct VNode {
    char data;                  // 顶点信息
    ArcNode *firstarc;          // 第一条弧
} VNode, AdjList[MaxVertexNum];

typedef struct {
    AdjList vertices;           // 邻接表
    int vexnum, arcnum;
} ALGraph;

// 邻接表版 DFS
void DFS_AL(ALGraph G, int v) {
    visit(v);
    visited[v] = true;
    ArcNode *p = G.vertices[v].firstarc;
    while (p != NULL) {
        if (!visited[p->adjvex])
            DFS_AL(G, p->adjvex);
        p = p->nextarc;
    }
}

非递归版(显式栈)

// 用显式栈实现 DFS(邻接矩阵)
void DFS_NonRecursive(MGraph G, int v) {
    int stack[MaxVertexNum], top = -1;
    for (int i = 0; i < G.vexnum; i++)
        visited[i] = false;

    stack[++top] = v;
    visited[v] = true;
    visit(v);

    while (top != -1) {
        int cur = stack[top];
        bool found = false;
        for (int w = 0; w < G.vexnum; w++) {
            if (G.edge[cur][w] != 0 && !visited[w]) {
                stack[++top] = w;
                visited[w] = true;
                visit(w);
                found = true;
                break;           // 找到一个就深入,模拟递归
            }
        }
        if (!found)
            top--;              // 回溯
    }
}

手算示例

给定无向图 G,顶点集 $V = \{0, 1, 2, 3, 4\}$,边集 $E = \{(0,1), (0,2), (1,3), (1,4), (2,3)\}$。

邻接矩阵:

    0  1  2  3  4
0 [ 0, 1, 1, 0, 0 ]
1 [ 1, 0, 0, 1, 1 ]
2 [ 1, 0, 0, 1, 0 ]
3 [ 0, 1, 1, 0, 0 ]
4 [ 0, 1, 0, 0, 0 ]

从顶点 0 开始 DFS(按编号从小到大访问邻接点):

步骤 1: 访问 0,visited = {0}
步骤 2: 0 的邻接点 {1, 2},选最小的 1
步骤 3: 访问 1,visited = {0, 1}
步骤 4: 1 的邻接点 {0, 3, 4},0 已访问,选 3
步骤 5: 访问 3,visited = {0, 1, 3}
步骤 6: 3 的邻接点 {1, 2},1 已访问,选 2
步骤 7: 访问 2,visited = {0, 1, 3, 2}
步骤 8: 2 的邻接点 {0, 3},都已访问,回溯
步骤 9: 回溯到 3,回溯到 1
步骤 10: 1 的下一个邻接点 4
步骤 11: 访问 4,visited = {0, 1, 3, 2, 4}
步骤 12: 4 的邻接点 {1},已访问,回溯
步骤 13: 回溯到 1,回溯到 0,所有顶点已访问

DFS 序:0 → 1 → 3 → 2 → 4
DFS 生成树的边:(0,1), (1,3), (3,2), (1,4)
对照动画下方「交互动画」演示的正是这张图,可逐步查看栈内容与 DFS 生成树的形成过程。

时间 / 空间复杂度

存储结构时间复杂度说明
邻接矩阵$O(|V|^2)$每个顶点查找所有邻接点 $O(|V|)$
邻接表$O(|V| + |E|)$遍历所有顶点 + 所有边

空间复杂度

  • visited 数组:$O(|V|)$
  • 递归栈深度:最坏 $O(|V|)$(图退化为链),最好 $O(\log |V|)$
  • 总空间:$O(|V|)$

常见考法

高频设问
  1. 给定图,写出从某顶点出发的 DFS 序(最常考)
  2. 画出 DFS 生成树
  3. DFS 与 BFS 序对比
  4. 邻接矩阵 vs 邻接表的 DFS 时间复杂度
  5. 非连通图的 DFS:需要多次调用 DFS
  6. DFS 的应用:连通性判断、拓扑排序、关键路径、迷宫求解
  7. 递归版 vs 非递归版的区别

易错点

必记
  1. 非连通图必须多次调用 DFSDFSTraverse 中对每个未访问顶点调用 DFS,否则会遗漏非连通分量。
  2. 邻接表的 DFS 序与插入顺序有关:邻接矩阵的邻接点顺序由编号决定(确定性),邻接表的顺序由建表时的插入顺序决定。
  3. visited 数组必须初始化为 false:忘初始化会导致遍历不完整。
  4. 递归栈溢出:对于结点数极大的图,递归版 DFS 可能栈溢出,此时需要使用非递归版(显式栈)。
  5. DFS 序不唯一:同一个图从不同起点、不同邻接点访问顺序,DFS 序都可能不同;但 DFS 生成树的结构在给定规则下是确定的。
  6. DFS 生成树中,树边数量 = $|V| - 1$(连通图),非树边为回边。

核心结论

必背
  1. DFS 使用栈(递归栈),优先深入探索,遇到死胡同再回溯。
  2. 邻接矩阵 DFS 时间 $O(|V|^2)$,邻接表 DFS 时间 $O(|V| + |E|)$。
  3. 连通图一次 DFS 即可遍历所有顶点;非连通图需对每个连通分量调用 DFS。
  4. DFS 生成树的边 = 递归过程中新访问的边。
  5. DFS 适合解决连通性、回溯搜索、拓扑排序等问题。
  6. DFS 序不唯一,但同一规则下 DFS 生成树唯一。

记忆卡片

DFS 的核心数据结构是什么?为什么?
栈(或递归调用栈)。走到死胡同需回溯到最近的分叉点,栈的 LIFO 特性完美匹配——最后压入的分叉点最先弹出。
对非连通图 DFS,为什么外层需要循环?
一次 DFS 只能访问起始顶点所在的连通分量,需对每个未访问顶点各调用一次才能遍历全图。
邻接矩阵与邻接表 DFS 的时间复杂度?
矩阵 $O(|V|^2)$(每顶点扫一行);表 $O(|V|+|E|)$(只走真实存在的边)。稀疏图上邻接表更优。
DFS 序一定唯一吗?
不一定。取决于起点与邻接点遍历顺序;但起点与顺序固定后 DFS 序唯一。
DFS 和递归有什么关系?
递归调用 = "深入",函数返回 = "回溯",递归栈就是 DFS 的隐式栈;递归版与显式栈版本质相同。
连通图 DFS 生成树有多少条边?
$|V| - 1$ 条树边,其余边为回边。

交互动画 · DFS 遍历过程

0 1 2 3 4 橙点 = 已访问 绿圈 = 当前栈顶
点击「播放」或「下一步」开始演示 DFS
栈:[ ] DFS 序:—
示意图:无向图 $V=\{0,1,2,3,4\}$,从 0 出发按编号从小到大访问邻接点,DFS 序为 0 → 1 → 3 → 2 → 4;橙色流动虚线即 DFS 生成树的树边。

相关知识点

breadth-first-search-bfs graph-traversal-and-applications graph-traversal

↑ 以上为站内 HTML 相对链接(纯网页可浏览);本页右上「在 Obsidian 中打开」跳回源笔记。