tarjan 算法应用
阅读原文时间:2023年07月09日阅读:1

主要讲证明,流程倒是也有

然后发现自己并不会严谨证明

其实后面一些部分流程还是挺详细

本来这篇blog叫做“图论部分算法证明”,然后发现OI中的图论想完全用数学上的方法证明完全超出我能力范围

而且只写了一个 tarjan 相关的内容长度就爆了,所以别的算法就令开blog吧

各个用途的模板代码可能细节上稍有不同(比如无向图中节点与父亲的连边到底算不算的回边),也可能是我理解没有透彻,所以学的时候看的什么样的代码就写的什么样的

也无所谓,个人感觉我的这些写法在实现和理解上还是比较方便的

懒得把题目链接放进来了


连通性相关

用来求强连通分量,或是求割点割边

一个强连通分量显然可以看作是一棵外向树加上很多边得到的。

一棵外向树的定义:一个外向树的任意一个节点要么为叶节点,要么它与孩子间的所有边都是由它指向孩子。

一棵外向树显然是一个DAG。

然而上面这个性质和用 tarjan 求强连通分量并没有多大关系

\(dfn_i\) 为 \(i\) 的 dfs 序,\(low_i\) 为 \(i\) 能到达的 dfs 序最小的点的 dfs 序

tarjan 求强连通分量的关键就是找到一个强连通分量的“根”,也就是最早 dfs 到的那个点

dfs 过程中,碰到哪个节点,就将哪个节点入栈。栈中节点只有在其所属的强连通分量已经全部求出时,才会出栈。

对于 \((u\rightarrow v)\):

  • 如果 \(v\) 没有被 dfs 过,则对它 dfs,并 \(low_u=\min(low_u,low_v)\)。因为 \(u\) 可达 \(v\), 所以 \(v\) 可达的最早的节点,也是 \(u\) 可达的。

  • 其它情况,也就是它被访问过了,如果 \(v\) 仍没有所属的强连通分量,那么 \(v\) 一定仍在栈中没弹出(至于为什么看如何通过我们求出的这两个数组确定强连通分量的地方就知道了),则是 \(u\) 的一个祖先,更新 \(low_u=\min(low_u,dfn_v)\)

    那此时为什么不更新 \(low_u=\min(low_u,low_v)\)?因为我们在访问这样的 \(v\) 时,一定会访问到一个 dfn 最小的节点,也就是 \(low_v=dfn_v\),到时候直接用它更新即可

    所以其实这里两种写法都可以,但求割点的时候就不了,具体看下面

当处理完 \(u\) 的所有出边时,\(dfn_u=low_u\),那么就把当前栈中的点弹出,直到弹到 \(u\),这些点构成一个强连通分量,那么 \(u\) 也就是这个强联通分量的“根”

那么我们证明为什么最后这样是可行的

把此时的点分成几类

  1. 还没被访问的节点
  2. 栈中进栈比 \(u\) 早的节点
  3. 栈中进栈比 \(u\) 晚的节点
  4. 进过栈,但已经出栈的节点
  5. 节点 \(u\)

对这几类点分别阐述是不是在当前强连通分量中:

  1. 不在,显然

  2. 不在,\(low_u=dfn_u\),无论如何 \(u\) 也到不了比它进栈早的点

  3. 在。设这样的节点用 \(x\) 表示。显然 \(u\) 可以到达 \(x\)。那么证明为什么 \(x\) 可以到达 \(u\)

    • 若 \(dfn_x=low_x\),那么在处理到 \(x\) 时,发现 \(x\) 是另一个强连通分量的“根”,此时就把 \(x\) 出栈了,矛盾

    • 若 \(low_x=dfn_y\),那么有 \(dfn_y>dfn_u\),因为 \(u\rightarrow x\rightarrow y\),如果 \(dfn_y<dfn_u\),那么就是 \(low_u=dfn_y<dfn_u\) 了,不成立

      此时也有 \(dfn_y=low_y\),否则 \(low_x\) 肯定可以更小,那又因为 \(dfn_y>dfn_u\),所以这个节点 \(y\) 也被当作强连通分量的“根”处理过,和 \(x\) 一起出栈了,矛盾

  4. 不在。

    • 如果比 \(u\) 更早被 dfs 到,并且更早的出了栈,说明 \(u\) 不能达到它

      如果能到达,那么 \(low_u=dfn_x<dfn_u\),不成立

    • 如果比 \(u\) 晚被 dfs 到,则它不能达到 \(u\)

      设 \(low_x=dfn_y\),只有满足 \(dfn_y>dfn_u\),它才能在处理 \(u\) 之前弹出栈

      而如果它能达到 \(u\),那么又因为 \(dfn_u<dfn_y\),所以应该是 \(low_x=dfn_u\),矛盾了

  5. 在,显然

