Kosaraju-Sharir 算法求有向图的强连通分量
题目描述
给定一个有向图 G = (V, E),我们希望找出它的所有强连通分量。强连通分量是一个最大顶点集合,其中任意两个顶点 u 和 v 都能互相可达(即存在从 u 到 v 的路径,也存在从 v 到 u 的路径)。
例如,在有向图中,强连通分量可能是一个环、一个单独顶点,或者一个互相可达的顶点组。我们需要一个高效的算法,在 O(|V| + |E|) 时间内解决这个问题。Kosaraju-Sharir 算法就是这样一个经典的两遍 DFS 算法,它利用图的反向图和 DFS 的完成顺序来找出所有强连通分量。
解题过程
我们将分步骤详细解释 Kosaraju-Sharir 算法的原理与实现。
第一步:理解算法核心思想
算法的关键在于两个观察:
- 如果我们将图 G 中的每个强连通分量压缩成一个“超级顶点”,那么得到的新图 G' 必然是一个有向无环图(DAG)。
- 在 DAG 中,如果我们先对原图 G 进行一次 DFS,并按照 DFS 完成时间(即回溯顺序)将顶点排序,那么在这个顺序中,属于同一个强连通分量的顶点可能会分散,但在反向图 G^T(将所有边反转得到的图)中进行 DFS 时,我们可以通过从“最晚完成”的顶点开始遍历,恰好能一次 DFS 收集到一个完整的强连通分量。
第二步:算法步骤分解
Kosaraju-Sharir 算法分为两个主要阶段:
-
第一阶段:在原图 G 上执行 DFS,记录顶点的完成顺序
- 初始化:对所有顶点标记为未访问。
- 对每个未访问的顶点执行 DFS,当从一个顶点 u 的递归调用返回时(即完成对 u 的访问),将 u 压入一个栈(或列表)中。这个栈按照完成时间的递增顺序存储顶点(栈顶是最晚完成的顶点)。
- 注意:这一阶段只是为了得到顶点的完成顺序,并不关心强连通分量本身。
-
第二阶段:在反向图 G^T 上按照完成顺序执行 DFS,找出强连通分量
- 构建反向图 G^T:将原图 G 中每条边 (u, v) 改为 (v, u)。
- 初始化:再次标记所有顶点为未访问。
- 从栈中依次弹出顶点(即从最晚完成的顶点开始),对于每个弹出的顶点 v,如果 v 未访问,则以 v 为起点在 G^T 上执行 DFS。这次 DFS 能访问到的所有顶点构成一个强连通分量。
- 记录每个分量,并继续处理下一个未访问的顶点,直到栈为空。
第三步:示例演示
考虑一个有向图 G:
顶点集 V = {A, B, C, D, E, F}
边集 E = {(A,B), (B,C), (C,A), (B,D), (D,E), (E,F), (F,E)}
这个图有两个强连通分量:{A, B, C} 和 {D} 和 {E, F}(注意 {D} 单独是一个分量,因为从 D 无法到达其他顶点,其他顶点也无法到达 D)。
执行过程:
- 第一阶段 DFS 在原图 G 上:
假设从 A 开始 DFS:A → B → C → D → E → F。完成顺序(栈从底到顶):[A, C, B, D, F, E](具体顺序可能因 DFS 起点和邻接顺序而异,但栈顶是最晚完成的顶点)。 - 构建反向图 G^T:边反向,例如 (A,B) 变为 (B,A)。
- 第二阶段在 G^T 上按栈顺序弹出顶点:
- 弹出 E(栈顶),未访问,从 E 开始 DFS 在 G^T 上:E 能到达 F,所以 {E, F} 是一个强连通分量。
- 弹出 F,已访问,跳过。
- 弹出 D,未访问,从 D 开始 DFS 在 G^T 上:D 无法到达其他顶点,所以 {D} 是一个强连通分量。
- 弹出 B,已访问(因为在 G^T 中 B 属于 {A,B,C} 分量),跳过。
- 弹出 C,已访问,跳过。
- 弹出 A,未访问,从 A 开始 DFS 在 G^T 上:A 能到达 B 和 C,所以 {A, B, C} 是一个强连通分量。
最终得到三个强连通分量:{A, B, C}, {D}, {E, F}。
第四步:为什么算法正确?
核心证明思路:
- 第一阶段得到的栈顺序保证了在反向图 G^T 中,当我们从最晚完成的顶点开始 DFS 时,我们恰好“反着”遍历 DAG G' 的拓扑顺序。
- 在反向图 G^T 中,从顶点 v 开始的 DFS 只能访问到那些在原图 G 中能到达 v 的顶点,而这正好是 v 所在强连通分量的所有顶点(因为在 DAG G' 中,反向图 DFS 不会跨越分量边界)。
- 因此,每次在 G^T 中从一个未访问顶点启动 DFS,就能完整收集一个强连通分量。
第五步:时间复杂度分析
- 第一阶段 DFS:O(|V| + |E|)
- 构建反向图:O(|V| + |E|)(可以用邻接表直接构建)
- 第二阶段 DFS:O(|V| + |E|)
总时间复杂度为 O(|V| + |E|),空间复杂度为 O(|V| + |E|)。
第六步:伪代码实现
Kosaraju(G):
初始化栈 stack,标记数组 visited 为 false
// 第一阶段:在原图 G 上 DFS,填充栈
for 每个顶点 v in V:
if not visited[v]:
DFS1(G, v, visited, stack)
// 构建反向图 G_rev
G_rev = 反转 G 的所有边
// 第二阶段:在 G_rev 上按栈顺序 DFS
visited 重置为 false
components = [] // 存储所有强连通分量
while stack 非空:
v = stack.pop()
if not visited[v]:
component = []
DFS2(G_rev, v, visited, component)
components.append(component)
return components
DFS1(G, v, visited, stack):
visited[v] = true
for 每个邻居 u in G.adj[v]:
if not visited[u]:
DFS1(G, u, visited, stack)
stack.push(v) // 完成时入栈
DFS2(G_rev, v, visited, component):
visited[v] = true
component.append(v)
for 每个邻居 u in G_rev.adj[v]:
if not visited[u]:
DFS2(G_rev, u, visited, component)
总结
Kosaraju-Sharir 算法通过巧妙的两次 DFS 和反向图,高效地找出了有向图的所有强连通分量。其核心在于利用 DFS 完成顺序和反向图的性质,确保每个强连通分量被完整地一次性收集。该算法易于实现,且时间与空间复杂度均为线性,是图论中解决强连通分量问题的经典方法之一。