【数据结构与算法-Day 31】图的遍历:深度优先搜索 (DFS) 详解,一条路走到黑的智慧
Langchain系列文章目录
01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘
08-【万字长文】MCP深度解析:打通AI与世界的“USB-C”,模型上下文协议原理、实践与未来
Python系列文章目录
PyTorch系列文章目录
机器学习系列文章目录
深度学习系列文章目录
Java系列文章目录
JavaScript系列文章目录
Python系列文章目录
Go语言系列文章目录
Docker系列文章目录
数据结构与算法系列文章目录
01-【数据结构与算法-Day 1】程序世界的基石:到底什么是数据结构与算法?
02-【数据结构与算法-Day 2】衡量代码的标尺:时间复杂度与大O表示法入门
03-【数据结构与算法-Day 3】揭秘算法效率的真相:全面解析O(n^2), O(2^n)及最好/最坏/平均复杂度
04-【数据结构与算法-Day 4】从O(1)到O(n²),全面掌握空间复杂度分析
05-【数据结构与算法-Day 5】实战演练:轻松看懂代码的时间与空间复杂度
06-【数据结构与算法-Day 6】最朴素的容器 - 数组(Array)深度解析
07-【数据结构与算法-Day 7】告别数组束缚,初识灵活的链表 (Linked List)
08-【数据结构与算法-Day 8】手把手带你拿捏单向链表:增、删、改核心操作详解
09-【数据结构与算法-Day 9】图解单向链表:从基础遍历到面试必考的链表反转
10-【数据结构与算法-Day 10】双向奔赴:深入解析双向链表(含图解与代码)
11-【数据结构与算法-Day 11】从循环链表到约瑟夫环,一文搞定链表的终极形态
12-【数据结构与算法-Day 12】深入浅出栈:从“后进先出”原理到数组与链表双实现
13-【数据结构与算法-Day 13】栈的应用:从括号匹配到逆波兰表达式求值,面试高频考点全解析
14-【数据结构与算法-Day 14】先进先出的公平:深入解析队列(Queue)的核心原理与数组实现
15-【数据结构与算法-Day 15】告别“假溢出”:深入解析循环队列与双端队列
16-【数据结构与算法-Day 16】队列的应用:广度优先搜索(BFS)的基石与迷宫寻路实战
17-【数据结构与算法-Day 17】揭秘哈希表:O(1)查找速度背后的魔法
18-【数据结构与算法-Day 18】面试必考!一文彻底搞懂哈希冲突四大解决方案:开放寻址、拉链法、再哈希
19-【数据结构与算法-Day 19】告别线性世界,一文掌握树(Tree)的核心概念与表示法
20-【数据结构与算法-Day 20】从零到一掌握二叉树:定义、性质、特殊形态与存储结构全解析
21-【数据结构与算法-Day 21】精通二叉树遍历(上):前序、中序、后序的递归与迭代实现
22-【数据结构与算法-Day 22】玩转二叉树遍历(下):广度优先搜索(BFS)与层序遍历的奥秘
23-【数据结构与算法-Day 23】为搜索而生:一文彻底搞懂二叉搜索树 (BST) 的奥秘
24-【数据结构与算法-Day 24】平衡的艺术:图解AVL树,彻底告别“瘸腿”二叉搜索树
25-【数据结构与算法-Day 25】工程中的王者:深入解析红黑树 (Red-Black Tree)
26-【数据结构与算法-Day 26】堆:揭秘优先队列背后的“特殊”完全二叉树
27-【数据结构与算法-Day 27】堆的应用:从堆排序到 Top K 问题,一文彻底搞定!
28-【数据结构与算法-Day 28】字符串查找的终极利器:深入解析字典树 (Trie / 前缀树)
29-【数据结构与算法-Day 29】从社交网络到地图导航,一文带你入门终极数据结构:图
30-【数据结构与算法-Day 30】图的存储:邻接矩阵 vs 邻接表,哪种才是最优选?
31-【数据结构与算法-Day 31】图的遍历:深度优先搜索 (DFS) 详解,一条路走到黑的智慧
文章目录
- Langchain系列文章目录
- Python系列文章目录
- PyTorch系列文章目录
- 机器学习系列文章目录
- 深度学习系列文章目录
- Java系列文章目录
- JavaScript系列文章目录
- Python系列文章目录
- Go语言系列文章目录
- Docker系列文章目录
- 数据结构与算法系列文章目录
- 摘要
- 一、什么是图的遍历?
- 1.1 遍历的定义与目标
- 1.2 为何需要遍历
- 1.3 两大核心策略:DFS 与 BFS
- 二、深度优先搜索 (DFS) 的核心思想
- 2.1 “一条路走到黑”的策略
- 2.2 核心步骤拆解
- 2.3 借助“辅助工具”:递归与栈
- 三、DFS 的实现方式
- 3.1 准备工作:图的表示
- 3.2 递归实现 (Recursive Implementation)
- 3.2.1 算法流程
- 3.2.2 代码示例 (Java)
- 3.2.3 可视化图解
- 3.3 迭代实现 (Iterative Implementation using a Stack)
- 3.3.1 算法流程
- 3.3.2 代码示例 (Java)
- 3.3.3 递归 vs. 迭代
- 四、DFS 的应用场景
- 4.1 判断图的连通性
- 4.1.1 连通分量 (Connected Components)
- 4.1.2 实现思路
- 4.2 寻找环 (Cycle Detection)
- 4.2.1 无向图中的环检测
- 4.2.2 有向图中的环检测
- 五、复杂度分析
- 5.1 时间复杂度
- 5.2 空间复杂度
- 六、总结
摘要
在上一篇文章中,我们学习了图的两种主要存储方式:邻接矩阵和邻接表。现在,我们拥有了表示图的工具,是时候学习如何“走遍”整个图了。图的遍历是所有图算法的基石,它系统地访问图中的每一个顶点,确保不重不漏。本文将聚焦于两种核心遍历算法之一——深度优先搜索 (Depth-First Search, DFS)。我们将通过生动的类比、清晰的图解和详尽的代码实现,带你领略 DFS “一条路走到黑,再回头另寻他路”的独特智慧。本文内容涵盖 DFS 的核心思想、递归与迭代两种实现方式、复杂度分析,以及在判断连通性、检测环等经典问题中的应用,助你彻底掌握这一强大的图算法。
一、什么是图的遍历?
在我们深入 DFS 之前,首先需要明确“图的遍历”究竟是什么,以及为什么它如此重要。
1.1 遍历的定义与目标
图的遍历 (Graph Traversal) 是指从图中的某个指定顶点出发,按照某种搜索策略,系统地访问图中所有顶点,且每个顶点仅被访问一次的过程。
想象一下,你是一个城市规划师,拿到了一张包含所有路口(顶点)和道路(边)的城市地图。你的任务是巡视每一个路口,检查其交通状况。图的遍历就像你设计一条巡视路线,确保你走过每一个路口,并且不会重复检查同一个路口。
其核心目标有两个:
- 完整性:访问到所有可达的顶点。
- 唯一性:每个顶点只访问一次。
1.2 为何需要遍历
图的遍历是解决众多图相关问题的基础。许多复杂的图算法,其核心都内嵌了遍历的逻辑。例如:
- 路径查找:从 A 点到 B 点是否存在路径?
- 连通性分析:整个网络是否是连通的?它由几个相互隔离的部分(连通分量)组成?
- 拓扑排序:确定一系列有依赖关系的任务的执行顺序。
- 寻找关键路径:在项目管理中,找到影响总工期的最长路径。
1.3 两大核心策略:DFS 与 BFS
要遍历一张图,主要有两种经典的策略:
- 深度优先搜索 (DFS, Depth-First Search):它像一个执着的探险家,选择一条路就一直走到底,直到无路可走时才返回上一个路口,选择另一条路继续探索。本文的重点。
- 广度优先搜索 (BFS, Breadth-First Search):它像一个谨慎的勘探队,从起点出发,先把所有相邻的路口都探查一遍,然后再从这些相邻的路口出发,继续向外围逐层扩展。我们将在下一篇文章中详细介绍。
二、深度优先搜索 (DFS) 的核心思想
2.1 “一条路走到黑”的策略
DFS 的策略正如其名——“深度优先”。它的核心思想可以形象地比喻为“走迷宫”:
你站在迷宫的一个入口,面前有多条岔路。你随便选择一条,然后沿着这条路一直走下去。每到一个新的岔路口,你都继续选择一条新路前进。直到你走到一个死胡同(没有新的路可走),你才会原路返回到上一个岔路口,看看那里是否还有你没走过的路。如果有,就走那条路继续探索;如果所有路都走过了,就再返回到更上一层的岔路口。如此反复,直到你走遍了所有能从入口到达的地方。
这种“不撞南墙不回头”的探索方式,就是 DFS 的精髓。
2.2 核心步骤拆解
为了在算法中实现这种策略,并避免无限循环(尤其是在有环的图中),我们需要一个“备忘录”来记录哪些顶点已经访问过。通常,我们使用一个布尔数组 visited
来完成这个任务。
DFS 的基本步骤如下:
- 选择一个起始顶点
u
,并将其标记为“已访问”。 - 对
u
进行访问操作(例如,打印其值)。 - 在
u
的所有邻接点中,寻找一个尚未被访问过的顶点v
。 - 如果找到了这样的
v
,则从v
出发,递归地执行深度优先搜索。 - 如果
u
的所有邻接点都已被访问,则回溯到上一个顶点(在递归实现中,这一步是自动完成的函数返回)。
对于非连通图,我们需要一个主循环来确保所有顶点都被访问。主循环遍历所有顶点,如果发现一个顶点尚未被访问,就从它开始新一轮的 DFS。
2.3 借助“辅助工具”:递归与栈
DFS 的“回溯”特性与计算机科学中的一个基本概念完美契合——栈 (Stack)。
-
递归实现:函数调用的本质就是利用了系统内置的“调用栈”。当我们从顶点
u
递归调用dfs(v)
时,系统会将u
的当前状态(如返回地址、局部变量)压入调用栈。当dfs(v)
执行完毕(即v
的所有路径都探索完后),函数返回,u
的状态从栈中弹出,程序回到u
的上下文继续执行,从而自然地实现了回溯。 -
迭代实现:我们也可以不依赖系统,而是自己手动维护一个栈来模拟递归过程。这种方式可以避免在图深度过大时,递归调用导致的“栈溢出”错误。
三、DFS 的实现方式
下面,我们将使用邻接表作为图的存储结构,分别展示 DFS 的递归和迭代实现。
3.1 准备工作:图的表示
我们先定义一个简单的图类,使用邻接表来表示。这里以 Java 为例:
import java.util.LinkedList;
import java.util.List;class Graph {private int V; // 顶点的数量private List<List<Integer>> adj; // 邻接表public Graph(int v) {V = v;adj = new ArrayList<>(V);for (int i = 0; i < V; i++) {adj.add(new LinkedList<>());}}// 添加一条边public void addEdge(int u, int v) {adj.get(u).add(v);// 如果是无向图,还需要添加反向边// adj.get(v).add(u); }// ... getter 方法省略 ...
}
3.2 递归实现 (Recursive Implementation)
递归是实现 DFS 最自然、最简洁的方式。
3.2.1 算法流程
- 创建一个
boolean[] visited
数组,初始化为false
。 - 编写一个主遍历函数
dfs()
, 它遍历图中的所有顶点。如果顶点i
未被访问,就调用辅助的递归函数dfsRecursive(i, visited)
。 - 在
dfsRecursive(u, visited)
中:- 将
visited[u]
设为true
。 - 打印(或处理)顶点
u
。 - 遍历
u
的所有邻接点v
。 - 如果
v
未被访问(!visited[v]
),则递归调用dfsRecursive(v, visited)
。
- 将
3.2.2 代码示例 (Java)
public class DFSRecursive {// 主遍历函数,处理非连通图的情况public void dfs(Graph graph) {int V = graph.getV();boolean[] visited = new boolean[V]; // 访问标记数组// 遍历所有顶点,确保每个连通分量都被访问for (int i = 0; i < V; i++) {if (!visited[i]) {dfsRecursive(i, visited, graph);}}}// 递归辅助函数private void dfsRecursive(int u, boolean[] visited, Graph graph) {// 1. 标记当前顶点为已访问visited[u] = true;System.out.print(u + " "); // 访问顶点// 2. 遍历该顶点的所有邻接点for (int v : graph.getAdj().get(u)) {// 3. 如果邻接点未被访问,则递归访问if (!visited[v]) {dfsRecursive(v, visited, graph);}}}public static void main(String[] args) {Graph g = new Graph(7);g.addEdge(0, 1);g.addEdge(0, 2);g.addEdge(1, 3);g.addEdge(1, 4);g.addEdge(2, 5);g.addEdge(2, 6);// 假设图 g 是有向图System.out.println("深度优先遍历 (递归):");DFSRecursive dfsTraversal = new DFSRecursive();dfsTraversal.dfs(g); // 输出可能为:0 1 3 4 2 5 6 (具体顺序取决于邻接表实现)}
}
3.2.3 可视化图解
让我们用 Mermaid 语法来可视化上述代码中图的 DFS 过程(假设从顶点 0 开始)。
graph TDsubgraph 图结构A[0] --> B[1]A --> C[2]B --> D[3]B --> E[4]C --> F[5]C --> G[6]endsubgraph 遍历过程 (访问顺序: 0 1 3 4 2 5 6)P1(访问 0) --> P2(深入 1)P2 --> P3(深入 3)P3 --> P4(3 无路可走, 回溯到 1)P4 --> P5(从 1 深入 4)P5 --> P6(4 无路可走, 回溯到 1)P6 --> P7(1 无路可走, 回溯到 0)P7 --> P8(从 0 深入 2)P8 --> P9(深入 5)P9 --> P10(5 无路可走, 回溯到 2)P10 --> P11(从 2 深入 6)P11 --> P12(6 无路可走, 回溯到 2)P12 --> P13(2 无路可走, 回溯到 0)P13 --> P14(0 无路可走, 结束)end
3.3 迭代实现 (Iterative Implementation using a Stack)
使用我们自己的栈,可以实现一个非递归版本的 DFS。
3.3.1 算法流程
- 创建一个
boolean[] visited
数组和一个Stack<Integer>
。 - 编写一个主遍历函数
dfs()
, 它遍历图中的所有顶点。如果顶点i
未被访问,就调用辅助的迭代函数dfsIterative(i, visited, graph)
。 - 在
dfsIterative(startNode, visited, graph)
中:- 将
startNode
压入栈中。 - 当栈不为空时,循环执行:
- 弹出栈顶顶点
u
。 - 如果
u
尚未被访问:- 标记
u
为已访问并进行处理(如打印)。 - 将
u
的所有未访问的邻接点v
压入栈中。
- 标记
- 弹出栈顶顶点
- 将
注意:迭代版本中,一个节点被压入栈时,并不意味着它被“访问”了,只有当它从栈中被弹出并处理时,才算真正被访问。
3.3.2 代码示例 (Java)
import java.util.Stack;public class DFSIterative {public void dfs(Graph graph) {int V = graph.getV();boolean[] visited = new boolean[V];Stack<Integer> stack = new Stack<>();for (int i = 0; i < V; i++) {if (!visited[i]) {// 开始新一轮的DFSstack.push(i);while (!stack.isEmpty()) {int u = stack.pop();// 如果节点已经访问过,则跳过// 这是因为一个节点可能被多次加入栈中,但我们只想访问一次if (visited[u]) {continue;}visited[u] = true;System.out.print(u + " ");// 将邻接点逆序入栈,以保证访问顺序与递归版本相似// (但这不是必须的,任何顺序都符合DFS的定义)List<Integer> neighbors = graph.getAdj().get(u);for (int j = neighbors.size() - 1; j >= 0; j--) {int v = neighbors.get(j);if (!visited[v]) {stack.push(v);}}}}}}public static void main(String[] args) {// 使用与递归示例相同的图Graph g = new Graph(7);g.addEdge(0, 1);g.addEdge(0, 2);g.addEdge(1, 3);g.addEdge(1, 4);g.addEdge(2, 5);g.addEdge(2, 6);System.out.println("\n深度优先遍历 (迭代):");DFSIterative dfsTraversal = new DFSIterative();dfsTraversal.dfs(g); // 输出可能为:0 1 3 4 2 5 6}
}
3.3.3 递归 vs. 迭代
特性 | 递归实现 | 迭代实现 (使用栈) |
---|---|---|
代码简洁性 | 非常高,代码直观,与 DFS 思想高度吻合。 | 相对复杂,需要手动管理栈。 |
空间开销 | 隐式的函数调用栈,最坏情况下深度为 O(V)O(V)O(V)。 | 显式的栈,最坏情况下大小为 O(V)O(V)O(V)。 |
栈溢出风险 | 当图的深度非常大时,可能导致栈溢出。 | 无此风险,只要内存足够。 |
控制性 | 对执行流程的控制较少。 | 可以更精细地控制栈内元素和遍历过程。 |
四、DFS 的应用场景
DFS 的强大之处在于它能够在图的结构上进行深度探索,这使得它在许多问题中都大有可为。
4.1 判断图的连通性
4.1.1 连通分量 (Connected Components)
对于一个无向图,如果从任意顶点 i
到任意顶点 j
都存在路径,则称这个图是连通图。如果一个图不是连通的,它可以被划分为多个连通分量,每个分量内部是连通的。
4.1.2 实现思路
我们可以利用 DFS 轻松地找出图中所有的连通分量。
- 初始化一个计数器
count = 0
。 - 遍历从 0 到
V-1
的所有顶点。 - 对于每个顶点
i
,如果它还没有被访问过:- 说明我们发现了一个新的连通分量的起点。
- 将
count
加 1。 - 从顶点
i
开始执行一次完整的 DFS。这次 DFS 会访问到该连通分量中的所有顶点。
- 遍历结束后,
count
的值就是图中连通分量的数量。
// 计算连通分量的伪代码
public int countConnectedComponents(Graph graph) {int V = graph.getV();boolean[] visited = new boolean[V];int count = 0;for (int i = 0; i < V; i++) {if (!visited[i]) {count++;// dfsRecursive or dfsIterative to visit all nodes in this componentdfsRecursive(i, visited, graph); }}return count;
}
4.2 寻找环 (Cycle Detection)
判断一个图中是否存在环是 DFS 的另一个经典应用。
4.2.1 无向图中的环检测
在无向图中,如果在 DFS 过程中,我们从顶点 u
访问到一个邻接点 v
,而 v
之前已经被访问过,并且 v
不是 u
在 DFS 树中的父节点,那么我们就找到了一个环。
实现思路:
在 DFS 递归函数中增加一个 parent
参数,即 dfs(u, parent, visited)
。
- 当从
u
遍历到邻接点v
时:- 如果
v
未被访问,继续递归dfs(v, u, visited)
。 - 如果
v
已被访问,且v != parent
,则说明v
是u
的一个祖先节点(非直接父节点),形成了一个回边 (Back Edge),从而构成了环。
- 如果
4.2.2 有向图中的环检测
有向图的环检测稍微复杂一些。仅仅使用 visited
数组是不够的。我们需要区分两种“已访问”状态:
- 当前递归路径上的访问:正在探索的路径上的点。
- 已完成探索的访问:所有子孙节点都已探索完毕的点。
实现思路:
使用一个额外的 recursionStack
(或颜色标记法:白色-未访问, 灰色-在递归栈中, 黑色-已完成探索) 布尔数组。
- 在进入
dfs(u)
时,将visited[u]
和recursionStack[u]
都设为true
。 - 当从
u
遍历到邻接点v
时:- 如果
v
未被访问,递归调用dfs(v)
。如果该调用返回true
(表示找到了环),则直接向上返回true
。 - 如果
v
的recursionStack[v]
为true
,说明v
正在当前的递归路径上,我们找到了一个环,返回true
。
- 如果
- 在
dfs(u)
即将返回时,将recursionStack[u]
设为false
,表示将u
从当前探索路径中移除(回溯)。
五、复杂度分析
5.1 时间复杂度
DFS 的时间复杂度取决于图的表示方式。
-
邻接表 (Adjacency List): 算法需要访问每个顶点一次,并且对于每个顶点,它会遍历其所有的邻接边。因此,总的操作数是所有顶点的度数之和。对于有向图和无向图,所有顶点的度数之和都等于 2E2E2E(EEE 为边数)。所以,时间复杂度为 O(V+E)O(V + E)O(V+E),其中 VVV 是顶点数,EEE 是边数。这是分析图算法时最常用的复杂度。
-
邻接矩阵 (Adjacency Matrix): 即使一个顶点只有一个邻接点,我们也需要检查一整行(VVV 个元素)来找出它的所有邻接点。因此,对于每个顶点,我们都需要 O(V)O(V)O(V) 的时间。总时间复杂度为 O(V2)O(V^2)O(V2)。
5.2 空间复杂度
空间复杂度主要由 visited
数组和递归/迭代所用的栈决定。
visited
数组需要 O(V)O(V)O(V) 的空间。- 递归栈/迭代栈: 在最坏的情况下,图可能是一条长链,递归深度或栈的大小会达到 VVV。因此,栈的空间复杂度为 O(V)O(V)O(V)。
综合起来,DFS 的空间复杂度为 O(V)O(V)O(V)。
六、总结
本文我们对图的深度优先搜索(DFS)进行了全面而深入的探讨。现在,让我们对核心知识点进行回顾:
-
核心思想: DFS 是一种图遍历算法,它遵循“一路走到底,再回头”的策略。它从一个起点开始,沿着一条路径尽可能深地探索,直到到达路径末端,然后回溯到上一个节点,继续探索其他未访问的分支。
-
实现方式: DFS 可以通过两种主要方式实现。递归是最自然、简洁的实现,它巧妙地利用了函数调用栈来处理回溯。迭代则需要手动维护一个栈,虽然代码稍显复杂,但能避免深度过大时的栈溢出问题。
-
复杂度: 当图以邻接表形式存储时,DFS 的时间复杂度为 O(V+E)O(V + E)O(V+E),空间复杂度为 O(V)O(V)O(V)。这是图算法中非常高效的复杂度。
-
关键应用: DFS 不仅仅是一种遍历方法,它还是解决众多图论问题的强大工具,例如高效地计算图的连通分量和在有向图/无向图中检测环的存在。
-
与 BFS 的区别: DFS 深入探索分支,而我们将在下一篇文章中学习的广度优先搜索 (BFS) 则以“层”为单位,向外围逐层扩展。两者在不同问题场景下各有优势。
掌握了 DFS,你就拥有了深入探索图结构内部秘密的一把钥匙。在接下来的学习中,我们会看到 DFS 如何在更多高级算法(如拓扑排序)中发挥其不可或缺的作用。