愉快的写出代码

void tarjan(int u){
    stack[top++]=u;low[u]=dfn[u]=++dfscnt;
    for(reg int v,i=fir[u];i;i=nex[i]){
        v=to[i];
        if(!dfn[v]){
            tarjan(v);
            low[u]=std::min(low[u],low[v]);
        }
        else if(!scc[v]) low[u]=std::min(low[u],dfn[v]);
    }
    if(dfn[u]==low[u]){
        scccnt++;
        do{
            scc[stack[--top]]=scccnt;size[scccnt]++;
        }while(stack[top]!=u);
    }
}

割点就是去掉这个点,使得图不连通的点,在无向图上

如果当前点是搜索树的根,那么它是割点等价于有多于一个的子树

这里说的是搜索树,并不是按原来的图来

如果不是,那么还是按照和求强连通分量类似的方法

记录 \(dfn_x\) 是 dfs 序,\(low_x\) 稍有不同,记录的是 \(x\) 不经过任何的父子边(“回边”),能到达的 dfs 序最小的点的 dfs 序

就是说要找到另一条路径

对于边 \((u,v)\),若 \(low_v\ge dfn_u\),那么说明 \(v\) 不经过 \(u\),就不能到达比 \(u\) 更靠上的点,那么 \(u\) 是割点

那为什么 \(low_v=\min(low_v,low_u)\) 在这时不行了呢?

在求强连通分量时,如果v已经在栈中,那么说明u,v一定在同一个强连通分量中,所以到最后low[u]=low[v]是必然的,提前更新也不会有问题,但是在求割点时,low的定义有了小小的变化,不再是最早能追溯到的祖先,(因为是个无向图)没有意义,应该是最早能绕到的割点,为什么用绕到,是因为是无向边,所以有另一条路可以走,如果把dfn[v]改掉就会上翻过头,可能翻进另一个环中,所以wa掉

void tarjan(int u,int fa){
    dfn[u]=low[u]=++dfscnt;
    int child=0;
    for(reg int v,i=fir[u];i;i=nex[i]){
        v=to[i];
        if(!dfn[v]){
            tarjan(v,u);
            low[u]=std::min(low[u],low[v]);
            if(low[v]>=dfn[u]&&u!=fa) cut[u]=1;
            child++;
        }
        low[u]=std::min(low[u],dfn[v]);
    }
    if(child>1&&u==fa) cut[u]=1;
}

一个点双连通图,就可以理解为没有割点的图

关于点双的更多东西:

首先每个点可能会属于多个双连通分量(其实就是割点),这和强连通分量是不同的

另外,以下文字和图来自 @小粉兔这篇blog,这张图也可以用来理解一下 \(low_v=\min(low_v,low_u)\) 为什么不行

可以发现,每个点双在 DFS 树上是一条直上直下的链,并至少包含两个点,并且每条树边恰好在一个点双内。

上面这个东西,有助于我们理解圆方树求法

也叫割边

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

严谨来说,就是:假设有连通图 \(G={V,E}\),\(e\) 是其中一条边(即 \(e\in E\)) ,如果 \(G-e\) 是不连通的,则边 \(e\) 是图 \(G\) 的一条割边(桥)。

摘自 oi-wiki

求法和割点极其类似

显然,如果 \(low_v\ge dfn_u\),说明不经过 \((u,v)\) 边,\(v\) 就不能达到 \(u\) 和它再网上的点,所以 \((u,v)\) 是桥

所以标记 \(v\),并记录 \(v\) 的父亲(实际上就是记录每个点的父亲),那么可以得知 \((father_v,v)\) 是一个桥

void tarjan(int u){
    dfn[u]=low[u]=++dfscnt;
    for(reg int v,i=G.fir[u];i;i=G.nex[i]){
        v=G.to[i];
        if(!dfn[v]){
            fa[v]=u;deep[v]=deep[u]+1;
            tarjan(v);
            low[u]=std::min(low[u],low[v]);
            if(low[v]>dfn[u]) bridge[v]=1,ans++;
        }
        else if(v!=fa[u]) low[u]=std::min(low[u],dfn[v]);
    }
}

圆方树

