Loading

【笔记】强连通分量

一、强连通分量

强连通:有向图 G 强连通是指 G 中任意两个节点相通。

强连通分量(Strongly Connected Componets, SCC),是指一个有向图中的极大强连通子图。

用处:可以把 SCC 看作一个点,构造新图解决问题。

二、Tarjan

Tarjan 是一种用来求强连通分量的算法。

我们 DFS 一个有向图,维护两个数组和一个栈:

\(dfn_u\)\(u\) 节点被搜索的次序。

\(low_u\)\(u\) 节点最多经过一条非树(DFS 生成树)边能回溯到最早进栈的节点。

什么是 DFS 生成树?

DFS 时走到一个新的节点,形成一条树边(图中黑边)。
其他的边统称非树边。按走到的点与出发点的关系还分为返祖边、横叉边、前向边(分别对应红、蓝、绿)。

为什么定义为最多经过一条非树边?考虑如下一张图:
pZx4tpV.png
这张图是一个强联通图。
如果在遍历 4 之前先遍历 2,3,那么 1 正确的 \(low\) 0 还没求出来,不能为 2,3 更新 1 的 \(low\)
而为 2,3 更新 1 的 \(dfn\),2,3 的 \(low\) 已经不等于其 \(dfn\) 了,说明它们不是其所在 SCC 中最早入栈的节点,目标已经达到了。

我们把每次遍历的节点 \(u\) 入栈,\(dfn_u\) 比上一个遍历的节点大 \(1\),然后往下遍历,更新 \(low_u\),设从 \(u\) 走到了 \(v\) 点,有三种情况:

  • \(v\) 在栈中,拿 \(dfn_v\) 更新 \(low_u\),取最小值。
  • \(v\) 没遍历过,遍历 \(v\),拿 \(low_v\) 更新 \(low_u\),取最小值。
  • \(v\) 遍历过且已经出栈,说明其不属于 \(u\) 所在的强连通分量,不进行操作。

因为回溯就是找环,从一个点出发走完它所有能到的点后,如果它不能通过环回到更早遍历到的节点(可以的话,即使下面有环也不是最大的),就说明其是某个 SCC 里最早进栈的点,而不在这个 SCC 中的点不会回溯到这个点,早已出栈,因此栈中这个节点及以上的所有节点就是这个 SCC。把它们都弹出即可求得。

代码:

int dfn[N],low[N],dfncnt,st[N],top;
int scc[N],sc;//结点 i 所在 SCC 的编号,SCC 计数
int sz[N];//强连通 i 的大小
bool stvis[N];
void tarjan(int u) {
    low[u]=dfn[u]=++dfncnt,st[++top]=u,stvis[u]=1;
    for(int i=h[u];i;i=e[i].nxt) {
        int v=e[i].to;
        if(!dfn[v]){
            tarjan(v);
            low[u]=min(low[u],low[v]);
        }else if(stvis[v]){
            low[u]=min(low[u],dfn[v]);
        }
    }
    if(dfn[u]==low[u]) {
        sc++;
        while(st[top]!=u) {
            scc[st[top]]=sc;
            sz[sc]++;
            stvis[st[top]]=0;
            top--;
        }
        scc[st[top]]=sc;
        sz[sc]++;
        stvis[st[top]]=0;
        top--;
    }
}

三、例题

Luogu P2863 [USACO06JAN] The Cow Prom S

简单题。Tarjan 求出每个 SCC 的节点个数,遍历统计大于 \(1\) 的 SCC 并输出即可。

#include<iostream>
#include<cstdio>
using namespace std;
const int maxn=1e4+10;
const int maxm=5e4+10;
struct Node{
	int from,to,nxt;
}e[maxm];
int n,m,cnt;
int tot,h[maxn];
int low[maxn],dfn[maxn],sz[maxn],sc,st[maxn],top,dfncnt;
bool stvis[maxn];
void Add(int u,int v){
	tot++;
	e[tot].from=u;
	e[tot].to=v;
	e[tot].nxt=h[u];
	h[u]=tot;
}
void tarjan(int u){
	low[u]=dfn[u]=++dfncnt;
	st[++top]=u;
	stvis[u]=1;
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].to;
		if(!dfn[v]){
			tarjan(v);
			low[u]=min(low[u],low[v]);
		}else if(stvis[v]){
			low[u]=min(low[u],dfn[v]);
		}
	}
	if(dfn[u]==low[u]){
		sc++;
		while(st[top]!=u){
			sz[sc]++;
			stvis[st[top]]=0;
			top--;
		}
		sz[sc]++;
		stvis[st[top]]=0;
		top--;
	}
}
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=m;i++){
		int u,v;
		scanf("%d%d",&u,&v);
		Add(u,v);
	}
	for(int i=1;i<=n;i++) if(!dfn[i]) tarjan(i);
	for(int i=1;i<=sc;i++) if(sz[i]>1) cnt++;
	cout<<cnt;
	return 0;
}

