Tarjan算法详解

借鉴文献

感谢这篇文章对我的启发,本文部分内容摘自这篇文章。

同时OI wiki上的介绍也写得不错。

前置内容

T a r j a n Tarjan Tarjan本质上是一个 D F S DFS DFS的过程,所以我们需要先了解一下 D F S DFS DFS

bool vis[MAXN];
 
void dfs(int u)
{
    vis[u] = true;
    for(int e = first[u]; e; e = nxt[e])
    {
        // 遍历连接u的每条边
        int v = go[e];
        if(!vis[v]) dfs(v);
        // 如果没有访问过就往下继续搜
    }
}

而对于 D F S DFS DFS到的所有的点,其访问的先后顺序有所不同。我们记录访问每个点的次序到一个数组dfn当中。

int dfn[MAXN], tot = 0;
 
void dfs(int u)
{
    dfn[u] = ++tot; // 时间戳,代表点u是第tot个被访问的节点
    for(int e = first[u]; e; e = nxt[e])
    {
        // 遍历连接u的每条边
        int v = go[e];
        if(!dfn[v]) dfs(v);
        // 如果没有访问过就往下继续搜
    }
}

dfn的值是从1开始的,并初始化为0。因此,如果dfn[i]==0,那么i还没有被访问到。

在一个图中做 D F S DFS DFS时,我们往往是将无向图的边转换为两条弧(也即“有向边”)。也就是说,我们可以把所有的图看作是有向图。而对于一次 D F S DFS DFS过程中有向图的边,我们做以下分类:

1. 树边

D F S DFS DFS的过程产生的是一棵树(深度优先遍历树1)。我们将这棵树上的边称为树边

2. 前向边

如果一条边从某个节点 u u u出发到 u u u在深度优先遍历树上的某个子孙节点 v v v v v v的深度严格大于 u u u),那么称这条边为前向边。前向边在被 D F S DFS DFS遍历到时一定有如下性质:

dfn[v] && dfn[v] > dfn[u]

dfn[v](!=0)是因为如果!dfn[v],那么 D F S DFS DFS遍历到这条前向边时一定会继续访问点 v v v,那么这条边就是树边了。因此在遍历这条前向边前一定已经访问了点 v v v。由于dfn记录访问顺序,而访问某个节点子孙节点的次序一定严格大于访问该节点的次序,故dfn[v] > dfn[u]

3. 返祖边

和前向边恰恰相反,如果一条边从某个节点 u u u出发到 u u u在深度优先遍历树上的某个祖先节点 v v v(祖先节点包括这个点自己),那么称这条边为返祖边。返祖边在被 D F S DFS DFS遍历到时一定有如下性质:

dfn[v] && dfn[v]<=dfn[u]

dfn[v](!=0)和前向边是同样的原理。由于祖先节点一定先被访问到(或“同时”被访问到),所以dfn[v]<=dfn[u]

4. 横叉边

除了上述三种边之外的所有边我们称之为横叉边。

下面这幅图,如果从 1 1 1开始遍历,得到四种边的分类如图所示:

四种边

横叉边有如下性质:

性质 横叉边 u → v u\rightarrow v uv满足dfn[u]>dfn[v]

证明dfn[u]==dfn[v],则这条边是返祖边,矛盾。若dfn[u]<dfn[v],则节点 u u u先被访问,也就是说访问到节点 u u u时节点 v v v还没有被访问。如果是这样,根据深度优先遍历的策略,访问节点 u u u后就会访问节点 v v v,那么 u → v u\rightarrow v uv要么是前向边(因为 u → v u\rightarrow v uv可能存在多条边),要么是树边。产生矛盾。因此,dfn[u]>dfn[v]

强连通分量

定义 在有向图 G G G中,如果两个顶点 u u u v v v间有一条从 u u u v v v的有向路径,同时还有一条从 v v v u u u的有向路径,则称两个顶点强连通。如果有向图 G G G的每两个顶点都强连通,称 G G G是一个强连通图。有向非强连通图的极大强连通子图,称为强连通分量( S t r o n g l y   C o n n e c t e d   C o m p o n e n t s , S C C Strongly~Connected~Components,SCC Strongly Connected Components,SCC)。

