摘要
强连通分量常用于缩点,是图论中一个重要的知识点。本文首先介绍了强连通分量的相关定义以及其应用范围,然后将着重介绍两种求强连通分量的算法:Kosaraju算法以及Tarjan算法,它们的时间复杂度都是O(n+m)(n:顶点数,m:边数)。
其中Kosaraju算法思想简单,操作方便,易于理解与代码实现,但是性能以及拓展性上比Tarjan略逊一筹;本文将会逐一介绍这两种算法的思想以及实现步骤,最后会以例题的形式给出代码模板。
相关定义
在有向图G中,如果两个顶点u,v间存在一条 u 到 v 的路径且也存在一条 v 到 u 的路径,则称这两个顶点u,v 是强连通的(strongly connected)。如果有向图G的任意两个顶点都强连通,则称G是一个强连通图。有向非强连通图的极大强连通子图,称为强连通分量(strongly connected components)。
极大强连通子图: G是一个极大强连通子图,当且仅当G是一个强连通子图且不存在另一个强连通子图G’,使得 G 是 G’ 的真子集。
强连通分量的应用
若将有向图中的强连通分量都缩为一个点,则原图会形成一个DAG(有向无环图),如图1所示:
图1:虚线部分构成一个强连通分量(图片来自ccf的博客)
强连通分量的常见用途有两个:
- 有向图的缩点。
- 解决2-SAT问题。
Kosaraju 算法
Kosaraju算法的时间复杂度是O(n+m),基于两次DFS的有向图强连通子图算法。
该算法共分为三步:
第一步,对原有向图G进行DFS,记录节点访问完的顺序d[i] , d[i] 表示第 i 个访问完的节点是d[i];
第二步,选择具有最晚访问完的顶点,对反向图GT 进行DFS,删除能够遍历到的顶点,这些顶点构成的一个强连通分量。
第三步,如果还有顶点没有删除,继续第二部,否则算法结束。
代码实现:
见附录部分 code-1:Kosaraju算法模板(POJ2186)
Tarjan算法
Tarjan算法是 Robert Tarjan 发明的一个算法,其时间复杂度也是O(n+m),但我们之所以在掌握了Kosaraju算法后仍要学习Tarjan算法的主要原因有以下三点:
- Tarjan算法效率比Kosaraju算法高大概30%,所以Kosaraju可能会被卡常。
- Kosaraju算法利用递归实现,可能会爆栈;而Tarjan则不会(因为根本没递归)。
- Tarjan算法还可以通过拓展解决求割点、割桥以及2-SAT等问题。
实际上如果出题人有这个想法,那么就不是可能会超时,是一定会超时;不是可能会爆栈,是一定会爆栈,所以还是要掌握该算法的。
基本概念
Tarjan算法是基于对图深度优先搜索(DFS)的算法,每个强连通分量为搜索树中的一棵子树。搜索时,把当前搜索树中未处理的节点加入一个栈,回溯时可以判断栈顶到栈中的节点是否构成一个强连通分量。
我们定义DFS过程中遇到的四种边:
- 树枝边:DFS时经过的边,即DFS搜索树上的边。
- 前向边:与DFS方向一致,从某个节点指向其某个子孙的边。
- 后向边:与DFS方向相反,从某个节点指向其某个祖先的边。
- 横向边:从某个节点指向搜索树中另一子树中的某节点的边。
定义DFN(u) 为节点 u 的搜索次序编号(时间戳),Low(u) 为u 或者u 的子树能够回溯到的最早的栈中节点的DFN值。
根据定义我们可以得出:
- 如果(u , v)为树枝边,u 为 v 的父节点,则 Low(u) = min{ Low(u) , Low(v) }。
- 如果(u , v)为后向边或指向栈中节点的横叉边,则Low(u) = min{ Low(u) , DFN(v) }。
当节点u的搜索过程结束后,若DFN(u) = Low(u),则以u为根的搜索子树上所有还在栈中的节点(即u和栈中在u之后的所有节点)是一个强连通分量,可退栈。通俗的说,若u为强连通分量的根,那么它的子孙中的最高最先应该就是它本身。
算法的主要过程
数组的初始化: 当首次搜索到点 u 时,DFN(u)为节点u的搜索次序编号(时间戳)。
堆栈: 将u压入堆栈。
更新Low(u):
- 如果(u,v)为树枝边(v不在栈中),u为v的父节点,则Low(u) = min{Low(u) , Low(v)}。
- 如果(u,v)为后向边或者指向栈中节点的横叉边(v在栈中),则Low(u) = min{ Low(u) DFN(v)}。
- 如果u的子树已经全部遍历后Low(u) = DFS(u),则将u和栈中在u之后的所有节点 弹出栈。这些出栈的元素组成一个强连通分量。
- 继续搜索(或许会更换搜索的起点,因为整个有向图可能分为多个不连通的部分),直到所有点被遍历。
代码实现
见附录部分code-2:POJ2182(Tarjan算法)
参考资料
- 董永建,信息学竞赛一本通提高篇,福州:福建教育出版社,2018.6,155-178
- 秋叶拓哉,挑战程序设计竞赛第2版,北京:人民邮电出版社,2013.6,320-324
附录
code-1:Kosaraju算法模板(POJ2182)
求所有“红牛”总数。(红牛即所有牛的偶像)
/*******************************************************************
最后修改:2019/8/15 Valenshi
使用说明:
用数组表示邻接表,分别建立了正图和反图;
只适用于节点从1~n的题型,切记若干节点编号是0~n-1会死循环!
主函数:scc(),用于求所有强连通分量,O(N+M),答案存放在kos数组。
*******************************************************************/
#include<cstdio>
#include<cstring>
const int N = 1e4+10;
const int M = 5e4+10;
int head[N],ver[M],nex[M],tot; //邻接表存放有向图
int rhead[N],rver[M],rnex[M],rtot;//存放反图
int vis[N],kos[N];//访问标记;节点所属联通分量标号
int ts[N],tc; //时间戳, dfs访问时的顺序;tc为当前"时间"
void addEdge(int x,int y){
/* 建立一条从x->y的有向边 , 同时在反图添加一条从y->x的有向边*/
ver[++tot] = y,nex[tot] = head[x], head[x] = tot;
rver[++rtot] = x,rnex[rtot] = rhead[y],rhead[y] = rtot;
}
void dfs(int x){
/*给节点x以及它的子孙打上时间戳*/
vis[x] = true;
for(int i = head[x] ;i ;i = nex[i]){
int y = ver[i];
if(!vis[y]) dfs(y);
}
ts[++tc] = x; //第tc个回溯的是点x
}
void rdfs(int x,int k){
/* 找出属于第k个强连通分量的所有点 */
vis[x] = true;kos[x] = k;
for(int i = rhead[x];i ;i = rnex[i]){
int y = rver[i];
if(!vis[y]) rdfs(y,k);
}
}
int n,m; //点的个数,编号为1~n;
int scc(){
/* 将原图分为若干强连通分量,并返回个数 */
memset(vis,0,sizeof vis);tc = 0;
for(int i = 1;i <= n;i++)
if(!vis[i]) dfs(i);
memset(vis,0,sizeof vis);
int k = 0;
for(int i = tc;i > 0;i--) if(!vis[ts[i]]) rdfs(ts[i],++k);
return k;
}
/***********************************
以下为POJ2186解题代码
***********************************/
int A[N],B[N];
void solve(){
int k = scc(); //备选答案总数
int y = 0,sum = 0;
for(int i = 1;i <= n;i++){
if(kos[i] == k){
y = i;sum++;
}
}
//检查是否从所有点可达
memset(vis,0,sizeof vis);
rdfs(y,0); //代码重用
for(int i = 1;i <= n;i++){
if(!vis[i]){
sum = 0;break;
}
}
printf("%d\n",sum);
}
int main(){
scanf("%d%d",&n,&m);
for(int i = 1,x,y;i <= m;i++){
scanf("%d%d",&x,&y);
addEdge(x,y);
}
solve();
return 0;
}
code-2:POJ2182(Tarjan算法模板)
/***************************************************************
最后更新:2019/8/16 ValenShi
使用说明:
1.记得初始化辅助数组dfn[],co[],head[],
以及"数组指针" ,top,tot,num,col ;
2.每个辅助数组具体作用见注释;
3.节点范围是1~n,若是0~n-1会死循环,可更改head[]初始值解决。
***************************************************************/
#include<cstdio>
#include<iostream>
using namespace std;
const int N = 1e4+10;
const int M = 1e5+10;
/* 邻接表存有向图 */
int head[N],ver[M],nex[M],tot;
inline void addEdge(int x,int y){
ver[++tot] = y,nex[tot] = head[x],head[x] = tot;
}
/*****************************************
Tarjan算法及其辅助数组:
Stack[]为栈,top为栈顶指针;
dfn[]为节点的时间戳,num为对应的"时间";
co[]为节点所在的强连通分量的编号,col对应编号;
******************************************/
int n,m;
int Stack[N],top;
int dfn[N],low[N],num,co[N],col;
void Tarjan(int u){
dfn[u] = low[u] = ++num;
Stack[++top] = u;
for(int i = head[u];i ;i = nex[i]){
int v = ver[i];
if(!dfn[v]){
Tarjan(v);
low[u] = min(low[u],low[v]);
}else if(!co[v]) low[u] = min(low[u],dfn[v]);
}
if(low[u] == dfn[u]){
co[u] = ++col;
while(Stack[top] != u){
co[Stack[top]] = col;
top--;
}
top--;
}
}
int chudu[N];/*用来记录每个强连通分量的出度 */
void solve(){
num = col = top = 0;
// memset(dfn,0,sizeof dfn);
// memset(co,0,sizeof co);
// memset(chudu,0,sizeof chudu);
for(int i = 1;i <= n;i++) if(!dfn[i]) Tarjan(i);
int ans = 0,tcnt = 0;
/*ans存放答案,tcnt表示出度为0的强连通分量的个数*/
for(int i = 1;i <= n;i++){
for(int j = head[i];j ;j = nex[j]){
int y = ver[j];
if(co[i] != co[y]) chudu[co[i]]++;
/*i所在的强连通分量的出度+1 */
}
}
int tcol = 1;/*tcol为出度为0的强连通分量的编号*/
for(int i = 1;i <= col;i++) if(!chudu[i]) tcnt++,tcol = i;
for(int i = 1;i <= n;i++) if(co[i] == tcol) ans++;
if(tcnt > 1) puts("0");
else printf("%d\n",ans);
}
int main(){
scanf("%d%d",&n,&m);
for(int i = 1,u,v;i <= m;i++){
scanf("%d%d",&u,&v);
addEdge(u,v);
}
solve();
return 0;
}