寻找有向图中的强连通分量(Kosaraju算法)
字数 4138 2025-12-19 16:00:31

寻找有向图中的强连通分量(Kosaraju算法)

题目描述

给定一个有向图 \(G = (V, E)\),其中 \(V\) 是顶点集合,\(E\) 是有向边集合。我们需要找出该有向图中所有的强连通分量(Strongly Connected Components, SCC)。一个强连通分量是图的一个极大子图,其中任意两个顶点都可以相互到达(即存在从一个顶点到另一个顶点的有向路径,反之亦然)。Kosaraju算法是一种基于深度优先搜索(DFS)的高效算法,可以在 \(O(|V| + |E|)\) 的时间内解决这个问题。


解题过程

第一步:理解算法核心思想

Kosaraju算法基于一个关键观察:如果将原图的所有边反向,强连通分量不会改变(即每个SCC内部结构不变,但SCC之间的边方向会反转)。算法分为两个主要阶段:

  1. 第一遍DFS:在原始图 \(G\) 上运行DFS,记录每个顶点完成搜索的时间(即后序遍历顺序),并将顶点按完成时间递减的顺序存储到一个栈中。这个栈相当于得到了一个“反拓扑排序”顺序(注意:有环图无拓扑排序,但这里得到的顺序能保证在第二阶段正确遍历SCC)。

  2. 第二遍DFS:在反向图 \(G^T\)(即将 \(G\) 中每条边反向得到的图)上,按照栈中弹出的顶点顺序进行DFS。每次DFS访问到的所有顶点构成一个强连通分量。

为什么这样有效?直觉上,第一遍DFS确保我们最后处理“源”SCC(即没有出边到其他SCC的分量),而反向图中,这个“源”SCC变成了“汇”SCC(没有入边),因此从它开始DFS不会跑到其他SCC中,正好能完整提取出一个SCC。


第二步:详细步骤分解

假设图用邻接表表示,顶点编号从0到 \(n-1\)

步骤1:构造反向图

  • 创建新图 \(G^T\),遍历 \(G\) 的每条边 \((u, v)\),在 \(G^T\) 中添加边 \((v, u)\)。时间复杂度 \(O(|V| + |E|)\)

步骤2:第一遍DFS(在原始图 \(G\) 上)

  • 初始化一个布尔数组 visited 记录顶点是否被访问,以及一个栈 stack
  • 对每个未访问的顶点 v,执行DFS:
    • 标记 v 为已访问。
    • 递归访问 v 的所有未访问邻居。
    • v 的所有邻居访问完毕后,将 v 压入栈。这是标准的后序入栈
  • 最终栈顶顶点是第一个完成DFS的顶点(即完成时间最晚)。

步骤3:第二遍DFS(在反向图 \(G^T\) 上)

  • 重置 visited 数组为全 false。
  • 初始化一个列表 scc 存储所有SCC。
  • 当栈非空时,弹出栈顶顶点 v
    • 如果 v 未访问,从 v 开始在新图上执行DFS,将访问到的所有顶点记录为一个SCC,加入 scc
  • 最终 scc 包含所有强连通分量。

第三步:示例演示

考虑一个有向图 \(G\)
顶点:0, 1, 2, 3, 4
边:0→1, 1→2, 2→0, 1→3, 3→4

步骤1:构造反向图 \(G^T\)
边:1→0, 2→1, 0→2, 3→1, 4→3

步骤2:第一遍DFS(在 \(G\) 上)

  • 从0开始DFS:0→1→2→3→4(访问顺序不影响,重点是后序入栈)。
  • 后序入栈顺序(完成时间顺序):4(完成最早,但最后入栈?注意:实际上是递归返回时入栈,所以顺序是完成时间递增入栈,出栈时就是递减)。具体过程:
    • DFS(0): 访问0→递归1→递归2(返回)→2入栈→递归3→递归4(返回)→4入栈→3入栈→1入栈→0入栈。
    • 最终栈(从底到顶):[2,4,3,1,0](这里为了清晰,我们假设入栈顺序是完成时间从早到晚,出栈时从0开始)。
      实际上,我们可以简化:按DFS结束时间记录,最后入栈的顶点是第一个完成的。但为简单起见,我们按标准实现:栈顶是最后完成的顶点。
      我们模拟一个常见顺序:
      假设从0开始:0→1→2(2无未访问邻居,2入栈)→回到1,访问3→3→4(4入栈)→3入栈→1入栈→0入栈。
      栈(从底到顶):[2,4,3,1,0]。弹出顺序:0,1,3,4,2。

