本文转自:http://blog.stqdd.com/?p=209
对于有向连通图,如果任意两点之间都能到达,则称为强连通图。如果对于有向图的一个子图是强连通的,则称为强连通子图;
极大的强连通子图称为强连通分量。一个有向图可以有多个强连通分量,一个点也算是强连通分量。强连通分量的术语是strongly connnected components,简称SCC
对于无向连通图,如果任意两点之间都有多于一条的路径,则称为双连通图。对于无向图的一个子图是双连通的,则称为双连通子图;
极大的双连通子图称为双连通分量。一个无向图可以有多个双连通分量,一个点也算是双连通分量。双连通分量的术语是biconnected components,简称为BCC
强连通分量是针对有向图而言的,从定义出现,我们可以得到一个直观的算法,网上流传的说法是双向遍历求交集,时间复杂度是O(N^2+M). 这里我对此做些简单的解释。为什么要解释呢?因为我总和kosaraju算法的双dfs搞混淆。
(1)因为强连通要求,任意两点之间都可到达,那么我们自然会这么做:任意一点开始深搜,对于该点深搜到的所有的点再次深搜看是否能够返回到原点。因为从这一点开始深搜能够搜索到一个点的集合,这些点集再次向原点深搜,势必会再次得到一个集合,这两个集合求个交集就是一个强连通分量;
void dfs( 原点s)
{
FOR 原点能够连接到的点e
dfs(e);
if( e能够到达s)
加入到强连通分量的集合中。
}
FOR 任意一点开始
if ( 没有搜索过 )
dfs( 该点 )
分析这样的程序,复杂度大约是N^2+M*alpha ,这里每条边并不是只遍历一次的。
举个例子来说明这个根据定义出现得出的低效率算法:
对于此图,我们先用(1)方法来做
从A点出现,搜索
遍历顺序: A
遍历顺序:A B
遍历顺序:A B C
遍历顺序:A (AB) B (BC) C (CD) D
遍历顺序:A (AB) B (BC) C (CD) D (DH) H
遍历顺序:A (AB) B (BC) C (CD) D (DH) H (HG) G
遍历顺序:A (AB) B (BC) C (CD) D (DH) H (HG) G (GF) F
到达F后,无法继续往前走了,此时,从F开始再次dfs看是否能够到达A.
结果图中绿色的线就是从F开始的DFS路线,很明显是不能到达的,所以F不和A在一个SCC中。
返回到G
从G开始DFS,绿色的线就是路径了,也无法到达A,所以G也不和A在一个SCC中。此时注意到,绿色线之前曾经被遍历过,所以此方法复杂度中的M是带有一个常数的。
继续返回至H
从H开始也无法到达A,所以H不是和A一个SCC中。
返回至D
D无法到达A
返回至C
可见C无法到达
返回至B,B还有一个儿子没有遍历
而E无法前行了,而E能够到达A,所以E是A的集合的
最后返回B
而B能够到达A,所以B也是A集合
最后返回到A,终结了一个强连通分量,那就是{A,B,E}.
接下来,到达C和F的时候还会分别找到两个强连通分量,这就是这个实现的示意图。
通过以上的例子说明了SCC的低效率的解法,那么kosaraju和tarjan算法到达快到哪里了呢?
先说说kosaraju算法把。其时间复杂度是O(N+M);
低效率算法的之所以慢,是因为要判断每个能够到达的点是否能够在到达原点,这就构成了一个双层的循环,N^2的算法了。如果我们能够找到一种方法来高效的解决到达的点是否能否在返回至原点的话,复杂度就会减少很多。
kosaraju是最早提出一种高效的方法来解决这个问题的。我们注意到一个有趣的现象,如果我们把有向边的方向转向的话,强连通分量并不会改变,kosaraju正是利用了这个性质,才规避了判断一个点是否能够到达原点的。到达的点是否能够返回,等价于我们把边反向,判断原点是否能够到达该点。这样,我们只需对每个点做两次dfs就求出SCC了。这样每个点出了第一次必须的dfs外,只需在额外的在一次dfs就可以,这样就比原先的多次dfs,效率提高了很多。
kosaraju算法还有一个优点就是求出来的SCC具有拓扑顺序的性质。kosaraju算法在实现的是否需要两次dfs和一个堆栈的辅助。堆栈记录第一次dfs的时候遍历按照左右中的顺序把节点保存到栈中的。例子如下:
从A出发,stack={F G H D C}
stack={F G H D C E}
stack={F G H D C E B}
stack={F G H D C E B A}
第一次遍历结束
然后从栈顶元素A开始反图上遍历。
从A出现,能够遍历到的点是{A E B},栈弹出这三个点,然后stack = {F G H D C},这其实也是按照缩图后拓扑排序的第一个SCC
从C出发,能够遍历到的点是{C D H},栈弹出这三个点,然后stack={F G},这其实是按照缩图后拓扑排序的下一个SCC
从G出发,能够遍历到的点是{G F},栈弹出这两个点,然后stack={},这就是最后的一个SCC了
缩图后的效果图如下:
从这个图中发现,{ABE}是拓扑的第一个点,{CDH}是第二个点,{FG}是第三个点
kosaraju算法毕竟需要两次dfs,效率自然没有tarjan算法高。接着介绍下tarjan算法,这个算法的应用非常的广。需要格外注意。
tarjan的主要思想是一次dfs,记录下遍历的编号dfn[i],和low[i](i点可以通过回边连接到的最小的遍历号),当遍历完一个节点的所有子树后,发现low[i]==dfn[i]时,那么从栈顶开始弹出节点,直到stk[top-1]==i.
FOR 对于i的所有孩子节点
if v没有访问过
dfs(孩子v)
low[i] = min(low[i],low[v]);
else if i在栈中的话
low[i] = min(low[i],dfn[v]);
if low[i]==dfn[i]
弹出一个SCC
这个算法实现简单,也很巧妙。还有一个gabow算法在此先不介绍,之所以介绍了tarjan算法,是因为这个算法在求解无向连通图的双连通分量的时候,也有用武之地。所以接下来要说下无向连通图的双连通分量。
对于无向连通图,关于双连通分量有两个概念,一个是点连通分量,一个是边连通分量。这两个又有区别又有联系。
点连通分量,是点连通度大于1的极大连通子图。其对应于割点,即去掉该点以及和该点相连接的边,原有的连通图,变成多个连通图。
边连通分量,是边连通度大于1的极大连通子图。其对应于桥,即去掉该边后,原有的连通图,变成多个连通图。
在一个大于2个顶点的图中,如果有桥的话一定有割点,但是又割点不一定有桥。
在POJ中3352和3177是求桥和点连通分量。
POJ 1144,1523,2117,是求割点以及每个割点能够划分几个连通分量。
对这个图,求割点,点连通分量(块),割边,边连通分量(缩点)
代码如下:
// include file #include <cstdio> #include <cstdlib> #include <cstring> #include <cmath> #include <cctype> #include <ctime> #include <iostream> #include <sstream> #include <fstream> #include <iomanip> #include <bitset> #include <algorithm> #include <string> #include <vector> #include <queue> #include <set> #include <list> #include <functional> using namespace std; // typedef typedef long long LL; typedef unsigned long long ULL; typedef __int64 Bint; // #define read freopen("in.txt","r",stdin) #define write freopen("out.txt","w",stdout) #define FORi(a,b,c) for(int i=(a);i<(b);i+=c) #define FORj(a,b,c) for(int j=(a);j<(b);j+=c) #define FORk(a,b,c) for(int k=(a);k<(b);k+=c) #define FORp(a,b,c) for(int p=(a);p<(b);p+=c) #define FORii(a,b,c) for(int ii=(a);ii<(b);ii+=c) #define FORjj(a,b,c) for(int jj=(a);jj<(b);jj+=c) #define FORkk(a,b,c) for(int kk=(a);kk<(b);kk+=c) #define FF(i,a) for(int i=0;i<(a);i++) #define FFD(i,a) for(int i=(a)-1;i>=0;i--) #define Z(a) (a<<1) #define Y(a) (a>>1) const double eps = 1e-6; const double INFf = 1e10; const int INFi = 1000000000; const double Pi = acos(-1.0); template<class T> inline T sqr(T a){return a*a;} template<class T> inline T TMAX(T x,T y) { if(x>y) return x; return y; } template<class T> inline T TMIN(T x,T y) { if(x<y) return x; return y; } template<class T> inline void SWAP(T &x,T &y) { T t = x; x = y; y = t; } template<class T> inline T MMAX(T x,T y,T z) { return TMAX(TMAX(x,y),z); } // code begin #define MAXN 100 #define MAXM 10000 int N,M; struct node1 { int e; int next; }; struct node2 { int next; }; node1 mem[MAXM]; node2 G[MAXN]; int dfn[MAXN]; int low[MAXN]; int used[MAXN]; int cnt; // 搜索编号 int cc; // 无向图连通分量的个数 // 记录割点 int cut[MAXN]; //标记哪些点是割点,并且记录下,每个割点可以分割成几个分量 int cutstk[MAXN],cuttop; // 记录下这些割点 // 记录块 int blockstk[MAXN],blocktop; //辅助栈 int block[MAXN][MAXN]; int blocksize[MAXN]; int blockid; // 块的编号,从1开始 // 记录割边 struct node3 { node3(){}; node3(int a,int b) { s=a; e=b; } int s; int e; }; node3 bridge[MAXM]; int bridgetop; // 记录边连通分量,其实还是记录一组点 int bbockstk[MAXN],bbocktop; //辅助栈 int bbock[MAXN][MAXN]; // int bbocksize[MAXN]; int bbockid; //边连通分量的编号 int bbockflag[MAXN]; //此时每个点都属于一个连通分量,所以可以给每天进行编号。 void Add_edge(int dx,int a,int b) { mem[dx].e = b; mem[dx].next = G[a].next; G[a].next = dx; } void BCC_tarjan(int i,int fa) { dfn[i] = cnt; low[i] = cnt; cnt++; used[i] = true; int son = 0; int dx = G[i].next; // 块的栈 blockstk[blocktop++] = i; // 边连通分量的辅助栈 bbockstk[bbocktop++] = i; while(dx!=-1) { int v = mem[dx].e; if(!used[v]) { BCC_tarjan(v,i); low[i] = TMIN(low[i],low[v]); // 此处用来求割点 if(fa==-1) son++; else if(low[v]>=dfn[i]) { cut[i]++; } // 此处用来记录块 if(low[v]>=dfn[i]) { int tp; do { tp = blockstk[--blocktop]; block[blockid][blocksize[blockid]++] = tp; }while(blockstk[blocktop-1]!=i); block[blockid][blocksize[blockid]++] = i; blockid++; } // 此处用来记录割边 if(low[v]>dfn[i]) { bridge[bridgetop++] = node3(i,v); } // 此处用来记录边连通分量 if(low[v]>dfn[i]) { int tp; while(bbockstk[bbocktop-1]!=i) { tp = bbockstk[bbocktop-1]; bbockflag[tp] = bbockid; bbock[bbockid][bbocksize[bbockid]++] = tp; bbocktop--; } bbockid ++; } } else if(v!=fa) { low[i] = TMIN(low[i],dfn[v]); } dx = mem[dx].next; } // 对根判断是否割点 if(fa==-1&&son>1) cut[i] += son-1; // 如果根是割点,块 if(fa==-1&&son>1) { int tp; do { tp = blockstk[--blocktop]; block[blockid][blocksize[blockid]++] = tp; }while(blockstk[blocktop-1]!=i); block[blockid][blocksize[blockid]++] = i; blockid++; } // 边连通分量 if(fa==-1) { while(bbocktop>0) { bbock[bbockid][bbocksize[bbockid]++] = bbockstk[bbocktop-1]; bbockflag[ bbockstk[bbocktop-1] ] = bbockid; bbocktop--; } bbockid++; } } int main() { read; write; int a,b,dx; while(scanf("%d %d",&N,&M)!=-1) { if(N+M==0) break; FORi(1,N+1,1) { G[i].next = -1; } dx = 0; FORi(0,M,1) { scanf("%d %d",&a,&b); Add_edge(dx++,a,b); Add_edge(dx++,b,a); } memset(dfn,0,sizeof(dfn)); memset(low,0,sizeof(low)); memset(used,0,sizeof(used)); cnt = 1; cc = 0; // 记录割点用的 cuttop = 0; // 记录块用的 blocktop = 0; blockid = 1; memset(blocksize,0,sizeof(blocksize)); // 记录割边用的 bridgetop = 0; // 记录边连通分量用的 bbocktop = 0; bbockid = 1; memset(bbocksize,0,sizeof(bbocksize)); memset(bbockflag,0,sizeof(bbockflag)); FORi(1,N+1,1) { if(!used[i]) { cc++; BCC_tarjan(i,-1); } } // 一次dfs // 找出割点了 printf("以下是割点:\n"); FORi(1,N+1,1) { if(cut[i]) { printf("%d ",i); } } printf("\n"); // 一下是给点编的块的号 printf("\n"); printf("有%d个块\n以下是求块,即点连通分量\n",blockid-1); FORi(1,blockid,1) { printf("第%d块\n",i); FORj(0,blocksize[i],1) { printf("%d ",block[i][j]); } printf("\n"); } printf("\n"); // 求割边 printf("\n有%d条割边\n以下是求割边:\n",bridgetop); FORi(0,bridgetop,1) { printf("割边%d:(%d %d)\n",i,bridge[i].s,bridge[i].e); } // 求边连通分量 printf("\n有%d个边连通分量:\n",bbockid-1); FORi(1,bbockid,1) { printf("第%d个边连通分量\n",i); FORj(0,bbocksize[i],1) { printf("%d ",bbock[i][j]); } printf("\n"); } printf("\n"); } return 0; }
输入数据:
7 8
1 2
1 4
3 2
3 4
4 5
5 6
5 7
6 7
0 0
输出数据:
以下是割点:
4 5
有3个块
以下是求块,即点连通分量
第1块
6 7 5
第2块
5 4
第3块
2 3 4 1
有1条割边
以下是求割边:
割边0:(4 5)
有2个边连通分量:
第1个边连通分量
6 7 5
第2个边连通分量
2 3 4 1