Luogu P3387【模板】缩点

解决这个问题我们可以把一个 SCC 缩成一个点,因为如果路径经过了这个 SCC 的任意一点那么就可以经过其他的点,而且不用担心重复经过一点/边。

使用 Tarjan 求出所有的 SCC,把所有 SCC 看成一个点,然后重新构造一个图,每一个点的点权都是这个 SCC 的所有节点的点权和。在原图中寻找连接两个 SCC 的边,然后再新图中连接。连接的时候计算每个新点的入度,方便跑拓扑排序。连完之后对新图进行拓扑排序,把所有入度为 \(0\) 的节点入栈然后往下遍历,每次遍历都把入度 \(-1\)(相当于忽视了已经遍历过的父节点,类似于递归),继续找入度为 \(0\) 的节点遍历。在拓扑排序的过程中动态规划,设与每次遍历的节点 \(u\) 相连的节点为 \(v\),新图的点权为 \(a\),那么 \(f_v=\max(f_v,f_u+a_v)\)。最后找到最大的结果输出。

update:不需要再拓扑排序。Tarjan 缩点时会把最后搜到的 SCC 先出栈,因此缩点后 SCC 的编号倒序就是一个拓扑序。直接按编号倒序遍历新图上的点,更新相邻的 \(f_v\)

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e4+10;
const int M=1e5+10;
int n,m,ans;
int a[N],a2[N],f[N];
int h[N],h2[N],tot;
int low[N],dfn[N],dfncnt;
int scc[N],sc;
int st[N],top;
bool stvis[N];
struct Node{
    int from,to,nxt;
}e[M],e2[M];
void Add(int u,int v){
    tot++;
    e[tot].from=u;
    e[tot].to=v;
    e[tot].nxt=h[u];
    h[u]=tot;
}
void Add2(int u,int v){
    tot++;
    e2[tot].from=u;
    e2[tot].to=v;
    e2[tot].nxt=h2[u];
    h2[u]=tot;
}
void tarjan(int u){
    low[u]=dfn[u]=++dfncnt;
    st[++top]=u,stvis[u]=1;
    for(int i=h[u];i;i=e[i].nxt){
        int v=e[i].to;
        if(!dfn[v]){
            tarjan(v);
            low[u]=min(low[u],low[v]);
        }else if(stvis[v]){
            low[u]=min(low[u],dfn[v]);
        }
    }
    if(low[u]==dfn[u]){
        sc++;
        while(st[top]!=u&&top){
            scc[st[top]]=sc;
            a2[sc]+=a[st[top]];
            stvis[st[top]]=0;
            top--;
        }
        scc[u]=sc;
        a2[sc]+=a[u];
        stvis[u]=0;
        top--;
    }
}
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n>>m;
    for(int i=1;i<=n;i++) cin>>a[i];
    for(int i=1;i<=m;i++){
        int u,v;
        cin>>u>>v;
        Add(u,v);
    }
    for(int i=1;i<=n;i++){
        if(!dfn[i]) tarjan(i);
    }
    tot=0;
    for(int i=1;i<=m;i++){
        int u=e[i].from,v=e[i].to;
        u=scc[u],v=scc[v];
        if(u!=v) Add2(u,v);
    }
    for(int i=1;i<=sc;i++) f[i]=a2[i];
    for(int i=sc;i>=1;i--){
        for(int j=h2[i];j;j=e2[j].nxt){
            int v=e2[j].to;
            f[v]=max(f[v],f[i]+a2[v]);
        }
    }
    for(int i=1;i<=n;i++) ans=max(ans,f[i]);
    cout<<ans;
    return 0;
}

