强连通分量

代码模板

强连通分量的原理与实现:Tarjan 算法求有向图的强连通分量与缩点。

一句话算法

Tarjan SCC 用栈维护当前 DFS 路径上的候选点,当 low[u] == dfn[u] 时,u 到栈顶就是一个强连通分量。

问题模型

在有向图中,如果两个点 u,vu,v 满足:

  • uu 能到达 vv;
  • vv 也能到达 uu;

则它们强连通。极大的强连通点集叫强连通分量,简称 SCC。

求 SCC 后,常见下一步是缩点:把每个 SCC 看成一个点,原图会变成一张 DAG。

核心直觉

DFS 时,栈里保存的是“已经访问,但还没有确定所属 SCC”的点。

low[u] 表示从 u 出发,沿 DFS 树边和返祖/横叉边,能回到的最早栈内点。

如果:

low[u]=dfn[u] low[u] = dfn[u]

说明 u 的子树没有办法回到 u 之前的栈内点。于是从 u 到栈顶这一段点互相可达,形成一个完整 SCC。

算法步骤

  1. 初始化 dfn[u] = 0,栈为空。
  2. DFS 到点 u:
    • 令 dfn[u] = low[u] = ++timer;
    • 把 u 入栈,并标记 in_stack[u] = true。
  3. 枚举有向边 u -> v:
    • 若 v 未访问,递归 DFS,并用 low[v] 更新 low[u];
    • 若 v 在栈中,用 dfn[v] 更新 low[u]。
  4. 如果 low[u] == dfn[u],不断弹栈直到弹出 u,这些点属于同一个 SCC。
  5. 对所有未访问点重复 DFS,处理不连通图。

算法证明

核心不变量:栈中的点是已经访问但尚未确定 SCC 的点。

  1. 如果边 u -> v 指向未访问点,v 子树能回到的最早栈内点也能被 u 通过 v 到达,所以用 low[v] 更新 low[u]。
  2. 如果 v 已访问且仍在栈中,说明 u 能到达一个未归属的祖先或同层候选点,所以用 dfn[v] 更新 low[u]。
  3. 当 low[u] == dfn[u],u 的 DFS 子树无法回到 u 之前的栈内点。此时栈顶到 u 的所有点都能通过 DFS 树边被 u 所在结构连接,又能通过 low 所代表的返祖关系回到同一段中。
  4. 这段点对外不能再和更早的栈内点合并,所以它是一个极大的强连通分量。

因此出栈时得到的每一组点都是 SCC,且每个点只会出栈一次。

复杂度分析

设点数为 nn,边数为 mm。

  • 时间复杂度:O(n+m)O(n+m)。
  • 空间复杂度:O(n+m)O(n+m)。

代码实现

C++
cpp
        
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); } } };
Python
python
        
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 用来判断变量与反变量是否冲突。