写在前面:整合和参考了网上的一些相关文章和刘汝付佳的《内功心法》里的部分内部。

      因为在求强连通分量,块,割点,桥的时候,其基本过程是DFS,所以对DFS的一基本的知识作些介绍。

      为了叙述方便,规定DFS过程将给结点着色:白色为没有考虑过的点,黑色为已经完全考虑过的点,灰色为发现过但没有处理过的点。灰色点组成了遍历边界。

DFS遍历

      DFS遍历优先扩展新发现的结点,它的过程可以看作是递归的。算法将得到DFS森林,类似于BFS树。DFS的特别之处在于可以进行边分类。我们首先需要在遍历时加上时间戳(time stamp)。

             发现时间d[u] 结点变灰的时间

             结束时间 f[u]结点变黑的时间

      初始化time为0,所有点为白色,DFS森林为空。则只需要对每个白色点u执行一次DFS-VISIT(u),每次处理一个点前和处理完后翥把当前时间加1,则所有点u的时间戳满足1<=d[u]&&f[u]<=2|v|。显然其时间复杂度是O(n+m)的。

这里有一个例子:

这里有一个例子:



所有时间戳都不同,且为1到2|V|的整数。从下图还可以直观看到,对于任意结点对(u, v),区间[d[u], f[u]]和[d[v], f[v]]要么完全分离,要么相互包含。如果u的区间完全包含在v的区间内,则在DFS树中u是v的后代。

      关于DFS树,我们有重要的:

白色路径定理 在DFS森林中v是u的后代当且仅当在u刚刚被发现时,v可以由u出发只经过白色结点到达。定理十分直观,证明略。

  DFS-VISIT可以顺便将边(u, v)分成以下几类:

             树边(Tree edges, T) v通过边(u, v)发现。

             后向边(Back edges, B) u是v的后代。

             前向边(Forward edges, F) v是u的后代。

             交叉边(Cross edges, C):其他边。可以连接同一个DFS树中没有后代关系的两个结点,也可以连接不同DFS树中的结点。

边分类算法把分类规则落实到程序中,只需要考虑边(u,v)时检查v的颜色:

      v是白色,(u,v)是T边

      v是灰色,(u,v)是B边,因此只有此时u的祖先是灰色

      v是黑色,继续判断。若d[u]<d[v],说明v是u的后代,因此是F边,否则是C边。否则是C边。

      DFS的时间复杂度仍为O(n+m)。容易证明:无向图只有T边和B边。下面的图把前面的例子中的DFS树画得更加直观,读者可以仔细体会T、B、F、C边是什么样的。



有向图的连通性

      有向图的边都是单向的,因此可达性不具有传递性:u可以到达v,不见得v到达u。但如果u和v相互可达,那么对于任意其他结点w来说,w、u之间的可达性与w、v之间的可达性相同。“相互可达”关系是一个等价关系,因此可以根据这个关系把所有结点分成若干集合,同一个集合内的点相互可达,不同集合内的点不相互可达,如下图:



每一个集合称为有向图的一个强连通分量(Strong Connected Component,SCC)。如果把一个集合看成一个点,那么所有SCC构成了一个SCC图:


求强连通分量的Tarjan算法

      对于每一个强连通分量SCC C,在DFS的过程中,都有一个最早被发现的点,设为x,则由魄路径定理,在C中的其他点都是x的后代。如果我们能在x访问完成时立刻输出C。这样,就可以在一棵DFS树中区分所有SCC了。因此问题的关键就是:判断一个点是否为SCC中最先发现的点。



      如上图,实线表示一条边,虚线表示一条或多条边。假设我们正在判断u是否是某SCC的第一个被发现结点。如果我们从u的儿子出发可以到达u的祖先w,显然u、v、w在同一个SCC中,因此u不是该SCC中第一个被发现的点。如果从v出发最多只能到达u,那么u是该SCC的第一个被发现的结点。这样,问题转化为求:一个点u最远能达到的祖先的d值。注意,这里的“到达”可以通过B边,不能通过C边,但前提是只能通过当前的搜索子树里的有的点(在程序里用栈保存的)而不是其他已经找出来的SCC里的点。

      定义low[u]为u及其后代能追溯到的最早(最先发现)祖先点v的时间戳dfn[v](dfn[v]表示v变灰的时间,即v最早发现的时间)

      程序用sta栈保存当前SCC中的结点(注意这些结点形成一棵子树,而不一定是一个链)。idx表示时间戳, scnt为SCC计数器,id[i]表示i所在的SCC编号,insta[i]表示i是否在栈中。注意限制v必须在栈中,因为从C边出发可能到达已经输出的SCC中。