时间复杂度 \(O(n+m)\)

Luogu P2341 [USACO03FALL / HAOI2006] 受欢迎的牛 G

在一个 SCC 里的牛是互相喜欢的。我们求出所有的 SCC 并缩点构造新图,新图中如果只有一个出度为 \(0\) 的节点那么这个 SCC 里的所有牛都是明星。如果不止一个出度为 \(0\) 的节点那么就都构不成明星关系,输出 \(0\)。如果新图只有一个节点那么输出这个节点里的牛数。

#include<iostream>
#include<cstdio>
using namespace std;
const int maxn=1e4+10;
const int maxm=5e4+10;
struct Node{
	int from,to,nxt;
}e[maxm],e2[maxm];
int n,m,ans,anss;
int tot,h[maxn],h2[maxn],outd[maxn];
int low[maxn],dfn[maxn],st[maxn],scc[maxn],sz[maxn],sc,dfncnt,top;
bool stvis[maxn];
void Add(int u,int v){
	tot++;
	e[tot].from=u;
	e[tot].to=v;
	e[tot].nxt=h[u];
	h[u]=tot;
}
void Add2(int u,int v){
	tot++;
	e2[tot].from=u;
	e2[tot].to=v;
	e2[tot].nxt=h[u];
	outd[u]++;
	h[u]=tot;
}
void tarjan(int u){
	low[u]=dfn[u]=++dfncnt;
	st[++top]=u;
	stvis[u]=1;
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].to;
		if(!dfn[v]){
			tarjan(v);
			low[u]=min(low[u],low[v]);
		}else if(stvis[v]){
			low[u]=min(low[u],dfn[v]);
		}
	}
	if(dfn[u]==low[u]){
		sc++;
		while(st[top]!=u){
			scc[st[top]]=sc;
			sz[sc]++;
			stvis[st[top]]=0;
			top--;
		}
		scc[st[top]]=sc;
		sz[sc]++;
		stvis[st[top]]=0;
		top--;
	}
}
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=m;i++){
		int u,v;
		scanf("%d%d",&u,&v);
		Add(u,v);
	}
	for(int i=1;i<=n;i++) if(!dfn[i]) tarjan(i);
	tot=0;
	for(int i=1;i<=m;i++){//构造新图。 
		int x=scc[e[i].from],y=scc[e[i].to];
		if(x!=y) Add2(x,y);
	}
	for(int i=1;i<=sc;i++){//求出度。 
		if(!outd[i]){
			ans++;
			anss=i;
		} 
	}
	if(ans==1) cout<<sz[anss];
	else cout<<0;
	return 0;
}

Luogu P2746 [USACO5.3] 校园网Network of Schools

子任务 A,Tarjan 求 SCC 并缩点之后输出入度为 \(0\) 的 SCC 的节点数,因为这些 SCC 中的学校无法接收到软件。

子任务 B,如果要把这些 SCC 合并成一个 SCC,它们必须再组成一个环。这时候把新图所有出度为 \(0\) 的节点和入度为 \(0\) 的节点两两相连,此时已经有环,且可能剩下的入度为 \(0\) 或出度为 \(0\) 的节点中一种,把他们连到环上(即一个点入度为 \(0\) 就把任意一个出入度均不为 \(0\) 即环上点连到这个点,因为其出度不为 \(0\) 所以可以回到环上,每个点需要一条边。出度为 \(0\) 的点同理)就能构成一个 SCC 了。设入度为 \(0\) 的节点数量为 \(in\),出度为 \(0\) 的节点数量为 \(out\)(若一个点是孤点,则应同时计入两种点中),那么最少所需要增加的边数量为 \(\max(in,out)\)。注意原图只有一个 SCC 的情况下答案为 \(0\)

