跳转至

强连通分量

一些定义ψ(`∇´)ψ

联通分量:对于一个有向图的分量当中的任意两点 \((u,v)\)\(\exists \delta (u,v) \land \delta (v,u)\),则称这个分量为联通分量

强联通分量 SCC:有向图的一个极大连通分量被称作强连通分量,极大的理解就是,不管再加上哪一个边和哪一个节点,他都不再是一个连通分量,也就是“大的不能再大”。

流图:对于一个有向图 \(G=(V,E)\)\(\exists r \in V\),满足 \(r\) 可以到达 \(V\) 中的任意节点,则称 \(G\) 为一个流图。

\(r\) 称作流图的源点。

搜索树:对于一个流图 \(G\) 进行深度优先遍历得到的一棵生成树 \(T\)

时间戳 \(dfn\) :对于 \(G\) 中的每一个节点,它在 \(T\) 中被搜索到的顺序(时间)。

为了方便叙述,这里再定义四种边,流图 \(G\) 的所有边必然是这四种边之一

  1. 树边:在搜索树里的边,从 \(u \to v\) ,且 \(u\)\(v\) 的父亲。
  2. 前向边:从 \(u \to v\) ,且 \(u\)\(v\)\(T\) 上的祖先。
  3. 后向边:从 \(u \to v\) ,且 \(v\)\(u\)\(T\) 上的祖先
  4. 横叉边:除了 123 的所有边,必然满足 \(u \to v,dfn_v<dfn_u\)

Tarjan 算法ψ(`∇´)ψ

考虑搜索树上的节点如何才能成为 SCC 当中的节点。

首先,一个孤立的点(这里指走出去了就没法回来)必然是一个 SCC。

如果想要更多点加入这个 SCC,实际上就是要找到一个环。

那么后向边必然是有用的,假设 \(v\)\(u\) 的祖先,那么必然存在一个路径从 \(v \to u\)

而这里又存在一条后向边 \(u \to v\) ,那么必然会出现一个环,也就出现了一个联通分量。

而横叉边也许会有用,只要从 \(u\) 经过一个横叉边走到 \(v\) ,且 \(v\) 可以到达 \(u\) 的祖先节点,那么也会出现一个环,也就是一个联通分量。

那么我们要做的就是在流图上找到尽可能大的,由 \(T\) 经过添加几条后向边和横叉边构成的环。

所以引入一个新的值 \(low_u\) ,表示 \(u\) 和它子树当中的节点能往上(在 \(T\) 当中的上)走到的最高(在 \(T\) 当中深度更低)的节点的时间戳 \(dfn\)

如果出现 \(low_u=dfn_u\),也就是 \(u\) 不可能再往上走,它的 \(low\) 就是自己的 \(dfn\) 的时候,那么 \(u\) 必然是它所在的强连通分量在 \(T\) 上最高的点。

然后在深度优先遍历的时候同时维护一个栈,保存当前能与从这个节点出发的后向边和横叉边构成环的所有节点。

\(anc(u)\) 表示 \(u\)\(T\) 上的所有祖先节点,那么栈中保存的实际上就是这两类节点:

  1. \(v \in anc(u)\),且有可能存在后向边 \(u \to v\)
  2. \(v \in V\) ,且存在路径 \(\delta(v\to w),w\in anc_u\)

在满足 \(low_u=dfn_u\) 的时候,把栈的节点不断弹出,直到 \(u\) 出栈,此时当前被弹出的所有节点就构成一个 SCC。

很明显 \(anc(u)\) 中的所有节点都先于 \(u\) 入栈。

所以在 \(u\) 弹出前弹出的一定是第二类节点。

具体实现:

每次新访问一个节点的时候初始化 \(dfn=time + 1,low=dfn\)

然后访问它的所有出边 \((u,v)\),如果出边对应的节点 \(v\) 没有访问过(当前访问的是树边),那么就递归然后用 \(low_v\) 更新 \(low_u\)

反之,如果 \(v\) 已经在栈中了,那么也就是说,存在一条后向边或者横叉边从 \(u\) 出发能到更上面。

根据栈中节点的性质,用 \(dfn_v\) 更新 \(low_u\) 即可。

注意这里要是 \(dfn_v\) 而不是 \(low_v\),Tarjan 老爷子亲自说过。

(TODO:自己尝试证明一下这儿是为啥)

如果把每一个强连通分量看作一个点。

那么缩完点之后的图就是一个 DAG。

并且 SCC 编号的顺序就是 逆拓扑序

缩点之后,我们就能非常方便地进行 DP,因为正常 DP 的顺序本来就是拓扑序。

一般都是直接递推求解。

Code
 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
std::stack<int>s;
bool ins[si_n]; // in the stack or not
int c[si_n]; //c[x] = the num of SCC which is x iside
std::vector<int>scc[si_n]; // scc[i] -> all node in i-th scc (information of i-th scc)
// 如果没有必要的话可以不要 vector
int dfn[si_n],low[si_n];
int n,m,cnt_t=0,tot=0; // tot = how many scc in the graph. 

void tarjan(int u){
    dfn[u]=low[u]=++cnt_t;
    s.push(u),ins[u]=true;
    for(register int i=e[u].head;i;i=e[i].Next){
        int v=e[i].ver;
        if(!dfn[v]){
            tarjan(v);
            low[u]=min(low[u],low[v]);
        }
        // 没有访问过,递归搜索然后更新 low。
        else if(ins[v]) low[u]=min(low[u],dfn[v]);
        // 已经在栈中了,用 dfn[v] 来更新 low[u]。
    }
    if(dfn[u]==low[u]){
        ++tot;
        int x;
        do{
            x=s.top(),s.pop(),ins[x]=false;
            c[x]=tot,scc[tot].pb(x);
        }while(u!=x);
    } // 出现了一个 SCC。
}

Edge edag[si_m];
int cnt_d=0;
void add_n(int u,int v){
    edag[++cnt_d].ver=v,edag[cnt_d].Next=edag[u].head;
    edag[u].head=cnt_d;
}
void contract(){
    for(register int u=1;u<=n;++u){
        for(register int i=e[u].head;i;i=e[i].Next){
            int v=e[i].ver;
            if(c[u]==c[v]) continue;
            add_n(c[u],c[v]);
        }
    } // 缩点。
}

Tarjan 缩完点之后给 SCC 标记的顺序是逆拓扑序的原因非常简单;

因为 Tarjan 的访问顺序是深度优先遍历的顺序(因为使用了栈)。

那么从层的角度来说,更靠上的 SCC 被标记到的时间必然更晚,而 Tarjan 缩完点之后 SCC 构成的图必然是一个 DAG,那么 SCC 的标记顺序就一定是一个合法的逆拓扑序。(可以看这个图理解一下):

scc-1.png


最后更新: May 9, 2023