Tarjan算法<笔记与补充>
一、Tarjan算法求强连通分量
1.简要
强连通的定义:有向图 G 强连通是指,G 中任意两个结点互相可达。
更好的理解:强连通图类似于嵌套的环,强连通图一定有环,但 n 个节点的强连通图不一定有 n 元环。
强连通分量(Strongly Connected Components,SCC)的定义:强连通分量是有向图的极大的强连通子图,所谓“极大”意味着,把图划分为若干个强连通分量后,不存在两个强连通分量相互可达。
A.参考博客
pecco大佬的博客:强连通分量定义,DFS生成树定义,Tarjan算法的正确性证明。
对求有向图强连通分量的tarjan算法原理的一点理解by naturerun
讲解视频:形象的例子,基础;视频中low
的意义与下文的第二种写法一样
B.Code
Tarjan算法求强连通分量的板子:
int n,m;
vector<int> G[MAXN],lxx;
int dfn[MAXN],low[MAXN];//这里的low表示 u 所在子树中的节点经过至多一条非树边能到达的节点中最小的dfs序
int st[MAXN],tp;//数组维护栈
bool ins[MAXN];//是否在栈中
vector<int> scc[MAXN];//每个强连通分量,用vector维护
int bel[MAXN];//bel[u]:u属于哪一个强连通分量
int cnt;//强连通分量数量
void Tarjan(int u){
dfn[u]=low[u]=++dfn[0];
st[++tp]=u;ins[u]=1;
for(int v:G[u]){
if(!dfn[v]){//v在u的DFS序生成树中
Tarjan(v);
low[u]=min(low[u],low[v]);
}else if(ins[v]) low[u]=min(low[u],dfn[v]);//在栈中
}
if(low[u]==dfn[u]){
cnt++;
while(st[tp]!=u){
int v=st[tp];tp--;ins[v]=0;
scc[cnt].push_back(v);
bel[v]=cnt;
}
int v=st[tp];tp--;ins[v]=0;
scc[cnt].push_back(v);
bel[v]=cnt;
}
}
map<pii,bool> mp,lx;//用于判重边
vector<int> Gv[MAXN];//新图
int in[MAXN],out[MAXN];//入度,出度
void Init(){...}
int main(){
...
for(int i=1;i<=n;i++){//求割点
if(!dfn[i]) Tarjan(i);
}
for(int u=1;u<=n;u++){//缩点,重建图
// printf("%d belongs to %d\n",u,bel[u]);
for(int v:G[u]){
int x=bel[u],y=bel[v];
if(x==y||mp[{x,y}]) continue;
mp[{x,y}]=1;
out[x]++,in[y]++;
Gv[x].push_back(y);
}
}
...
return 0;
}
C.初学会遇到的问题:
- 为什么把
else if(ins[v]) low[u]=min(low[u],dfn[v]);
写成了:else if(ins[v]) low[u]=min(low[u],dfn[v]);
,也能过题? 答案见下 - 为什么
if(ins[v])
,要求v
要在栈中?因为要保证v
能到达u
,详见第一篇参考文章。
2.low的两种写法(个人补充)
上面是第一种写法。大部分时间都用的这一种,因为这种写法和求割点割边很像。求割点和割边不能用第二种写法!!!
很少看到对第两种写法的具体说明,在这里做简单的补充。
第一种写法的中间部分可以改为:
for (int v : G[u]) {
if (!vis[v])
dfs(v), low[u] = min(low[u], low[v]);
else if (ins[v])
low[u] = min(low[u], low[v]);
}
区别在于:对 栈中的节点 v 的更新方式.
产生区别的原因是:
第一种写法中的
low[u]
表示 u 所在子树中的节点经过至多一条非树边能到达的节点中最小的dfs序。第二种写法中的
low[u]
表示 u 所在子树中的节点能到达的节点中最小的dfs序。这是更好理解的做法。
两种写法是等价的
如上图 (假设点的编号就是该点的DFN序)
在第一中写法中,low[6]=4
,low[4]=2
在第二中写法中,low[6]=low[4]=2
可以肯定的是,第二种写法是对的,但是第一种为什么也是对的呢?因为low[u]
最终只要小于dfn[u]
就不会认定这个点为强连通分量的根。我们只需要保证找到的“根”是对的,强连通分量就是对的,所以只用保证low[u]<dfn[u]
就可以了,我们不关心low[u]
的具体值。
3.运用
常见的使用场景是:
对一张存在环的有向图求出强连通分量,缩点,即将原图转化为DAG。
缩点的过程:求出每个点属于的强连通分量编号,原图中的边更新到新图上,注意判重边(map)。
习题:
-
P2272 [ZJOI2007] 最大半连通子图:典型缩点题目,一个结论:G的最大半连通子图拥有的节点数K就是最长链长度
-
「POJ2762」Going from u to v or from v to u? : 这个题是最大半连通子图的简单版,放在这里是提醒一下,判断DAG中的最长链不能只用入度、出度判断(1->2, 2->->3 ,1->3)。要用DP。
-
网络协议:有一个性质补充,使一张图变为强连通的需要添加的最少边数为:这张图缩点后的DAG中,入度为0的点数量与出度为0的点的数量的最大值
二、Tarjan算法求双连通分量(割点和桥)
1.简要:
连通的:略
割点:对于一个无向图,如果把一个点删除后这个图的极大连通分量数增加了,那么这个点就是这个图的割点(又称割顶)。
割边(桥):对于一个无向图,如果删掉一条边后图中的连通分量数增加了,则称这条边为桥或者割边。
A.参考博客
从动态规划的角度理解 Tarjan 算法:解释了low
数组的由来,为什么会有这个算法。
讲解视频:可以听一听例子的讲解,后面代码讲解部分是错的(low
错了)
B.Code
求割点:
vector<int> G[MAXN],lxx;
int n,m;
int dfn[MAXN],low[MAXN];
bool cnt[MAXN];//是否是割点
void dfs(int u,int fa){//找割点
dfn[u]=low[u]=++dfn[0];
int son=0;
for(int v:G[u]){
if(!dfn[v]){//v在u的DFS生成数的子树中
dfs(v,u);
son++;
if(fa!=-1&&low[v]>=dfn[u]) cnt[u]=1;//注意特判根节点
low[u]=min(low[u],low[v]);
}else if(v!=fa) low[u]=min(low[u],dfn[v]);//当然不能走回父亲
}
if(fa==-1&&son>=2) cnt[u]=1;//注意特判根节点
}
int vis[MAXN];//注意:割点多次被计算!!!vis要用int
int tot=0;//当前双联通分量的大小(不同的题目要维护的值不一样,这里维护的是大小)
int Cnt=0;//该双连通分量连接的割点数量
int group;//当前边双连通分量的编号
void DFS(int u){//遍历双连通分量
vis[u]=group;tot++;
for(int v:G[u]){
if(cnt[v]){
if(vis[v]!=group) vis[v]=group,Cnt++;
}else if(vis[v]!=group) DFS(v);
}
}
void Init(){...}
int main(){
Init();
...//输入
for(int i=1;i<=n;i++){//求割点
if(!dfn[i]) dfs(i,-1);
}
for(int i=1;i<=n;i++){//求点双
if((!cnt[i])&&(!vis[i])){//这个点不是割点,也没有被遍历过
tot=Cnt=0;
group++;
DFS(i);
...//其他的计算
}
}
...
return 0;
}
求割边:
vector<pii> G[MAXN],lxx;
int n,m;
void Init(){...}
int dfn[MAXN],low[MAXN];
map<pii,bool> mp,Mp;//存割边的方式
void dfs(int u,int fa){
dfn[u]=low[u]=++dfn[0];
for(pii t:G[u]){
int v=t.fi;
if(!dfn[v]){
dfs(v,u);
low[u]=min(low[u],low[v]);
if(low[v]>dfn[u]) mp[{u,v}]=mp[{v,u}]=1;//注意和求割点不一样了
}else if(v!=fa) low[u]=min(low[u],dfn[v]);
}
}
int bel[MAXN];//每个点属于哪一个边双
int cnt;//边双个数
bool vis[MAXN];//与点双不同,每个点只会被遍历一次,所以vis可以用bool
void DFS(int u){//根据割边求出边双
vis[u]=1;bel[u]=cnt;
// printf("%d belongs to %d\n",u,cnt);
for(pii t:G[u]){
int v=t.fi;
if(mp[{u,v}]||vis[v]) continue;
DFS(v);
}
}
int dep[MAXN],Fa[MAXN];
void pre(int u){//树上的处理
for(pii t:G[u]){
int v=t.fi;
if(v!=Fa[u]){
Fa[v]=u;dep[v]=dep[u]+1;
pre(v);
}
}
}
int main(){
...//输入
for(int i=1;i<=n;i++){//求割边
if(!dfn[i]) dfs(i,-1);
}
for(int i=1;i<=n;i++){//求边双
if(!vis[i]) {cnt++;DFS(i);}
}
//重建图 变为树
Init();//重建了图,这里把之前的图覆盖了因为之前的已经没用了
for(int u=1;u<=n;u++){
for(pii t:G[u]){
int v=t.fi;
int x=bel[u],y=bel[v];
// printf("%d %d %d %d\n",u,v,x,y);
G[x].push_back({y,t.se});
}
}
rt=1;
pre(rt);//缩点后,原图变为树
...//树上的操作
return 0;
}
注意:
- 求割点和求割边不一样的地方:点双:
low[v]>=dfn[u]
,边双low[v]>dfn[u]
。解释可以看参考视频,很直观。- 关于
else if(v!=fa)
:求割点不一定需要,求割边一定需要。- 与求“强连通分量”中的不同:
low
的意义,见下。
2.只有一种写法
其实上面的那篇博客讲得很清楚,low[u]
的由来,。下面解释为什么low[u]
不能像在求强连通分量是那样表示 u 所在子树中的节点经过非树边能到达的节点中最小的dfs序,low[u]
只能表示表示 u 所在子树中的节点只经过一条非树边能到达的节点中最小的dfs序。
看上图,如果 low[u]
表示 u 所在子树中的节点经过非树边能到达的节点中最小的dfs序,那么
low[6]=2
low[5]=2
low[4]=2
low[3]=3
low[2]=2
low[1]=1
那么根据 割点low[v]>dfn[u]
的判断,4号点不会被判为割点。但是事实上4号点是割点。
更广泛的解释:如果一个点 u
子树内的某一结点 v
有一条连向 u
的非树边,那么在错误的做法下,low[v]
可能会被更新成小于 dfn[u]
的值。但是如果 u
是割点,那在求 删除 u
点后的连通性时,就完全不能考虑 v
.
3.具体运用
割点和割边,通常都是与点/边双连通分量有关系。
-
求出的割点来把图划分为若干个双连通分量,通常会对每一个双连通分量,考虑其连接的割点数,再进行分类讨论。
-
求出割边,缩点把原图变为一棵树,就可以把原问题转化为树上问题。
例题:
点双:
矿场搭建:典型的求割点并划分原图,对每个点双分讨
夺回据点: 也是对每个点双分讨
边双:
越狱老虎桥:转化为树上问题
每个点恰属于一个边双,每条边可能恰属于一个边双(非割边),也可能不属于任何边双(割边);每条边恰属于一个点双,每个点可能属于一个点双(非割点),也可能属于多个点双(割点)。
本文来自博客园,作者:bwartist,转载请注明原文链接:https://www.cnblogs.com/bwartist/p/17752306.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】