我们对于极大强连通子图的定义如下:图 G 0 G_0 G0是图 G G G的强连通子图,且不存在图 G G G的强连通子图 G 1   s . t .   G 0 ⊂ G 1 G_1~s.t.~G_0\subset G_1 G1 s.t. G0G1。也就是说,对于 G 0 G_0 G0外的任意一点 u u u G 0 G_0 G0内的任意一点 v v v,都有 u u u v v v不强连通

我们可以得知,一个环上的所有的点一定属于同一个强连通分量,因为环上的任意两点都可以互相到达。于是我们可以得出以下关于后向边的性质

性质 若存在返祖边 u → v u\rightarrow v uv,那么深度优先遍历树上 v v v u u u两点(包含)之间路径上的所有点一定在同一个强连通分量中。

证明 如果 u u u v v v是同一个点,也即返祖边 u → v u\rightarrow v uv是自环,那么该结论成立。否则, v v v必然是 u u u在深度优先遍历树上的祖先节点,那么存在从点 v v v u u u的有向路径,这条路径在到达 u u u后又可以通过返祖边回到 v v v,于是 v v v u u u(包含)之间的路径上的所有点形成一个环,于是它们必然在同一个强连通分量中。

T a r j a n Tarjan Tarjan算法

T a r j a n Tarjan Tarjan算法中,我们构建一个low数组,并对它定义如下:对于一个节点 u u u,它能够通过若干(可以为0)条树边和最多一条返祖边或横叉边(二选一)(并且横叉边指向的点在那时2不能属于任何强连通分量)到达的dfn[v]值最小的 v v v点的dfn值即为low[u]。我们再使用一个布尔数组inStack来记录一个点是否在当前的递归栈中。我们创建一个栈,并创建一个数组co和计数器col。每当 D F S DFS DFS到一个点的时候就将这个点入栈。当访问完这个点周围的节点后(同时也更新好了low值),判断这个点的low值是否等于它的dfn值。如果是这样,那么将col的值加一,并将这个点和栈中它上面的所有的点弹出,同时将它们的co值都设为col

lowinStack数组以及栈的实际意义我们先抛开不谈,我们来想想如何求得low[u]的值。

low[u]的初始值就是dfn[u],因为点 u u u不通过任何一条边到达的点就是它自己,而实际能到达的最小值(low[u])一定是小于等于这个值(dfn[u])的。

我们思考一下 D F S DFS DFS的过程。假如说当前 D F S DFS DFS到的节点就是 u u u,程序将会遍历从 u u u出发的所有有向边。当枚举到边 e e e、到达的点为 v v v时,如果点 v v v已经被访问过,那么 e e e可能是返祖边、前向边或者横叉边。但是如果 v v v在递归栈中,也即如果inStack[v](==true),那么这条边就是返祖边或符合要求的横叉边,这时就要用dfn[v]来更新low[u]的(最小)值。否则,这条边就是前向边,那么我们就不考虑更新。如果点 v v v没有被访问过,那么边 e e e就是树边,此时我们递归解决这个问题:因为如果从点 u u u先经过若干条树边,再经过至多一条返祖边或符合要求的横叉边(实际上是走且仅走一条,否则到达点的dfn值一定大于 u u u点的dfn值)到达能到达的dfn最小的点,一定会经过点 v v v,此时问题就转化为了从 v v v出发直接经过一条返祖边或符合要求的横叉边、不经过任何边和先经过若干条树边,再经过至多一条返祖边或一条符合要求的横叉边到达能到达的dfn最小的点(实质上就是求点 v v vlow值),而这就变成了一个递归的过程(有点像 D P DP DP)。

于是,就有了如下代码:

#include <stack>
 
int dfn[MAXN], tot = 0;
bool inStack[MAXN];
int low[MAXN];
int co[MAXN], col = 0;
std::stack<int> stk;
 
void Tarjan(int u)
{
    dfn[u] = ++tot;
    low[u] = dfn[u]; // 一开始low[u]是自己
    stk.push(u);
    inStack[u] = true;
    for(int e = first[u]; e; e = nxt[e])
    {
        int v = go[e];
        if(!dfn[v])
        {
            Tarjan(v);
            low[u] = min(low[u], low[v]); // 子节点更新了,我也要更新
        }
        else if(inStack[v]) // v访问过且在栈中
        {
            low[u] = min(low[u], dfn[v]);
        }
    }
    if(low[u] == dfn[u]) 
    {
        co[u] = ++col;
        while(stk.top() != u) co[stk.top()] = col, inStack[stk.top()] = false, stk.pop();
            // 标号(“染色”),弹栈
        inStack[u] = false;
        stk.pop(); // 最后把u弹出去
    }
}

