Tarjan算法超超超详解(ACM/OI)(强连通分量/缩点)(图论)(C++)

I 前置知识:深度优先搜索与边的分类

首先我们来写一段基本的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);
        // 如果没有访问过就往下继续搜
    }
}

  这段代码我们再熟悉不过了。接下来我们要引入一个叫做时间戳(也叫dfs序)的概念,它代表了每个节点被第一次访问的时间(相邻两个节点的访问时间是连续的)。我们用tot变量作为当前时间,每次访问一个节点tot++。越先访问的节点时间戳越小,越后访问的节点时间戳越大。在下面的代码中,我们用dfn(dfs number)数组作为每个点的时间戳,这样就可以取代vis数组来判断某个点有没有被访问过。具体来说,若没有被访问过,则该点的dfn为0。

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[]随访问顺序严格单调递增。dfn[]数组的某些性质可以为我们寻找强连通分量奠定基础
  在介绍如何寻找强连通分量之前,我们必须利用dfs对图的边进行分类。图的边分为4类:

(1)树边。 指深度优先搜索树上的边。具体来说,如果上面的代码中这句话

if(!dfn[v]) dfs(v);

的if条件成立,即v没有被访问过、接下来要从v开始搜,那么边u→v就被称为树边。

(2)后向边。 是指将节点u连接到其在深度优先搜索树中的祖先节点v的边u→v。在上面的代码中,我们并不能根据条件判断一条边是否一定是后向边,不过我们知道一定有

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

即:v被访问过,且v比u先被访问。自循环(u→u)也被认为是后向边(所以是小于等于)。

(3)前向边。 是指将节点u连接到其在深度优先搜索树中的后代节点v的边u→v。在上面的代码中,我们也不能根据条件判断一条边是不是后向边,不过我们知道一定有

dfn[v] != 0 && dfn[v] > dfn[u]

即:v被访问过,且v比u后被访问。举个例子:
在这里插入图片描述
  这张图中的搜索顺序为1→2→3→4。节点1、2、3、4的时间戳(dfn)分别为1、2、3、4。在考察边2→4的时候,由于dfn[4]>dfn[2],所以2→4是前向边。又dfn[1]<dfn[4],故4→1是后向边。

(4)横向边。 所有其他边都称为横向边。挺没有存在感的。 换句话说,就是一个点不是另一个的点的祖先。 这两个点可以在同一棵深度优先搜索树上,也可以在两棵不同的深度优先搜索树上。(一张图可以包含很多个深度优先搜索树。)
在这里插入图片描述
其中6→3是横向边(属于在同一棵树上的),因为2→3→4和2→5→6→7分别是树上的两条链,6和3互相不是对方的祖先。

对于横向边,我们有如下性质:

定理1 横向边u→v满足dfn[u]>dfn[v]。
证明:根据深度优先搜索的策略,访问到结点u之后,接下来会访问它所有邻接的未被访问的结点,u到所有这些结点的边都是树边。因为此处u→v不是树边,而是横向边,所以在访问u时v一定已被访问过。根据dfn[]随访问顺序严格单调递增,显然有dfn[u]>dfn[v]。

定理2 若有横向边u→v,则必不存在从v到u的路径。
证明:反证法。假设存在从v到u的路径,则该路径必是深度优先搜索树上的一条链。这是因为,在访问节点v时,该路径上的点都没有被访问过。因此根据前面的代码,访问v时必会继续搜索这条路径上的点,这条路径上的每条边必是树边。这样,v就是u的祖先了,u→v就是后向边,与u→v是横向边矛盾。因此假设不成立,不存在从v到u的路径。证毕。

定理2告诉我们,若有横向边u→v,则u、v必不在同一个强连通分量中。这为后面Tarjan算法忽略横向边提供了理论依据。

思考题:如何证明所有的边必属于这4类中的某一类?

II 强连通分量

定义 在有向图G中,如果两个顶点u,v间有一条从u到v的有向路径,同时还有一条从v到u的有向路径,则称两个顶点强连通。如果有向图G的每两个顶点都强连通,称G是一个强连通图。有向非强连通图的极大强连通子图,称为强连通分量(Strongly Connected Components, SCC)。

  换句话说,一个强连通分量中的每两个点可以互相到达,且这个强连通分量所包含的的节点数尽可能大。例如:(下图中被框起来的子图就是强连通分量,共三个):
在这里插入图片描述

  显然,环是一个强连通分量的子图。例如上面的1→2→3→1和5→6→7→8→5。不过,强连通分量不一定是环,也有可能是几个环并起来的,还可能只含有一个节点。

定理3 若存在后向边u→v,则u、v在同一个强连通分量中。
证明:由u→v知v是u的祖先节点,所以路径v→u存在, 且是深度优先搜索树上的一条链。故v→u→v构成一个环。因此u、v在同一个强连通分量中。

定理3表明后向边是构成强连通分量的关键因素。但对于前向边u→v而言,其发挥的作用和树边是相同的——反正不管走树边还是前向边,都可以从u到v,但还是不知道能否从v到u。因此,Tarjan算法只考察树边和后向边。

总结一下:图的边分为四类:树边、后向边、前向边、横向边。其中,前向边和横向边对于我们判断SCC(强连通分量)没有任何帮助,因此我们忽略它们,只考察树边和后向边。

III Tarjan算法