#include<iostream>
#include<cstdio>
using namespace std;
const int maxn=110;
const int maxm=1e4+10;
struct Node{
	int from,to,nxt;
}e[maxm],e2[maxm];
int n,m,cnt1,cnt2;
int tot,h[maxn],h2[maxn],ind[maxn],outd[maxn];
int low[maxn],dfn[maxn],scc[maxn],sz[maxn],sc,st[maxn],top,dfncnt;
bool stvis[maxn];
void Add(int u,int v){
	tot++;
	e[tot].from=u;
	e[tot].to=v;
	e[tot].nxt=h[u];
	h[u]=tot;
}
void Add2(int u,int v){
	tot++;
	e2[tot].from=u;
	e2[tot].to=v;
	e2[tot].nxt=h2[u];
	ind[v]++;
	outd[u]++;
	h2[u]=tot;
}
void tarjan(int u){
	low[u]=dfn[u]=++dfncnt;
	st[++top]=u;
	stvis[u]=1;
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].to;
		if(!dfn[v]){
			tarjan(v);
			low[u]=min(low[u],low[v]);
		}else if(stvis[v]){
			low[u]=min(low[u],dfn[v]);
		}
	}
	if(dfn[u]==low[u]){
		sc++;
		while(st[top]!=u){
			scc[st[top]]=sc;
			sz[sc]++;
			stvis[st[top]]=0;
			top--;
		}
		scc[st[top]]=sc;
		sz[sc]++;
		stvis[st[top]]=0;
		top--;
	}
}
int main(){
	scanf("%d",&n);
	for(int i=1;i<=n;i++){
		int v;
		while(1){
			scanf("%d",&v);
			if(v==0) break;
			else{
				Add(i,v);	
				m++;
			} 
		}
	} 
	for(int i=1;i<=n;i++) if(!dfn[i]) tarjan(i);
	tot=0;
	for(int i=1;i<=m;i++){
		int x=scc[e[i].from],y=scc[e[i].to];
		if(x!=y){
			Add2(x,y);
		} 
	}
	for(int i=1;i<=sc;i++){//统计入度为0和出度为0的节点。 
		if(!ind[i]) cnt1++;
		if(!outd[i]) cnt2++;
	}
	cout<<cnt1<<endl;
	if(sc==1) cout<<0;
	else cout<<max(cnt1,cnt2);
	return 0;
}

Luogu P1262 间谍网络

第一问判断是否能控制所有间谍,也就是所有间谍是否与受贿间谍连通,可以从所有受贿间谍进行 Tarjan,最终没被遍历到的,即没有 \(dfn\) 的间谍就是不会被控制的间谍。Tarjan 完之后从小到大遍历到第一个没有 \(dfn\) 的间谍就输出 NO 和这个间谍的编号。否则就输出 YES,进行第二问。

第二问我们把所有 SCC 缩成点并建新图,新图的每个点的点权就是原图中每个 SCC 中受贿间谍钱数的最小值。然后输出新图所有入度为 \(0\) 的节点的点权和即可。

#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std;
const int maxn=3e3+10;
const int maxr=8e3+10;
struct Node{
	int from,to,nxt;
}e[maxr];
int n,r,p,ans;
int tot,h[maxn],a[maxn],ind[maxn],mon[maxn];
int low[maxn],dfn[maxn],st[maxn],scc[maxn],sz[maxn],sc,top,dfncnt;
bool stvis[maxn];
void Add(int u,int v){
	tot++;
	e[tot].from=u;
	e[tot].to=v;
	e[tot].nxt=h[u];
	h[u]=tot;
}
void tarjan(int u){
	low[u]=dfn[u]=++dfncnt;
	st[++top]=u;
	stvis[u]=1;
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].to;
		if(!dfn[v]){
			tarjan(v);
			low[u]=min(low[u],low[v]);
		}else if(stvis[v]){
			low[u]=min(low[u],dfn[v]);
		}
	}
	if(low[u]==dfn[u]){
		sc++;
		while(st[top]!=u){
			scc[st[top]]=sc;
			sz[sc]++;
			stvis[st[top]]=0;
			mon[sc]=min(a[st[top]],mon[sc]);
			top--;
		}
		scc[st[top]]=sc;
		sz[sc]++;
		stvis[st[top]]=0;
		mon[sc]=min(a[st[top]],mon[sc]);
		top--;
	}
}
int main(){
	memset(a,0x3f,sizeof(a));
	memset(mon,0x3f,sizeof(mon));
	scanf("%d%d",&n,&p);
	for(int i=1;i<=p;i++){
		int b,c;
		scanf("%d%d",&b,&c);
		a[b]=c;
	}
	scanf("%d",&r);
	for(int i=1;i<=r;i++){
		int u,v;
		scanf("%d%d",&u,&v);
		Add(u,v);
	}
	for(int i=1;i<=n;i++) if(!dfn[i]&&a[i]!=0x3f3f3f3f) tarjan(i);
	for(int i=1;i<=n;i++){
		if(!dfn[i]){
			cout<<"NO"<<endl<<i;
			return 0;
		}
	} 
	for(int i=1;i<=r;i++){
		int x=scc[e[i].from],y=scc[e[i].to];
		if(x!=y) ind[y]++;
	}
	for(int i=1;i<=sc;i++){
		if(!ind[i]){
			ans+=mon[i];
		}
	}
	cout<<"YES"<<endl<<ans;
	return 0;
}

