Tarjan 求强联通分量 SCC

Tarjan 求强联通分量 SCC


强联通分量(Strongly Connected Component,SCC)是一个 有向图 中的点集

  • 这个点集内的任意两个点,都可以相互抵达
  • 比如说,一个环,它是一个 SCC
  • 比如说,一条链,虽然链头可以抵达任何点,但是其他点都无法抵达链头
    在链的情况下,每个点都是一个独立的 SCC,大小为 1

我们讨论的强联通分量,一般默认是极大的,举个例子:

  • 如果若干个简单环构成了一个大环,我们将大环内的点全部归入同一个 SCC

可以根据上述的定义,得到两个有用的结论:

  1. 如果我们从 SCC 里的任何一个点开始 dfs,那么能访问到这个 SCC 里的所有点
  2. 如果我们从 SCC 里的任何一个点开始 dfs,那么未访问的点,不在这个 SCC 里


Tarjan 的 SCC 算法是一种基于 dfs 的算法

在了解这个算法之前,我们先来了解一些关于 dfs 的性质:

  • 每次 dfs 都会产生一颗 dfs 生成树,下图是一个不错的例子(贺了 oiwiki 的图)
    image

  • [ 树边 ] 是正常的 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
也就是说:

  1. 如果一个点 v 可以访问其祖先 f,那么 vf 在同一个 SCC 里
    • v 经过返祖边,访问其祖先
    • v 经过横叉边,最后经过返祖边,访问其祖先
  2. 如果一个点 v 的孩子 ch 可以访问 v 的祖先 f,那么 vf 在同一个 SCC 里
    而且 vch 路径上的所有点,都和 f 在同一个 SCC 里

Tarjan 的算法还有一个想法:

  • 因为我们找的 SCC 是极大的,一旦某个 SCC 被确定,则不会继续扩大了

我们按 dfs 的顺序,给访问到的每个点一个编号,即 dfs 序,这里称其为 dfn
此外为每个点定义一个 low,表示这个点可以访问到的合法节点中,最小的 dfn 值
这个值可以在 dfs 递归的时候维护好

lowi=min{dfnilowu,uoutiuStack

这里 outi 表示节点 i 可以直接访问的目前还在栈内的点,即之前说的合法
让我们来解释一下这个栈是什么


考虑 dfs 过程,我们每次会按某些顺序访问所有可达的点,
对于每个 SCC,一定有一个点会被先枚举到,有 low = dfn
递归回来时,这个 SCC 所有的点都被访问过了,我们希望能找到它们

根据上面所有的观察,我们可以维护一个栈,在 dfs 过程中不断将节点推入
在离开节点 u 时,如果满足 low = dfn,则一直弹栈,直到 u 不在栈内
这个过程中弹出的所有节点,都应该和 u 处于同一个 SCC 中

也就是说,这个栈维护的是,目前尚未组成极大 SCC 的点
我们可能通过返祖边 / 横叉边,将这些点和自己放入同一个 SCC 中


我们可以定义一个 dfs 函数 tarjan(u),表示

  1. 算出子树内所有的 low,并且来更新自己的 low
  2. 找出 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 啊(笑)

posted @   Aurora5090  阅读(17)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)

再次右键以切换模式

点击右上角即可分享
微信分享提示