图的深度优先搜索(DFS)算法原理、实现与应用
图的深度优先搜索是一种遍历或搜索图或树数据结构的算法。与广度优先搜索(BFS)一层一层探索不同,DFS会沿着一条路径尽可能深地探索,直到无法继续,然后回退到上一个分支点,选择另一条路径继续深入。它是解决许多图论问题的基础。
一、 核心思想与类比
想象一下,你身处一个巨大的迷宫,手里拿着一团绳子。你从入口出发,会选择一个方向一直走下去,并在岔路口标记你的选择。当走到死胡同时,你顺着绳子原路返回到最近的一个还没探索过的岔路口,然后选择另一条路继续深入探索。这个“一条路走到黑,不行就返回”的过程,就是DFS的核心思想。在图中,绳子就相当于调用栈(或递归栈),而岔路口就是图中的顶点。
二、 算法过程详解
假设我们有一个用邻接表表示的无向连通图(有向图或非连通图的过程类似,后文会提)。算法从任意一个起始顶点 s 开始。
递归版本 (最直观的实现):
- 初始化:创建一个标记数组
visited,记录每个顶点是否已被访问。初始时,所有顶点标记为“未访问”。 - 从起点递归:调用辅助函数
DFS_Visit(u)来探索顶点u。- 标记顶点
u为“已访问”(例如,visited[u] = true)。 - 处理顶点:可以在此输出顶点值,或进行其他计算。
- 对于顶点
u的每一个邻接顶点v:- 如果
v尚未被访问(visited[v] == false),则递归地调用DFS_Visit(v)。
- 如果
- 标记顶点
- 遍历所有顶点:在外部函数中,如果图是连通图,只需要从
s调用一次DFS_Visit即可。如果图是非连通图,则需要对所有顶点进行检查。如果某个顶点i未被访问,则从i开始调用DFS_Visit(i),以确保遍历到图的每一个连通分量。
手动执行示例(以邻接表表示的图为例):
假设有顶点 0, 1, 2, 3, 4。邻接关系:0-1, 0-2, 1-2, 1-3, 2-4。起始点为0。
初始 visited = [F, F, F, F, F]
DFS_Visit(0):
visited[0]=True, 输出0
邻接点:1(未访) -> 递归DFS_Visit(1)
visited[1]=True, 输出1
邻接点:0(已访), 2(未访) -> 递归DFS_Visit(2)
visited[2]=True, 输出2
邻接点:0(已访), 1(已访), 4(未访) -> 递归DFS_Visit(4)
visited[4]=True, 输出4
邻接点:2(已访) -> 返回
返回
邻接点:3(未访) -> 递归DFS_Visit(3)
visited[3]=True, 输出3
邻接点:1(已访) -> 返回
返回
邻接点:2(已访) -> 返回
结束
最终遍历(输出)顺序:0 -> 1 -> 2 -> 4 -> 3
注意,遍历顺序和邻接表的具体存储顺序有关。核心是深度优先。
三、 显式栈版本(非递归)
递归的本质是函数调用栈。我们可以用显式的栈(Stack)数据结构来模拟这个过程,避免递归深度过大可能导致的栈溢出,并且思路更清晰。
- 初始化
visited数组,全部设为false。 - 创建一个栈
stack,将起始顶点s压栈。 - 当栈不为空时循环:
a. 从栈顶弹出顶点u。
b. 如果u未被访问:
* 标记visited[u] = true。
* 处理顶点u。
* 将u的所有邻接顶点中未被访问的顶点,依次压入栈中。(注意:这里压栈的顺序决定了同层邻居的探索顺序。为了实现和标准DFS类似的顺序,有时会逆序压栈邻接表)。 - (对于非连通图) 同样需要对所有未访问顶点重复步骤2-3。
注意:在显式栈版本中,一个顶点可能在它被处理(标记为已访问)之前,就被多次压入栈中(通过不同的邻居)。因此,在弹出顶点时,必须检查其 visited 状态,避免重复处理。而递归版本中,进入 DFS_Visit 函数的第一步就是标记,所以不会发生这种情况。
四、 时间与空间复杂度分析
- 时间复杂度:
O(V + E),其中 V 是顶点数,E 是边数。每个顶点被访问(入栈/出栈)一次,每条边在其两个端点被访问时被检查两次(无向图)或一次(有向图)。这与图的表示无关(邻接表或邻接矩阵),但对于邻接矩阵,检查一个顶点的所有邻接点需要 O(V) 时间,总时间会上升到 O(V^2)。 - 空间复杂度:
*visited数组:O(V)。
* 递归调用栈/显式栈:在最坏情况下(如一条链状图),深度为 O(V)。
因此,总空间复杂度为 O(V)。
五、 关键应用场景
DFS 之所以重要,是因为它能自然地生成图的一种树形(或森林)结构——DFS生成树/森林,并附带一些重要的“时间戳”信息(如发现时间、完成时间),这使得它成为解决许多复杂图论问题的基石。
-
连通性检测:
- 连通分量:在无向图中,一次DFS遍历能走完一个连通分量中的所有顶点。对每个未访问顶点执行DFS,就能找出所有连通分量。
- 强连通分量:在有向图中,通过两次DFS(第一次在原图上得到完成时间的逆序,第二次在转置图上按此顺序DFS)可以找到所有强连通分量,这是经典的Kosaraju或Tarjan算法的基础。
-
环检测:
- 无向图:在DFS过程中,如果遇到一条边指向一个已访问的顶点,并且这个顶点不是当前顶点的父节点(递归调用树中的直接前驱),则说明图中存在环。
- 有向图:需要引入第三种状态“正在访问中”。在DFS过程中,如果遇到一条边指向一个“正在访问中”的顶点,则说明存在有向环。这是拓扑排序的关键检查步骤。
-
拓扑排序:对有向无环图(DAG)进行DFS,在一个顶点的递归调用完成时(即从DFS_Visit返回前),将该顶点压入一个栈。最终栈顶到栈底的序列就是一种拓扑排序。因为一个顶点的完成意味着其所有后继都已处理完,它自然可以排在后继的前面。
-
路径查找:在DFS过程中,记录下到达每个顶点的前驱顶点(父节点),就可以回溯出从起点到目标点的一条路径(不保证是最短路径)。
-
解决回溯与状态空间搜索问题:许多组合、排列、棋盘(如八皇后)、数独问题,都可以看作在一个隐式图(状态空间)中搜索特定路径的问题。DFS天然适合这种“尝试-回退”的搜索模式。
六、 与广度优先搜索(BFS)的对比
- 数据结构:DFS使用栈(递归/显式),BFS使用队列。
- 遍历顺序:DFS深度优先,BFS广度优先。
- 结果特性:BFS找到的无权图最短路径是顶点数最少的最短路径。DFS找到的路径则不保证最短。
- 适用场景:BFS适合寻找最短路径、层次遍历。DFS适合寻找所有解、拓扑排序、检测环、解决回溯问题。
总结:深度优先搜索是一种通过递归或显式栈实现的图遍历算法,其“深入到底再回溯”的策略,使其成为分析图结构、检测环、拓扑排序以及解决复杂状态空间搜索问题的强大工具。理解其递归调用栈的展开与回退过程,是掌握其所有高级应用的关键。