[cpp]  view plain copy
  1. vector<int> adj[N];  
  2. int low[N],dfn[N],id[N],sta[N],idx,top,scnt;//scnt从1开始  
  3. bool insta[N];  
  4. void tarjan(int u)  
  5. {  
  6.     insta[u]=1; sta[top++]=u;  
  7.     low[u]=dfn[u]=++idx;  
  8.     for(int i=0;i<(int)adj[u].size();i++) {  
  9.         int v=adj[u][i];//用vector存的点  
  10.         if(!dfn[v]) {  
  11.             tarjan(v);  
  12.             low[u]=min(low[u],low[v]);  
  13.         }  
  14.         else if(insta[v]&&dfn[v]<low[u]) low[u]=dfn[v];  
  15.     }  
  16.     if(low[u]==dfn[u]) {  
  17.         int tmp;  
  18.         ++scnt;  
  19.         do {  
  20.             tmp=sta[--top];  
  21.             //vis[tmp]=0;这里写错了。  
  22.             insta[tmp]=0;//应该是把在栈中的标记取消。  
  23.             id[tmp]=scnt;  
  24.         }while(tmp!=u);  
  25.     }  
  26. }  

无向图的连通性

      求无向图的连通分量是平凡的,但无向图的连通性问题远不止于此。如果连通无向图G中存在一个点u,删除u后G不同连通,则称u为G的一个割顶(articulation point)。没有割顶的连通图称为双连通图(Biconnectedgraph)。对于任意两条边e1和e2,如果e1=e2或者它们在同一个环中,则称它们满足关系R。容易证明R是一个等价关系。根据此等价关系我们把G的边分为不相交集E1,E2,…,Ek,设Vi是Ei中包含的点集,则每个子图Gi={Vi,Ei}称为G的一个双连通分量(Biconnected component,BCC)或称块(block)。双连通分量具有如下性质:

      1.    双连通分量都是双连通的。

      2.   任意两个不同的双连通分量最多只有一个公共点。

      3.   结点u是图G的割顶当且仅当是某两个不同的双连通分量的公共点。

      注意到在无向图中只有B边和T边两种,因此DFS可以很方便的求出G的所有双连通分量。首先我们需要判断出所有割顶。关于割顶,我们有以下引理。

引理:对于连通无向图G={V,E},S={V,T}为G的一个DFS树,则结点u是G的割顶当且仅当下面条件之一被满足:

      1.  u是T的根且u至少有两个儿子

      2.  u不是T的根且存在u的某个儿子w,使得从w或者w的后代没有边连回u的祖先(注意,不是连回u本身)。

证明:首先考虑u是根的情况。如果它有两个儿子v和w,其中v先被发现。由于w不是v的后代,所以v到w没有白色路径。由于v和w是连通的,而且此时只有u被标记为灰,所以v到w的所有点都必须经过u。删除u后v和w不再连通。反过来,如果u只有一个儿子,删除以后显然剩下点在T-{u}中连通,当然在G-{u}中也连通了。

      如果u不是根,情况如下图。如果w或w的后代没有连回u祖先的边,那么w到u父亲f的所有路必须经过u。注意无向图是没有F边和C边的,所以w及后代只能通过B边连回祖先。



       反过来,如果u的每个儿子都可以连回f(如果可以连轴得更远,顺着树边走最终可以到f),删除u至少不会引起u的这些儿子和f不连通。有没有可能引起另外两个点不连通呢?假设存在x和y使得删除u后引起x和y不连通。显然x和y至少有一个应该是u的后代,否则不考虑以u为根的子树x和y也可以通过T的剩下部分连通。这样,设x是u的后代。注意我们假设的是x可以连回f,因此图看起来应该是下面两种情况之一:

      情况一 y不是u的后代,这样x先走到f,再沿着T中的边走到v,并没有经过u。

      情况二 y也是u的后代,则y也可连回f。这样x先走到f,再从f到y,并没有经过u。

      在这两种情况下,都不会引起x和y的不连通。

      类似于割顶,我们可以定义无向连通图的桥(bridge):如果删除一条边e后无向图G不再连通,称e为G的桥。桥的判定也不难,只需要在发现T边(u,v)时进行判断。如果v后它的后代无法连回u或者u的祖先,则删除(u, v)后u和v不连通。即:发现T边(u, v)并递归遍历v后若dfn[u]<low[v],则(u, v)为桥。类似于割顶,我们称没有桥的图为边连通图。如果一个无向图是边连通的,可以把它的边定向,得到一个强连通的有向图。

      low[u]、dfn[u]、idx和上一个程序的定义是相同的,fa表示转移到u结点的u的父亲结点,cnt表示当前结点u有个儿子结点,root表示这棵DFS搜索树的根结点。

求割点和桥

[cpp]  view plain copy
  1. void tarjan(int u,int fa)  
  2. {  
  3.     int cnt=0;  
  4.     low[u]=dfn[u]=++idx;  
  5.     for(int i=0;i<(int)adj[u].size();i++)  
  6.     {  
  7.         int v=adj[u][i];  
  8.         if(v==fa) continue;  
  9.         if(!dfn[v])   
  10.         {  
  11.             tarjan(v,u);  
  12.             ++cnt;  
  13.             low[u]=min(low[u],low[v]);  
  14.             if( (root==u&&cnt>1)||(root!=u&&dfn[u]<=low[v]) ) //判断是否是割点  
  15.                 isap[u]=1;  
  16.             if(dfn[u]<low[v]) cutE[++numE]=Edge(u,v);//判断是否是桥,视具体情况采用恰当的结构记录。  
  17.         }  
  18.         else low[u]=min(low[u],dfn[v]);//这里不用判断是否点v在栈中  
  19.     }  
  20. }  

