Tarjan 求强联通分量 SCC
Tarjan 求强联通分量 SCC
强联通分量(Strongly Connected Component,SCC)是一个 有向图 中的点集
- 这个点集内的任意两个点,都可以相互抵达
- 比如说,一个环,它是一个 SCC
- 比如说,一条链,虽然链头可以抵达任何点,但是其他点都无法抵达链头
在链的情况下,每个点都是一个独立的 SCC,大小为 1
我们讨论的强联通分量,一般默认是极大的,举个例子:
- 如果若干个简单环构成了一个大环,我们将大环内的点全部归入同一个 SCC
可以根据上述的定义,得到两个有用的结论:
- 如果我们从 SCC 里的任何一个点开始 dfs,那么能访问到这个 SCC 里的所有点
- 如果我们从 SCC 里的任何一个点开始 dfs,那么未访问的点,不在这个 SCC 里
Tarjan 的 SCC 算法是一种基于 dfs 的算法
在了解这个算法之前,我们先来了解一些关于 dfs 的性质:
-
每次 dfs 都会产生一颗 dfs 生成树,下图是一个不错的例子(贺了 oiwiki 的图)
-
[ 树边 ] 是正常的 dfs 过程中访问的边,此外还有几类特殊的边
-
[ 前向边 ] 图上的例子是 3 → 6
也就是从 父亲 3 → 已访问过的 子树节点 6- 观察一下,树边是向下的,前向边也是向下的,
所有点的连通性不变,并不会导致 SCC 的产生
- 观察一下,树边是向下的,前向边也是向下的,
-
[ 返祖边 ] 图上的例子是 7 → 1
也就是从一个节点出发,能返回其祖先的边
SCC 产生的最主要原因是它,我们有 1 到 7 的一条树链,再加上一条回边
这样就可以产生一个 SCC 了 -
[ 横叉边 ] 图上的例子是 9 → 7
这两个点有相同的 LCA(最近公共祖先),并分居在 LCA 的两棵子树里- 本质上不会直接导致 SCC 的产生,但是加上之前的返祖边,就会产生 SCC 了
考虑 dfs 过程,我们每次会按某些顺序访问所有可达的点,
对于每个 SCC,一定有一个点会被先枚举到,这里称其为 \(r\)
根据之前的结论,从 \(r\) 出发,可以向下访问到所有和 \(r\) 在同一 SCC 里的点
由于树边全部向下,只要任意添加向上的边,就可以产生 SCC
也就是说:
- 如果一个点 \(v\) 可以访问其祖先 \(f\),那么 \(v\) 和 \(f\) 在同一个 SCC 里
- \(v\) 经过返祖边,访问其祖先
- \(v\) 经过横叉边,最后经过返祖边,访问其祖先
- 如果一个点 \(v\) 的孩子 \(ch\) 可以访问 \(v\) 的祖先 \(f\),那么 \(v\) 和 \(f\) 在同一个 SCC 里
而且 \(v\) 到 \(ch\) 路径上的所有点,都和 \(f\) 在同一个 SCC 里
Tarjan 的算法还有一个想法:
- 因为我们找的 SCC 是极大的,一旦某个 SCC 被确定,则不会继续扩大了
我们按 dfs 的顺序,给访问到的每个点一个编号,即 dfs 序,这里称其为 dfn
此外为每个点定义一个 low,表示这个点可以访问到的合法节点中,最小的 dfn 值
这个值可以在 dfs 递归的时候维护好
这里 \(\operatorname{out}_i\) 表示节点 \(i\) 可以直接访问的目前还在栈内的点,即之前说的合法
让我们来解释一下这个栈是什么
考虑 dfs 过程,我们每次会按某些顺序访问所有可达的点,
对于每个 SCC,一定有一个点会被先枚举到,有 low = dfn
递归回来时,这个 SCC 所有的点都被访问过了,我们希望能找到它们
根据上面所有的观察,我们可以维护一个栈,在 dfs 过程中不断将节点推入
在离开节点 \(u\) 时,如果满足 low = dfn,则一直弹栈,直到 \(u\) 不在栈内
这个过程中弹出的所有节点,都应该和 \(u\) 处于同一个 SCC 中
也就是说,这个栈维护的是,目前尚未组成极大 SCC 的点
我们可能通过返祖边 / 横叉边,将这些点和自己放入同一个 SCC 中
我们可以定义一个 dfs 函数 tarjan(\(u\)),表示
- 算出子树内所有的 low,并且来更新自己的 low
- 找出 \(u\) 的子树内(含 \(u\) 自己)所有极大 SCC,
如果不能保证极大,则应该把这些点继续留在栈内
int dfn[maxn], low[maxn], dfncnt;
int bel[maxn], siz[maxn], scccnt;
int stk[maxn], ins[maxn], top;
// bel[i] 表示 i 所属的 SCC 编号
// siz[i] 表示第 i 个 SCC 的大小
// stk 是栈,ins[i] 表示 i 是否在栈内
void tarjan(int p) {
dfn[p] = low[p] = ++dfncnt; // 初始化
stk[++top] = p, ins[p] = 1; // 入栈
for (auto to : G[p]) {
if (!dfn[to]) { // 如果节点还没有被访问过
tarjan(to);
chmin(low[p], low[to]);
} else if (ins[to]) // 否则如果节点在栈内
chmin(low[p], dfn[to]);
// chmin(low[p], low[to]); 也是可以的
}
if (dfn[p] == low[p]) { // 如果自己是 SCC 的第一个节点
scccnt++;
int f = 1;
while (f) { // 不断弹栈
siz[scccnt]++;
bel[stk[top]] = scccnt;
ins[stk[top]] = false;
f -= (p == stk[top]);
top--;
}
}
}
一些其他推论:
-
SCCcnt 的逆序是拓扑序
- 越靠近叶子的 SCC 越早弹栈,越靠近根的越晚弹栈
- 如果要拓扑排序的话,直接倒着枚举即可,不需要记度数
-
一个 SCC 中,特殊边不会只有横叉边
- 考虑树边都向下,如果要能够通过若干条额外的横叉边返回自己,
那么最后那条横叉边肯定不是横叉边,而是返祖边,或者是树边
证明是口胡的,要是错了麻烦 diss 我
- 考虑树边都向下,如果要能够通过若干条额外的横叉边返回自己,
-
使用非树边更新 low,可以使用 low 或 dfn 来更新
- 因为论文原文定义的 low 是,只经过一条非树边可以到达的最小 dfn
- 但只经过一条带来的性质没有被论文使用,不影响最后的答案正确性
碎碎念:
你看 dfn 相当于点的编号,然后当 low = dfn 的时候才弹栈,
我们称最上面的那个为 \(r\),被弹的都满足 low ≥ dfn[r]
是不是一股并查集的味道,\(r\) 是这个树的根,然后你最后弹完就是一个森林
更新 low 的过程其实就是动态合并的过程,只是这里直接能 O(1) 合并到根
伟大之 tarjan 相关的算法,都有股味 er 啊(笑)

浙公网安备 33010602011771号