在阅读下列内容之前,请务必了解图论基础部分。
强连通的定义是:有向图 G 强连通是指,G 中任意两个结点连通。
强连通分量(Strongly Connected Components,SCC)的定义是:极大的强连通子图。
不懂再看看另一个版本的介绍
在有向图G中,如果两个顶点间至少存在一条路径,称两个顶点强连通(strongly connected)。
如果有向图G的每两个顶点都强连通,称G是一个强连通图。
非强连通图有向图的极大强连通子图,称为强连通分量(SCC)。
这里想要介绍的是如何来求强连通分量。
Robert E. Tarjan (1948~) 美国人。
Tarjan 发明了很多算法结构。光 Tarjan 算法就有很多,比如求各种联通分量的 Tarjan 算法,求 LCA(Lowest Common Ancestor,最近公共祖先)的 Tarjan 算法。并查集、Splay、Toptree 也是 Tarjan 发明的。
我们这里要介绍的是在有向图中求强连通分量的 Tarjan 算法。
另外,Tarjan 的名字 j 不发音,中文译为塔扬。
在介绍该算法之前,先来了解 DFS 生成树 ,我们以下面的有向图为例:
有向图的 DFS 生成树主要有 4 种边(不一定全部出现):
- 树边(tree edge):绿色边,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边。
- 反祖边(back edge):黄色边,也被叫做回边,即指向祖先结点的边。
- 横叉边(cross edge):红色边,它主要是在搜索的时候遇到了一个已经访问过的结点,但是这个结点并不是当前结点的祖先时形成的。
- 前向边(forward edge):蓝色边,它是在搜索的时候遇到子树中的结点的时候形成的。
我们考虑 DFS 生成树与强连通分量之间的关系。
如果结点u是某个强连通分量在搜索树中遇到的第一个结点,那么这个强连通分量的其余结点肯定是在搜索树中以u为根的子树中。u被称为这个强连通分量的根。
反证法:假设有个结点v在该强连通分量中但是不在以u为根的子树中,那么u到v的路径中肯定有一条离开子树的边。但是这样的边只可能是横叉边或者反祖边,然而这两条边都要求指向的结点已经被访问过了,这就和u是第一个访问的结点矛盾了。得证。
在 Tarjan 算法中为每个结点u维护了以下几个变量:
- DFN[u]:深度优先搜索遍历时结点 被搜索的次序。
- LOW[u]:设以u为根的子树为Subtree(u) 。 LOW[u]定义为以下结点的 的最小值:Subtree(u)中的结点;从Subtree(u)通过一条不在搜索树上的边能到达的结点。
一个结点的子树内结点的 DFN 都大于该结点的 DFN。
从根开始的一条路径上的 DFN 严格递增,LOW 严格非降。
按照深度优先搜索算法搜索的次序对图中所有的结点进行搜索。在搜索过程中,对于结点 和与其相邻的结点 (v 不是 u 的父节点)考虑 3 种情况:
- v未被访问:继续对v进行深度搜索。在回溯过程中,用LOW[v]更新LOW[u]。因为存在从u到v的直接路径,所以v能够回溯到的已经在栈中的结点,u也一定能够回溯到。
- v被访问过,已经在栈中:即已经被访问过,根据LOW值的定义(能够回溯到的最早的已经在栈中的结点),则用DFN[v]更新LOW[u]。
- v被访问过,已不在在栈中:说明v已搜索完毕,其所在连通分量已被处理,所以不用对其做操作。
下面更有助于理解,要好好看看:
做一遍DFS,用dfn[i]表示编号为i的节点在DFS过程中 的访问序号(也可以叫做开始时间)。
在DFS过程中会形成 一搜索树。在搜索树上越先遍历到的节点,显然DFN的值就越小。DFN值越小的节点,就称为越“早” 。
用low[i]表示从i节点出发DFS过程中i下方节点(开始时间大 于DFN[i],且由i可达的节点)所能到达的最早的节点的开始时间。初始时LOW[i]=DFN[i]
DFS过程中,碰到哪个节点,就将哪个节点入栈。栈中节点只有在其所属的强连通分量已经全部求出时,才会出栈。
如果发现某节点u有边连到栈里的节点v,则更新u的LOW值 为min(LOW[u],DFN[v]) ,若LOW[u]被更新为DFN[v],则表明目前 发现u可达的最早的节点是v.
对于u的子节点v,从v出发进行的DFS结束回到u时,使得 LOW[u] = min(LOW[u],LOW[v])。因为u可达v, 所以v可达的最早的节点,也是u可达的。
如果一个节点u,从其出发进行的DFS已经全部完成并回到u,而且此时其LOW值等于DFN值,则说明u可达的所有节点,都不能到达任何比u早的节点 --- 那么该节点u就是一个强连通分量在DFS搜索树中的根。
此时,显然栈中u上方的节点,都是不能到达比u早的节点的。将栈中节点弹出,一直弹到u(包括u), 弹出的节点就构成了一个强连通分量.
将上述算法写成伪代码:
1 TARJAN_SEARCH(int u) 2 vis[u]=true 3 low[u]=dfn[u]=++dfncnt 4 push u to the stack 5 for each (u,v) then do 6 if v hasn't been search then 7 TARJAN_SEARCH(v) // 搜索 8 low[u]=min(low[u],low[v])// 回溯 9 else if v has been in the stack then 10 low[u]=min(low[u],dfn[v])
对于一个连通分量图,我们很容易想到,在该连通图中有且仅有一个DFN[u]=LOW[u] 。该结点一定是在深度遍历的过程中,该连通分量中第一个被访问过的结点,因为它的 DFN 值和 LOW 值最小,不会被该连通分量中的其他结点所影响。
因此,在回溯的过程中,判定DFN[u]=LOW[u]的条件是否成立,如果成立,则栈中从u后面的结点构成一个 SCC。
1 int dfn[N], low[N], dfncnt, s[N], tp; 2 int scc[N], sc; // 结点 i 所在 scc 的编号 3 int sz[N]; // 强连通 i 的大小 4 void tarjan(int u) { 5 low[u] = dfn[u] = ++dfncnt, s[++tp] = u; 6 for (int i = h[u]; i; i = e[i].nex) { 7 const int &v = e[i].t; 8 if (!dfn[v]) 9 tarjan(v), low[u] = min(low[u], low[v]); 10 else if (!scc[v]) 11 low[u] = min(low[u], dfn[v]); 12 } 13 if (dfn[u] == low[u]) { 14 ++sc; 15 while (s[tp] != u) scc[s[tp]] = sc, sz[sc]++, --tp; 16 scc[s[tp]] = sc, sz[sc]++, --tp; 17 } 18 }
时间复杂度O(n+m) 。
Kosaraju 算法(直接粘别人的,我也没怎么看:) ,不是很懂)
Kosaraju 算法依靠两次简单的 DFS 实现。
第一次 DFS,选取任意顶点作为起点,遍历所有为访问过的顶点,并在回溯之前给顶点编号,也就是后序遍历。
第二次 DFS,对于反向后的图,以标号最大的顶点作为起点开始 DFS。这样遍历到的顶点集合就是一个强连通分量。对于所有未访问过的结点,选取标号最大的,重复上述过程。
两次 DFS 结束后,强连通分量就找出来了,Kosaraju 算法的时间复杂度为O(n+m)。
1 // g 是原图,g2 是反图 2 3 void dfs1(int u) { 4 vis[u] = true; 5 for (int v : g[u]) 6 if (!vis[v]) dfs1(v); 7 s.push_back(v); 8 } 9 10 void dfs2(int u) { 11 color[u] = sccCnt; 12 for (int v : g2[u]) 13 if (!color[v]) dfs2(v); 14 } 15 16 void kosaraju() { 17 sccCnt = 0; 18 for (int i = 1; i <= n; ++i) 19 if (!vis[i]) dfs1(i); 20 for (int i = n; i >= 1; --i) 21 if (!color[s[i]]) { 22 ++sccCnt; 23 dfs2(s[i]) 24 } 25 }
Garbow 算法(不写了,也用不上,感兴趣的自己去了解一下吧)
接下来我们讨论一下Tarjan算法能够干一些什么:
既然我们知道,Tarjan算法相当于在一个有向图中找有向环,那么我们Tarjan算法最直接的能力就是缩点辣!
缩点基于一种染色实现,我们在DFS的过程中,尝试把属于同一个强连通分量的点都染成一个颜色,那么同一个颜色的点,就相当于一个点。
将一个有向带环图变成了一个有向无环图(DAG图)。很多算法要基于有向无环图才能进行的算法就需要使用Tarjan算法实现染色缩点,如拓扑排序等,建一个DAG图然后再进行算法处理。
在这种场合,Tarjan算法就有了很大的用武之地辣!
举个简单的例子,求一条路径,可以经过重复结点,要求经过的不同结点数量最多。
下面转载一篇特别易懂的关于Tarjan算法的博客
原文:https://blog.csdn.net/justlovetao/article/details/6673602
直接根据定义,用双向遍历取交集的方法求强连通分量,时间复杂度为O(N^2+M)。更好的方法是Kosaraju算法或Tarjan算法,两者的时间复杂度都是O(N+M)。本文介绍的是Tarjan算法。 [Tarjan算法]
Tarjan算法是基于对图深度优先搜索的算法,每个强连通分量为搜索树中的一棵子树。搜索时,把当前搜索树中未处理的节点加入一个堆栈,回溯时可以判断栈顶到栈中的节点是否为一个强连通分量。
定义DFN(u)为节点u搜索的次序编号(时间戳),Low(u)为u或u的子树能够追溯到的最早的栈中节点的次序号。由定义可以得出,
1 Low(u)=Min 2 { 3 DFN(u), 4 Low(v),(u,v)为树枝边,u为v的父节点 5 DFN(v),(u,v)为指向栈中节点的后向边(非横叉边) 6 }
当DFN(u)=Low(u)时,以u为根的搜索子树上所有节点是一个强连通分量。
算法伪代码如下
1 tarjan(u) 2 { 3 DFN[u]=Low[u]=++Index // 为节点u设定次序编号和Low初值 4 Stack.push(u) // 将节点u压入栈中 5 for each (u, v) in E // 枚举每一条边 6 if (v is not visted) // 如果节点v未被访问过 7 tarjan(v) // 继续向下找 8 Low[u] = min(Low[u], Low[v]) 9 else if (v in S) // 如果节点v还在栈内 10 Low[u] = min(Low[u], DFN[v]) 11 if (DFN[u] == Low[u]) // 如果节点u是强连通分量的根 12 repeat 13 v = S.pop // 将v退栈,为该强连通分量中一个顶点 14 print v 15 until (u== v) 16 }
接下来是对算法流程的演示。(重要,建议自己也在草稿纸上跟着模拟一下)
从节点1开始DFS,把遍历到的节点加入栈中。搜索到节点u=6时,DFN[6]=LOW[6],找到了一个强连通分量。退栈到u=v为止,{6}为一个强连通分量。
返回节点5,发现DFN[5]=LOW[5],退栈后{5}为一个强连通分量。
返回节点3,继续搜索到节点4,把4加入堆栈。发现节点4向节点1有后向边,节点1还在栈中,所以LOW[4]=1。节点6已经出栈,(4,6)是横叉边,返回3,(3,4)为树枝边,所以LOW[3]=LOW[4]=1。
继续回到节点1,最后访问节点2。访问边(2,4),4还在栈中,所以LOW[2]=DFN[4]=5。返回1后,发现DFN[1]=LOW[1],把栈中节点全部取出,组成一个连通分量{1,3,4,2}。
至此,算法结束。经过该算法,求出了图中全部的三个强连通分量{1,3,4,2},{5},{6}。
可以发现,运行Tarjan算法的过程中,每个顶点都被访问了一次,且只进出了一次堆栈,每条边也只被访问了一次,所以该算法的时间复杂度为O(N+M)。
求有向图的强连通分量还有一个强有力的算法,为Kosaraju算法。Kosaraju是基于对有向图及其逆图两次DFS的方法,其时间复杂度也是 O(N+M)。与Trajan算法相比,Kosaraju算法可能会稍微更直观一些。但是Tarjan只用对原图进行一次DFS,不用建立逆图,更简洁。在实际的测试中,Tarjan算法的运行效率也比Kosaraju算法高30%左右。此外,该Tarjan算法与求无向图的双连通分量(割点、桥)的Tarjan算法也有着很深的联系。学习该Tarjan算法,也有助于深入理解求双连通分量的Tarjan算法,两者可以类比、组合理解。
求有向图的强连通分量的Tarjan算法是以其发明者Robert Tarjan命名的。Robert Tarjan还发明了求双连通分量的Tarjan算法,以及求最近公共祖先的离线Tarjan算法,在此对Tarjan表示崇高的敬意。
附:Tarjan算法的C++程序
1 #include<iostream> 2 #include<cstring> 3 #include<cstdio> 4 using namespace std; 5 #define N 100 6 #define M 100 7 struct Edge 8 { 9 int v; 10 int next; 11 }; 12 Edge edge[M];//边的集合 13 14 int node[N];//顶点集合 15 int instack[N];//标记是否在stack中 16 int stack[N]; 17 int Belong[N];//各顶点属于哪个强连通分量 18 int DFN[N];//节点u搜索的序号(时间戳) 19 int LOW[N];//u或u的子树能够追溯到的最早的栈中节点的序号(时间戳) 20 int n, m;//n:点的个数;m:边的条数 21 int cnt_edge;//边的计数器 22 int Index;//序号(时间戳) 23 int top; 24 int Bcnt;//有多少个强连通分量 25 26 void add_edge(int u, int v)//邻接表存储 27 { 28 edge[cnt_edge].next = node[u]; 29 edge[cnt_edge].v = v; 30 node[u] = cnt_edge++; 31 } 32 void tarjan(int u) 33 { 34 int i,j; 35 int v; 36 DFN[u]=LOW[u]=++Index; 37 instack[u]=true; 38 stack[++top]=u; 39 for (i = node[u]; i != -1; i = edge[i].next) 40 { 41 v=edge[i].v; 42 if (!DFN[v])//如果点v没被访问 43 { 44 tarjan(v); 45 if (LOW[v]<LOW[u]) 46 LOW[u]=LOW[v]; 47 } 48 else//如果点v已经被访问过 49 if (instack[v] && DFN[v]<LOW[u]) 50 LOW[u]=DFN[v]; 51 } 52 if (DFN[u]==LOW[u]) 53 { 54 Bcnt++; 55 do 56 { 57 j=stack[top--]; 58 instack[j]=false; 59 Belong[j]=Bcnt; 60 } 61 while (j!=u); 62 } 63 } 64 void solve() 65 { 66 int i; 67 top=Bcnt=Index=0; 68 memset(DFN,0,sizeof(DFN)); 69 memset(LOW,0,sizeof(LOW)); 70 for (i=1;i<=n;i++) 71 if (!DFN[i]) 72 tarjan(i); 73 } 74 int main() 75 { 76 freopen("in.txt","r",stdin); 77 int i,j,k; 78 cnt_edge=0; 79 memset(node,-1,sizeof(node)); 80 scanf("%d%d",&n,&m); 81 for(i=1;i<=m;i++) 82 { 83 scanf("%d%d",&j,&k); 84 add_edge(j,k); 85 } 86 solve(); 87 for(i=1;i<=n;i++) 88 printf("%d ",Belong[i]); 89 } 90
我自己根据模板写的适合我用的(可以忽略)
1 #include <stdio.h> 2 #include <string.h> 3 #include <algorithm> 4 #include <stack> 5 using namespace std; 6 #define N 100 7 #define M 100 8 9 struct Edge{ 10 int v; 11 int next; 12 }Edge[M];//边的集合 13 14 int node[N];//顶点集合 15 int instack[N];//标记是否在stack中 16 int Belong[N];//各顶点属于哪个强连通分量 17 int DFN[N];//节点u搜索的序号(时间戳) 18 int LOW[N];//u或u的子树能够追溯到的最早的栈中节点的序号(时间戳) 19 int n,m;//n:点的个数;m:边的条数 20 int cnt_edge;//边的计数器 21 int Index;//序号(时间戳) 22 int Bcnt; //有多少个强连通分量 23 stack<int> sk; 24 25 void add_edge(int u,int v)//邻接表存储 26 { 27 Edge[cnt_edge].next=node[u]; 28 Edge[cnt_edge].v=v; 29 node[u]=cnt_edge++; 30 } 31 32 void tarjan(int u) 33 { 34 DFN[u]=LOW[u]=++Index; 35 instack[u]=1; 36 sk.push(u); 37 for(int i=node[u];i!=-1;i=Edge[i].next) 38 { 39 int v=Edge[i].v; 40 if(!DFN[v])//如果点v没被访问 41 { 42 tarjan(v); 43 LOW[u]=min(LOW[u],LOW[v]); 44 } 45 else //如果点v已经被访问过 46 { 47 if(instack[v]&&DFN[v]<LOW[u]) 48 LOW[u]=DFN[v]; 49 } 50 } 51 if(DFN[u]==LOW[u]) 52 { 53 Bcnt++; 54 int t; 55 do{ 56 t=sk.top(); 57 sk.pop(); 58 instack[t]=0; 59 Belong[t]=Bcnt; 60 }while(t!=u); 61 } 62 } 63 64 int main() 65 { 66 freopen("sample.txt","r",stdin); 67 memset(node,-1,sizeof(node)); 68 scanf("%d %d",&n,&m); 69 for(int i=1;i<=m;i++) 70 { 71 int a,b; 72 scanf("%d %d",&a,&b); 73 add_edge(a,b); 74 } 75 for(int i=1;i<=n;i++) 76 { 77 if(!DFN[i]) 78 { 79 tarjan(i); 80 } 81 } 82 for(int i=1;i<=n;i++) 83 { 84 printf("%d ",Belong[i]); 85 } 86 return 0; 87 }