步骤3:第二遍DFS(在 \(G^T\) 上)

  • 弹出0,从0在 \(G^T\) 上DFS:0→2→1(边反向后,0有入边2,2有入边1,1有入边0?等一下,反向图 \(G^T\) 中0的邻居是2(来自原边2→0),2的邻居是1(1→2),1的邻居是0(0→1)。所以从0访问2和1,全部三个顶点 {0,1,2} 被标记为一个SCC。
  • 弹出1(已访问),跳过。
  • 弹出3(未访问),从3在 \(G^T\) 上DFS:3→1(已访问,停止),只有 {3} 一个顶点?检查反向图:3的邻居是1(已访问)和4(边4→3)。但4未访问?等等,反向图中3的入边是4→3,所以3没有出边到4。从3只能到1(通过边3→1),但1已访问。所以 {3} 单独是一个SCC?不对,原图中3→4,反向图中4→3,所以3和4应该互相可达,属于同一个SCC。这里错误在于:我们在反向图上应从3开始DFS,但需要遵循反向图的边方向。反向图中3的邻居是1(因为原边1→3反向为3→1),但1已访问,DFS结束,漏掉了4。这是因为我们之前栈顺序可能有问题。让我们重新严谨执行。

重新执行(更严谨的栈顺序)
第一遍DFS在G上,从0开始:

  • 访问0,递归1:
    • 访问1,递归2:
      • 访问2,邻居0已访问,返回,2入栈。
    • 回到1,递归3:
      • 访问3,递归4:
        • 访问4,邻居无(或已访问),返回,4入栈。
      • 回到3,邻居1已访问,返回,3入栈。
    • 回到1,邻居都已访问,返回,1入栈。
  • 回到0,邻居都已访问,返回,0入栈。
    栈(从底到顶):[2,4,3,1,0] → 弹出顺序:0,1,3,4,2。

第二遍在 \(G^T\) 上:

  • visited重置。
  • 弹出0,从0开始DFS:反向图中0的邻居只有2(因为原边2→0)。访问2,2的邻居是1(原边1→2)。访问1,1的邻居是0(已访问)和3(原边3→1,但反向为1→3?等一下,反向图 \(G^T\) 的边:0←1, 1←2, 2←0, 1←3, 3←4。所以:
    • 顶点0的邻居:2
    • 顶点2的邻居:1
    • 顶点1的邻居:0, 3
    • 顶点3的邻居:4
    • 顶点4的邻居:无
      从0开始:访问0,2,1(通过0→2→1),然后1的邻居0已访问,3未访问→访问3,3的邻居4未访问→访问4。所以一次DFS访问了 {0,1,2,3,4} 全部顶点!这说明整个图是一个SCC?但原图中从4无法到达0,1,2,3,所以不是强连通。问题出在哪里?错误在于:反向图中1的邻居是0和3,但原边是0→1和3→1,反向后是1→0和1→3,这是正确的。但我们在反向图上从0开始DFS,沿着边0→2→1→3→4,这是反向图的边方向,但对应原图的边方向是相反的。这意味着在反向图上从0能到达所有顶点,但在原图上从所有顶点是否能到0?不一定。实际上,原图中4无法到达0,所以 {0,1,2,3,4} 不是SCC。

这揭示了一个关键点:Kosaraju算法中,第二遍DFS在反向图上得到的连通分量,必须是在反向图上从起点能到达的所有顶点,但这正是原图中能到达起点的所有顶点。因为反向图上u能到v等价于原图上v能到u。所以,反向图上从0能访问所有顶点,意味着原图中所有顶点都能到达0。但原图中4无法到达0,矛盾。这说明我们的图示例不满足这个性质。检查原图:边是0→1, 1→2, 2→0, 1→3, 3→4。从4出发没有出边,所以4无法到达任何其他顶点。因此原图中所有顶点不能都到达0。所以我们的反向图构造有误?我们仔细检查反向图 \(G^T\)
原边:0→1, 1→2, 2→0, 1→3, 3→4
反向边:1→0, 2→1, 0→2, 3→1, 4→3
所以反向图的邻接表:
0: [2]
1: [0]
2: [1]
3: [1]
4: [3]
从0开始DFS:0→2→1→停止(因为1的邻居0已访问,没有其他邻居)。等等,1的邻居是0,已访问,没有边到3!因为反向图中3→1,是3指向1,不是1指向3。所以从1无法到3。所以实际上从0只能访问{0,1,2}。这才是正确的。之前我错误地将1的邻居写成了0和3。纠正后,从0在反向图上DFS只访问{0,1,2}。这样正确了。

继续:

  • 弹出1(已访问),跳过。
  • 弹出3(未访问),从3在反向图上DFS:3→1(已访问),停止,只访问{3}。
  • 弹出4(未访问),从4在反向图上DFS:4→3(已访问),停止,只访问{4}。

最终SCC:{0,1,2}, {3}, {4}。正确。


第四步:算法实现(伪代码)

def kosaraju_scc(n, edges):
    # 构建邻接表
    graph = [[] for _ in range(n)]
    reverse_graph = [[] for _ in range(n)]
    for u, v in edges:
        graph[u].append(v)
        reverse_graph[v].append(u)
    
    visited = [False] * n
    stack = []
    
    # 第一遍DFS
    def dfs1(u):
        visited[u] = True
        for v in graph[u]:
            if not visited[v]:
                dfs1(v)
        stack.append(u)  # 后序入栈
    
    for i in range(n):
        if not visited[i]:
            dfs1(i)
    
    # 第二遍DFS
    visited = [False] * n
    scc_list = []
    
    def dfs2(u, component):
        visited[u] = True
        component.append(u)
        for v in reverse_graph[u]:
            if not visited[v]:
                dfs2(v, component)
    
    while stack:
        u = stack.pop()
        if not visited[u]:
            component = []
            dfs2(u, component)
            scc_list.append(component)
    
    return scc_list

第五步:算法正确性直观解释

  • 第一遍DFS得到一个顺序,保证如果存在从SCC A到SCC B的路径,那么A中的顶点在栈中会比B中的顶点更晚弹出(因为A的后代包括B,完成时间更晚)。
  • 反向图中,所有SCC间的边方向反转,所以从SCC A到SCC B的边变成B到A。因此,在第二遍DFS中,我们从最晚完成的顶点(即某个“源”SCC在原始图中的代表)开始,在反向图上只能访问到自己SCC内的顶点(因为指向其他SCC的边现在是指向自己,但其他SCC已标记?不,实际上因为栈顺序保证我们先处理“源”SCC,它在反向图中是“汇”,没有出边到其他SCC,所以DFS不会跑出去)。
  • 每次DFS恰好取出一个SCC。

第六步:时间与空间复杂度

  • 时间复杂度:两次DFS遍历,每个顶点和边访问一次,\(O(|V| + |E|)\)
  • 空间复杂度:存储图、反向图、栈和访问数组,\(O(|V| + |E|)\)

总结:Kosaraju算法通过两遍DFS,巧妙利用图的反转和顶点处理顺序,高效找出有向图的所有强连通分量。理解的关键在于把握栈顺序如何保证在反向图上每次DFS恰好分离出一个SCC。

寻找有向图中的强连通分量(Kosaraju算法) 题目描述 给定一个有向图 \( G = (V, E) \),其中 \( V \) 是顶点集合,\( E \) 是有向边集合。我们需要找出该有向图中所有的 强连通分量 (Strongly Connected Components, SCC)。一个强连通分量是图的一个极大子图,其中任意两个顶点都可以相互到达(即存在从一个顶点到另一个顶点的有向路径,反之亦然)。Kosaraju算法是一种基于深度优先搜索(DFS)的高效算法,可以在 \( O(|V| + |E|) \) 的时间内解决这个问题。 解题过程 第一步:理解算法核心思想 Kosaraju算法基于一个关键观察: 如果将原图的所有边反向,强连通分量不会改变 (即每个SCC内部结构不变,但SCC之间的边方向会反转)。算法分为两个主要阶段: 第一遍DFS :在原始图 \( G \) 上运行DFS,记录每个顶点完成搜索的时间(即后序遍历顺序),并将顶点按完成时间 递减 的顺序存储到一个栈中。这个栈相当于得到了一个“反拓扑排序”顺序(注意:有环图无拓扑排序,但这里得到的顺序能保证在第二阶段正确遍历SCC)。 第二遍DFS :在反向图 \( G^T \)(即将 \( G \) 中每条边反向得到的图)上,按照栈中弹出的顶点顺序进行DFS。每次DFS访问到的所有顶点构成一个强连通分量。 为什么这样有效?直觉上,第一遍DFS确保我们 最后处理“源”SCC (即没有出边到其他SCC的分量),而反向图中,这个“源”SCC变成了“汇”SCC(没有入边),因此从它开始DFS不会跑到其他SCC中,正好能完整提取出一个SCC。 第二步:详细步骤分解 假设图用邻接表表示,顶点编号从0到 \( n-1 \)。 步骤1:构造反向图 创建新图 \( G^T \),遍历 \( G \) 的每条边 \( (u, v) \),在 \( G^T \) 中添加边 \( (v, u) \)。时间复杂度 \( O(|V| + |E|) \)。 步骤2:第一遍DFS(在原始图 \( G \) 上) 初始化一个布尔数组 visited 记录顶点是否被访问,以及一个栈 stack 。 对每个未访问的顶点 v ,执行DFS: 标记 v 为已访问。 递归访问 v 的所有未访问邻居。 当 v 的所有邻居访问完毕后,将 v 压入栈。这是标准的 后序入栈 。 最终栈顶顶点是第一个完成DFS的顶点(即完成时间最晚)。 步骤3:第二遍DFS(在反向图 \( G^T \) 上) 重置 visited 数组为全 false。 初始化一个列表 scc 存储所有SCC。 当栈非空时,弹出栈顶顶点 v : 如果 v 未访问,从 v 开始在新图上执行DFS,将访问到的所有顶点记录为一个SCC,加入 scc 。 最终 scc 包含所有强连通分量。 第三步:示例演示 考虑一个有向图 \( G \): 顶点:0, 1, 2, 3, 4 边:0→1, 1→2, 2→0, 1→3, 3→4 步骤1:构造反向图 \( G^T \) 边:1→0, 2→1, 0→2, 3→1, 4→3 步骤2:第一遍DFS(在 \( G \) 上) 从0开始DFS:0→1→2→3→4(访问顺序不影响,重点是后序入栈)。 后序入栈顺序(完成时间顺序):4(完成最早,但最后入栈?注意:实际上是递归返回时入栈,所以顺序是 完成时间递增 入栈,出栈时就是递减)。具体过程: DFS(0): 访问0→递归1→递归2(返回)→2入栈→递归3→递归4(返回)→4入栈→3入栈→1入栈→0入栈。 最终栈(从底到顶):[ 2,4,3,1,0 ](这里为了清晰,我们假设入栈顺序是完成时间从早到晚,出栈时从0开始)。 实际上,我们可以简化:按DFS结束时间记录,最后入栈的顶点是第一个完成的。但为简单起见,我们按标准实现:栈顶是 最后完成 的顶点。 我们模拟一个常见顺序: 假设从0开始:0→1→2(2无未访问邻居,2入栈)→回到1,访问3→3→4(4入栈)→3入栈→1入栈→0入栈。 栈(从底到顶):[ 2,4,3,1,0 ]。弹出顺序:0,1,3,4,2。 步骤3:第二遍DFS(在 \( G^T \) 上) 弹出0,从0在 \( G^T \) 上DFS:0→2→1(边反向后,0有入边2,2有入边1,1有入边0?等一下,反向图 \( G^T \) 中0的邻居是2(来自原边2→0),2的邻居是1(1→2),1的邻居是0(0→1)。所以从0访问2和1,全部三个顶点 {0,1,2} 被标记为一个SCC。 弹出1(已访问),跳过。 弹出3(未访问),从3在 \( G^T \) 上DFS:3→1(已访问,停止),只有 {3} 一个顶点?检查反向图:3的邻居是1(已访问)和4(边4→3)。但4未访问?等等,反向图中3的入边是4→3,所以3没有出边到4。从3只能到1(通过边3→1),但1已访问。所以 {3} 单独是一个SCC?不对,原图中3→4,反向图中4→3,所以3和4应该互相可达,属于同一个SCC。这里错误在于:我们在反向图上应从3开始DFS,但需要遵循反向图的边方向。反向图中3的邻居是1(因为原边1→3反向为3→1),但1已访问,DFS结束,漏掉了4。这是因为我们之前栈顺序可能有问题。让我们重新严谨执行。 重新执行(更严谨的栈顺序) : 第一遍DFS在G上,从0开始: 访问0,递归1: 访问1,递归2: 访问2,邻居0已访问,返回,2入栈。 回到1,递归3: 访问3,递归4: 访问4,邻居无(或已访问),返回,4入栈。 回到3,邻居1已访问,返回,3入栈。 回到1,邻居都已访问,返回,1入栈。 回到0,邻居都已访问,返回,0入栈。 栈(从底到顶):[ 2,4,3,1,0 ] → 弹出顺序:0,1,3,4,2。 第二遍在 \( G^T \) 上: visited重置。 弹出0,从0开始DFS:反向图中0的邻居只有2(因为原边2→0)。访问2,2的邻居是1(原边1→2)。访问1,1的邻居是0(已访问)和3(原边3→1,但反向为1→3?等一下,反向图 \( G^T \) 的边:0←1, 1←2, 2←0, 1←3, 3←4。所以: 顶点0的邻居:2 顶点2的邻居:1 顶点1的邻居:0, 3 顶点3的邻居:4 顶点4的邻居:无 从0开始:访问0,2,1(通过0→2→1),然后1的邻居0已访问,3未访问→访问3,3的邻居4未访问→访问4。所以一次DFS访问了 {0,1,2,3,4} 全部顶点!这说明整个图是一个SCC?但原图中从4无法到达0,1,2,3,所以不是强连通。问题出在哪里?错误在于:反向图中1的邻居是0和3,但原边是0→1和3→1,反向后是1→0和1→3,这是正确的。但我们在反向图上从0开始DFS,沿着边0→2→1→3→4,这是反向图的边方向,但对应原图的边方向是相反的。这意味着在反向图上从0能到达所有顶点,但在原图上从所有顶点是否能到0?不一定。实际上,原图中4无法到达0,所以 {0,1,2,3,4} 不是SCC。 这揭示了一个关键点: Kosaraju算法中,第二遍DFS在反向图上得到的连通分量,必须是在反向图上从起点能到达的所有顶点,但这正是原图中能到达起点的所有顶点 。因为反向图上u能到v等价于原图上v能到u。所以,反向图上从0能访问所有顶点,意味着原图中所有顶点都能到达0。但原图中4无法到达0,矛盾。这说明我们的图示例不满足这个性质。检查原图:边是0→1, 1→2, 2→0, 1→3, 3→4。从4出发没有出边,所以4无法到达任何其他顶点。因此原图中所有顶点不能都到达0。所以我们的反向图构造有误?我们仔细检查反向图 \( G^T \): 原边:0→1, 1→2, 2→0, 1→3, 3→4 反向边:1→0, 2→1, 0→2, 3→1, 4→3 所以反向图的邻接表: 0: [ 2 ] 1: [ 0 ] 2: [ 1 ] 3: [ 1 ] 4: [ 3 ] 从0开始DFS:0→2→1→停止(因为1的邻居0已访问,没有其他邻居)。等等,1的邻居是0,已访问,没有边到3!因为反向图中3→1,是3指向1,不是1指向3。所以从1无法到3。所以实际上从0只能访问{0,1,2}。这才是正确的。之前我错误地将1的邻居写成了0和3。纠正后,从0在反向图上DFS只访问{0,1,2}。这样正确了。 继续: 弹出1(已访问),跳过。 弹出3(未访问),从3在反向图上DFS:3→1(已访问),停止,只访问{3}。 弹出4(未访问),从4在反向图上DFS:4→3(已访问),停止,只访问{4}。 最终SCC:{0,1,2}, {3}, {4}。正确。 第四步:算法实现(伪代码) 第五步:算法正确性直观解释 第一遍DFS得到一个顺序,保证如果存在从SCC A到SCC B的路径,那么A中的顶点在栈中会比B中的顶点 更晚弹出 (因为A的后代包括B,完成时间更晚)。 反向图中,所有SCC间的边方向反转,所以从SCC A到SCC B的边变成B到A。因此,在第二遍DFS中,我们从最晚完成的顶点(即某个“源”SCC在原始图中的代表)开始,在反向图上只能访问到自己SCC内的顶点(因为指向其他SCC的边现在是指向自己,但其他SCC已标记?不,实际上因为栈顺序保证我们先处理“源”SCC,它在反向图中是“汇”,没有出边到其他SCC,所以DFS不会跑出去)。 每次DFS恰好取出一个SCC。 第六步:时间与空间复杂度 时间复杂度:两次DFS遍历,每个顶点和边访问一次,\( O(|V| + |E|) \)。 空间复杂度:存储图、反向图、栈和访问数组,\( O(|V| + |E|) \)。 总结 :Kosaraju算法通过两遍DFS,巧妙利用图的反转和顶点处理顺序,高效找出有向图的所有强连通分量。理解的关键在于把握栈顺序如何保证在反向图上每次DFS恰好分离出一个SCC。