顾名思义,肯定要包含 圆点 和 方点

圆点就是原图中有的点,而每个方点对应了一个点双连通分量

每个方点,和它对应的双连通分量中的每个点(圆点)连边

形态长这样,WC的ppt的一个图,也是从粉兔的blog中看到的:

至于为什么这是棵树看一看上面的图差不多就直到了

求法其实还是用到 tarjan,和割点有关

与求强连通分量相似,维护一个栈,一旦找到了一个割点(\(low_v\ge dfn_u\)),此时可以确定 \(u\) 是割点,并且 \(u\) 是当前找到的这个双联通分量中 \(dfn\) 最小的点

那么出栈直到出到 \(v\) 就行了

之所以是出到 \(v\),并不是说 \(u\) 不用连边,而是要给 \(u\) 连边,但是不出栈,因为 \(u\) 是割点,还可能作为其它双联通分量的点,现在就出了以后就连不了边了

然后实在懒得严格证明了,感性理解吧

tarjan 这玩意的一些用法,网上严谨证明的资料也确实不多,包括上面求强连通分量的证明,也只能算是一部分,并不完整

性质:

  • 显然建出的是一堆无根树构成的森林,原图上联通的点圆方树(森林)上也联通
  • 在圆方树上,每条边肯定都是一端圆点一端方点
  • 对于每个圆点,只要不是叶子节点(度数大于一,有不止一条边,连向多个方点,属于多个点双联通分量),那么他就是割点,因为一个不是割点的点不会属于多个点双联通分量
  • 由上面的,所以对于两点,他们如果在同一双联通分量(路径上不包含任何割点,也就是没有必须经过的点),则在圆方树上,只要经过一个方点便可以将他们连通

这有一个算是是模板的题,P5058 [ZJOI2004]嗅探器

知道这个就能做:对于两个点在圆方树上走过的非叶子节点的圆点,就是原图上他们之间必须经过的点(割点)

代码在这

下面的代码不是上面那个题的,是个模板代码

#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cmath>
#include<map>
#include<vector>
#include<iomanip>
#include<cstring>
#define reg register
#define EN std::puts("")
#define LL long long
inline int read(){
    register int x=0;register int y=1;
    register char c=std::getchar();
    while(c<'0'||c>'9'){if(c=='-') y=0;c=std::getchar();}
    while(c>='0'&&c<='9'){x=x*10+(c^48);c=std::getchar();}
    return y?x:-x;
}
#define N 100006
#define M 400006
int n,m;
int fir[N],nex[M],to[M],tot;
int fir_[N*2],nex_[M],to_[M],tot_;
int dfn[N],low[N],dfscnt;
int stack[N],top;
int bcccnt;
std::vector<int>bcc[N*2];
inline void add(int u,int v){
    to[++tot]=v;
    nex[tot]=fir[u];fir[u]=tot;
}
inline void add_(int u,int v){
    to_[++tot_]=v;
    nex_[tot_]=fir_[u];fir_[u]=tot_;
}
void tarjan(int u){
    low[u]=dfn[u]=++dfscnt;stack[top++]=u;
    for(reg int v,i=fir[u];i;i=nex[i]){
        v=to[i];
        if(!dfn[v]){
            tarjan(v);
            low[u]=std::min(low[u],low[v]);
            if(low[v]>=dfn[u]){
                bcccnt++;
                do{
                    add_(bcccnt,stack[--top]);add_(stack[top],bcccnt);
                    bcc[bcccnt].push_back(stack[top]);
                }while(stack[top]!=v);
                add_(bcccnt,u);add_(u,bcccnt);
                bcc[bcccnt].push_back(u);
            }
        }
        else low[u]=std::min(low[u],dfn[v]);
    }
}
int main(){
    n=bcccnt=read();m=read();//bcccnt 从 n 开始,这样来取分圆点和方点
    for(reg int u,v,i=1;i<=m;i++){
        u=read();v=read();
        add(u,v);add(v,u);
    }
    for(reg int i=1;i<=n;i++)if(!dfn[i]) tarjan(i),top=0;

    std::printf("n : %d  bcccnt : %d\n",n,bcccnt);
    for(reg int i=n+1;i<=bcccnt;i++){
        std::printf("bcc %d :  ",i-n);
        int size=bcc[i].size();
        for(reg int j=0;j<size;j++) std::printf("%d ",bcc[i][j]);
        EN;
    }
    std::puts("tree:");
    for(reg int i=1;i<=bcccnt;i++){
        std::printf("from %d : ",i);
        for(reg int j=fir_[i];j;j=nex_[j]) std::printf("%d ",to_[j]);
        EN;
    }
    return 0;
}

