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)。

弹栈时,从栈中弹出直到 vu 也加入当前点双但不出栈(割点可属于多个分量)。

单独一条边连接两个点也是一个点双。孤立点需特殊处理。

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 中的贡献、方案相互独立,依据乘法原理计算:

  1. 如果这个DCC包含了1个以上的割点,那么无论拿一个割点被堵,都可以通过其他割点逃向其他救生点,没有贡献答案。
  2. 如果这个DCC只有1个割点,如果割点被堵,就在DCC内设立一个逃生点,如果逃生点被堵,就从割点出去。贡献为1,方案数为size[i]-1(除去割点本身)。
  3. 如果这个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 弹到 vx 不出栈
一个点属于几个分量 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 仍然是割点
posted on 2026-07-16 20:54  郑浩崎  阅读(4)  评论(0)    收藏  举报