算法学习笔记:Tarjan算法

在上一篇文章当中我们分享了强连通分量分解的一个经典算法Kosaraju算法,它的核心原理是通过将图翻转,以及两次递归来实现。今天介绍的算法名叫Tarjan,同样是一个很奇怪的名字,奇怪就对了,这也是以人名命名的。和Kosaraju算法比起来,它除了名字更好记之外,另外一个优点是它只需要一次递归,虽然算法的复杂度是一样的,但是常数要小一些。它的知名度也更高,在竞赛当中经常出现。

先给大家提个醒,相比于Kosaraju算法,Tarjan算法更难理解一些。所以如果你看完本文没有搞明白的话,建议可以阅读一下上一篇文章。这两个算法的效果和复杂度都是一样的,其实学会一个就可以,没必要死磕

算法数据结构 | 三个步骤完成强连通分量分解的Kosaraju算法

算法框架

我们来思考一个问题,对于强连通分量分解的算法来说,它的核心原理是什么?

如果你看过我们之前的文章,那么这个问题对你来说应该不难回答。既然是强连通分量,意味着分量当中每个点都可以互相连通。所以我们很容易可以想到,我们可以从一个点出发,找到一个回路让它再回到起点。这样途中经过的点就都是强连通分量的一部分。

但是这样会有一个问题,就是需要保证强连通分量当中的每个点都被遍历到,不能有遗漏。针对这个问题我们也可以想到解法,比如可以用搜索算法去搜索它所有能够达到的点和所有的路径。但是这样一来,我们又会遇到另外一个问题。这个问题就是强连通分量之间的连通问题

我们来看个例子:

在上面这张图当中如果我们从点1出发,我们可以达到图中的每一个点。但是我们会发现1,2,3是一个强连通分量,4,5,6是另外一个。当我们寻找1所在的强连通分量的时候,很有可能会把4,5,6这三个点也带进来。但问题是它们是自成分量的,并不应该算在1的强连通分量当中。

我们整理一下上面的分析和思路可以发现强连通分量分解这个算法的核心其实就是解决这两个问题,就是完备性问题。完备意味着不能遗漏也不能冗余和错误,我们想明白核心问题所在之后就很容易搭建起思维框架,接下来我们再来看算法的描述会容易理解得多。

算法细节

Tarjan算法的第一个机制是时间戳,也就是在遍历的时候对每一个遍历到的点打上一个值。这个值表示这是第几个遍历的元素。

这个应该很好理解,我们只需要维护一个全局的变量,在遍历的时候去让它自增就可以了。我们来写下Python代码给大家演示一下:

stamp = 0
stamp_dict = {}
def dfs(u):
    stamp_dict[u] = stamp
    stamp += 1
    for v in Graph[u]:
        dfs(v)

通过时间戳我们可以知道每个点被访问的顺序,这个顺序是正向顺序。举个例子,比如说假设u和v两个点,u的时间戳比v小。那么它们之间的关系只有两种可能,第一种是u能够连通到v,说明从u到v的链路可以走通。第二种是u不能连通到v,这种情况不论反向的从v到u能否连通都不具有讨论意义,因为它们一定不能互相连通。

所以我们想要找到连通的通路还需要找到反向的路径,在Kosaraju算法当中我们是通过反向图来实现的。在Tarjan当中则采取了另外一种方法。因为我们已经知道各个点的时间戳了,我们完全可以通过时间戳来寻找反向的路径。什么意思呢?其实很简单,当我们在遍历u的时候如果遇到了一个比u时间戳更小的v,那么说明就存在一条反向的路径从u通向v。如果v这时候还没有出栈,意味着v是u的上游的话,那么也就说明存在一条路径从v通向u。这样就说明了u和v可以互相连通。

既然找到了一对互相连通的u和v,那么我们需要把它们记录下来。但问题是我们怎么知道记录到什么时候为止呢?这个边界在哪里?Tarjan算法设计了另外一个巧妙的机制解决了这个问题。

