tarjan 算法及其应用

· · 个人记录

tarjan 算法及其应用

0.前言

tarjan 算法,听名字也能知道,又是 tarjan 他老人家发明的。以他的名字命名的算法很多,我们这里要介绍的是在有向图中求强连通分量的 Tarjan 算法。

算法并不复杂,但思想及应用还算广泛,今天在这里总结一下。

1.问题

先来看我们的问题:
【模板】缩点

看到这个题,我们可以想到用 dp 解决
但是,单纯的一张有向图,是不满足我们的无后效性的。比如:

在这张图中,我们对 1 更新答案的时候,需要知道 3 的答案,但事实上 3 的答案我们需要知道 2 的答案后才能求出,所以这并不满足 dp 所需要的无后效性。

那什么样的图才满足我们所需要的无后效性呢?

我们发现,在这张图中,1、2、3、5 这4个点是可以任意两两到达的。也就是说只要我们走到了其中一个点,其他点我们一定可以到达,之后再回到当前点。

像这样一组可以两两互相到达的点,我们称之为一个强连通分量。 贴一下强连通分量的定义:

“有向图强连通分量:在有向图G中,如果两个顶点vi,vj间(vi>vj)有一条从vi到vj的有向路径,同时还有一条从vj到vi的有向路径,则称两个顶点强连通(strongly connected)。如果有向图G的每两个顶点都强连通,称G是一个强连通图。有向图的极大强连通子图,称为强连通分量(strongly connected components)。”

那我们是不是可以把它们 4 个点缩在一起,看成一个大点呢?

所以上面的图我们可以把它变成这样:

对于所有的有向图,都可以把其中可以互相走到的点缩在一起,即把强连通分量缩在一起,这样我们就会得到一张新图。在这张图上,任意两个点均无法互相走到,即原图所有强连通分量都是一个单独的点,这张图也就是一个有向无环图
而在有向无环图上,是满足我们的无后效性的

之后在这张无后效性的图上,我们就可以按照拓扑序,愉快的跑 dp 了 qwq。

所以我们现在的问题是,怎么找强连通分量呢?

2.有向图缩点

我们先来看看有向图中的情况。