先弄明白仙人掌是啥

任意一条边至多只出现在一条简单回路的无向连通图称为仙人掌。

下面这个图来自这篇blog

构建方法:

  1. 对于每一个环,建一个方点
  2. 每个原来的点,都是一个圆点
  3. 每个环构建的方点,都像它那个环中,每个点对应的圆点连边
  4. 对于不在同一个环上的两个圆点,保留原图的边

显然,这样构建,就只会存在圆圆边和圆方边

性质

仙人掌的圆方树肯定是一个树,废话不然为啥叫圆方树,因为:

  • 首先环不可能只是由圆点组成的,如果全是圆点那么这就是一个环,要对应一个方点,肯定不行
  • 也不能都是方点,因为连两个相邻的方点都不会有
  • 环中有相邻的圆点,并且存在方点。也不行,因为有圆点相邻,而且还是个环,就会导致尝试重构原图是,发现那两个相邻的圆点会把原本的多个环(也可以是一个),连成同一个(如果是一个环,那就是相邻圆点都属于这个唯一环),那么显然这两个点在同一个环,中间的边应该去掉,不行
  • 没有相邻的圆点,且存在方点,那么就是 圆点-方点-圆点-方点·····。也不行,重构原图会发现这样不满足每条边最多只出现在一个环中

上面的证明应该比较全面了,其实画图说明会更好,但我懒得画了

其它性质以后补充


洛谷P5236 【模板】静态仙人掌

给你一个有 \(n\) 个点和 \(m\) 条边的仙人掌图,和 \(q\) 组询问

每次询问两个点 \(u,v\),求两点之间的最短路。

要首先考虑的一点是,如何确定树上的边权

  • 显然,对于由规则4 连出的圆圆点,边权就是原图上的边权(其实这里可以理解为就是保留了原图)
  • 现在,定义一个环的“根”:要钦定一个圆点为根,让圆方树变成一个有根树,那么一个环的“根”就是它对应的方点,的父亲节点(显然这是个圆点)
  • 那么,对于由一个环的根(父亲),连向所对应的方点的(儿子)边,边权为 \(0\)
  • 对于由一个环所对应的方点(父亲),连向这个环的一个 不是它的根 点(儿子)的边,边权为这个儿子点,到这个环的根的最短距离

这样定义,有一个小性质:对于一个环,如果我们从一个不是这个环的根的点进入,走上对应方点,再从它的根离开,那么我们走的距离,实际上就是在原图中的最短距离

当然,从一个环的根进入,从一个不是根的点离开也可以

但是从一个非根点进入,非根点离开显然不行

所以,可以再考虑怎么求 \(u,v\) 之间的最短路了

求出他们的 \(lca\)

  • 如果这个 \(lca\) 是圆点,根据上面说的性质,树上这两点的距离就是实际中的最短路

  • 如果不是,那么找出这个 \(lca\) 的两个子节点,\(a,b\),使得他们分别是 \(u,v\) 的祖先

    此时,\(a,b\) 显然是圆点,所以 \(a,u\) 之间,\(b,v\) 之间的树上距离就是他们实际上的最短距离

    然后再求一下 \(a,b\) 在环上的最短距离,就能得出了 \(u,v\) 最短距离了

然后 \(lca\) 用倍增求,也能便捷的找出那两个 \(a,b\),用树剖也行但是稍微麻烦

求环,同时建立圆方树,当然也是用 tarjan,这个过程和前面的 tarjan 稍有不同

而且语言在这时显得苍白无力,所以也懒得描述一遍了,代码中有注释,最好用调试跟着程序跑一遍,看一看那些判断是为什么成立的

给出一个例子

1 2 1
1 4 1
3 4 1
2 3 1
3 7 1
7 8 2
7 9 2
1 5 3
1 6 4
5 6 1

这个画出图来就是这样

构建出的圆方树是:

