大家好,很高兴又和大家见面啦!!!
在上一篇中,我们共同揭开了广度优先搜索(BFS)的神秘面纱:它以“分层扩散”的方式遍历图结构,借助队列实现层序遍历,擅长解决最短路径和连通性分析问题(例如社交网络中的好友推荐)。BFS如同一束光波,由近及远均匀覆盖每个角落,确保无遗漏地探索所有可能性。
而今天,我们将潜入另一种经典策略——深度优先搜索(DFS)。与BFS的“广撒网”不同,DFS更像一位执着探险家,认准一条路走到尽头,再回溯寻找新路径。这种策略在迷宫探索、拓扑排序、环路检测等场景中大放异彩。
为何需要DFS?关键差异一目了然👇
本文你将收获:
阅读建议:搭配上一篇“BFS详解”食用更佳!通过对比两大算法,你将真正掌握“何时用BFS,何时选DFS”的决策智慧。文末附生成树案例详解,帮助你将抽象理论转化为直观洞察。🚀
现在,让我们一起潜入图论的深海,揭开DFS的层层奥秘吧!
深度优先搜索(Depth-First-Search, DFS),简单的理解就是尽可能深的进行遍历,用一句话来描述就是一条路走到黑。
在二叉树的遍历算法中,按照遍历的方式,我们可以将其分为4类:
其中层序遍历实际上就是我们所说的:广度优先搜索(BFS)在树中的一种实际应用。在上一篇的内容中我们已经详细介绍,这里就不再赘述。
下面我们就来分析一下其他的三种遍历;
不管是先根遍历还是后根遍历,其遍历的方式都是沿着一条路径先找到最深的结点,再去找其他结点。
对于先根遍历与后根遍历这种每次遍历时都是沿着一条路径,往深处走的方式进行遍历,就是**深度优先遍历(Depth-First-Traversal, DFT)**。
当我们要查找具体的对象时,采用这种沿着一条路径,往深处走的方式进行查找,这就是**深度优先搜索(Depth-First-Search, DFS)**。
这里我们需要区分一下遍历与搜索:
由此可以看到,当所有元素都是搜索中的特定元素时,那么我们对存储这些数据元素的数据结构进行搜索时,实际上就是在遍历该数据结构。
因此我们可以简单的理解为,遍历与搜索的区别就是:对元素的访问条件不同。
深度优先遍历(DFT) 和 深度优先搜索(DFS) 是同一策略的不同应用场景,但术语上更常用 DFS 统称。
这里一定要注意,在下面的介绍中,我们说的 DFS
实际上是说的对图的遍历算法,而不是查找特定值的算法。
理解了深度优先遍历与深度优先搜索后,下面我们就来了解一下其算法思路;
深度优先搜索的算法思路如下:
这里我们以二叉树的先序遍历为例:
上图中使用的是先根遍历的方式进行展示:
对于图而言,其遍历序列根据其存储结构的不同而有所不同:
上图所示的遍历序列对于除邻接矩阵外的存储结构而言,只是其中的一种遍历序列,仅供大家参考。
今天我们要介绍的图的深度优先搜索与树的先根遍历类似,都是先访问当前顶点,再对一条路径进行深入,直至该路径无法继续深入后,开始回溯,选择下一条路径进行深入;
在图的 DFS
中我们需要对已经完成访问的顶点进行标记,因此需要一个标记数组 visited[]
来记录当前顶点是否被访问。整个过程如下所示:
FirstNeighbor(G, x)
获取当前顶点x的下一个邻接顶点编号y,并通过 visited[y]
进行判断该顶点是否被访问: visited[y] == false
则未被访问,继续对顶点y进行 DFS
visited[y] == true
则已被访问,则说明该路径上的顶点都已被访问,接下来通过 NexttNeighbor(G, x, y)
获取顶点x除了顶点y之外的下一个邻接顶点编号z,并对该顶点进行判断是否被访问: DFS
其代码表达如下所示:
// 深度优先搜索
bool visited[MAXVERSIZE];
void DFS(graph* g, int x) {
visit(g, x); // 访问当前顶点
visited[x] = true; // 标记当前顶点
for (int y = FirstNeighbor(g, x); y >= 0; y = NextNeighbor(g, x, y)) {
// FirstNeighbor(g, x): 当前顶点x存在下一个邻接点,则返回对应顶点编号,否则,返回-1
// NextNeighbor(g, x, y): 当前顶点x存在下一个除顶点y以外的邻接点,则返回对应顶点编号,否则,返回-1
// y == -1时,说明此时该路径中不存在未被访问的邻接点
if (!visited[y]) { // 判断当前顶点是否被访问
DFS(g, y); // 未被访问,则对该点进行深度优先搜索
}
}
}
对于连通图而言,上述代码逻辑足以完成所有顶点的遍历,但是在非连通图中,从某一起始点开始进行 DFS
只能完成该点所在连通分量的所有顶点的遍历。
为了确保能够对非连通图完成所有顶点的遍历,我们需要借助 visited[]
数组来查找未被访问过的顶点信息,如下所示:
void DFSTraverse(graph* g) {
// 初始化标记数组
for (int i = 0; i < MAXVERSIZE; i++) {
visited[i] = false;
}
for (int i = 0; i < MAXVERSIZE; i++) {
if (!visited[i]) {
DFS(g, i);
}
}
}
在该函数中,每一次调用 DFS
就是对图中的一个连通分量进行遍历,图中存在多少个连通分量,就会调用多少次 DFS
;
图的遍历算法的本质就是通过边来找顶点,因此对于 DFS
而言,其时间复杂度与 BFS
的时间复杂度一致:
在 DFS 中,我们可以像上述展示的代码一样,通过递归实现,其对应的空间复杂度为:O(|V|)
同样也可以通过栈的方式来实现,对应的空间复杂度依然是:O(|V|)
与广度优先生成树一致,深度优先生成树也是在遍历图的过程中保留所有的顶点与其访问的边所得到的一棵生成树,我们以下面的例子来说明:
graph LR
a---b---d
a---c---e---f---g
b---e
e---g
在上图中,其顶点集与边集如下所示:
当我们从起始点 a 开始进行遍历时,此时点a 会被标记,其对应的生成树为:
graph LR
a
对于点a而言,其邻接点有两个:
当我们找到第一个邻接点b时,点b会被标记,所对应的边 (a, b) 被访问,对应的生成树为:
graph TB
a---b
对于点b而言,其邻接点有3个:
这时找到的邻接点a已经被访问,算法会继续寻找除了点a外的下一个邻接点d。
当找到点d后,点d会被标记,所对应的边 (b, d) 被访问,其对应的生成树为:
graph TB
a---b---d
对于点d而言,其邻接点有1个:
由于点d不存在未被访问的邻接点,算法会开始回溯到点b,这时会继续寻找与点b邻接的下一个邻接点e。
当找到点e后,点e会被标记,所对应的边 (b, e) 被访问,对应的生成树为:
graph TB
a---b---d
b---e
对于点e而言,其邻接点有4个:
这时算法会重复上述的步骤依次找到并标记以下顶点与边:
此时所有的顶点都完成了标记,我们也就得到了该图的深度优先生成树:
graph TB
a---b---d
b---e---c
e---f---g
深度优先生成树在不同的存储结构中,同样不相同:
具体的原因我这里再重复一遍:
现在我们已经了解了图的两种遍历方式:BFS
与 DFS
,不管是哪种方式,在对无向图进行遍历与对有向图进行遍历时,是有些许区别的:
在这两个篇章中我们都是以无向图为例进行说明,但是在实际问题中,我们还是需要根据具体情况进行具体分析。
通过本篇的探索,我们见证了深度优先搜索(DFS)如何以“不撞南墙不回头”的执着,在图结构中开辟出一条条纵深路径。与广度优先搜索(BFS)的“层层递进”不同,DFS以递归与回溯为利器,在迷宫寻路、拓扑排序、连通分量统计等场景中展现独特优势。
关键回顾🔍
实践启示💡