强连通分量

摘要

强连通分量常用于缩点,是图论中一个重要的知识点。本文首先介绍了强连通分量的相关定义以及其应用范围,然后将着重介绍两种求强连通分量的算法: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):

  1. 如果(u,v)为树枝边(v不在栈中),u为v的父节点,则Low(u) = min{Low(u) , Low(v)}。
  2. 如果(u,v)为后向边或者指向栈中节点的横叉边(v在栈中),则Low(u) = min{ Low(u) DFN(v)}。
  3. 如果u的子树已经全部遍历后Low(u) = DFS(u),则将u和栈中在u之后的所有节点 弹出栈。这些出栈的元素组成一个强连通分量。
  4. 继续搜索(或许会更换搜索的起点,因为整个有向图可能分为多个不连通的部分),直到所有点被遍历。
代码实现

见附录部分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;
}
  • 4
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

迷亭1213

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值