← 返回数据结构与算法目录
← 返回学习笔记首页
强连通分量(Tarjan / Kosaraju)
数据结构与算法专题 · 有向图的强连通区域分解
专题: 数据结构与算法系统学习
关键词: 数据结构, 算法, 强连通分量, SCC, Tarjan, Kosaraju, 缩点, DAG, 2-SAT, 时间戳
一、引言:什么是强连通分量
强连通分量 (Strongly Connected Component,简称SCC)是有向图 理论中最核心的概念之一。给定一个有向图 G = (V, E) ,若其中的一个顶点子集 C ⊆ V 满足:对于任意两个顶点 u, v ∈ C ,既存在从 u 到 v 的路径,也存在从 v 到 u 的路径,则称 C 是一个强连通分量。
直观理解: 在一个强连通分量内部,任意两个顶点都可以"互相到达"。你可以从任何一个节点出发,沿着有向边到达分量内的所有其他节点,并且还能走回来。这与无向图中的"连通分量"概念相对——在有向图中,即使图是"弱连通的",也不一定强连通。
为什么研究强连通分量?因为SCC揭示了有向图的骨架结构 。将每个SCC"缩"成一个超级节点后,原图就变成了一个有向无环图(DAG) 。DAG具有天然的拓扑序,许多在图上的复杂问题(如可达性查询、2-SAT求解、程序依赖分析)都可以在SCC缩点后的DAG上高效解决。
核心思想: SCC分解 = 将有向图划分为多个"环路区域",每个区域内部任意两点互相可达;区域之间形成有向无环结构。
二、SCC的定义与数学性质
2.1 形式化定义
设 G = (V, E) 是一个有向图。定义顶点间的强连通关系 ~ 为:u ~ v 当且仅当存在从 u 到 v 的路径且存在从 v 到 u 的路径。容易证明,"~"是一个等价关系 (满足自反性、对称性、传递性)。强连通分量就是该等价关系下的等价类。每个顶点恰好属于一个SCC。
2.2 重要性质
极大性: SCC是极大强连通子图——往SCC中添加任意一个外部顶点都会破坏强连通性。
划分性: 所有SCC构成原图顶点集的一个划分(partition),即每个顶点属于且仅属于一个SCC。
DAG性质: 将每个SCC缩为一个节点后,新图是一个有向无环图(DAG),称为SCC缩点图 (Condensation Graph)。
拓扑序: 缩点后的DAG存在一个唯一的拓扑排序(可能不唯一),且该拓扑序与Kosaraju算法中第二次DFS的完成时间顺序密切相关。
反向图等价性: 原图的SCC与反向图的SCC完全相同(因为强连通路径反转后仍然存在)。
单点SCC: 任意一个顶点自身构成一个平凡SCC,除非它与其他顶点互达。
2.3 示例
1 → 2 → 3 → 1
2 → 4 → 5 → 6
5 → 4
SCC1: {1,2,3}(形成一个有向环1→2→3→1)
SCC2: {4,5}(4→5→4构成双向连通)
SCC3: {6}(孤立点,平凡SCC)
缩点后DAG: SCC1 → SCC2 → SCC3
三、Kosaraju算法(两次DFS法)
3.1 算法思想
Kosaraju算法由 S. Rao Kosaraju 在1978年提出(也独立由Micha Sharir发现),是最直观的SCC求解算法。其核心思想基于一个关键观察:原图与反向图具有完全相同的SCC集合 。算法通过两次深度优先搜索(DFS)完成分解:第一次DFS确定顶点的"完成时间"(finishing time)顺序;第二次DFS在反向图上按照逆完成时间顺序遍历,每次遍历到的一组顶点就是一个SCC。
算法本质: 利用反向图将"强连通性"转化为"可达性"问题。在反向图上,如果按照原图DFS完成时间的逆序进行遍历,那么每次搜索访问到的顶点恰好构成一个SCC。
3.2 算法步骤
第一次DFS(原图): 在原图 G 上执行DFS,记录每个顶点的"离开时间"(finish time)。将所有顶点按照finish time的降序压入一个栈(或列表)。
构建反向图: 将原图的所有边方向反转,得到反向图 GT 。
第二次DFS(反向图): 按照finish time从大到小的顺序(即从栈顶依次弹出顶点),在 GT 上执行DFS。每次DFS遍历到的所有顶点构成一个SCC。
输出: 将每个顶点标记为所属的SCC编号。
3.3 时间复杂度
O(V + E) ,即与图的顶点数和边数呈线性关系。两次DFS各需要O(V+E)时间,构建反向图需要O(V+E)空间和时间。Kosaraju算法是最优的线性时间SCC算法之一。
3.4 Python实现
from collections import defaultdict
class KosarajuSCC :
def __init__ (self , n, edges):
self .n = n # 顶点数(顶点编号0~n-1)
self .graph = defaultdict(list) # 原图
self .rgraph = defaultdict(list) # 反向图
for u, v in edges:
self .graph[u].append(v)
self .rgraph[v].append(u)
def dfs1 (self , u, visited, order):
# 第一次DFS:记录完成顺序
visited[u] = True
for v in self .graph[u]:
if not visited[v]:
self .dfs1(v, visited, order)
order.append(u) # 记录完成时间(后序)
def dfs2 (self , u, visited, comp_id, scc):
# 第二次DFS:在反向图上收集SCC
visited[u] = True
scc.append(u)
self .scc_id[u] = comp_id
for v in self .rgraph[u]:
if not visited[v]:
self .dfs2(v, visited, comp_id, scc)
def find_sccs (self ):
# 第一步:第一次DFS,确定finish order
visited = [False ] * self .n
order = []
for i in range (self .n):
if not visited[i]:
self .dfs1(i, visited, order)
# 第二步:在反向图上按逆finish order进行DFS
visited = [False ] * self .n
self .scc_id = [-1 ] * self .n
comp_id = 0
scc_list = []
for u in reversed (order):
if not visited[u]:
scc = []
self .dfs2(u, visited, comp_id, scc)
scc_list.append(scc)
comp_id += 1
return scc_list
def build_condensation (self ):
# 构建缩点后的DAG
if not hasattr (self , 'scc_id' ):
self .find_sccs()
dag = defaultdict(set)
for u in range (self .n):
for v in self .graph[u]:
if self .scc_id[u] != self .scc_id[v]:
dag[self .scc_id[u]].add(self .scc_id[v])
return dag
Kosaraju算法的优雅之处在于它的简洁和直观:两次DFS,一次正向一次反向,SCC就自然浮现了。缺点是需要构建反向图,占用额外的O(E)空间。
四、Tarjan算法(时间戳+低链接值)
4.1 算法思想
Tarjan算法由 Robert Tarjan 在1972年提出,只需要一次DFS 即可找出所有SCC。Tarjan算法定义了每个顶点的两个关键属性:
dfn[u](时间戳/发现时刻): 顶点 u 在DFS中被首次访问时的顺序编号。类似于传统DFS中的discovery time。
low[u](低链接值): 从 u 出发,通过至多一条反向边 (即DFS树中的回边或横叉边)能到达的、具有最小dfn值的顶点的dfn值。更形式化地说,low[u] = min( dfn[u], dfn[v] ) ,其中 v 是 u 的子树中通过一条非树边能到达的顶点。
核心判据: 当 dfn[u] == low[u] 时,以 u 为根的DFS子树中,所有仍在栈中的顶点构成一个完整的SCC。
4.2 算法步骤
对图的每个顶点执行DFS,维护全局时间戳计数器 timer 。
访问顶点 u 时,设置 dfn[u] = low[u] = ++timer ,将 u 压入栈。
遍历 u 的所有邻接点 v :
若 v 未被访问,递归DFS v ,然后用 low[v] 更新 low[u] = min(low[u], low[v]) 。
若 v 已被访问且仍在栈中 (说明 (u,v) 是一条回边或横叉边指向当前正在处理的SCC),用 dfn[v] 更新 low[u] = min(low[u], dfn[v]) 。
递归返回后,若 dfn[u] == low[u] ,则从栈顶弹出元素直到弹出 u ,这些元素构成一个SCC。
4.3 时间复杂度
Tarjan算法同样只需要 O(V + E) 时间。与Kosaraju相比,Tarjan只需要一次DFS,不需要构建反向图,因此常数更小,空间占用更少 。Tarjan算法也是线性时间SCC算法中的经典代表。
4.4 Python实现
class TarjanSCC :
def __init__ (self , n, edges):
self .n = n
self .graph = [[] for _ in range (n)]
for u, v in edges:
self .graph[u].append(v)
self .dfn = [-1 ] * n # 时间戳,-1表示未访问
self .low = [0 ] * n
self .stack = []
self .in_stack = [False ] * n
self .timer = 0
self .scc_id = [-1 ] * n
self .comp_cnt = 0
self .scc_list = []
def dfs (self , u):
self .timer += 1
self .dfn[u] = self .low[u] = self .timer
self .stack.append(u)
self .in_stack[u] = True
for v in self .graph[u]:
if self .dfn[v] == -1 : # 树边
self .dfs(v)
self .low[u] = min (self .low[u], self .low[v])
elif self .in_stack[v]: # 回边/横叉边指向栈中节点
self .low[u] = min (self .low[u], self .dfn[v])
# 如果u是SCC的根节点,弹栈
if self .dfn[u] == self .low[u]:
scc = []
while True :
w = self .stack.pop()
self .in_stack[w] = False
self .scc_id[w] = self .comp_cnt
scc.append(w)
if w == u:
break
self .scc_list.append(scc)
self .comp_cnt += 1
def find_sccs (self ):
for i in range (self .n):
if self .dfn[i] == -1 :
self .dfs(i)
return self .scc_list
记忆口诀: "时间戳、低链接、DFS来遍历;子节点更新low,dfn与low相等时弹栈成SCC。"
4.5 Tarjan算法示例推演
以之前的图为例(顶点1-6):
DFS访问顺序:1 → 2 → 3(回到1)→ 4 → 5(回到4)→ 6
dfn: [1, 2, 3, 4, 5, 6] 对应顶点[1,2,3,4,5,6]
low: [1, 1, 1, 4, 4, 6] 对应顶点[1,2,3,4,5,6]
顶点3返回时:dfn[3]=3, low[3]=1, 不等→不弹栈
顶点1返回时:dfn[1]=1, low[1]=1, 相等→弹栈得到SCC1={3,2,1}
顶点5返回时:dfn[5]=5, low[5]=4, 不等→不弹栈
顶点4返回时:dfn[4]=4, low[4]=4, 相等→弹栈得到SCC2={5,4}
顶点6返回时:dfn[6]=6, low[6]=6, 相等→弹栈得到SCC3={6}
五、Tarjan vs Kosaraju 算法对比
Tarjan和Kosaraju都是线性时间的SCC求解算法,它们在设计理念和性能特征上存在显著差异。下面从多个维度进行对比:
对比维度
Kosaraju算法
Tarjan算法
DFS次数
2次DFS(正向+反向)
1次DFS
空间复杂度
O(V+E)——需要存储反向图
O(V)——只需要栈和dfn/low数组
反向图
必须构建反向图
不需要
思路直观性
非常直观,容易理解和记忆
需要理解low值含义,较抽象
常数因子
略大(两次DFS+反向图构建)
较小(一次DFS,无额外构建)
适用场景
教学、对代码简洁性要求高的场合
竞赛编程、性能敏感场景
最坏情况
O(V+E),稳定
O(V+E),稳定
能否同时求割点
不能直接扩展
简单扩展即可求割点和桥
发明时间
1978年
1972年(更早)
选择建议: 如果你更注重代码的可读性和教学意义,选择Kosaraju;如果你在编写竞赛代码或工程实现,并且需要更优的常数性能,选择Tarjan。两种算法在生产环境中都有广泛应用。
综合分析
Kosaraju的美在于其对称性和直观性 ——两次DFS对称操作,让人一目了然。Tarjan的妙在于其精巧的递归发现机制 ——通过low值在单次DFS中同时完成探索和归约,展现了Tarjan在算法设计上的非凡才华。从实际应用角度看,Tarjan略占优势,因为它不需要额外的反向图存储,且在求SCC的同时还可以扩展求解无向图的割点(articulation point)和桥(bridge)。
共同局限: 两种算法都依赖递归DFS,对于极大规模的图(如百万级顶点),递归深度可能导致栈溢出。此时可以考虑使用显式栈模拟DFS,或使用迭代的Kosaraju变体。
六、SCC缩点与DAG构建
6.1 缩点概念
SCC缩点 (Condensation)是指将每个强连通分量视为一个"超级节点",原图中分量之间的边变为超级节点之间的有向边。缩点后得到的图必然是一个有向无环图(DAG) 。这一性质使得原本在有向图上复杂的问题可以简化为在DAG上处理。
6.2 缩点算法
在完成SCC分解后,缩点过程非常直接:
为每个SCC分配一个唯一的编号(0, 1, 2, ...)。
遍历原图的每一条边 (u, v) ,令 id[u] 和 id[v] 分别为 u 和 v 所属的SCC编号。
若 id[u] != id[v] ,则在缩点图中添加一条从 id[u] 到 id[v] 的有向边(注意去除重边)。
6.3 缩点后的性质
DAG性质: 缩点图一定无环。若存在环,则该环上的所有超级节点实际上属于同一个更大的SCC,与SCC的极大性矛盾。
拓扑序继承: Kosaraju第二次DFS的访问顺序天然就是缩点DAG的拓扑逆序。Tarjan算法中SCC被发现的顺序也是拓扑逆序。
可达性等价: 原图中节点 u 能到达 v ,当且仅当在缩点图中 id[u] 能到达 id[v] (或两者属于同一个SCC)。
6.4 Python实现:完整SCC分解与缩点
def tarjan_scc_condensation (n, edges):
"""Tarjan算法求解SCC并构建缩点DAG"""
graph = [[] for _ in range (n)]
for u, v in edges:
graph[u].append(v)
dfn = [-1 ] * n
low = [0 ] * n
on_stack = [False ] * n
stack = []
scc_id = [-1 ] * n
timer = 0
comp_cnt = 0
def dfs (u):
nonlocal timer, comp_cnt
timer += 1
dfn[u] = low[u] = timer
stack.append(u)
on_stack[u] = True
for v in graph[u]:
if dfn[v] == -1 :
dfs(v)
low[u] = min (low[u], low[v])
elif on_stack[v]:
low[u] = min (low[u], dfn[v])
if dfn[u] == low[u]:
while True :
w = stack.pop()
on_stack[w] = False
scc_id[w] = comp_cnt
if w == u:
break
comp_cnt += 1
for i in range (n):
if dfn[i] == -1 :
dfs(i)
# 构建缩点DAG
dag_edges = set ()
for u in range (n):
for v in graph[u]:
if scc_id[u] != scc_id[v]:
dag_edges.add((scc_id[u], scc_id[v]))
cond_graph = [[] for _ in range (comp_cnt)]
for u, v in dag_edges:
cond_graph[u].append(v)
return scc_id, comp_cnt, cond_graph
缩点应用: 一旦得到SCC缩点DAG,就可以在DAG上进行动态规划求解最长路径、最小路径覆盖、拓扑排序等经典问题。
七、SCC的经典应用场景
7.1 2-SAT问题(布尔可满足性)
2-SAT (2可满足性问题)是SCC最著名的应用之一。给定一组布尔变量和形如 (x ∨ y) 的二元子句(每个子句恰好两个文字),2-SAT问题判断是否存在一组变量赋值使所有子句同时成立。2-SAT可以通过SCC在 O(n + m) 时间内求解(其中 n 为变量数,m 为子句数),这是多项式时间的经典结果。
2-SAT的SCC解法: 对于每个布尔变量 x ,创建两个节点 x (代表 x = true )和 ¬x (代表 x = false )。每个子句 (a ∨ b) 等价于蕴含式 ¬a → b 和 ¬b → a 。添加对应的有向边后,若 x 和 ¬x 处于同一个SCC中,则无解;否则,通过选择每个SCC中拓扑序靠后的文字作为赋值方案即可得到一组可行解。
def two_sat (n, clauses):
"""2-SAT求解器:基于SCC缩点
n: 布尔变量数量(编号0~n-1)
clauses: 子句列表,每个子句为(a, b),表示(a OR b)
正数表示x_i,负数表示NOT x_i(如-3表示¬x₃)
"""
# 节点编号:x_i -> i, ¬x_i -> i + n (i从0开始)
def node (lit):
return abs (lit) - 1 if lit > 0 else abs (lit) - 1 + n
edges = []
for a, b in clauses:
edges.append((node(-a), node(b))) # ¬a → b
edges.append((node(-b), node(a))) # ¬b → a
# 使用Tarjan算法求SCC
scc_id, comp_cnt, _ = tarjan_scc_condensation(2 * n, edges)
# 检查是否有变量x和¬x在同一SCC中
for i in range (n):
if scc_id[i] == scc_id[i + n]:
return None # 无解
# 构造一组可行解:选择SCC拓扑序较大的文字
result = [False ] * n
for i in range (n):
# scc_id越大,拓扑序越靠后,选择它
result[i] = (scc_id[i] > scc_id[i + n])
return result
7.2 程序依赖分析与软件模块化
在编译器设计和软件工程中,SCC用于分析程序中的循环依赖 。将程序中的每个函数看作一个节点,函数调用关系看作有向边,那么SCC就代表了"互相递归调用"的函数集合。识别这些集合有助于:
编译优化: SCC内部的函数可以一起内联优化。
代码重构: 检测循环依赖,指导模块解耦。
包管理: 分析库之间的依赖关系,发现循环依赖问题。
循环检测: 在软件架构中,SCC大小超过阈值通常被视为设计"坏味道"(code smell)。
7.3 社交网络社区检测
在社交网络分析中,有向图SCC可以用来发现紧密联系的社区 。如果一个用户群组在关注/好友关系中形成强连通分量,说明他们之间有很强的双向互动关系。这些可以是:
兴趣小组: 互相频繁互动的用户群体。
信息传播: 在SCC内部信息可以传播到所有成员,形成"舆论回声室"。
影响力分析: SCC之间的边揭示了信息在不同社区之间的流动方向。
7.4 网页链接分析与搜索引擎
互联网的网页链接图是一个巨大的有向图。SCC在其中扮演了重要角色:
Web中的"核心": 研究发现WWW图中存在一个巨大的SCC(称为"核心"),包含约四分之一的网页。从这个核心出发可以到达大量网页,也能被大量网页到达。
PageRank优化: 在PageRank计算中,SCC内部的网页可以预先聚合计算,加速迭代收敛。
爬虫策略: 了解SCC结构可以帮助设计更高效的网页爬取策略。
7.5 其他应用
电路设计
在数字逻辑电路中,SCC检测时序逻辑中的反馈回路,帮助进行时序分析和优化。
等价关系判定
在自动机理论中,SCC用于检测非确定有限自动机(NFA)中的等价状态,进行状态最小化。
数据库事务
在事务依赖图中,SCC用于检测死锁环——如果事务形成SCC,则可能存在死锁。
应用启发: 任何涉及"双向可达性"的场景,或者需要将"循环结构"从"层次结构"中分离出来的问题,都可以尝试使用SCC分解作为第一步。
八、Garbow算法简介
8.1 算法概述
Garbow算法 是Tarjan算法的一个变体,由Garbow在1985年提出。它与Tarjan算法的核心区别在于:Garbow使用两个辅助栈 来跟踪路径信息,而不是使用low值来判定SCC的根节点。
8.2 算法思想
Garbow算法维护两个栈:
栈1(主栈): 存放当前DFS路径上的顶点,与Tarjan中的栈功能相同。
栈2(次级栈): 存放"候选SCC根节点"。每当从栈2中弹出的节点恰好是当前递归返回的节点时,就找到了一个SCC。
Garbow算法在DFS过程中,当遇到指向栈中顶点的回边时,会从栈2中弹出所有dfn值大于被指向顶点dfn值的节点。递归返回时,如果栈2的栈顶元素等于当前节点,则当前节点是一个SCC根节点,从主栈中弹出所有节点构成一个SCC。
8.3 时间复杂度
同样为 O(V + E) 。但Garbow算法在实践中比Tarjan稍慢,因为需要维护两个栈的额外操作。不过,某些特定图结构下Garbow可能表现更优。
8.4 Python实现
class GarbowSCC :
def __init__ (self , n, edges):
self .n = n
self .graph = [[] for _ in range (n)]
for u, v in edges:
self .graph[u].append(v)
self .dfn = [-1 ] * n
self .stack1 = [] # 主栈
self .stack2 = [] # 次级栈(候选根节点)
self .timer = 0
self .comp_cnt = 0
self .scc_id = [-1 ] * n
self .scc_list = []
def dfs (self , u):
self .timer += 1
self .dfn[u] = self .timer
self .stack1.append(u)
self .stack2.append(u)
for v in self .graph[u]:
if self .dfn[v] == -1 :
self .dfs(v)
elif self .scc_id[v] == -1 :
# v已访问但未被分配SCC,说明它是祖先或横叉边指向栈中节点
# 从stack2中弹出dfn值大于dfn[v]的节点
while self .stack2 and self .dfn[self .stack2[-1 ]] > self .dfn[v]:
self .stack2.pop()
# 如果栈2顶是u,则u是SCC的根节点
if self .stack2 and self .stack2[-1 ] == u:
self .stack2.pop()
scc = []
while True :
w = self .stack1.pop()
self .scc_id[w] = self .comp_cnt
scc.append(w)
if w == u:
break
self .scc_list.append(scc)
self .comp_cnt += 1
def find_sccs (self ):
for i in range (self .n):
if self .dfn[i] == -1 :
self .dfs(i)
return self .scc_list
Garbow vs Tarjan: Garbow使用次级栈替代了Tarjan的low值判定逻辑。两种算法本质等价,只是不同的表现形式。Tarjan更广为人知,而Garbow在某些教材中有提及,作为理解SCC问题的另一种视角。
九、核心要点总结
强连通分量(SCC)知识体系要点:
定义本质: SCC是等价类划分——有向图中任意两点互相可达的极大子图。
核心定理: 缩点后的图必定是DAG,这是在SCC上做DP、拓扑排序等操作的理论基础。
三种算法对比: Kosaraju(两次DFS,直观)、Tarjan(一次DFS,高效)、Garbow(Tarjan变体,双栈)。三者复杂度均为O(V+E)。
Tarjan关键: dfn记录访问时间,low记录可回溯的最早祖先,dfn==low时弹栈成SCC。
Kosaraju关键: 反向图 + 按finish time逆序遍历 = SCC的自然划分。
缩点步骤: 遍历原图边,不同SCC之间建边,去重后得到DAG。
2-SAT: 每个变量拆两点(true/false),子句转蕴含图,SCC判定可满足性。
扩展应用: 程序依赖分析、社交网络社区发现、网页核心结构分析、电路反馈检测等。
常见陷阱: Tarjan中"仍在栈中"的检查不可或缺(否则会错误合并SCC);Kosaraju需要确保图是连通的(考虑孤立节点)。
工程实践: 对于超大规模图,使用迭代DFS而非递归以避免栈溢出;使用邻接表存储节省空间。
进阶阅读方向
SCC在流图中的应用: 程序控制流图中,SCC用于循环检测和循环优化。
SAT问题的扩展: 从2-SAT到一般SAT的求解器(DPLL、CDCL)中,SCC也扮演了角色。
并行SCC算法: Forward-Backward算法、颜色排序算法等可在分布式/并行环境中高效求解SCC。
SCC与图神经网络的结合: 在GNN中利用SCC结构增强消息传递的表达能力。
在线SCC维护: 当图动态变化(增删边)时,如何高效维护SCC划分?这是近年来的研究课题。
一句话总结: 强连通分量是把有向图"化繁为简"的终极工具——它剥离出图中的循环结构,暴露出隐藏的DAG骨架,为后续的路径分析、优化求解和系统建模奠定了坚实基础。
十、典型练习题与思考题
基础练习: 请手动画出一个包含5个顶点、7条边的有向图,使其包含2个非平凡SCC和1个单点SCC,然后用Tarjan算法手动模拟运算过程,写出每个顶点的dfn和low值。
经典题目: 在LeetCode或Codeforces上搜索以下题目进行练习——LeetCode 1192(查找集群内的关键连接)、POJ 2186(受欢迎的牛)、Codeforces 427C(Checkposts)、UVA 315(Network,求割点)。
思考题: 给定一个有向无环图,最少添加多少条有向边可以使整个图变成强连通图?如果有多个SCC的缩点DAG,最少需要添加多少条边才能使整个原图强连通?(答案:设缩点DAG的入度为0的节点数为s,出度为0的节点数为t,则最少需要添加max(s, t)条边。)