#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cmath>
#include<map>
#include<iomanip>
#include<cstring>
#define reg register
#define EN std::puts("")
#define LL long long
inline int read(){
    register int x=0;register int y=1;
    register char c=std::getchar();
    while(c<'0'||c>'9'){if(c=='-') y=0;c=std::getchar();}
    while(c>='0'&&c<='9'){x=x*10+(c^48);c=std::getchar();}
    return y?x:-x;
}
#define N 20006
#define M 40006
struct graph{
    int to[M],nex[M],fir[N],w[M],tot;
    inline void add(int u,int v,int wei){
        to[++tot]=v;w[tot]=wei;
        nex[tot]=fir[u];fir[u]=tot;
    }
}G,T;
int n,m;
int dfn[N],low[N],stack[N],top,bcccnt,dfscnt;
LL sum[N],summ[N];//sum 是搜索树上到根的距离,summ 是一个环的长度和
int deep[N],fa[20][N];
LL dis[N];//这个指的是圆方树上根的距离
void tarjan(int u,int father){
    dfn[u]=low[u]=++dfscnt;stack[top++]=u;
    for(reg int v,i=G.fir[u];i;i=G.nex[i]){
        v=G.to[i];
        if(v==father) continue;
        if(!dfn[v]){//是个树边
            sum[v]=sum[u]+G.w[i];//更新下一个节点在搜索树中到根的距离
            tarjan(v,u);
            low[u]=std::min(low[u],low[v]);
            if(low[v]>dfn[u]) T.add(u,v,G.w[i]),T.add(v,u,G.w[i]);//说明 u,v 不在同一个环,两点保留原边
        }
        else if(low[u]>dfn[v]){//此时,v 应该是这个环的根,这条边也就是回边
            low[u]=dfn[v];bcccnt++;
            summ[bcccnt]=sum[u]-sum[v]+G.w[i];//计算环的长度
            for(reg int j=top-1;stack[j]^v;j--){//每次其实都是顺着搜索树的路径往上找,直到找到环的根
                int w=std::min(sum[stack[j]]-sum[v],summ[bcccnt]-sum[stack[j]]+sum[v]);//找到去这个环的根的最短路
                T.add(bcccnt,stack[j],w);T.add(stack[j],bcccnt,w);
            }
            T.add(bcccnt,v,0);T.add(v,bcccnt,0);
        }
    }
    top--;//回溯的时候再退栈,这才能满足刚才处理回边时的要求,用调试跟着程序跑一遍观察一下栈元素的进出就好
}
void dfs(int u){
    deep[u]=deep[fa[0][u]]+1;
    for(reg int v,i=T.fir[u];i;i=T.nex[i]){
        v=T.to[i];
        if(v==fa[0][u]) continue;
        dis[v]=dis[u]+T.w[i];
        fa[0][v]=u;dfs(v);
    }
}
inline LL ask(int x,int y){
    int lca;LL ret=dis[x]+dis[y];
    if(deep[x]<deep[y]) x^=y,y^=x,x^=y;
    for(reg int i=15;~i;i--)
        if(deep[fa[i][x]]>=deep[y]) x=fa[i][x];
    if(x==y) lca=x;
    else{
        for(reg int i=15;~i;i--)if(fa[i][x]^fa[i][y])
            x=fa[i][x],y=fa[i][y];
        lca=fa[0][x];
    }
    if(lca<=n) return ret-dis[lca]*2;//圆点
    if(dfn[x]>dfn[y]) std::swap(x,y);//方点,条完 LCA 之后的 x,y 其实就是我们要找的那两个 u,v 的祖先的点
    return ret-dis[x]-dis[y]+std::min(sum[y]-sum[x],summ[lca]-sum[y]+sum[x]);
}
int main(){
    bcccnt=n=read();m=read();int q=read();
    for(reg int u,v,w,i=1;i<=m;i++){
        u=read();v=read();w=read();
        G.add(u,v,w);G.add(v,u,w);
    }
    tarjan(1,0);
    dfs(1);
    for(reg int i=1;i<=15;i++)
        for(reg int j=1;j<=bcccnt;j++)
            fa[i][j]=fa[i-1][fa[i-1][j]];
    reg int u,v;
    while(q--){
        u=read();v=read();
        std::printf("%lld\n",ask(u,v));
    }
//
//        EN;EN;std::puts("graph :");
//        for(reg int i=1;i<=n;i++){
//            std::printf("%d : ",i);
//            for(reg int j=G.fir[i];j;j=G.nex[j]) std::printf("%d ",G.to[j]);
//            EN;
//        }
//        std::puts("\ntree :");
//        for(reg int i=1;i<=bcccnt;i++){
//            std::printf("%d : ",i);
//            for(reg int j=T.fir[i];j;j=T.nex[j]) std::printf("%d(len %d) ",T.to[j],T.w[j]);
//            EN;
//        }
    return 0;
}

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器