Tarjan 求强连通分量和缩点
\(\texttt{Upd 23.8.29}\) 修正错别字、证明,添加代码和注释
欢迎批评指正!
注意:本文只针对有向图。
前置芝士
- 什么是强连通分量(SCC)?
强连通分量,一般指 有向图的极大强连通子图,在这些子图中,所有点双向可达。 - dfs 序:即 dfs 过程中访问点的顺序。
- dfs 生成树:由 dfs 过程中访问的边组成的边集 和 原图的点集 组成的树。
- 树边,非树边:属于 dfs 过程中访问的边 为树边,否则为非树边。
- 返祖边(反向边):从孩子连接到祖先的边。
- 前向边:从祖先连接到孩子的边。
- 横插边:除返祖边和前向边之外的非树边。(连接没有祖孙关系的两点的边。)
- 某个强连通分量的根:这个强连通分量重 dfs 序最小的节点。
Tarjan 算法
设两个数组 \(\mathit{dfn}\) 和 \(\mathit{low}\),分别表示 dfs 序和至多通过 \(1\) 条非树边所能到达的点的 \(\mathit{dfn}\) 的最小值。
看下面这张图:
每个节点的编号就是它的 \(\mathit{dfn}\),旁边标注的数字是 \(\mathit{low}\),可以自行理解一下。
另外,我们还需要一个栈 \(\mathit{stk}\) 存节点。
算法流程
Tarjan 是在 dfs 中实现的,每次访问到当前节点 \(u\),就将它压入 \(\mathit{stk}\) 中。随后访问所有相邻的点 \(v\)。
-
如果没访问过,说明 \((u,v)\) 是一条树边,继续 dfs。
dfs \(v\) 的子树结束后,更新 \(\mathit{low}_u\gets\min(\mathit{low}_u,\mathit{low}_v)\)。因为根据 \(\mathit{low}\) 的定义,走多少条树边都没关系,而 \((u,v)\) 是一条树边,我们可以从 \(u\) 走到 \(v\) 后再继续往上爬。也就是说,\(v\) 能到的 \(\mathit{low}_v\),\(u\) 也能到。于是更新。
-
如果 \(v\) 已被访问过且已属于另一个 SCC,说明 \((u,v)\) 是一条横插边,两个 SCC 无关,直接跳过,不予处理。
-
如果 \(v\) 被访问过且在 \(\mathit{stk}\) 内,说明 \((u,v)\) 是一条返祖边,更新 \(\mathit{low}_u\gets\min(\mathit{low}_u,\mathit{dfn}_v)\)。
因为 \(\mathit{low}\) 的定义为仅通过 \(1\) 条非树边,故可以直接走返祖边 \((u,v)\),如果 \(\mathit{dfn}_v\) 比 \(\mathit{low}_u\) 小,则更新。
关于为什么不是 \(\mathit{low}_u\gets\min(\mathit{low}_u,\mathit{low}_v)\),因为 \(\mathit{low}_v\) 可能已经经过了一条非树边,如果再走返祖边 \((u,v)\),就可能走了两条非树边,与 \(\mathit{low}\) 的定义相悖。(其实这样些也可以得到正确答案,但是求割点时就会错。)
-
然后判断 \(u\) 是否是根:若 \(\mathit{dfn}_u=\mathit{low}_u\),则是当前 SCC 的根,然后退栈到 \(u\),保存答案。
因为 \(\mathit{dfn}_u=\mathit{low}_u\),所以 \(u\) 的子树内的所有点都不能通过返祖边到达 \(\mathit{dfn}\) 比 \(u\) 小的点(否则可以通过树边到达这些子节点,然后走返祖边。使 \(\mathit{low}_u<\mathit{dfn}_u\)。)
关于为什么跳出子树至多经过 \(1\) 条非树边:如果有 \(2\) 条甚至更多,必然有走的最后一条边跳出了子树。那么跳出子树前的点必然在子树内,可以走树边到达,故至多(其实是要且只要)走 \(1\) 条非树边。
代码
int n,m;
vector<int> e[11451],stk;// e:边表
int bel[11451];// bel[i] 表示 i 属于哪个 SCC(编号)
vector<vector<int>> scc={{}};// 存每个强连通分量的点的编号
int cnt=1,dfn[11451],low[11451];// cnt:SCC数量(+1)
int dfncnt=1;// 时间戳
void tarjan(int u)
{
dfn[u]=low[u]=dfncnt++;
stk.push_back(u);
for(int v:e[u])// 范围 for,遍历邻居
{
if(!dfn[v])// 未访问
{
tarjan(v);
low[u]=min(low[u],low[v]);// 访问并更新
}
else if(!bel[v])// in stk
{
low[u]=min(low[u],dfn[v]);// 单纯更新
}
}
if(low[u]==dfn[u])// 是当前 SCC 的根
{
int t;
scc.push_back({});
while(stk.back()!=u)// 退栈到 u
{
t=stk.back();
stk.pop_back();
scc[cnt].push_back(t);// 保存
bel[t]=cnt;
}
scc[cnt].push_back(u);
sort(scc[cnt].begin(),scc[cnt].end());
bel[u]=cnt++;
stk.pop_back();
}
}
注意图可能不连通,所以主函数里要加上:
for(int i=1;i<=n;i++)
{
if(!dfn[i])
{
tarjan(i);
}
}
每次都调用一遍 tarjan
。
缩点
缩点很好理解,就是将每个强连通分量中的点的信息合并,缩成一个点,形成一个 DAG(有向无环图)。
代码
for(int i=1;i<=n;i++)
{
newval[bel[i]]+=val[i];
for(int j:e[i])
{
if(bel[i]!=bel[j])
{
newe[bel[i]].push_back(bel[j]);
}
}
}
其中 e
为原图,newe
为新图,val
为原图点权(或者什么类似点权的值),newval
为新图点权。