Luogu P3627 [APIO2009] 抢掠计划

这个题与【模板】缩点类似,但是有一个地方需要多加注意,就是在拓扑排序的时候不能只把起点 \(s\) 入栈,而是要把所有入度为零的节点入栈,但是只在 dp 中记录 \(s\) 所在 SCC 的点权,其余赋值为极小值以防影响 dp 的结果。这么做的原因是,与起点相连的节点不一定入度为 \(1\),而从所有入度为 \(0\) 的节点出发就可以把整个图搜完,不会导致没有节点入度为 \(0\) 导致搜到第二层就直接结束。

#include<iostream>
#include<cstdio>
using namespace std;
const int maxn=5e5+10;
const int INF=2147483647;
struct Node{
	int from,to,nxt;
}e[maxn],e2[maxn];
int n,m,s,p,ans;
int h[maxn],h2[maxn],tot;
int a[maxn],a2[maxn],ind[maxn],dp[maxn];
int low[maxn],dfn[maxn],dfncnt,st[maxn],top,scc[maxn],sc,sz;
bool bar[maxn],stvis[maxn];
void Add(int u,int v){
	tot++;
	e[tot].from=u;
	e[tot].to=v;
	e[tot].nxt=h[u];
	h[u]=tot;
}
void Add2(int u,int v){
	tot++;
	e2[tot].from=u;
	e2[tot].to=v;
	e2[tot].nxt=h2[u];
	ind[v]++;
	h2[u]=tot;
}
void Tarjan(int u){
	low[u]=dfn[u]=++dfncnt;
	st[++top]=u;
	stvis[u]=1;
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].to;
		if(!dfn[v]){
			Tarjan(v);
			low[u]=min(low[u],low[v]);
		}else if(stvis[v]){
			low[u]=min(low[u],dfn[v]);
		}
	}
	if(low[u]==dfn[u]){
		sc++;
		while(st[top]!=u){
			scc[st[top]]=sc;
			a2[sc]+=a[st[top]];
			stvis[st[top]]=0;
			top--;
		}
		scc[st[top]]=sc;
		a2[sc]+=a[st[top]];
		stvis[st[top]]=0;
		top--;
	}
}
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=m;i++){
		int u,v;
		scanf("%d%d",&u,&v);
		Add(u,v);
	}
	for(int i=1;i<=n;i++){
		scanf("%d",&a[i]);
	}
	scanf("%d%d",&s,&p);
	for(int i=1;i<=p;i++){
		int x;
		scanf("%d",&x);
		bar[x]=1;
	}
	for(int i=1;i<=n;i++){
		if(!dfn[i]) Tarjan(i);
	}
	tot=0;
	for(int i=1;i<=m;i++){
		int x=scc[e[i].from],y=scc[e[i].to];
		if(x!=y) Add2(x,y);
	}
	top=0;
	for(int i=1;i<=sc;i++){
		if(!ind[i]){
			st[++top]=i;
		}
	}
	for(int i=1;i<=sc;i++) dp[i]=-INF;
	dp[scc[s]]=a2[scc[s]];
	while(top){
		int u=st[top--];
		for(int i=h2[u];i;i=e2[i].nxt){
			int v=e2[i].to;
			dp[v]=max(dp[v],dp[u]+a2[v]);
			ind[v]--;
			if(!ind[v]) st[++top]=v;
		}
	}
	for(int i=1;i<=n;i++){
		if(bar[i]){
			ans=max(ans,dp[scc[i]]);
		}
	}
	cout<<ans;
	return 0;
}
posted @ 2025-12-12 23:07  Seqfrel  阅读(60)  评论(0)    收藏  举报