这里主要谈及强连通分量(以下简称SCC,strongly connected component)三种常见的求法(以下涉及的图均为有向图),即Kosaraju、Tarjan和Gabow。三种算法背后的基础思想都是DFS,只是它们通过DFS获得了不同的信息。各位大哥大姐继续往下读之前,最好对DFS相关的概念和性质比较熟悉,例如,什么叫做<a title="DFS" href="http://en.wikipedia.org/wiki/Depth-firstsearch" target="blank">反向边、交叉边</a>。
Kosaraju
我就不八卦这大哥了。Kosaraju的方法也就是导论第二版中文22章中讲的方法, 即广为人知的两遍DFS。Kosaraju算法说白了就是先对原图来一遍DFS, 再把所有边的方向都倒过来,按照刚才DFS求出的结点完成时间的逆序,再来一遍DFS。
那么为啥可以这么做呢?
这就需要一个性质来帮忙,即当A、B为两个SCC,且存在有向边(a, b),其中a∈A,b∈B, 那么必然有:A的完成时间晚于B(一个SCC的完成时间表示该SCC中所有结点完成时间最晚的一个)。
可以简单证明一下:
如果我们在第一遍DFS的时候,A先于B被访问,且A中的第一个被访问到的结点是x, 那A和B中所有的结点显然都能在x之后被访问到。于是x的完成时间要晚于B中任何一个结点, 从而A的完成时间晚于B。
如果B先于A被访问,由于A和B是不同的两个SCC,而且只有A到B的边,于是B就不能到达A。 那么当B中的结点被访问完之后,A中的点仍然处于为访问状态,自然A的完成时间也就晚于B了。
所以在第二遍DFS时,第一次取到的那个完成时间最晚的结点u, 它所在的SCC在转置图中就不能有指向外的边。于是对转置图的第二遍DFS, 从u开始,便能轻易走遍所有处于同一SCC的结点。后续的遍历步骤也就类似了。
时间复杂度自然是算在两次DFS头上,O(V+E)。
Tarjan
Tarjan貌似跟Hopcroft都是Cornell的大神。总的来说, Tarjan算法基于一个观察,即:同处于一个SCC中的结点必然构成DFS树的一棵子树。 我们要找SCC,就得找到它在DFS树上的根。
那么怎么找呢?
考虑一下,如果DFS访问到了某个结点u,又顺着u来到了结点v, 但从v发出了一条反向边,指向了u的前驱w,那根据DFS的性质, u->v->w->u构成了一个环。这一堆东西必然处于同一个SCC。 所以某个要找到SCC子树的根,就得找那个在DFS树中最早被发现的结点,且这个结点要与它的一堆后继结点形成环。
这时候DFS的特性就派上用场了。最早发现的结点可以通过记录发现时间来实现,而反向边的判断可以通过结点颜色,即访问状态来实现。 定义一个结点的low值为:从该节点的子树结点可达的,尚未求出属于哪个SCC的结点的最早访问时间。 由于SCC构成子树,所以求没求出某个结点所在的SCC用栈来刻画就可以了: 每次访问到一个结点u,记录发现时间visit,并将它推到栈里去。 如果从u可达的结点v没访问过,那么访问v,用v的low值更新u; 否则,如果v已访问过,那就看看它在不在栈中。如果在,说明还没确定v到底属于哪个SCC, 这时(u, v)就是一条反向边了,根据v的visit值,更新u的low值即可。 最后回到u结点时,如果u的low值和visit相等了,显然u就是我们要找的根节点了。 从栈里把u和其上所有结点弹出来,这一堆东西就在一个SCC里了。上伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// n: number of nodes in the graph // visit[i]: discovery time of node i // low[i]: low-value of node i // time: the time stamp // S: the stack time <- 1 FIND_SCC(G): for i<-1 to n: if visit[i] is not defined: TARJAN(i) TARJAN(u): visit[u] <- time low[u] <- time time <- time + 1 push(S, u) for each edge (u, i) in the graph: if visit[i] is not defined: TARJAN(i) low[u] <- min{ low[u], low[i] } else if i is in S: low[u] <- min{ low[u], visit[i] } // things popped here are in the same SCC if visit[u] == low[u]: pop all node above u on stack including u |
每个结点入栈一次出栈一次,每条边访问一次,O(V+E)。
Gabow
Gabow与Tarjan的思路是一致的。但Gabow使用了另一个栈来找出SCC子树的根。 Gabow使用的栈S与Tarjan一样,保存尚未决定属于哪个SCC的结点; 栈P保持如下性质:栈顶结点始终具有最小的visit值, 即保持栈顶元素的visit值小于等于当前发现的反向边指向的祖先结点的visit值。
栈S和P都随着DFS的进行增长。若当前正在访问结点u,从u可达点v, 先将u压入两个栈中。这一步骤相当于Tarjan中初始化一个结点的low值为当前visit值。 如果v没有访问过,则访问v;否则判断v是否在S栈中。 如果在,那么(u, v)为反向边,此时从P栈顶弹出那些晚于v被发现的结点。为啥? 因为此时v是u的后继结点,我们得找出以u为根的子树结点能到达的最早访问的结点, 类似于Tarjan算法中对low值的更新。
再一次回到u时,若P栈栈顶的元素就是u,表明u就是SCC子树的根。 与Tarjan类似,从S栈中弹出元素即找到了一个SCC,代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
GABOW(u): visit[u] <- time time <- time + 1 push(S, u) push(P, u) for each edge (u, i) in the graph: if visit[i] is not defined: GABOW(i) else if i is in S: repeat popping nodes on P until visit[top(P)] <= visit[i] // things popped here are in the same SCC if top(P) == u: pop all node above u on S including u pop u from P |
Gabow时间复杂度也为O(V+E),常数因子的差别各位大神请自行分析。