为什么这个程序不用判断点v是否在栈中呢?

举个例子:



       根据dfn 可以看出搜索的顺序是1->2->5->6形成一个强连通分量(2,5,6),于是开始退栈,回溯到1 从3 出发到达4,此时如果直接用dfn[2]更新low[4]的话,会得到low[4]=2,变小后而与dfn[4]不再相等,不能退栈,这与最后的4形成一个单独强连通分量是不符合的,所以,不在栈中的点,不能用来更新当前点的low[]值,为什么无向图不用标记呢,那时因为,边是无向的,有边从4->2同时也必有边2->4 由于2 之前被标记过,而遍历到当前结点4 又不是通过w(2,4)这条边过来的,则必还存在另一条路径可以使2 和4 是相通的,(即图中的4-3-1-2),从而2,4 是双连通的。

      无向图的缩点与有向图是类似的,只要把无向图转化成有向图,即对于每条无向边(u,v),建成有向边u->v,v->u即可。

      当然也有无向图的作法,只是在有向图的基础上变动了一点。

[cpp]  view plain copy
  1. void tarjan(int u,int pre)  
  2. {  
  3.     sta[top++]=u;  
  4.     low[u]=dfn[u]=++idx;  
  5.     bool flag=1;  
  6.     for(int i=0;i<(int)adj[u].size();i++) {  
  7.         int v=adj[u][i];//用vector存的点  
  8.         if(v==pre&&flag) //判重边  
  9.         {  
  10.             flag=0; continue;  
  11.         }  
  12.         if(!dfn[v]) {  
  13.             tarjan(v);  
  14.             low[u]=min(low[u],low[v]);  
  15.         }  
  16.         else if(dfn[v]<low[u]) low[u]=dfn[v];  
  17.         //为什么这里可以不要求[v]在栈中上面解释过了。  
  18.     }  
  19.     if(low[u]==dfn[u]) {  
  20.         int tmp;  
  21.         ++scnt;  
  22.         do {  
  23.             tmp=sta[--top];  
  24.             id[tmp]=scnt;  
  25.         }while(tmp!=u);  
  26.     }  
  27. }  


       在无向图中也可以这么写。前面的写法是规定low[u]是其子孙通过一条返祖边直接到达的点,把这个改成是其子孙可以连续通过多条返祖边所能到达的点。那么low[u]=min(low[v],low[u]);

[cpp]  view plain copy
  1. void tarjan(int u)  
  2. {  
  3.     sta[top++]=u;  
  4.     low[u]=dfn[u]=++idx;  
  5.     for(int i=0;i<(int)adj[u].size();i++) {  
  6.         int v=adj[u][i];//用vector存的点  
  7.         if(!dfn[v]) tarjan(v);  
  8.         low[u]=min(low[u],dfn[v]);  
  9.         //为什么这里可以不要求[v]在栈中上面解释过了。  
  10.     }  
  11. }  

       这样做的缺陷是,不能求割点了,多次返祖会导致求割点的错误,在多环两两以单个点相连排成一条线,且每两个连接点间只有一条边的情况中,那些连接点本应是割点,但是在dfs过程中,这些连接点之间的边又恰好不是树枝边的话,low[u]可能会通过多次返祖,从一个割点不断的经过这些割点到达最上边的割点才记录下low[u]。

      这样中间的割点就都不符合dfn(u)<=low[v]了。

      但是这样做有一个好处,就是所有的对于边的双连通分支都以low标记出来了,即属于同一双连通分支的所有点的low都等于同一个值。因为在不遇到桥的情况下,low可以返祖到该连同分支在遍历树中的最高点(dfn最小的点)。

求无向图的块

[cpp]  view plain copy
  1. void dfs(int v)    
  2. {    
  3.     low[v]=dfn[v]=++itime;    
  4.     sta[top++]=v;    
  5.     for(int i=head[v];i!=-1;i=edge[i].pre)    
  6.     {    
  7.         int to=edge[i].to;    
  8.         if(dfn[to]) low[v]=min(low[v],dfn[to]);  //因为是无向边,所以没有在不在栈中的限制  
  9.         else     
  10.         {    
  11.             dfs(to);    
  12.             low[v]=min(low[v],low[to]);    
  13.             if(dfn[v]<=low[to])                  //如果它的子图里的点都没有办法到达v的祖先节点,说明构成一个连通子图。  
  14.             {    
  15.                 block.clear();    
  16.                 while(1)    
  17.                 {    
  18.                     block.push_back(sta[top-1]);    
  19.                     if(sta[--top]==to) break;  //没有把v弹出  
  20.                 }    
  21.                 block.push_back(v);             //虽然没有弹出,但是也放到统计里。  
  22.                 count();    
  23.             }    
  24.         }    
  25.     }    
  26. }