这个机制就是low机制,low[u]表示u这个点能够连通到的所有的点的时间戳的最小值。时间戳越小说明在搜索树当中的位置越高,也可以理解成u能够连通到的处在搜索树中最高的点。那么很明显了,这个点就是u这个点所在强连通分量所在搜索树某一棵子树的树根。

这里可能有一点点绕,我们再来看张图:

图中节点所在的序号就是递归遍历的时间戳,我们可以发现对于图上的每个点来说它们的low值都是1。很明显1这个点在搜索树当中是2,3,4这三个点的祖先。也就是说这一个强连通分量的遍历是从1这个点开始的。当1这个点出栈的时候,意味着以1位树根的子树已经遍历完了,所有可能存在的强连通分量也都已经找完了。

这就带来了另外一个问题,我们假设当前点是u,我们如何知道u这个点是否是图中1这样的树根呢?有没有什么办法可以标记出来呢?

当然是有的,这样的点有一个特性就是它们的时间戳等于它们的low。所以我们可以用一个数组维护找到的强连通分量,当这些强连通分量能够遍历到的树根出栈的时候,把数组清空。

我们把上面的逻辑整理一下就可以写出代码来了:

scc = []
stack = []

def tarjan(u):
    dfn[u], low[u] = stamp, stamp
    stamp += 1
 stack.append(u)
    
    for v in Graph[u]:
        if not dfn[v]:
            tarjan(v)
            low[u] = min(low[u], low[v])
        elif v in stack:
         low[u] = min(low[u], dfn[v])
    
   if dfn[u] == low[u]:
        cur = []
        # 栈中u之后的元素是一个完整的强连通分量
        while True:
            cur.append(stack[-1])
            stack.pop()
            if cur[-1] == u:
                break
        scc.append(cur)
// Cpp
int dfn[N], low[N], dfncnt, s[N], in_stack[N], tp;
int scc[N], sc;  // 结点 i 所在 scc 的编号
int sz[N];       // 强连通 i 的大小
void tarjan(int u) {
    low[u] = dfn[u] = ++dfncnt, s[++tp] = u, in_stack[u] = 1;
    for (int i = h[u]; i; i = e[i].nex) {
        const int& v = e[i].t;
        if (!dfn[v]) {
            tarjan(v);
            low[u] = min(low[u], low[v]);
        } else if (in_stack[v]) {
            low[u] = min(low[u], dfn[v]);
        }
    }
    if (dfn[u] == low[u]) {
        ++sc;
        while (s[tp] != u) {
            scc[s[tp]] = sc;
            sz[sc]++;
            in_stack[s[tp]] = 0;
            --tp;
        }
        scc[s[tp]] = sc;
        sz[sc]++;
        in_stack[s[tp]] = 0;
        --tp;
    }
}

最后,我们来看一下之前讲过的经典例子:

首先我们从1点开始,一直深搜到6结束,当遍历到6的时候,DFN[6]=4,low[6]=4,当6出栈时满足条件,6独立称为一个强连通分量。

同理,当5退出的时候也同样满足条件,我们得到了第二个强连通分量。

接着我们回溯到节点3,节点3还可以遍历到节点4,4又可以连向1。由于1点已经在栈中,所以不会继续递归1点,只会更新low[4] = 1,同样当4退出的时候又会更新3,使得low[3] = 1。

最后我们返回节点1,通过节点1遍历到节点2。2能连通的4点已经在栈中,并且DFN[4] > DFN[2],所以并不会更新2点。再次回到1点之后,1点没有其他点可以连通,退出。退出的时候发现low[1] = DFN[1],此时栈中剩下的4个元素全部都是强连通分量。


到这里,整个算法流程的介绍就算是结束了,希望大家都可以enjoy今天的内容。

posted @ 2020-11-24 18:08  RioTian  阅读(272)  评论(0编辑  收藏  举报