无向图的缩点与强连通分量

无向图的缩点与强连通分量

Created
Dec 13, 2021 05:04 PM
Last edited time
Nov 24, 2022 04:47 AM
Tags
图是我接触的很少的一类数据结构,但是前几天在写树形背包的时候,需要使用tarjan对环进行缩点。于是来记录一下学tarjan这个算法的一些心路历程。首先要缩点必须要先有一个算法识别图里所有的强连通分量,这样的常用算法有两种,一种是tarjan,另一种是用两次相反的dfs进行判断的算法,那个先不聊。
首先聊一下强连通这个概念,强连通图代表图中任意两个点可以互达,而强连通分量,则是无向图中任意强连通的子图。接下来来看这样一个图。
我们从1出发,可以很容易看到这个图中一共有3个强连通分量,分别是{1,2,3,4}、{6}、{5}。
notion image
接下来看看tarjan是如何实现在这个图中找到所有强连通分量的,首先tarjan这个算法也是在dfs过程中实现的。在开始之前准备一个空栈,一个dfn数组一个lowlink数组,每当dfs遍历到了一个新的结点,就将这个结点存入栈中,而且将dfn[i]=lowlink[i]=++index,这里的index是一个时间戳,用来记录这是第几个被遍历到的结点。这里的dfn的定义是,保存一个i结点唯一的标志,index的作用是作为一个唯一的标记记录结点。而这个lowlink[i]数组则是能从i这个结点访问的子树,选最小的下标,而事实上,这个最小的下标是根据子树中的叶结点所连接的第一条回边中的dfn更新的。这个定义其实有点难理解,可以在看完代码以后再来讨论一下。
struct node {
    int v,next;
}edge[1001];
int DFN[1001],LOW[1001];
int stack[1001],heads[1001],visit[1001],cnt,tot,index;
void add(int x,int y)
{
    edge[++cnt].next=heads[x];
    edge[cnt].v = y;
    heads[x]=cnt;
    return ;    
}
void tarjan(int x)//代表第几个点在处理。递归的是点。
{
   DFN[x]=LOW[x]=++tot;// 新进点的初始化。
   stack[++index]=x;//进站
   visit[x]=1;//表示在栈里
   for(int i=heads[x];i!=-1;i=edge[i].next)
   {
       if(!DFN[edge[i].v]) {//如果没访问过
          tarjan(edge[i].v);//往下进行延伸,开始递归
          LOW[x]=min(LOW[x],LOW[edge[i].v]);
    }
        else if(visit[edge[i].v ]){  //如果访问过,并且还在栈里。
          LOW[x]=min(LOW[x],DFN[edge[i].v]);//比较谁是谁的儿子/父亲。就是链接对应关系
      }
  }
   if(LOW[x]==DFN[x]) //发现是整个强连通分量子树里的最小根。
    {
        do{
           printf("%d ",stack[index]);
           visit[stack[index]]=0;
	         index--;
       }while(x!=stack[index+1]);//出栈,并且输出。
       printf("\n");
    }
   return ;
}
上面这一段是tarjan的实现,可以看到基本思路就是每遇到一个结点先进行初始化:将结点i入栈,并且将dfn和lowlink初始化,然后遍历i的每条边(每个子节点)。如果子节点未访问过就继续dfs下去,并且在返回的时候根据子节点v的low的值更新当前结点i的low。如果访问过,并且那个节点还在栈中,说明这是一条回边,即返回父结点的边,即这个结点v是i的父节点,所以根据low的定义,v结点并非i的子树中的结点,到这里就可以更新lowlink并继续下一个结点了。而当前i结点的所有边(子节点)被访问过以后,就可以判断是否LOW[i]==DFN[i]了,因为在栈中当前scc(强连通分量)i的子节点应当全部在递归返回的时候被更新成了那条回边的值,而这个scc的根不会被更新(那条回边的值就是这个scc根节点的值),如果这是scc的根节点,那么就可以将栈中所有元素出栈,并且这个栈中的所有元素都是scc中的结点。
前面说过,这个问题其实有一个很难以理解的地方就是,根据上面两篇文章的定义,以及步骤推演,当我们遍历到2这个结点的时候:
notion image
显然DFN=++index=6,而2连接了4结点,继续递归下去,发现4已经被访问过而且还在栈中,那么就更新low(i)=min(low(i),dfn(v)) = 5,那么这里的low显然被更新为了5,根据上面文章中对low的定义:作为每个点在这颗树中的,最小的子树的根,每次保证最小,like它的父亲结点的时间戳这种感觉。或者是,能够到达这个点的最小编号是多少。显然这两种定义对于这个问题都不是很贴切,因为按上面的定义来说low(2)应该等于1,因为1才是这个scc中最小的值,按那个定义来说应该是low(i) = min(low(i),low(v))才对。那么为什么这里的大家写的都是dfn(v)呢?换成low(v)行不行呢?
答案是可以的,在这个算法中这个位置替换掉dfn(v)不会影响结果,但是导致的问题是lowi也就失去了它定义中的子树中最小编号的含义,同时也不代表它一定是scc中最小下标。而这个值将取决于dfs的顺序,变为了一个不确定值,具体参考这篇文章中的例子——文章 。而在这个算法中判断一个结点是否为scc的根,我们也根本不需要知道根节点的标志值,我们只需要low(v)≠dfn(v)就可以了。
那么通过tarjan可以很快的找到所有的强连通分量,我们只需要在tarjan中加入一些额外操作就可以很快实现缩点啦!
缩点的时候只需要用一个染色数组,将同一强连通分量中的结点全部染色为同一个标记数就可以在重新建树的时候使用了。