day 8 图论选讲之连通分量
图论选讲之连通分量
强连通分量(SCC)
定义:有向图中极大的子图,满足子图中的任意两点之间相互可达。
求法:Tarjan算法
在 dfs 搜索时,维护:
dfn[u],结点 u 的 dfs 序low[u],表示结点 u 和以结点 u 为子树的结点,经过一条返祖边或横叉边所能到达 dfn 最小的点的 dfn,例如样例中的结点 5,以结点 5 为跟的子树中存在结点 6,经过一天横叉边指向了 4,所有 \(low[5]=4\),样例中 low 数组构造完成后如下:1 1 3 1 4 4 5 5 9
考虑 low 如何计算:
dfs 每一条路线,如果走到的点没有被处理过,那么向下递归,更新 low:low[u]=min(low[u],low[v])。(因为这样是满足 low 数组定义的)
我们需要开一个搜索栈,并记录每个结点是否在栈中。
在搜索时,如果搜到的点在栈中,那么更新 low[u]=min(low[u],dfn[v])。(注意不能写成 low[u]=min(low[u],low[v]),虽然这暂时是可以通过的)
在 dfs 过程中,遇到一个强连通分量的第一个点,具有性质:dfn[x]==low[x]。
dfn[x]==low[x] 表示从 x 的子树出发无法回到 x 的祖先,子树内部形成封闭的强连通分量。此时把栈中从 x 到栈顶的所有点弹出,就找到了一个强连通分量。
时间复杂度 \(O(n+m)\)。
void tarjan(int u){
dfn[u]=low[u]=++ind; // 初始化时间戳和追溯值
s[++top]=u; ins[u]=1; // 入栈,标记在栈中
for(int v:g[u]){ // 遍历邻接点
if(!dfn[v]){ // 未访问:树边
tarjan(v); // 递归搜索
low[u]=min(low[u],low[v]); // 用子节点low更新自己
}else if(ins[v]){ // 已访问且在栈中:返祖边/横叉边
low[u]=min(low[u],dfn[v]); // 用dfn[v]更新(不可用low[v])
}
}
if(low[u]==dfn[u]){ // u是SCC的根
cnt++; // 新SCC编号
int now;
do{ // 弹栈,直到弹出u
now=s[top];top--;
ins[now]=0; // 标记出栈
sum[cnt]+=a[now]; // 累加点权到SCC
scc[now]=cnt; // 记录归属
}while(now!=u);
}
}
强连通分量缩点
缩点后变成:DAG(有向无环图)这样可以便于拓扑排序、DP。
做法:枚举所有边,如果边连接的是两个不同的强连通分量,就建一条边进行连接,这样又可能出现重边,但是大多情况下不需要去重。
void solve(){
for(int i=1;i<=n;i++){
for(int j:g[i]){
if(scc[i]!=scc[j]){
new_g[scc[i]].push_back(scc[j]);
}
}
}
}
例题 1:P3387 缩点(强连通分量+缩点+DP)
给定一个 n 个点 m 条边有向图,每个点有一个权值,求一条路径,使路径经过的点权值之和最大。你只需要求出这个权值和。
\(n\le 10^4,m\le 10^5\)
先按上述说的缩点,注意在求的时候要记录每个连通分量的权值和,也就是之后点的全职,将图变成一个 DAG,考虑如何 DP。
记 \(dp[i]\) 为从点 i 出发的一条路径,获得的最大权值和。
每次从一个点开始 dfs,更新 dp 值,最后取 max 即可。
时间复杂度 \(O(n^2)\)。
// 强连通分量
#include<bits/stdc++.h>
using namespace std;
int n,m,a[10005];
vector<int>g[10005];
int dfn[10005],low[10005],s[10005],ins[10005],sum[10005],scc[10005];
int ind,top,cnt;
vector<int>new_g[10005];
int dp[10005];
void tarjan(int u){
dfn[u]=low[u]=++ind;
s[++top]=u;ins[u]=1;
for(int v:g[u]){
if(!dfn[v]){
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]){
int now=-1;
cnt++;
while(now!=u){
now=s[top];top--;
ins[now]=0;
sum[cnt]+=a[now];
scc[now]=cnt;
}
}
}
void solve(){
for(int i=1;i<=n;i++){
for(int j:g[i]){
if(scc[i]!=scc[j]){
new_g[scc[i]].push_back(scc[j]);
}
}
}
}
int dfs(int u){
if(dp[u]!=-1)return dp[u];
dp[u]=sum[u];
for(int v:new_g[u]){
dp[u]=max(dp[u],dfs(v)+sum[u]);
}
return dp[u];
}
int main(){
cin>>n>>m;
for(int i=1;i<=m;i++){
int u,v;cin>>u>>v;
g[u].push_back(v);
}
for(int i=1;i<=n;i++)if(!dfn[i])tarjan(i);
for(int i=1;i<=n;i++)cout<<low[i]<<" ";
solve();
memset(dp,-1,sizeof dp);
int ans=0;
for(int i=1;i<=cnt;i++){
ans=max(ans,dfs(i));
}
cout<<ans;
return 0;
}
例题 2:P4819 [中山市选] 杀人游戏(强连通分量+缩点+概率)
一位杀手潜入假装成平民,警察希望能在 N 个人里面,查出谁是杀手。警察能够对每一个人进行查证,假如查证的对象是平民,他会告诉警察,他认识的人,谁是杀手,谁是平民。假如查证的对象是杀手,杀手将会把警察干掉。现在警察掌握了每一个人认识谁。每一个人都有可能是杀手,可看作他们是杀手的概率是相同的。
问:根据最优的情况,保证警察自身安全并知道谁是杀手的概率最大是多少?\(N\le10^5\)
人们的关系可以看成一张图,a 认识 b 就加上一条 a 到 b 的有向边,用 Tarjan 进行缩点,把图变成一个 DAG,发现必须询问入度为 0 的强连通分量,因为没有任何人认识他,设有 c 个入度为 0 的强连通分量,那么成功的概率就是 \(1-\frac{c}{n}\)。
但是通过样例我们可以看到一个特殊情况,如果有一个点独自成为一个强连通分量,那么我们可以通过对别的信息,利用排除法得出,不必冒险,这样只能少问一个人,概率为 \(1-\frac{c-1}{n}\)。
#include<bits/stdc++.h>
using namespace std;
int n,m;
const int N=1e5+10;
vector<int>g[N];
int dfn[N],low[N],s[N],ins[N],scc[N];
int ind,top,cnt;
vector<int>ng[N];
int du[N],sz[N];
void tarjan(int u){
dfn[u]=low[u]=++ind;
s[++top]=u;ins[u]=1;
for(int v:g[u]){
if(!dfn[v]){
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]){
int now=-1;
cnt++;
while(now!=u){
now=s[top];top--;
ins[now]=0;
scc[now]=cnt;
sz[cnt]++;
}
}
}
void solve(){
for(int i=1;i<=n;i++){
for(int j:g[i]){
if(scc[i]!=scc[j]){
ng[scc[i]].push_back(scc[j]);
du[scc[j]]++;
}
}
}
}
int main(){
cin>>n>>m;
for(int i=1;i<=m;i++){
int u,v;cin>>u>>v;
g[u].push_back(v);
}
for(int i=1;i<=n;i++)if(!dfn[i])tarjan(i);
solve();
int flag=0,num=0;
for(int i=1;i<=cnt;i++){
if(du[i]==0)num++;
if(sz[i]==1&&du[i]==0&&ng[i].empty())flag=1;
}
double ans;
if(flag)ans=1.0-(double)(num-1)/(double)n;
else ans=1.0-(double)num/(double)n;
printf("%.6f",ans);
return 0;
}
点双连通分量(V-DCC)
定义:无向图中极大的子图,满足删掉任意一个点后,剩余部分仍然连通。
求法:与 SCC 类似,但无向图需跳过父节点(v==fa)。
判定条件:low[v]>=dfn[u](等于也成立,因为删掉 u 后,v 即使能回到 u 也失去意义)
割点:删去该点后,图不再连通。此时 u 即为割点(非根节点直接标记,根节点需 child >= 2)。
弹栈时,从栈中弹出直到 v,u 也加入当前点双但不出栈(割点可属于多个分量)。
单独一条边连接两个点也是一个点双。孤立点需特殊处理。
void tarjan(int u,int fa){
dfn[u]=low[u]=++ind; // 初始化时间戳和追溯值
s[++top]=u; // 当前点入栈
int child=0; // 统计根节点的子树数量(用于根割点判断)
for(int v:g[u]){
if(v==fa)continue; // 跳过父节点(无向图防回头)
if(!dfn[v]){
child++; // 子树计数
tarjan(v,u);
low[u]=min(low[u],low[v]); // 用子节点low更新自己
if(low[v]>=dfn[u]){ // 割点判定:v子树回不到u的祖先
if(fa!=0)cut[u]=1; // 非根节点标记割点
cnt++; // 新点双分量编号
int now;
do{
now=s[top--];
dcc[cnt].push_back(now);
}while(now!=v); // 弹栈到v为止(u不出栈)
dcc[cnt].push_back(u); // u也属于该点双,但保留在栈中
}
}
else low[u]=min(low[u],dfn[v]); // 回边更新low
}
if(fa==0&&child>=2)cut[u]=1; // 根节点且有≥2个子树 → 割点
if(fa==0&&child==0){ // 孤立点(根无子)
cnt++;
dcc[cnt].push_back(u);
top--;
}
}
例题 1:UOJ67 新年的毒瘤(点双+割点+树)
给定一个无向图,问有多少个点满足:删除该点后图变成一棵树。
一个满足以下性质的图,一定是一棵树:
- 有 n 个点,\(n-1\) 条边
- 图是连通的
那么,我们只需要枚举所有点,满足:
- 删去这个点后,边的数量为 \(n-2\),可以通过处理点的度来计算
- 这个点不是割点,可以预处理求出所有割点
复杂度 \(O(n+m)\)。
#include<bits/stdc++.h>
using namespace std;
const int N=5e5+10;
int n,m;
vector<int>g[N];
int dfn[N],low[N],s[N];
int ind,top,cnt;
vector<int>dcc[N];
int cut[N],du[N];
void tarjan(int u,int fa){
dfn[u]=low[u]=++ind;
s[++top]=u;
int child=0;
for(int v:g[u]){
if(v==fa)continue;
if(!dfn[v]){
child++;
tarjan(v,u);
low[u]=min(low[u],low[v]);
if(low[v]>=dfn[u]){
if(fa!=0)cut[u]=1;
cnt++;
int now;
do{
now=s[top--];
dcc[cnt].push_back(now);
}while(now!=v);
dcc[cnt].push_back(u);
}
}
else low[u]=min(low[u],dfn[v]);
}
if(fa==0&&child>=2)cut[u]=1;
if(fa==0&&child==0){
cnt++;
dcc[cnt].push_back(u);
top--;
}
}
int main(){
cin>>n>>m;
for(int i=1;i<=m;i++){
int u,v;cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
du[u]++;du[v]++;
}
for(int i=1;i<=n;i++)if(!dfn[i])tarjan(i,0);
int ans=0;
for(int i=1;i<=n;i++){
if(m-du[i]==n-2&&cut[i]==0) ans++;
}
cout<<ans<<"\n";
for(int i=1;i<=n;i++){
if(m-du[i]==n-2&&cut[i]==0) cout<<i<<" ";
}
return 0;
}
例题 2:P3225 [HNOI2012] 矿场搭建(点双+割点+乘法原理)
给定一个无向图,问至少要设置多少个关键点使得,删除任意一个点后,所有点都能走到其中一个关键点,并求方案数。\(m\le 500\)
每个 DCC 中的贡献、方案相互独立,依据乘法原理计算:
- 如果这个DCC包含了1个以上的割点,那么无论拿一个割点被堵,都可以通过其他割点逃向其他救生点,没有贡献答案。
- 如果这个DCC只有1个割点,如果割点被堵,就在DCC内设立一个逃生点,如果逃生点被堵,就从割点出去。贡献为1,方案数为
size[i]-1(除去割点本身)。 - 如果这个DCC没有割点。就要用两个逃生点互保。贡献为2,方案数为
size[i]*(size[i]-1)/2。所有方案数乘起来就是答案。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=5e5+10;
int n,m;
vector<int>g[N];
int dfn[N],low[N],s[N];
int ind,top,cnt;
vector<int>dcc[N];
int cut[N];
void tarjan(int u,int fa){
dfn[u]=low[u]=++ind;
s[++top]=u;
int child=0;
for(int v:g[u]){
if(v==fa)continue;
if(!dfn[v]){
child++;
tarjan(v,u);
low[u]=min(low[u],low[v]);
if(low[v]>=dfn[u]){
if(fa!=0)cut[u]=1;
cnt++;
int now;
do{
now=s[top--];
dcc[cnt].push_back(now);
}while(now!=v);
dcc[cnt].push_back(u);
}
}
else low[u]=min(low[u],dfn[v]);
}
if(fa==0&&child>=2)cut[u]=1;
if(fa==0&&child==0){
cnt++;
dcc[cnt].push_back(u);
top--;
}
}
signed main(){
for(int t=1;;t++){
cin>>m;
if(m==0)return 0;
for(int i=1;i<=500;i++){
g[i].clear();
dfn[i]=0;low[i]=0;s[i]=0;
ind=top=cnt=0;
dcc[i].clear();
cut[i]=0;n=0;
}
for(int i=1;i<=m;i++){
int u,v;cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
n=max(n,max(u,v));
}
for(int i=1;i<=n;i++)if(!dfn[i])tarjan(i,0);
int x=0,y=1;
for(int i=1;i<=cnt;i++){
int cutn=0;
for(int j:dcc[i]){
cutn+=cut[j];
}
if(cutn==1){
x++;
y*=dcc[i].size()-1;
}
if(cutn==0){
x+=2;
y*=dcc[i].size()*(dcc[i].size()-1)/2;
}
}
cout<<"Case "<<t<<": "<<x<<" "<<y<<"\n";
}
return 0;
}
边双连通分量(E-DCC)
定义:无向图中极大的子图,满足删掉任意一条边后,剩余部分仍然连通。
求法:先找桥,再染色(不走桥即可)。
判定条件:low[v] > dfn[u](严格大于,若等于则存在重边使边不为桥)
割边(桥):删去该边后,图不再连通。
建图时 tot=1,边成对存储,用 id ^ 1 取反向边。fa_edge 传边编号,用 id == (fa_edge ^ 1) 跳过回头边(可正确处理重边)。
void tarjan(int u,int fa_edge){ // fa_edge是进入u的边编号
dfn[u]=low[u]=++ind;
for(auto[v,id]:g[u]){ // 遍历邻接边
if(id==(fa_edge^1))continue;// 跳过刚走过的反向边(不走回头路)
if(v==u)continue; // 跳过自环(自环不影响边双)
if(!dfn[v]){ // 子节点未访问
tarjan(v,id); // 递归搜索
low[u]=min(low[u],low[v]);
if(low[v]>dfn[u]) // 桥判定:v子树回不到u(严格大于)
bridge[id]=bridge[id^1]=1; // 标记正反两条边为桥
}
else low[u]=min(low[u],dfn[v]); // 回边更新low
}
}
void dfs(int u,int c){ // 染色划分边双(不经过桥)
col[u]=c; // 标记当前点属于第c个边双
dcc[c].push_back(u); // 加入该分量
for(auto[v,id]:g[u]){
if(bridge[id])continue; // 跳过桥(桥是分量分界线)
if(v==u)continue; // 跳过自环
if(!col[v])dfs(v,c); // 递归染色未访问的邻点
}
}
边双缩点后,会变成一棵树(森林)
代码如下:
for(int u=1;u<=n;u++){
for(auto[v,id]:g[u]){
if(bridge[id]&&col[u]!=col[v]){
tr[col[u]].push_back(col[v]);
tr[col[v]].push_back(col[u]);
}
}
}
例题:CF1000E We Need More Bosses(边双+缩点+树直径)
给定一个 \(n\) 个点 \(m\) 条边的无向图,保证图连通。找到两个点 \(s,t\),使得 \(s\) 到 \(t\) 必须经过的边最多(一条边无论走哪条路线都经过它,这条边就是必须经过的边),\(2 \le n \le 3 \times 10^5,1 \le m \le 3 \times 10^5\)。
我们用边双连通分量进行缩点,把图变成一棵树,最终答案就是树的直径。
直径求法:从任意点开始进行 dfs,找到距离最大的点 w,再从点 w 开始 dfs,找到距离最大的点 v,点 \(w,v\) 就是这个树的直径。
缩点的方法上面讲过了,直接上代码,时间复杂度 $O(n+m) $。
#include<bits/stdc++.h>
using namespace std;
const int N=300005;
int n,m;
vector<pair<int,int>>g[N];
int dfn[N],low[N],ind;
bool bridge[600005];
int col[N];
int tot=1;
vector<int>dcc[N];
vector<int>tr[N];
void tarjan(int u,int fa_edge){
dfn[u]=low[u]=++ind;
for(auto[v,id]:g[u]){
if(id==(fa_edge^1))continue;
if(v==u)continue;
if(!dfn[v]){
tarjan(v,id);
low[u]=min(low[u],low[v]);
if(low[v]>dfn[u])bridge[id]=bridge[id^1]=1;
}
else low[u]=min(low[u],dfn[v]);
}
}
void dfs(int u,int c){
col[u]=c;
dcc[c].push_back(u);
for(auto[v,id]:g[u]){
if(bridge[id])continue;
if(v==u)continue;
if(!col[v])dfs(v,c);
}
}
int maxh=-1,maxid;
void bfs(int start){
maxh=-1;maxid=start;
vector<int>dist(n+1,-1);
queue<int>q;
q.push(start);
dist[start]=0;
while(!q.empty()){
int u=q.front();
q.pop();
if(dist[u]>maxh)maxh=dist[u],maxid=u;
for(int v:tr[u]){
if(dist[v]==-1){
dist[v]=dist[u]+1;
q.push(v);
}
}
}
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cin>>n>>m;
for(int i=1;i<=m;i++){
int u,v;cin>>u>>v;
g[u].push_back({v,++tot});
g[v].push_back({u,++tot});
}
for(int i=1;i<=n;i++)if(!dfn[i])tarjan(i,0);
int cnt=0;
for(int i=1;i<=n;i++)if(!col[i])dfs(i,++cnt);
for(int u=1;u<=n;u++){
for(auto[v,id]:g[u]){
if(bridge[id]&&col[u]!=col[v]){
tr[col[u]].push_back(col[v]);
tr[col[v]].push_back(col[u]);
}
}
}
bfs(1);
bfs(maxid);
cout<<maxh;
return 0;
}
总结:三种连通分量对比
对比表
| 对比项 | 强连通分量 (SCC) | 点双连通分量 (V-DCC) | 边双连通分量 (E-DCC) |
|---|---|---|---|
| 适用图 | 有向图 | 无向图 | 无向图 |
| 核心判定 | dfn[x] == low[x] |
low[v] >= dfn[u](割点) |
low[v] > dfn[u](桥) |
| 栈里存什么 | 点 | 点(割点不出栈) | 点(但用两次DFS) |
| 弹栈时机 | 发现SCC根 | 发现割点就弹 | 不弹栈,用染色 |
| 弹到谁 | 弹到 x |
弹到 v,x 不出栈 |
— |
| 一个点属于几个分量 | 1个 | 多个(割点跨分量) | 1个 |
| 缩点后变成 | DAG(有向无环图) | 圆方树 / 块割树 | 树 / 森林 |
需要 ins 标记 |
✅ 需要 | ❌ 不需要 | ❌ 不需要 |
| 处理重边 | 不需要特殊处理 | 需要 v == fa 跳过 |
需要 id == (fa_edge^1) |
| 根节点特判 | 不需要 | 需要(child >= 2 才标记割点) |
不需要 |
| 典型应用 | 缩点DP、2-SAT | 求割点、点双缩点 | 缩点成树、求桥 |
三个判定条件的本质区别
| 判定 | 含义 | 为什么 |
|---|---|---|
dfn[x] == low[x] (SCC) |
无法回到更早的节点,形成封闭环 | 有向图中,回不到祖先就是独立的SCC |
low[v] >= dfn[u] (割点) |
v的子树回不到u的祖先 | 删掉u,v的子树就断了(即使能回到u,但u被删了也没用) |
low[v] > dfn[u] (桥) |
v的子树回不到u | 删掉这条边,v的子树就断了 |
为什么点双和边双差一个等号?
- 边双(桥):
v能回到u本身,边u-v就不是桥(有备胎路径) - 点双(割点):
v能回到u本身,但u被删了,回不去了,所以u仍然是割点
浙公网安备 33010602011771号