Tarjan学习笔记
Tarjan 学习笔记
题外话
Tarjan 真的是算法领域的神
下文基本上摘抄于 OI Wiki,大概率有雷同
理解所有 Tarjan 算法,最关键的地方在于把图当做一个 dfs 树看待,然后就能够直观地感受出“边”的类别。
应用
求有向图中的强连通分量
求割点
强连通分量
2-SAT
算法分析
引理
- 对于每一个强连通分量,对其进行 dfs,一定可以保证能把这个强连通分量中的所有点遍历完,
- 建立 dfs 树,对于一个强连通分量来说,一定可以从某个点开始,将所有该 scc 中的节点遍历完,并且形成一棵子树,并且:
在整张图 dfs 的过程中,对于某个强连通分量来说,访问到的第一个其中的点一定是这个子树的根节点。 - 一棵树的边可以分为四种边: 树边(正常边),返祖边(可以指向祖先节点的边),横叉边(指向已经访问过的节点,但是指向的点并不是祖先节点),前向边(指向子树的一条边)。
流程
把每一个点所属的 scc 计算出来,所需的关键就是这个根节点,以我自己的能力想不出来怎么利用,但是 Tarjan 老爷子真是太天才了。。。
一定要利用好 dfs 的栈性质,想一下如何维护每一个点对应的这个根节点。
于是定义 \(low[i]\) 为 \(i\) 这个节点能够通过返祖边到达的最高的节点,这里的“高”我们用 dfn 序来刻画。
随便口胡一下的话,每个节点的 \(low\) 值先赋为自身的 \(dfn\),我们可以通过递归先算出子节点的 \(low\) 值,然后当前和子节点的 \(low\) 取一个min。
这时候我们回看一下提到的根节点的 \(low\) 值有什么特点,发现它是这个 scc 子树里面唯一一个 \(low=dfn\) 的节点,当我们递归回到这个节点的时候,就可以把栈里面当前节点以上所有的节点出栈,这就形成了一个 scc 。
由于我们是不断递归该操作,所以可以做到“层层脱衣”的效果,不会使得出栈的时候两个不同分量的点被放到一个集合里面。
不过口胡归口胡,还是得想想具体的流程。
上述提到的是 子节点 \(v\) 没有被访问过的情况,我们对其进行 dfs,然后对 \(low_x\) 进行更新。
如果 \(v\) 被访问过的话,分为两种情况
- \(v\) 在栈里面,说明 \(v\) 很可能和当前节点位于一个 scc 中,于是我们同样用 \(low_v\) 更新 \(low_x\)
- \(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
- 上述流程中 第二大点 第一小点,\(low_x\) 的更新换成注释里面的写法也无所谓,仅仅对于求强连通分量来说,两种写法是等效的。
- 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;
}
本文来自博客园,作者:Hanggoash,转载请注明原文链接:https://www.cnblogs.com/Hanggoash/p/18790040

浙公网安备 33010602011771号