Tarjan学习笔记

Tarjan 学习笔记

题外话

Tarjan 真的是算法领域的神
下文基本上摘抄于 OI Wiki,大概率有雷同

理解所有 Tarjan 算法,最关键的地方在于把图当做一个 dfs 树看待,然后就能够直观地感受出“边”的类别。

应用

有向图中的强连通分量
割点

强连通分量

2-SAT

算法分析

引理

  1. 对于每一个强连通分量,对其进行 dfs,一定可以保证能把这个强连通分量中的所有点遍历完,
  2. 建立 dfs 树,对于一个强连通分量来说,一定可以从某个点开始,将所有该 scc 中的节点遍历完,并且形成一棵子树,并且:
    整张图 dfs 的过程中,对于某个强连通分量来说,访问到的第一个其中的点一定是这个子树的根节点
  3. 一棵树的边可以分为四种边: 树边(正常边),返祖边(可以指向祖先节点的边),横叉边(指向已经访问过的节点,但是指向的点并不是祖先节点),前向边(指向子树的一条边)。

流程

把每一个点所属的 scc 计算出来,所需的关键就是这个根节点,以我自己的能力想不出来怎么利用,但是 Tarjan 老爷子真是太天才了。。。
一定要利用好 dfs 的性质,想一下如何维护每一个点对应的这个根节点
于是定义 \(low[i]\)\(i\) 这个节点能够通过返祖边到达的最高的节点,这里的“高”我们用 dfn 序来刻画。
随便口胡一下的话,每个节点的 \(low\) 值先赋为自身的 \(dfn\),我们可以通过递归先算出子节点的 \(low\) 值,然后当前和子节点的 \(low\) 取一个min。
这时候我们回看一下提到的根节点\(low\) 值有什么特点,发现它是这个 scc 子树里面唯一一个 \(low=dfn\) 的节点,当我们递归回到这个节点的时候,就可以把栈里面当前节点以上所有的节点出栈,这就形成了一个 scc 。
由于我们是不断递归该操作,所以可以做到“层层脱衣”的效果,不会使得出栈的时候两个不同分量的点被放到一个集合里面。

不过口胡归口胡,还是得想想具体的流程。
上述提到的是 子节点 \(v\) 没有被访问过的情况,我们对其进行 dfs,然后对 \(low_x\) 进行更新。
如果 \(v\) 被访问过的话,分为两种情况

  1. \(v\) 在栈里面,说明 \(v\) 很可能和当前节点位于一个 scc 中,于是我们同样用 \(low_v\) 更新 \(low_x\)
  2. \(v\) 不在栈里面,但是 \(v\)\(dfn\) 序已经算出来了,明 \(v\) 是一个已经被算出了 scc 的点,我们没必要在这种情况上做更多的考虑了。

Code

#include<bits/stdc++.h>
using namespace std;

const int N=2e5+10;
int dfn[N],low[N],ins[N],cnt;
int s[N],top;
int scc[N],num,siz[N];
vector<int> g[N];
vector<int> e[N];
int n,m;
inline void dfs(int x)
{
    dfn[x]=low[x]=++cnt;
    s[++top]=x,ins[x]=1;
    for(int v:e[x])
    {
        if(!dfn[v]) 
        {
            dfs(v);
            low[x]=min(low[x],low[v]);
        }
        else if(ins[v])low[x]=min(low[x],dfn[v]);
        //low[x]=min(low[x],dfn[v])
    }
    if(dfn[x]==low[x])
    {
        num++;
        do
        {
            siz[num]++;
            scc[s[top]]=num;
            g[num].push_back(s[top]);
            ins[s[top]]=0;
        }while(s[top--]!=x);
    }
}
int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
    cin>>n>>m;
    for(int i=1,u,v;i<=m;++i)
    {
        cin>>u>>v;
        e[u].push_back(v);
    }
    vector<int> vis(n+1,0);   
    for(int i=1;i<=n;++i)if(!dfn[i])dfs(i);
    for(int i=1;i<=num;++i)sort(g[i].begin(),g[i].end());
    cout<<num<<'\n';
    for(int i=1;i<=n;++i)
    {
        if(vis[scc[i]])continue;
        vis[scc[i]]=1;
        for(int x:g[scc[i]])cout<<x<<' ';
        cout<<'\n';
    }   
    return 0;
}

Bonus

  1. 上述流程中 第二大点 第一小点,\(low_x\) 的更新换成注释里面的写法也无所谓,仅仅对于求强连通分量来说,两种写法是等效的。
  2. scc的编号是某种拓扑序的反序,这是dfs的特性造成的。

割点&割边

算法流程

核心思想和强连通分量部分并无差异,就是在于使用 dfs 树把边分类,同时利用 \(dfn,low\) 来进一步判断。

强连通分量部分是把同一个 scc 里面的点都“挂”在第一个遍历到的节点上。
割点就是判断子节点是否能通过返祖边到达比父节点更高的地方。

也就是说若 \(\exists v(fa_v==x),low_v\ge dfn_x\),那么 \(x\) 就是一个割点。
但是对于根节点而言,这并不是充分条件,这需要根节点至少要有 \(2\) 个子节点。

而割边就是把条件中的等号去掉即可

上述讨论可以写成如下的代码:

Code

#include<bits/stdc++.h>
using namespace std;
const int N=2e5+10;

int dfn[N],low[N],cnt;
int isg[N];
vector<int> e[N];
int n,m;
int root;
vector<int> cutvertex;
vector<pair<int,int>> cutedge;
inline void dfs(int x,int fa)
{
    dfn[x]=low[x]=++cnt;
    int sonsize=0;
    for(int v:e[x])
    {
        if(!dfn[v])
        {
            dfs(v,x);
            low[x]=min(low[x],low[v]);

            if(low[v]>=dfn[x])sonsize++;//割点
                 
            if(low[v]>dfn[x])cutedge.push_back({x,v});//割边
        }
        else if(v!=fa) low[x]=min(low[x],dfn[v]);
    }   
    if((x==root&&sonsize>=2)||(x!=root&&sonsize))cutvertex.push_back(x); 
}
int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
    cin>>n>>m;
    for(int i=1,u,v;i<=m;++i)
    {
        cin>>u>>v;
        e[u].push_back(v);
        e[v].push_back(u);
    }
    for(int i=1;i<=n;++i)if(!dfn[i])root=i,dfs(i,0);
    sort(cutvertex.begin(),cutvertex.end());
    cout<<cutvertex.size()<<'\n';
    for(int x:cutvertex)cout<<x<<" ";
    return 0;
}

posted @ 2025-03-24 19:48  Hanggoash  阅读(23)  评论(0)    收藏  举报
动态线条
动态线条end