点双,边双,强连通分量
前言
打模拟赛时\(\mathcal{tarjan}\)不会了,所以严肃复习一下,故作瓷片博客
关键准备
求联通分量,其实在做一个\(dfs\),用\(tarjan\)就需要请出这两位大将:\(\color{aqua}{时间戳}\) 与 \(\color{aqua}{回溯值}\)。
简明解释一下:时间戳是\(dfs\)时第一次访问到该点的时刻,而回溯值是节点子树通过非树边(即\(dfs\)不经过的边)能到达的点的最小时间戳。
若时间戳等于回溯值,则说明该节点不能通过非树边走到祖先节点,这也是\(tarjan\)算法的关键。
无向图的\(tarjan\)
前话
在无向图中有两种强连通性:边的强连通,与点的强连通。
在边的强连通中去掉任意一边,图仍然连通。
在点的强连通中去掉任意一点以及连接它的边,图仍然连通。
为了求连通分量就要引出以下定义:
(为方便描述,当前操作的节点为 “节点” ,\(dfn[x]\)为时间戳,\(low[x]\)为回溯值)。
割点
在无向图中,割点就是连接两部分的关键点,即把它删掉图被分成两部分。
具体算法流程:
- \(dfs\)进入一个新点更新 \(dfn[x]\)(时间戳)与 \(low[x]\)(回溯值)
- 搜索子树,若节点未被更新,进行第一步操作,回溯时更新\(low[x]=min(low[x],low[y])\),否则更新\(low[x]=min(low[x],dfn[y])\)。
- 如果节点非根,并且存在子节点的回溯值大于等于节点的时间戳,则节点为割点。
- 如果节点为根,存在两个子节点满足以上条件即可。
一些问题:
Q: 为什么子节点回溯值小于节点时间戳即可?
A: 说明子节点无法通过非树边到达祖先,去掉节点,子节点与祖先失去连通性
Q: 为什么访问已更新的点,用时间戳而不是回溯值去更新节点回溯值
A: 回溯值表示节点及子树能通过非树边回到的最早时间。如图:
红色为树边,黄色为非树边。