(一张朴实无华的有向图

边的分类

我们想得到一张图上的信息,最朴实无华的方法不过是 DFS 了。
我们从 1 号点开始通过 DFS 遍历整张图,只要图是连通的,一定会依次经过每一个点。
如果我们在经过每一个点的时候,记录下它是第几个被我们经过的点,这样所有点就都被我们按照一定顺序记录了下来。
这个顺序叫做 DFS 搜索序。我们将每个点经过的次序记作这个点的 dfn 值,也可以叫做这个点的时间戳

这时候,我们来看看我们走过的是哪几条边。

蓝色的是我们经过的边。
如果将其他的边全部去掉,这张图其实就变成一棵树了,这棵树我们把它叫做搜索树。所以我们可以把它叫做树边

再看看其他的边,有没有什么共同之处。
其实可以发现它们分为三种。

我们可以这样理解这 4 种边:

但是也有一种情况我们是需要考虑横插边的,如下图:

这里的 2 号点和 1 号点是在同一个强连通分量里的,即通过 2 可以走到 1,所以此时 (3,2) 构成了强连通分量,我们需要考虑它。

点的分类

我们再来看看经过这 4 种边,点又可以分成哪些情况:

我们记录下从每个点开始,可以到达的 dfn 序最小的节点(包括自己),将这个节点的 dfn 值记录下来,记作原来节点的 low 值。
这样一来,对于每个强连通分量,我们可以在 dfn 值最小的节点记录它,这样可以避免错算和漏算。

之后每个节点可以分成两种情况:

low 值计算

我们先来看看怎么计算 low 值。

比较明显,这玩意又是一个 dp。

欸,不是说不满足无后效性,没法进行 dp 嘛?
(开始套娃

我们这里用了一个比较巧妙的办法。我们记录的 low 值并不是可以到达的所有节点 dfn 的最小值,这是无法 dp 求出的。这里的 low 值是以下节点的 dfn 的最小值;

  1. 自己子树中的节点。
  2. 通过 1 条不在搜索树上的边(返祖边或第二种横插边),能够到达的点。

看出来了嘛,这里的区别在于我们只考虑经过 1 条边到达的点,并不考虑它所能到达的最小值是多少。即,对于一条边我们只用指向点的 dfn 值来更新当前点的 low 值,所以在我们求当前节点的 low 值时不需要知道指向节点的 low 值,因此这个 dp 是满足无后效性的

但这样更新出来的 low 值并不是我们想要的所有可到达节点的最小值,这样怎么保证正确性呢?

对于这张图,4号点可以到达的编号最小的点为 1,而根据 low 的计算方法,4 号点在只经过一条返祖边时,low 值会被更新为 2。
可以发现,虽然并不是我们想要的 1,但通过一条返祖边或第二种横插边,我们一定可以走到一个比自己编号更小的点,这样就已经可以说明这个点不是该强连通分量的最小编号节点了,所以并不影响结果。

现在的问题是,在知道一个点是最小节点后,我们如何找到整个强连通分量?

在我们搜索的时候,所有的点访问的顺序满足先进后出,即后访问的节点一定会先被返回,利用这一点性质,我们可以利用来保存我们访问到的节点。

另一个性质是,强连通分量的访问在栈中一定是连续的。不可能存在我们先访问了强连通分量 A,之后抵达强连通分量 B,在强连通分量 B,返回之前,又访问到强连通分量 A 的情况。所以利用这一点,我们可以在每次找到一个强连通分量最小节点时,不断弹栈直到这个节点,这样就可以处理到所有强连通分量中的节点了。

我们来总结一下,展开一个节点时,入栈,更新 dfn 值,访问出边,更新 low 值,判断是否为最小节点,返回。更新 low 值利用下面的规则:

  1. 自己子树中的节点。
    d[x].low=min(d[x].low,d[t[i].ver].low)
  2. 通过 1 条不在搜索树上的边(返祖边),能够到达的点。
    d[x].low=min(d[x].low,d[t[i].ver].dfn)

    (这里判断是不是返祖边有个小技巧,记录一下一个节点是否在栈中即可,或判断这个点是否被打上了已有的强连通分量标记)

部分代码:

inline void dfs(int x){
    d[x].low=d[x].dfn=++cnt;
    s.push(x);
    for(int i=d[x].hed;i;i=t[i].nxt){
        if(d[t[i].ver].dfn==0){//树边
            dfs(t[i].ver);
            d[x].low=min(d[x].low,d[t[i].ver].low);
        }
        if(d[t[i].ver].ver==0)//返祖边,即没有被打上强连通分量的标记
        d[x].low=min(d[x].low,d[t[i].ver].dfn);
    }
    if(d[x].dfn==d[x].low){//一个强连通分量的编号最小的节点
        ++cncnt;
        while(s.empty()==0&&s.top()!=x){
            d[s.top()].ver=cncnt;
            dd[cncnt].val+=d[s.top()].val;
            s.pop();
        }
        d[x].ver=cncnt;
        dd[cncnt].val+=d[x].val;//把点缩在一起,贡献要加起来
        s.pop();
    }
    return;
}
int main(){
    for(int i=1;i<=n;++i){
        if(d[i].dfn==0) dfs(i);
    }
    for(int i=1;i<=m;++i){
        if(d[t[i].fver].ver!=d[t[i].ver].ver){//如果一条边连接两个强连通分量,建边
            crr(d[t[i].fver].ver,d[t[i].ver].ver);
        }
    }
}

这样在配合上拓扑序进行 dp,就可以解决这个问题了。

 for(int i=1;i<=cncnt;++i){
        dd[i].ans=dd[i].val;
        if(dd[i].rd==0){
            q.push(i);
        }
    }
    while(q.empty()==0){
        x=q.front();
        q.pop();
        for(int i=dd[x].hed;i;i=tt[i].nxt){
            dd[tt[i].ver].rd--;
            dd[tt[i].ver].ans=max(dd[tt[i].ver].ans,dd[x].ans+dd[tt[i].ver].val);
            if(dd[tt[i].ver].rd==0) q.push(tt[i].ver);
        }
    }
    int ans=0;
    for(int i=1;i<=cncnt;++i){
        ans=max(ans,dd[i].ans);
    }

2.割点与桥

在无向图中,tarjan 算法依旧能发挥它的作用。

无向图必然是没有强连通分量的概念了,但有点双连通分量和边双连通分量

在一张连通的无向图中,对于两个点 x 和 y,如果无论删去哪条边(只能删去一条)都不能使它们不连通,我们就说 x 和 y 边双连通。

在一张连通的无向图中,对于两个点 x 和 y,如果无论删去哪个点(只能删去一个,且不能删 x 和 y 自己)都不能使它们不连通,我们就说 x 和 y 点双连通。

有了它俩的定义,就有了割点和桥。

对于一个无向图,如果把一个点删除后这个图的极大连通分量数增加了,那么这个点就是这个图的割点(又称割顶)。
人话:删去割点原图不连通。

对于一个无向图,如果删掉一条边后图中的连通分量数增加了,则称这条边为桥或者割边。
人话:删去桥后原图不连通。

这俩玩意怎么求呢?

桥:

一条边 (x,y) 是桥,当且仅当 x 的 dfn 值小于 y 的 low 值。

割点:

一个点 x 是割点当且仅当搜索树上 x 的一个子节点 y 满足 x 的 dfn 值小于等于 y 的 low 值。特别的是对于根节点的判断,根节点是割点需要至少有两个子节点满足条件。

贴一下求割点代码:

void dfs(ll x,bool rt)
{
    q.push(x);
    d[x].v=1;
    d[x].dfn=++tot;
    d[x].low=tot;
    for(int i=d[x].hed;i;i=t[i].nxt)
    {
        if(d[t[i].ver].v==0)
        {
            dfs(t[i].ver,0);
            if(d[x].dfn<=d[t[i].ver].low&&d[x].cut==0)
            {
                if(rt==1) rt=0;//对根节点的特殊判定
                else
                {
                    d[x].cut=1;
                    ans++;
                }

            }
            d[x].low=min(d[x].low,d[t[i].ver].low);
        }
        else
        if(d[t[i].ver].v==1) d[x].low=min(d[x].low,d[t[i].ver].dfn);
    }
    return;
}

3.题目

4.补充 2-SAT

来看这样一道题目:
【模板】2-SAT 问题

对于这个问题,我们需要做的就是在众多限制下,确定每个点是 1 还是 0。

观察每个条件,比如 a 为假或 b 为假,其实可以换一种理解方式:

这样就把一个条件拆成了两个依赖关系

还记得关押罪犯嘛?这里用到了类似的思想。
我们这里将一个点拆成两个,分别代表该点选择为真还是为假。

对于每个依赖关系,我们进行连边,表示前一个条件满足,后一个条件一定需要满足

当我们对所有的条件进行处理之后,得到的就是一张有向图了。
这时我们对每个点进行判断:

那么如何判断他们之间能否相互走到呢? 我们可以利用强连通分量缩点,缩点完成之后形成一张有向无环图。在这张图上我们按照拓扑序标记每个强连通分量,会有如下性质:

所以我们只需要缩点求拓扑序就好了。
这里有一个小技巧,我们计算强连通分量时打上的标记,其实就是按照反向的拓扑序进行的,因为我们按照搜索树的顺序展开和返回,拓扑序大的强连通分量会被我们先返回,所以我们只需要利用这个标号计算即可,不需要再次进行拓扑排序。

部分代码:

inline void dfs(int x){
    d[x].dfn=d[x].low=++cnt;
    s.push(x);
    for(int i=d[x].hed;i;i=t[i].nxt){
        if(d[t[i].ver].dfn==0){
            dfs(t[i].ver);
            d[x].low=min(d[x].low,d[t[i].ver].low);
        }
        else if(d[t[i].ver].ver==0) d[x].low=min(d[x].low,d[t[i].ver].dfn);
    }
    if(d[x].dfn==d[x].low){
        ++cncnt;
        while(s.empty()==0&&s.top()!=x){
            d[s.top()].ver=cncnt;
            s.pop();
        }
        d[x].ver=cncnt;
        s.pop();
    }
    return;
}
int main(){
    for(int i=1;i<=m;++i){
        x=read();
        a=read();
        y=read();
        b=read();
        cr(a==0?x:x+n,b==0?y+n:y);
        cr(b==0?y:y+n,a==0?x+n:x);
    }
    for(int i=1;i<=2*n;++i){
        if(d[i].dfn==0) dfs(i);
    }
    for(int i=1;i<=n;++i){
        if(d[i].ver==d[i+n].ver){
            printf("IMPOSSIBLE");
            return 0;
        }
        ans[i]=d[i].ver<d[i+n].ver?1:0;
    }
}

放几道题目:

5.参考资料