强连通分量
代码模板强连通分量的原理与实现:Tarjan 算法求有向图的强连通分量与缩点。
一句话算法
Tarjan SCC 用栈维护当前 DFS 路径上的候选点,当 low[u] == dfn[u] 时,u 到栈顶就是一个强连通分量。
问题模型
在有向图中,如果两个点
能到达 ; 也能到达 ;
则它们强连通。极大的强连通点集叫强连通分量,简称 SCC。
求 SCC 后,常见下一步是缩点:把每个 SCC 看成一个点,原图会变成一张 DAG。
核心直觉
DFS 时,栈里保存的是“已经访问,但还没有确定所属 SCC”的点。
low[u] 表示从 u 出发,沿 DFS 树边和返祖/横叉边,能回到的最早栈内点。
如果:
说明 u 的子树没有办法回到 u 之前的栈内点。于是从 u 到栈顶这一段点互相可达,形成一个完整 SCC。
算法步骤
- 初始化
dfn[u] = 0,栈为空。 - DFS 到点
u:- 令
dfn[u] = low[u] = ++timer; - 把
u入栈,并标记in_stack[u] = true。
- 令
- 枚举有向边
u -> v:- 若
v未访问,递归 DFS,并用low[v]更新low[u]; - 若
v在栈中,用dfn[v]更新low[u]。
- 若
- 如果
low[u] == dfn[u],不断弹栈直到弹出u,这些点属于同一个 SCC。 - 对所有未访问点重复 DFS,处理不连通图。
算法证明
核心不变量:栈中的点是已经访问但尚未确定 SCC 的点。
- 如果边
u -> v指向未访问点,v子树能回到的最早栈内点也能被u通过v到达,所以用low[v]更新low[u]。 - 如果
v已访问且仍在栈中,说明u能到达一个未归属的祖先或同层候选点,所以用dfn[v]更新low[u]。 - 当
low[u] == dfn[u],u的 DFS 子树无法回到u之前的栈内点。此时栈顶到u的所有点都能通过 DFS 树边被u所在结构连接,又能通过low所代表的返祖关系回到同一段中。 - 这段点对外不能再和更早的栈内点合并,所以它是一个极大的强连通分量。
因此出栈时得到的每一组点都是 SCC,且每个点只会出栈一次。
复杂度分析
设点数为
- 时间复杂度:
。 - 空间复杂度:
。
代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
struct TarjanScc {
int n, timer;
std::stack<int> st;
bool in_stack[maxn];
int dfn[maxn], low[maxn], scc_id[maxn];
int scc_cnt; // SCC 的总数
void set(int _n) {
n = _n;
timer = scc_cnt = 0;
memset(dfn, 0, sizeof(dfn));
memset(in_stack, 0, sizeof(in_stack));
}
// 有向图,不要加father参数
void dfs(int u) {
dfn[u] = low[u] = ++timer;
st.push(u);
in_stack[u] = true;
for (int i = e.h[u]; ~i ; i = e[i].next) {
int v = e[i].v;
if (!dfn[v]) { // 如果 v 没被访问过
dfs(v);
// 根据子节点的 low 值更新当前节点的 low 值
low[u] = std::min(low[u], low[v]);
} else if (in_stack[v]) { //返祖边, 如果 v 在栈中,说明构成了环
low[u] = std::min(low[u], dfn[v]);
}
}
// 如果 dfn == low,说明找到了一个 SCC 的起始点
if (low[u] == dfn[u]) {
scc_cnt++;
while (1) {
int v = st.top(); st.pop();
in_stack[v] = 0;
scc_id[v] = scc_cnt; // 标记所属 SCC 编号
if (v == u) break; // 直到找到起始点
}
}
}
void solve() {
for (int i = 1; i <= n; i++) {
if (!dfn[i]) dfs(i);
}
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# 有向图 Tarjan 求强连通分量:先把有向边 e.add(u, v) 加进全局 e,再 scc.set(n)、scc.solve()。
# 结果在 scc.scc_id[u](所属 SCC 编号,从 1 开始)与 scc.scc_cnt。
# dfs 改显式栈:有向图可以是 1e5 长链,递归写法会爆 Python 栈。
from collections import defaultdict
class Edge:
"""链式前向星的边:v 终点、next 下一条出边编号。"""
__slots__ = ("u", "v", "w", "next")
def __init__(self, u: int, v: int, w: int, next: int) -> None:
self.u = u
self.v = v
self.w = w
self.next = next
class LinkList:
"""与 linklist.py 的 linkList 等价的最小实现,保证本文件可独立复制使用。"""
def __init__(self) -> None:
self.reset()
def reset(self) -> None:
self.edge_cnt = 0
self.e: list[Edge] = []
self.h: defaultdict[int, int] = defaultdict(lambda: -1)
def add(self, u: int, v: int, w: int = 0) -> None:
self.e.append(Edge(u, v, w, self.h[u]))
self.h[u] = self.edge_cnt
self.edge_cnt += 1
def __getitem__(self, i: int) -> Edge:
return self.e[i]
e = LinkList()
class TarjanScc:
"""dfn/low 是时间戳,st 是 Tarjan 栈,in_stack[v] 表示 v 还在栈中。"""
def __init__(self) -> None:
self.n = 0
self.timer = 0
self.st: list[int] = []
self.in_stack: list[bool] = []
self.dfn: list[int] = []
self.low: list[int] = []
self.scc_id: list[int] = []
self.scc_cnt = 0
def set(self, _n: int) -> None:
"""按点数 n 重新分配数组并清零(C++ 里是 memset 定长数组)。"""
self.n = _n
self.timer = 0
self.scc_cnt = 0
self.dfn = [0] * (_n + 1)
self.low = [0] * (_n + 1)
self.scc_id = [0] * (_n + 1)
self.in_stack = [False] * (_n + 1)
self.st = []
def dfs(self, start: int) -> None:
"""从 start 出发做 Tarjan;用显式栈模拟递归,回溯时更新父节点 low。"""
self.timer += 1
self.dfn[start] = self.low[start] = self.timer
self.st.append(start)
self.in_stack[start] = True
# 栈帧 (节点, 下一条待处理出边编号)
call: list[tuple[int, int]] = [(start, e.h[start])]
while call:
u, i = call[-1]
advanced = False
while i != -1:
v = e[i].v
i = e[i].next
if self.dfn[v] == 0:
call[-1] = (u, i)
self.timer += 1
self.dfn[v] = self.low[v] = self.timer
self.st.append(v)
self.in_stack[v] = True
call.append((v, e.h[v]))
advanced = True
break
if self.in_stack[v]:
# 返祖边:只能回到栈中的点,用 dfn[v] 更新 low[u]。
self.low[u] = min(self.low[u], self.dfn[v])
if advanced:
continue
call.pop()
if self.low[u] == self.dfn[u]:
self.scc_cnt += 1
while True:
v = self.st.pop()
self.in_stack[v] = False
self.scc_id[v] = self.scc_cnt
if v == u:
break
if call:
# 对应递归返回后 low[父] = min(low[父], low[子])。
pu = call[-1][0]
self.low[pu] = min(self.low[pu], self.low[u])
def solve(self) -> None:
for i in range(1, self.n + 1):
if self.dfn[i] == 0:
self.dfs(i)
测试用例
输入图:
5 6
1 2
2 3
3 1
3 4
4 5
5 4
强连通分量为:
{1,2,3}
{4,5}
1,2,3 互相可达,4,5 互相可达;两个 SCC 之间只有从前者到后者的方向。
应用分类详解
SCC 的本质是把有向图中的环状互达结构压成一个点。
一、有向图缩点
典型模式: 有向图里存在环,需要把互相可达的点合并。
识别信号: 出现“强连通”“缩点”“有向图变 DAG”。
核心建模: 每个 SCC 缩成一个点,跨 SCC 的边形成 DAG。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 缩点模板 | luogu-P3387 | SCC 缩点后 DAG DP |
| 受欢迎的牛 | poj-2186 | 缩点后找唯一出度为 0 的 SCC |
二、判断两个点是否在同一个有向环结构中
典型模式: 判断两个点是否互相可达。
识别信号: 题面要求“能互相到达”“是否处在同一个循环依赖中”。
核心建模: 如果 scc_id[u] == scc_id[v],则两点互相可达。
三、2-SAT 的基础组件
典型模式: 布尔变量约束,判断是否存在合法取值。
识别信号: 出现“每个变量二选一”“若 A 则 B”。
核心建模: 建蕴含图后,如果变量和它的反变量在同一 SCC,则无解。
经典例题
1. luogu-P3387
缩点模板题。SCC 缩点后在 DAG 上做最长路/DP。
2. poj-2186
受欢迎的牛。缩点后若只有一个出度为 0 的 SCC,则答案是它的大小。
3. luogu-P4782
2-SAT 模板题。SCC 用来判断变量与反变量是否冲突。