实际上,这些被弹出的所有的点构成一个强连通分量,而col就是这个强连通分量的编号。

关于这个方法,证明如下:

我们采用数学归纳法。先证明第一次出现lowdfn相等的情况(记这个点为点 u u u)时,弹出的所有点构成一个强连通分量。(这时,每一条横叉边都是“符合条件”的)首先,当遍历完从 u u u出发的所有边之后回溯到点 u u u时,才判断lowdfn是否相等,也就是说此时栈中 u u u上方的节点都是 u u u的子孙节点3。而它们都没有被弹出(因为 u u u是算法遇到的第一个lowdfn相等的点),也就是说它们都在栈中。换言之, u u u的所有子孙节点都在 u u u的上方,并且 u u u的上方没有别的节点。并且, u u u所有的子节点 v v v都满足low[v]<dfn[v]4。那么,实际上点 u u u的每个子孙节点都可以通过若干条树边和至多一条返祖边(实际上就是一条)到达某个dfn值严格小于这个子孙节点的节点,而它可以到达的这个节点又可以通过同样的方式继续往上走(因为它的low也小于dfn),直到走到点 u u u为止。每一个子孙结点都可以像这样到达点 u u u,而点 u u u又可以通过树边到达任何子孙节点。这样,点 u u u的每个子孙节点都和点 u u u强连通,也就是说 u u u子树上的所有的点都在一个强连通分量上,也即栈中 u u u及其上方所有的点都属于同一个强连通分量。我们再来证明它是“极大”的。因为low[u]==dfn[u],所以如果点 u u u的出边有返祖边,那一定是自环。点 u u u的出边不可能有横叉边。并且,它的子孙节点的返祖边和符合要求的横叉边也最多到达点 u u u,不可能到达dfn更小的节点。那么,点 u u u一定不与dfn小于它的点强连通,也即点 u u u不与以它为根的子树外的任何一个点强连通。那么,以 u u u为跟的子树上所有的点构成一个强连通分量。

接下来归纳证明,假设此前弹出的所有的点都构成各自的强连通分量,那么如果出现了lowdfn相等的情况(还是记这个点为点 u u u),在栈中它上方的点仍然都是它的子孙节点,并且还是和点 u u u强连通(证明过程于“第一次”类似)。但是可能缺失了一部分已经被弹出的、构成强连通分量的点。但是,此时 u u u u u u上方所有的点也一定构成极大强连通子图,因为 u u u子树上已经弹出的点必然不与子树上剩余的点强连通,而除子树外的所有点又与子树上的所有点不强连通。因此,在这种情况下,栈中 u u u u u u上方的所有的点构成强连通分量。到此,我们证明了这个方法是正确的。

**注意:**Tarjan本质上是dfs,对于不连通的图要用这样的循环:

    for(int i = 1; i <= n; ++i)
        if(!dfn[i])
        {
            Tarjan(i);
        }

以确保所有节点都被访问过。

最后,第 23 23 23行的代码else if(inStack[v])可以写成else if (!co[v])inStack数组可以不要。因为如果一个节点被访问过却没被“染色”,那它一定在栈中。


2023_10_3更正

由于如果横叉边存在,那么遍历到这条横叉边时其指向的节点一定已经被染色了。否则用假设法,一直跳low,最后一定跳到一个low等于dfn的点,这个点一定已经被染色。所以实际上low指的是通过返祖边回溯的节点。


  1. 关于深度优先遍历树,可以看看这篇文章↩︎

  2. 也就是程序执行到正 D F S DFS DFS到点 u u u,将要遍历那条横叉边时。 ↩︎

  3. 这也可以归纳证明。对于叶子节点来说,遍历完所有出边回溯到它时没有节点在它的上方。归纳假设回溯到一个节点的子节点时对于这个子节点此结论成立,那么访问完所有的子节点后,这个节点上方就都是它的子孙节点(毕竟,所有的子节点都在它的上方,而子节点的子孙节点又都在子节点的上方)。 ↩︎

  4. 算法过程中,我们用相关节点的相关值来更新low值的最小值,因为low的初始值是dfn,所以实际上low一定是小于等于dfn的。在这里,因为不相等,所以就是小于。 ↩︎

  • 4
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值