其中\(X\)能通过非树边到达\(Y\),\(Y\)能通过非树边到达\(Z\),显然\(X\)这棵子树无法借助非树边到达\(Z\)。
Q: 为什么对于根要有两个子节点满足条件?
A: 对于根节点,所有子节点均满足条件,若只有一个子节点满足条件,意味着根通过树边只连一个孩子,去掉根,图仍然连通。
例题
n个城市之间有通讯网络,每个城市都有通讯交换机,直接或间接与其它城市连接。因电子设备容易损坏,需给通讯点配备备用交换机。但备用交换机数量有限,不能全部配备,只能给部分重要城市配置。于是规定:如果某个城市由于交换机损坏,不仅本城市通讯中断,还造成其它城市通讯中断,则配备备用交换机。请你根据城市线路情况,计算需配备备用交换机的城市个数,及需配备备用交换机城市的编号。
示例
#include<iostream>
#include<string.h>
#include<string>
#include<math.h>
#include<algorithm>
#include<vector>
#include<queue>
using namespace std;
int n;
vector<int> e[110];
int dfn[110],cnt,low[110];
int num,root;
bool vis[110];
void tarjan(int x,int fa)
{
dfn[x]=low[x]=++cnt;
int tag=0;
for(int i:e[x])
{
if(i==fa) continue;
if(!dfn[i])
{
tarjan(i,x);
low[x]=min(low[x],low[i]);
if(dfn[x]<=low[i])
{
tag++;
if(x!=root||tag>1)
{
if(!vis[x])
{
num++;
vis[x]=1;
}
}
}
}
else low[x]=min(low[x],dfn[i]);
}
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n;
int a,b;
while(cin>>a>>b)
{
if(a==b) continue;
e[a].push_back(b);
e[b].push_back(a);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])
{
root=i;
tarjan(i,0);
}
}
cout<<num<<'\n';
for(int i=1;i<=n;i++) if(vis[i]) cout<<i<<'\n';
return 0;
}
割边(桥)
桥是连接图的关键边,删掉它,图就变为互不连通的两部分。
因此我们能发现,如果图的任意两点之间有两条互不相交的路径,那么图没有桥。
求桥的算法流程:
- \(dfs\)进入新节点,更新回溯值与时间戳。
- 继续搜索未被访问的子节点,回溯时更新节点回溯值。
- 若子节点之前被访问过,更新节点回溯值。
- 当子节点的回溯值大于节点时间戳,则连接节点与该子节点的边为桥。
一个问题:
Q: 为什么子节点的回溯值等于节点时间戳时,不符合条件
A: 当子节点回溯值等于节点时间戳,说明子节点能通过非树边到达节点,节点与子节点有至少两条不交路径。
一个注意:
- 用链式前向星存边,从\(2\)开始存边,使得同一条边的编号异或\(1\)可得,方便判断到父亲的边是树边还是非树边。
例题
天凯是苏联的***。苏联有n个城市,某些城市之间修筑了公路。任意两个城市都可以通过公路直接或者间接到达。 天凯发现有些公路被毁坏之后会造成某两个城市之间无法互相通过公路到达。这样的公路就被称为\(dangerous pavement\)。 为了防止美帝国对\(dangerous pavement\)进行轰炸,造成某些城市的地面运输中断,天凯决定在所有的\(dangerous pavement\)驻扎重兵。可是到底哪些是\(dangerous pavement\)呢?你的任务就是找出所有这样的公路。
code
#include<iostream>
#include<string.h>
#include<string>
#include<math.h>
#include<algorithm>
#include<vector>
#include<queue>
using namespace std;
int n,m;
int tot=1,h[155];
struct bian
{
int next,to;
}e[610];
void addedge(int from,int to)
{
e[++tot]={h[from],to};
h[from]=tot;
}
int dfn[155],low[155],cnt;
bool vis[610];
void tarjan(int x,int faedge)
{
dfn[x]=low[x]=++cnt;
for(int i=h[x];i;i=e[i].next)
{
int y=e[i].to;
if(!dfn[y])
{
tarjan(y,i);
low[x]=min(low[x],low[y]);
if(dfn[x]<low[y])
{
vis[i]=vis[i^1]=1;
}
}
else if(i!=(faedge^1)) low[x]=min(low[x],dfn[y]);
}
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=1,l,r;i<=m;i++)
{
cin>>l>>r;
addedge(l,r);
addedge(r,l);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i]) tarjan(i,0);
}
vector<pair<int,int>> q;
for(int i=2;i<=tot;i+=2)
{
if(vis[i]) q.push_back({min(e[i^1].to,e[i].to),max(e[i].to,e[i^1].to)});
}
sort(q.begin(),q.end());
for(auto it:q) cout<<it.first<<" "<<it.second<<'\n';
return 0;
}
中话
至此割边、割点概念引述完成,顺此,点双连通分量、边双连通分量也就很好理解了。
点双连通分量
已知删去割点会将图分为不连通的两部分,所以求点双连通分量只需让图中不包含割点即可。
回想求割点的过程,满足\(low[son]>=dfn[fa]\)的子树就是一个点双,但为了求极大点双,需要再把割点放进去(割点作为子图的根节点,在子图中不作为割点)。
但还需注意子树中可能也有割点,所以我们可以用栈维护未被划分到点双的节点,当确定割点后,不断弹栈至弹出子节点,弹出的节点与割点构成点双。
例题
code
#include<bits/stdc++.h>
using namespace std;
const int maxn=5e5+10;
const int maxm=2e6+10;
int n,m;
int h[maxn],dcc_num,dcc_size[maxn],cnt,tot,dfn[maxn],low[maxn],root;
vector<int> dcc[maxn];
stack<int> q;
struct nd
{
int next,to;
}v[maxm*2];
void add(int x,int y)
{
v[++tot].next=h[x];
h[x]=tot;
v[tot].to=y;
}
void tarjan(int x)
{
dfn[x]=low[x]=++cnt;
q.push(x);
if(x==root&&h[x]==0)//孤立点,特判
{
q.pop();
dcc_num++;
dcc[dcc_num].push_back(x);
dcc_size[dcc_num]++;
}
for(int i=h[x];i;i=v[i].next)
{
int y=v[i].to;
if(!dfn[y])
{
tarjan(y);
low[x]=min(low[x],low[y]);
if(dfn[x]<=low[y])
{
dcc_num++;
int op=0;
do
{
op=q.top();
q.pop();
dcc_size[dcc_num]++;
dcc[dcc_num].push_back(op);
}while(op!=y);
dcc[dcc_num].push_back(x);
dcc_size[dcc_num]++;
}
}
else low[x]=min(low[x],dfn[y]);
}
}
signed main()
{
cin>>n>>m;
for(int i=1;i<=m;i++)
{
int l,r;
cin>>l>>r;
if(l!=r)
{
add(l,r);
add(r,l);
}
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])
{
root=i;
tarjan(i);
}
}
cout<<dcc_num<<'\n';
for(int i=1;i<=dcc_num;i++)
{
cout<<dcc_size[i]<<" ";
while(!dcc[i].empty())
{
cout<<dcc[i].back()<<" ";
dcc[i].pop_back();
}
cout<<'\n';
}
return 0;
}
边双连通分量
与求点双相似,找到割边,将子树所有未被划分到边双的节点弹出,构成一个点双。
因此我们可以找到割边后进行弹栈。当然还可以在遍历完子树后进行判断弹栈。
详细地说:
- 对节点所有子树遍历,不断更新回溯值。
- 当节点最后的回溯值仍然等于时间戳,则说明以节点为根的子树可构成一个边双,连接节点与父亲的边为割边
原因:回溯值等于时间戳时,表明节点无法通过非树边到祖先节点,则说明节点与父亲的边为割边。易知桥一定是树边,算法正确。
这种写法对于孤立点非常友好,不需要进行特判。
例题
code
#include<bits/stdc++.h>
using namespace std;
const int maxn=5e5+10;
const int maxm=2e6+10;
int n,m,low[maxn],tot=1,cnt,dcc_num,dcc_size[maxn],dfn[maxn],h[maxn];
stack<int> q;
struct nd
{
int next,to;
}v[maxm*2];
bool bian[maxm*2];
vector<int> dcc[maxn];
void add(int x,int y)
{
v[++tot].next=h[x];
h[x]=tot;
v[tot].to=y;
}
void tarjan(int x)
{
low[x]=dfn[x]=++cnt;
q.push(x);
for(int i=h[x];i;i=v[i].next)
{
bian[i]=true;
if(bian[i^1]) continue;
int y=v[i].to;
if(!dfn[y])
{
tarjan(y);
low[x]=min(low[x],low[y]);
}
else low[x]=min(low[x],dfn[y]);
}
if(dfn[x]==low[x])//孤立点也能get到
{
dcc_num++;
int op=0;
do
{
op=q.top();
q.pop();
dcc[dcc_num].push_back(op);
dcc_size[dcc_num]++;
}while(op!=x);
}
}
signed main()
{
cin>>n>>m;
for(int i=1;i<=m;i++)
{
int l,r;
cin>>l>>r;
if(l!=r)
{
add(l,r);
add(r,l);
}
}
for(int i=1;i<=n;i++) if(!dfn[i]) tarjan(i);
cout<<dcc_num<<'\n';
for(int i=1;i<=dcc_num;i++)
{
cout<<dcc_size[i]<<" ";
while(!dcc[i].empty())
{
cout<<dcc[i].back()<<" ";
dcc[i].pop_back();
}
cout<<'\n';
}
return 0;
}
边双or点双缩点
对于边双,用割边连接两个连通分量,构建一棵树。
对于点双,用割点连接若干个连通分量,构建一棵树。
实际应用时,根据题目要求缩成点双或边双即可。
例题
code
#include<iostream>
#include<string.h>
#include<string>
#include<math.h>
#include<algorithm>
#include<vector>
#include<set>
#include<ctime>
#include<unordered_map>
using namespace std;
const int maxn=5e3+5;
const int maxm=1e4+5;
int n,m;
int h[maxn],tot=1;
struct bian
{
int to,next;
}e[maxm<<1];
void addedge(int from,int to)
{
e[++tot]={to,h[from]};
h[from]=tot;
}
int dfn[maxn],low[maxn],cnt;
int pos[maxn],dcc_cnt;
vector<int> stack;
bool vis[maxm<<1];
void tarjan(int x,int fa)
{
dfn[x]=low[x]=++cnt;
stack.push_back(x);
for(int i=h[x];i;i=e[i].next)
{
int y=e[i].to;
if(i==(fa^1)) continue;
if(!dfn[y])
{
tarjan(y,i);
low[x]=min(low[x],low[y]);
if(low[y]>dfn[x]) vis[i]=vis[i^1]=1;
}
else low[x]=min(low[x],dfn[y]);
}
if(dfn[x]==low[x])
{
dcc_cnt++;
int op=0;
do
{
op=stack.back();
pos[op]=dcc_cnt;
stack.pop_back();
}while(op!=x);
}
}
int in[maxn];
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=1,l,r;i<=m;i++)
{
cin>>l>>r;
addedge(l,r);
addedge(r,l);
}
tarjan(1,0);
for(int i=2;i<=tot;i++)
{
if(vis[i]) in[pos[e[i].to]]++;
}
int ret=0;
for(int i=1;i<=dcc_cnt;i++)
{
if(in[i]==1) ret++;
}
ret=(ret+1)/2;
cout<<ret;
return 0;
}
有向图的\(tarjan\)
强连通分量
在有向图中,任意两点可以相互到达,为强连通图。而有向图的极大强连通子图为强连通分量。
求强连通的过程和无向图很相似,当节点的时间戳等于回溯值时,从节点出发无法回到祖先节点,以节点为根的子树中所有未被划分为连通分量的节点构成一个新的分量。
但又与无向图有些差别,在此之前需要知道这些:
- 树边:搜索树的边,在上面提及过。
- 前向边:祖先节点指向孩子节点的非树边,无用,已经有一条祖先到孩子的节点。
- 后向边:孩子节点指向祖先的边,是构成连通分量的重要边,借助它,孩子可以到达以祖先节点为根的子树任意点。
- 横叉边:节点指向了一个未在当前搜索树(实际上是条链)上的点。分两种情况,若指向的点还可以到达节点的祖先,则该边作用与后向边相似。否则无用,因为指向的点无法到达节点位置。
因此在更新回溯值时,除了在到达树边时更新,在后向边与横叉边的第一种情况也可以更新。
发现后两种情况所指向的点一定还在栈中(指向点的回溯值所对应的点还在搜索阶段),所以简化操作,如果搜到的点在栈中,则可以更新回溯值。
例题
code
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e4+10;
const int maxm=5e4+10;
int n,m,scc[maxn],scc_num,siz[maxn],tot,h[maxn],dfn[maxn],low[maxn],cnt;
stack<int> q;
bool iszai[maxn];
struct nd
{
int to,next;
}v[maxm];
void add(int x,int y)
{
v[++tot].next=h[x];
v[tot].to=y;
h[x]=tot;
}
void tarjan(int x)
{
dfn[x]=low[x]=++cnt;
q.push(x);
iszai[x]=true;
for(int i=h[x];i;i=v[i].next)
{
int y=v[i].to;
if(!dfn[y])
{
tarjan(y);
low[x]=min(low[x],low[y]);
}
else if(iszai[y])
{
low[x]=min(low[x],dfn[y]);
}
}
if(dfn[x]==low[x])
{
int op=0;
scc_num++;
do
{
op=q.top();
q.pop();
iszai[op]=false;
scc[op]=scc_num;
siz[scc_num]++;
}while(x!=op);
}
return;
}
int main()
{
cin>>n>>m;
for(int i=1;i<=m;i++)
{
int l,r;
cin>>l>>r;
add(l,r);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i]) tarjan(i);
}
int ans=0;
for(int i=1;i<=scc_num;i++)
{
if(siz[i]>1) ans++;
}
cout<<ans;
return 0;
}
缩点
对于每条边,若连接的点属于不同强连通分量,则在两个强连通分量建边。

浙公网安备 33010602011771号