数据结构:深度优先搜索 (Depth-First Search, DFS)

目录

DFS的诞生------"不撞南墙不回头"

DFS的核心机制------如何实现"回溯"?

DFS算法流程图解(递归版)

C/C++代码实现

DFS的应用


上一节我们学习了广度优先搜索 (BFS),它像水面的波纹一样,一层一层地向外探索。今天,我们要学习它的"兄弟"算法------深度优先搜索 (Depth-First Search, DFS),它体现了完全不同的探索哲学。

DFS的诞生------"不撞南墙不回头"

再次回到那个巨大的迷宫入口。上次,我们的策略是"由近及远"。但还有一种同样符合直觉的策略,那就是"一条路走到黑"。

  1. 你面前有多条路,随便选 一条 走下去。

  2. 在下一个路口,你再随便选一条路,继续 深入

  3. 你就这样一直走,直到前面是死胡同(没有新的路可走),或者回到了之前走过的路口。

  4. 这时,你怎么办?你会 原路返回 到上一个路口,看看那个路口还有没有 其他没走过的路

  5. 如果有,就选那条新路继续深入。如果也没有,就再退一步...

这种"勇往直前,走不通再退回来换条路"的探索策略,就是 深度优先搜索 (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)

  1. 主程序调用 DFS(A)

  2. 访问 A,标记 visited[A] = T

  3. 查看 A 的邻居:BD

  4. 选择第一个邻居 BB 未被访问。

  5. 递归调用 DFS(B)DFS(A) 在这里暂停,等待 DFS(B) 返回。

状态:

  • 访问顺序: A

  • visited: [A:T, B:F, ...]

  • 调用栈: [ main, DFS(A) ]


第2步: DFS(B)

  1. DFS(B) 开始执行。

  2. 访问 B,标记 visited[B] = T

  3. 查看 B 的邻居:ACA 已被访问,跳过。

  4. 选择下一个邻居 CC 未被访问。

  5. 递归调用 DFS(C)DFS(B) 在此暂停。

状态:

  • 访问顺序: A, B

  • visited: [A:T, B:T, C:F, ...]

  • 调用栈: [ main, DFS(A), DFS(B) ]


第3步: DFS(C)

  1. DFS(C) 开始执行。

  2. 访问 C,标记 visited[C] = T

  3. 查看 C 的邻居:B, E, FB 已被访问,跳过。

  4. 选择下一个邻居 EE 未被访问。

  5. 递归调用 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)

  1. DFS(E) 开始执行。

  2. 访问 E,标记 visited[E] = T

  3. 查看 E 的邻居:C, FC 已被访问。

  4. 选择下一个邻居 FF 未被访问。

  5. 递归调用 DFS(F)DFS(E) 在此暂停。

状态:

  • 访问顺序: A, B, C, E

  • visited: [..., E:T, F:F]

  • 调用栈: [..., DFS(C), DFS(E) ]


第5步: DFS(F) 与回溯

  1. DFS(F) 开始执行。

  2. 访问 F,标记 visited[F] = T

  3. 查看 F 的邻居:C, E。都已被访问。

  4. DFS(F) 没有可递归的调用了,执行完毕**,**返回。

  5. 程序回到 DFS(E) 的暂停点。

状态:

  • 访问顺序: A, B, C, E, F

  • visited: [..., E:T, F:T]

  • 调用栈: [..., DFS(C), DFS(E) ] -> [..., DFS(C) ] (DFS(F)返回, DFS(E)恢复)


第6步: 继续回溯

  1. DFS(E) 恢复执行。它已经检查完所有邻居,执行完毕,返回

  2. 程序回到 DFS(C) 的暂停点。C 检查过 E,现在检查下一个邻居 FF 已被访问。

  3. DFS(C) 检查完所有邻居,执行完毕,返回

  4. 程序回到 DFS(B) 的暂停点。B 检查完所有邻居,执行完毕,返回

  5. 程序回到 DFS(A) 的暂停点。

状态:

  • 调用栈: [ main, DFS(A) ] (一层层返回)

第7步: 探索新分支

  1. DFS(A) 恢复执行。它上次探索了邻居 B。现在看下一个邻居 D

  2. D 未被访问。

  3. 递归调用 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是图论算法的左膀右臂,掌握了它们,你就真正地入门了图的世界。

相关推荐
C语言小火车7 小时前
【C++八股文】数据结构篇
数据结构·数据库·c++·c++八股文
Boop_wu8 小时前
[数据结构] 链表
数据结构·链表
闪电麦坤958 小时前
数据结构:图的表示 (Representation of Graphs)
数据结构·算法·图论
胡萝卜3.09 小时前
【LeetCode&数据结构】设计循环队列
数据结构·算法·leetcode·队列·循环队列
闻缺陷则喜何志丹9 小时前
【线段树 懒删除堆】P12372 [蓝桥杯 2022 省 Python B] 最优清零方案|普及+
数据结构·c++·线段树·懒删除堆
闻缺陷则喜何志丹9 小时前
【 线段树】P12347 [蓝桥杯 2025 省 A 第二场] 栈与乘积|普及+
数据结构·c++·蓝桥杯·线段树·洛谷
徐归阳9 小时前
数组本身的深入解析
数据结构·c++·算法
睡不醒的kun10 小时前
leetcode算法刷题的第二十六天
数据结构·c++·算法·leetcode·职场和发展·贪心算法
fangzelin511 小时前
基础排序--冒泡--选择--插入
数据结构·c++·算法