那么我们怎么判断一条边是是不是后向边呢?我们看到,后向边u→v满足dfn[v]≤dfn[u],同时,横向边也满足dfn[v]<dfn[u]。因此我们不能简单地根据dfn数组来区分这两种边。那么如何区分呢?我们考虑维护一个栈:栈中的元素是当前搜索树上的点。显然,如果一条边u→v是后向边,那么当我们在访问u时会发现v已经在栈中。然后,如果dfn[v]<dfn[u],则u→v是后向边。 如何判断一个数是否在栈中?我们定义instack[]数组,节点u入栈时instack[u]=true,出栈时instack[u]=false,查询v是否在栈中用if(instack[v])。而栈中的元素是当前搜索树上的点,横向边的两个节点不可能在一个当前搜索树当中,当前搜索树当中的节点的边都为树边。

#include <stack>
 
int dfn[MAXN], tot = 0;
bool instack[MAXN]; // 可以考虑用bitset<MAXN>
std::stack<int> stk;
 
void dfs(int u)
{
    
    
    dfn[u] = ++tot;
    stk.push(u);
    instack[u] = true;
    for(int e = first[u]; e; e = nxt[e])
    {
    
    
        int v = go[e];
        if(!dfn[v]) dfs(v);
        else if(instack[v]) // v访问过且在栈中,意味着u→v是后向边
        {
    
    
            DO_SOMETHING
        }
    }
    stk.pop();
    instack[u] = false;
}

所以知道u→v是后向边之后,我们要做什么呢(代码中的DO_SOMETHING)?此时,我们希望用一种方法标明,栈中的元素,从v到u,都属于同一个SCC。我们引入low[]数组,low[u]代表包含u的SCC中第一个被搜索到的节点的dfn值,也可以理解为从u出发能回溯到的dfn最小的节点的dfn值。 显然,若u→v是一个后向边,那么v是u的祖先,v是v、u所在的SCC中最先被访问到的节点,low[u]=dfn[v]。而且,对于v→u路径(都是树边)上的每一个节点w,有low[w]=dfn[v],因为w和v、u属于同一个SCC。

UPDATE:这里用low[u]=low[v](而不是dfn[v])也完全可以。因为low[v]=dfn[v]成立。不过在用Tarjan算法求割点和桥时可不能这么写~

举个例子:
在这里插入图片描述
从上图可见,2、3、4、5属于同一个SCC,那么它们每个点的low值都应该是dfn[v]=dfn[2]=2。

问题来了:以何种方式更新low数组呢?可不可以把栈中压在v以上的元素的low值全部改为dfn[v]?可以是可以,但是没有必要。我们这么做:在回溯的时候,设当前节点为u,子节点为v,则执行low[u] = min(low[u], low[v])。

不过为什么要用low[u]=min(low[u], low[v]),而不是直接low[u]=low[v]呢,不直接将SCC中U的low值取为low[v]即SCC中最小的那个值?因为若假设节点low值直接去dfn的索引值,则low[v]=dfn[v],low[u]=dfn[u],则可能low[u]<low[v](u比v先被访问),所以取二者较小的。

以上结论的代码实现:

#include <stack>
 
int dfn[MAXN], tot = 0;
bool instack[MAXN];
int low[MAXN];
std::stack<int> stk;
 
void dfs(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])
        {
    
    
            dfs(v);
            low[u] = min(low[u], low[v]); // 子节点更新了,我也要更新
            // 若子节点没更新,则min能够保证low[u] == dfn[u]
        }
        else if(instack[v]) // v访问过且在栈中,意味着u→v是后向边
        {
    
    
            low[u] = min(low[u], dfn[v]);
            // 此处用min的原因是u→v可能是前向边,此时dfn[v]>dfn[u]
        }
    }
    stk.pop();
    instack[u] = false;
}

  那么怎么标记每一个强连通分量呢?我们采用这样的策略:给每个节点“染色”,在同一个SCC中的节点拥有相同的颜色。当然,这个“色”不是真的色,而是一个树。我们用co[]数组来表示:co[u]代表节点u的颜色。 第1,2,3,…个SCC对应的颜色分别是1,2,3…。我们用全局变量col来表示当前颜色,也表示已经染了的颜色的个数。当我们发现low[u] == dfn[u]时,代表u是其所在的SCC的最先访问到的节点。此时,栈中压在u以上的所有元素,包括u,构成一个SCC(不在该SCC中的元素都已经弹出去了)。然后将u即压在它上面的所有元素的颜色标记为++col,并弹出。

#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]); // 子节点更新了,我也要更新
            // 若子节点没更新,则min能够保证low[u] == dfn[u]
        }
        else if(instack[v]) // v访问过且在栈中,意味着u→v是后向边
        {
    
    
            low[u] = min(low[u], dfn[v]);
        }
    }
    if(low[u] == dfn[u]) // 是SCC中的第一个被访问的节点
    {
    
    
        co[u] = ++col;
        while(stk.top() != u) co[stk.top()] = col, instack[stk.top()] = false, stk.pop();
            // 染色,弹栈
        instack[u] = false;
        stk.pop(); // 最后把u弹出去
    }
}

其实我们可以不用instack数组,而将else if(instack[v])改为else if(!co[v]),表示v访问过且未被染色。这两种写法是等价的

注意:Tarjan本质上是dfs,对于不连通的图要用

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

以确保所有节点都被访问过。
————————————————
版权声明:本文为CSDN博主「seh_sjlj」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qaqwqaqwq/article/details/119884138

猜你喜欢

转载自blog.csdn.net/lockhou/article/details/129877144