zoukankan      html  css  js  c++  java
  • tarjan 强连通分量

    一、强连通分量定义

    有向图强连通分量在有向图G中,如果两个顶点vi,vj间(vi>vj)有一条从vi到vj的有向路径,同时还有一条从vj到vi的有向路径,则称两个顶点强连通(strongly connected)。如果有向图G的每两个顶点都强连通,称G是一个强连通图。有向图的极大强连通子图,称为强连通分量(strongly connected components)SCC。

    以上是摘自百科的一段对有向图强连通图分量的形式化定义。其实不难理解,举个例子

    如上图,{a,b,c,d}为一个强连通分量,{e}为一个强连通分量,{f}为一个强连通分量。

    二、SCC求解算法

    给定一个有向图,如何求它的强连通分量呢?

    一般有两种算法:

    1、kosaraju算法:该算法分别对图G和其转置GT做dfs,第一次对G做dfs确定出各个强连通分量之间的拓扑序,第二按照拓扑逆序对G(T)做dfs,这样每次dfs都得到一个强连通分量。算法复杂度为O(V+E)。

    2、tarjan算法:tarjan算法也是基于dfs求解SCC,与kosaraju通过拓扑序做dfs使得各个SCC“互不干涉”的思想不同,tarjan算法只对原图做一次dfs,运行各个SCC在dfs过程中“交织在一起”,并且在dfs过程中记录一些节点信息,通过这些信息识别出一个个SCC。算法复杂度为O(V+E)。

    相比而言,tarjan算法不需要对图做转置,而且只做一次dfs,所以执行效率更高。

    本文主要梳理tarjan 强连通分量算法。

    三、dfs相关概念

    tarjan算法本身是一个dfs算法,用到了dfs的一些性质,所以具体展开tarjan SCC算法之前,先来梳理一些相关的基础。

    1、搜索树(森林):对一个图做dfs搜索,搜索过程形成一颗搜索树。

    2、节点访问次序d[i]: dfs过程中,按照节点访问到的顺序个每个节点记录一个值d[i]。另外,也可以对每个节点记录访问完成的次序f[i]。

    3、dfs对边的分类:

      树边:搜索过程中自然形成的边。如果通过节点i dfs搜索其邻居节点j时,发现j之前还未被访问,在dfs搜索j,这时边<i,j>形成搜索树种一条搜索经过的边,称作树边

           反向边:搜索过程中后裔指向祖先的边。如果通过节点i dfs搜索其邻居节点j时,发现j在搜索树中是i的祖先(j已经被dfs搜索到,但是还没递归返回),这时边<i,j>是反向边。

      正向边:搜索过程中祖先指向后裔的边。

      交叉表:搜索过程中不同子树之间的边。

      一条性质:在dfs过程中,通过节点i遍历邻居j时候,如果j已经被之前访问到(不一定递归访问完成)且d[i]>d[j]则边<i,j>是一条反向边或交叉边。这条性质在tarjan算法中会用到。

    注:关于dfs及dfs对边的分类等知识,《算法导论》中有详细的描述,这里只列出对本文讲解tarjan算法相关的内容。

    下图是对以上概念的一个解释:

    图中左边是原图,右边是一棵dfs搜索树,标注了节点d[i]值和都边的分类。根据dfs访问节点顺序不同,搜索树、d[i]已经边的分类也会不同,但并不影响相关的性质。

    四、tarjan SCC算法

    理解了一些基础概念,现在还看看tarjan求解强连通分量的算法。水平有限,关于算法的形式化证明不会涉及,只会阐述算法的思想和过程。

    算法基本思想

     tarjan算法的思想基于以下性质

    a)如果节点i和节点j在同一个强连通分类中,那么它们在搜索树中会有共同的祖先

    b) 强连通分量在搜索树中形成一棵子树。

    先上图直观理解下:

    对于性质a)我们可以看到,{a,b,c,d}是一个SCC,在搜索树中,它们之中任意两个节点都是有共同祖先的。

    性质b)是性质a)的自然推论,可以看到,三个SCC {a,b,c,d} {e} {f} 都对应搜索树中的一个子树,用虚线框了起来。

    其实不难理解性质的正确性,想象一下dfs的过程,一旦dfs访问到SCC中的一个节点,这个SCC中的所有节点后续都会都访问到,应为它们是相互可达的。这样的话,一个SCC中任意两个节点拥有一个共同祖先就是肯定的了,因为最坏的情况下,它们的共同祖先可以是这个SCC中第一个被访问到的节点。以上图为例,c和b有个公共祖先b,c和d用公共祖先a,a作为该SCC中第一个被dfs访问到的节点,可以作为任意两个节点的公共祖先。这样的话性质a)就是正确的,至于性质b),就是性质a)的自然推论了,同一SSC中的节点都有一个共同的祖先(第一个比访问到的节点),不就是一个子树嘛。

    上图中红色节点标志了一个子树的根,称为scc root,它是所在的SCC中第一个被dfs遍历到的节点。

    理解了上面两条性质,已经基本上可以看出tarjan算法求解SCC的基本思想了:既然每个SCC都是一个搜索树的一个子树,那么找到子树的根,从搜索树中“摘下”一个子树,不就是一个SCC吗?问题是,怎么找到子树根呢?

    求解

    定义以下变量:

    d[i]:节点i的访问顺序,上文已提到;

    low[i]: 节点i 通过零或多条树边 之后,再经过至多一条 反向边或交叉边 所能到达的同一个SCC中的最早访问的节点k的访问次序d[k]。

    根据low[i]的定义,其计算过程如下:

    low[i] = min{low[i],low[j]}  : 如果<i,j>是树边,根据定义j能到达那个节点,i通过树边<i,j>之后也可以到达

    low[i] = min[low[i],d[j]]: 如果<i,j>是反向边或交叉边,根据定义,i只能到达j值,因为路径上只允许有最多一条反向边或交叉边,而且是以该边结束的。

    注:正向边不影响SCC连通性tarjan算法不考虑正向边。

    绕口的一B是吧,看个例子:

    如图: 节点上标注了d[i]/low[i]。{a,b,c,d}是一个SCC,节点b经过一条树边和一条反向边可以到达同一个SCC中的最早访问节点a,节点d[i]值为1,所以low[b]=1; 节点d经过0条树边,一条交叉边后可以到达达同一个SCC中的最早访问节点c,节点c的d[i]值d[c]=3,所以low[d]=3。

    有了以上定义之后,tarjan算法有一条关键的定理:

    定理: d[i]=low[i]  <=>  节点 i 是一个scc root

    该定理的严格形式化证明可以参考下论文原文。其实简单理解就是,同一个SCC中的节点只有scc root 满足d[i]=low[i],为什么呢?

    考虑同一个SCC中的节点,分两个情况:

    1、对于scc root节点,显然满足d[i]=low[i]

    2、对于其他节点,一定low[i]<d[i]。再分成两种情况考虑:

      2.1、节点i通过反向边(无论是直接一条反向边或通过它的子节点)访问到了自己的祖先k,则low[i]=d[k], k值i的祖先,所以d[i]<low[i]。如上图中的c或d,它们都通过反向边(包含树边)可以到达祖先a;

      2.2、节点i通过交叉边到达同一个scc中的节点:通过交叉边到达节点k,有low[i]=d[k],最好的情况下k是scc root,或者一个情况下k值ssc中其他节点,无论哪种情况,由于k之前已经被访问到,所以low[i]=d[k]<d[i]。如上图中的节点d,通过交叉边到达c。

    通过以上两点,就可以证明在一个SCC中,只有scc root满足 d[i] = low[i] 。

    上文说过了,tarjan算求解SSC已经转换为求解scc root的问题,而这条定理给出了scc root的求解方法,至此,整个流程就通了: dfs遍历原图,递归计算low[i],节点i递归遍历完成后,如果发现d[i]=low[i]则,找到了一个SCC,当然,这其中还涉及到找到scc root之后,如何根据scc root 得到一个scc的问题,其实tarjan算法dfs过程中,用栈来记录访问到的节点,找到scc root之后,从栈顶一次弹出节点,直到遇到scc root节点,便可构成一个scc,下面的算法实现描述了这一过程。

    实现

    1、首先看一下tarjan论文中的伪代码实现,个人加了相关注释:

    原文的 NUMBER对应本文中d[i], LOWLINK对应本文中low

    2、java实现:

    //dfs过程中节点状态

    1 1 public enum Status {
    2 2     NOT_VISIT,VISITING,VISITED;
    3 3 }
    View Code

    //SCC 数据结构

     1 1 public class SccMeta {
     2  2     private int sccCount;
     3  3     private List<List<Node>> sccList;
     4  4 
     5  5     public SccMeta(){
     6  6         this.sccCount = 0;
     7  7         this.sccList = new ArrayList<>();
     8  8     }
     9  9 
    10 10     public int getSccCount(){
    11 11         return sccCount;
    12 12     }
    13 13 
    14 14     public List<List<Node>> getSccList(){
    15 15         return sccList;
    16 16     }
    17 17 
    18 18     public void addScc(List<Node> scc){
    19 19         this.sccList.add(scc);
    20 20         this.sccCount++;
    21 21     }
    22 22 
    23 23     @Override
    24 24     public String toString(){
    25 25         StringBuilder s = new StringBuilder();
    26 26         s.append("Strongly Connected Componnet:").append("
    ");
    27 27         for(int i=0; i< sccCount; i++){
    28 28             s.append("scc " + i+":");
    29 29             s.append(sccList.get(i).toString());
    30 30             s.append("
    ");
    31 31         }
    32 32         return s.toString();
    33 33     }
    34 34 }
    View Code

    //tarjan scc 主函数,依次遍历每一个未访问的节点

     1 1 public SccMeta scc() {
     2  2         initScc();
     3  3         SccMeta sccMeta = new SccMeta();
     4  4         int nodeNum = size();
     5  5         for(int i=0; i<nodeNum;i++){
     6  6             if(status[i] == Status.NOT_VISIT){
     7  7                 tarjan(i, sccMeta);
     8  8             }
     9  9         }
    10 10         return sccMeta;
    11 11     }
    View Code

    //tarjan 递归过程

     1 private void tarjan(int i,SccMeta sccMeta){
     2         low[i] = d[i] = timer++;
     3         stack.push(i);
     4         status[i] = Status.VISITING;
     5         List<Integer> neighbors = adjacencyList.get(i);
     6         for(int j : neighbors){
     7             if(status[j] == Status.NOT_VISIT){ // 节点j未访问,边<i,j>是树边
     8                 tarjan(j, sccMeta);
     9                 low[i] = Math.min(low[i],low[j]);
    10             }else if(d[i] > d[j]){ // 节点j节点j已经被访问过,<i,j>是反向边或交叉边
    11                 if(stack.contains(j)){
    12                     low[i] = Math.min(low[i],d[j]);
    13                 }
    14             }else{
    15                 //节点j已经被访问过,而且<i,j>是前向边,忽略
    16             }
    17         }
    18         status[i] = Status.VISITED;
    19 
    20         if(d[i] == low[i]){ // node i is a ssc root
    21             List<Node> scc = new LinkedList<>();
    22             int k;
    23             do{
    24                 k = stack.pop();
    25                 scc.add(getNode(k));
    26             }while(k != i);
    27             sccMeta.addScc(scc);
    28         }
    29     }
    View Code

    算法执行过程演示

    图中用不同颜色表示节点访问的不同节点,绿色为当前访问节点,灰色为访问中的节点(还未递归返回),黑色为递归访问完成的节点

    1、原图

    2、dfs访问到c,遇到反向边

     

    4、访问节点f完成,得到一个SCC {f}

     5、节点c递归访问完成,递归回退到b

    6、访问e遇到交叉边;e访问完成后得到第二个SCC {e}

    7、节点b递归访问完成

     8、访问d遇到交叉边;d访问完成

    9、a递归访问完成,得到第三个SCC {a,b,c,d}

    五、总结

    本文介绍从定义、算法思想、实现和演示介绍了tarjan求解强连通分量算法。对算法的整体思想和流程算是有比较清晰的描述了,但是算法的严格形式化证明,水平有限就涉及了,感兴趣的可以参考下tarjan原论文。

    附录:

    1、tarjan算法论文:DEPTH-FIRST SEARCH AND LINEAR GRAPH ALGORITHMS

    2、本文中算法的java实现放在github:https://github.com/Tswaf/algorithm/tree/master/src/graph

  • 相关阅读:
    字符串hash+回文树——hdu6599
    数位dp——牛客多校H
    线段树区间离散化——牛客多校E
    最小表示法——牛客多校第七场A
    后缀自动机求多串LCS——spojlcs2
    后缀自动机求LCS——spoj-LCS
    后缀自动机求字典序第k小的串——p3975
    后缀自动机模板——不同子串个数p2408
    同构图+思维构造——牛客多校第六场E
    封装,调用函数,以及参数化
  • 原文地址:https://www.cnblogs.com/dreamvibe/p/8996609.html
Copyright © 2011-2022 走看看