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,一定有一个点会被先枚举到,这里称其为
根据之前的结论,从
由于树边全部向下,只要任意添加向上的边,就可以产生 SCC
也就是说:
- 如果一个点
可以访问其祖先 ,那么 和 在同一个 SCC 里 经过返祖边,访问其祖先 经过横叉边,最后经过返祖边,访问其祖先
- 如果一个点
的孩子 可以访问 的祖先 ,那么 和 在同一个 SCC 里
而且 到 路径上的所有点,都和 在同一个 SCC 里
Tarjan 的算法还有一个想法:
- 因为我们找的 SCC 是极大的,一旦某个 SCC 被确定,则不会继续扩大了
我们按 dfs 的顺序,给访问到的每个点一个编号,即 dfs 序,这里称其为 dfn
此外为每个点定义一个 low,表示这个点可以访问到的合法节点中,最小的 dfn 值
这个值可以在 dfs 递归的时候维护好
这里
让我们来解释一下这个栈是什么
考虑 dfs 过程,我们每次会按某些顺序访问所有可达的点,
对于每个 SCC,一定有一个点会被先枚举到,有 low = dfn
递归回来时,这个 SCC 所有的点都被访问过了,我们希望能找到它们
根据上面所有的观察,我们可以维护一个栈,在 dfs 过程中不断将节点推入
在离开节点
这个过程中弹出的所有节点,都应该和
也就是说,这个栈维护的是,目前尚未组成极大 SCC 的点
我们可能通过返祖边 / 横叉边,将这些点和自己放入同一个 SCC 中
我们可以定义一个 dfs 函数 tarjan(
- 算出子树内所有的 low,并且来更新自己的 low
- 找出
的子树内(含 自己)所有极大 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 的时候才弹栈,
我们称最上面的那个为
是不是一股并查集的味道,
更新 low 的过程其实就是动态合并的过程,只是这里直接能 O(1) 合并到根
伟大之 tarjan 相关的算法,都有股味 er 啊(笑)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)