从零开始的简单图论


掐指一算,上一次学图论可能还是在学 MST 的时候,以至于连 Dijkstra 都忘得差不多了。

两年来欠下的债好多啊,要慢慢还了/kk


1. 最短路

1.1. Bellman-Ford

你看确实是从零开始吧。

其实挺暴力的,就是不断更新最短路就行。

具体地,对于每一条边 \((u,v)\),用 \(d_u+w_{u,v}\) 更新 \(d_v\)。每次至少有一个节点的最短路被更新,那么松弛 \(n-1\) 次即可。

正确性证明:假设源点为 \(1\)。那么在 \(1\to u\) 的最短路 \(1\to p_1\to\cdots\to u\) 中,对于每一个节点 \(p_i\)\(1\to p_1\to\cdots\to p_i\) 也一定是 \(1\to p_i\) 的最短路。所以一个节点的最短路一定是由另一个节点的最短路扩展而来的。因为最短路最多有 \(n-1\) 条边,而第 \(i\) 次松弛会得到边数为 \(i\) 的最短路,故最多只要松弛 \(n-1\) 次。

此外该算法还可以用来判负环:如果在第 \(n\) 次松弛时仍有节点的最短路被更新,那么存在负环。

时间复杂度 \(\mathcal{O}(nm)\)

本来想学一个 SPFA,但是为什么要学一个死掉的算法呢?

1.2. Dijkstra

基于贪心的最短路算法,适用于非负权图

在已经得到最短路的节点中,取出没有扩展过的距离最小的节点并扩展。因为没有负权边,所以取出的节点的最短路长度单调不降。正确性显然。

取出距离最小节点的过程一般用优先队列 priority_queue

1.3. Johnson 全源最短路径

前置知识:Bellman-Ford & Dijkstra。

建一个虚拟节点 \(0\) 并向剩余所有节点连一条边权为 \(0\) 的边,一次 Bellman-Ford 跑出 \(0\to i\) 的最短路 \(h_i\)

接着把每条边赋值为 \(w_{u,v}+h_u-h_v\),得到的新图与原图任意两点间的最短路径不变,同时没有负边权,可以使用 dijkstra,最后不要忘了\(u\to v\) 的最短路加上 \(h_v-h_u\)

实际上,如果给每个点随机赋值 \(val_i\) 并把每条边赋值为 \(w_{u,v}+val_u-val_v\),任意两点间的最短路径都是不变的
。而预先跑一次 BF 算法的目的,是为了通过三角形不等式保证新边权非负

时间复杂度 \(\mathcal{O}(nm\log m)\)

模板题 P5905 代码:

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define pii pair <int,int>
#define fi first
#define se second

const int N=3e3+5;
const ll inf=1e9;

ll n,m,h[N],dis[N],ans;
vector <pii> e[N];

void BF(){
	memset(h,0x3f,sizeof(h)),h[0]=0;
	for(int i=0,upd;i<=n;i++){
		upd=0;
		for(int j=0;j<=n;j++)
			for(pii it:e[j])
				if(h[it.fi]>h[j]+it.se)
					upd=1,h[it.fi]=h[j]+it.se;
		if(i==n&&upd)puts("-1"),exit(0);
	}
}

priority_queue <pii,vector<pii>,greater<pii> > q;
void dij(int s){
	memset(dis,0x3f,sizeof(dis)),q.push({dis[s]=0,s});
	while(!q.empty()){
		pii t=q.top(); q.pop();
		int id=t.se,ds=t.fi;
		if(dis[id]<ds)continue;
		for(pii it:e[id]){
			int d=ds+it.se+h[id]-h[it.fi];
			if(dis[it.fi]>d)q.push({dis[it.fi]=d,it.fi});
		}
	} for(int i=1;i<=n;i++)ans+=min(dis[i]-h[s]+h[i],inf)*i;
	cout<<ans<<endl,ans=0;
}

int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++)e[0].push_back({i,0});
	for(int i=1;i<=m;i++){
		int u,v,w; cin>>u>>v>>w;
		e[u].push_back({v,w});
	} BF();
	for(int i=1;i<=n;i++)dij(i);
	return 0;
}

1.4. k 短路

堆的可持久化

妈耶,看不太懂论文啊。

懂了,但是不会可持久化左偏树怎么办啊?

通过 Bellman-Ford 算法的正确性证明,可知所有点到点 \(n\) 的最短路形成了一棵树。

剩下来的看课件吧(跳过 A* 部分),感觉说的挺清楚。

可持久化左偏树可以到 OI-Wiki 里面学,挺快的。

重边害死人,调了 1h+。有重边就不能记录父节点,而是记录最短路中连向该节点的边的编号了。

时间复杂度 \(\mathcal{O}(n\log n+m\log m+k\log k)\)(dij + 建堆 + k 短路)。

模板题 P2483 代码:

#pragma GCC optimize(3)

#include <bits/stdc++.h>
using namespace std;

#define pdi pair <double,int>
#define fi first
#define se second

const int N=2e5+5;

struct graph{
	int cnt,hd[N],nxt[N<<1],to[N<<1];
	double val[N<<1];
	void add(int u,int v,double w){
		nxt[++cnt]=hd[u],hd[u]=cnt,to[cnt]=v,val[cnt]=w;
	}
}e,re;

int node,rt[N],ls[N<<5],rs[N<<5],d[N<<5],to[N<<5];
double val[N<<5];
int merge(int x,int y){
	if(!x||!y)return x|y;
	if(val[x]>val[y])swap(x,y);
	int p=++node;
	val[p]=val[x],to[p]=to[x],ls[p]=ls[x],rs[p]=merge(rs[x],y);
	if(d[ls[p]]<d[rs[p]])swap(ls[p],rs[p]);
	return d[p]=d[rs[p]]+1,p;
} int New(int t,double v){
	int p=++node;
	return to[p]=t,val[p]=v,p;
}

double E,dis[N];
int fa[N],n,m,ans;
priority_queue <pdi,vector<pdi>,greater<pdi> > q;

void dij(){
	memset(dis,127,sizeof(dis)),q.push({dis[n]=0,n});
	while(!q.empty()){
		pdi t=q.top(); q.pop();
		int id=t.se; double ds=t.fi;
		if(dis[id]<ds)continue;
		for(int i=re.hd[id];i;i=re.nxt[i]){
			int to=re.to[i];
			if(dis[to]>ds+re.val[i])
				fa[to]=i,q.push({dis[to]=ds+re.val[i],to});
		}
	}
}

int main(){
	cin>>n>>m>>E;
	for(int i=1;i<=m;i++){
		int u,v; double w;
		cin>>u>>v>>w;
		if(u==n)continue;
		e.add(u,v,w),re.add(v,u,w);
	} dij();
	vector <int> s(n);
	for(int i=0;i<n;i++)s[i]=i+1;
	sort(s.begin(),s.end(),[](int a,int b){return dis[a]<dis[b];});
	for(int i=1;i<n;i++){
		int id=s[i];
		for(int i=e.hd[id];i;i=e.nxt[i]){
			if(fa[id]==i)continue;
			rt[id]=merge(rt[id],New(e.to[i],e.val[i]+dis[e.to[i]]-dis[id]));
		} rt[id]=merge(rt[id],rt[e.to[fa[id]]]);
	}
	E-=dis[1],ans++;
	if(rt[1])q.push({val[rt[1]],rt[1]});
	while(!q.empty()){
		pdi t=q.top(); q.pop();
		int id=t.se; double ds=t.fi;
		if(ds+dis[1]>E)break;
		E-=ds+dis[1],ans++;
		if(ls[id])q.push({ds-val[id]+val[ls[id]],ls[id]});
		if(rs[id])q.push({ds-val[id]+val[rs[id]],rs[id]});
		if(rt[to[id]])q.push({ds+val[rt[to[id]]],rt[to[id]]});
	} cout<<ans<<endl;
	return 0;
}

1.5. SPFA

关于 SPFA,它死了。

队列优化的 Bellman-Ford,具体来说就是在松弛一个点 \(x\) 时找到接下来有可能松弛的点(与 \(x\) 相邻的最短路被更新的点)并弹进队列里面。

代码就不放了,挺好写的。

1.6. 例题

*I. P5304 [GXOI/GZOI2019]旅行者

有趣的题!

\(\mathcal{O}(Tn\log n\log k)\) 做法:核心思想:二进制分组,然后每一组向对面跑最短路。因为两个不同的数二进制必定至少有一位不同,正确性显然。在 Luogu 上被卡了,要开 O2 才能过。其实随机分组也可以,这不禁让我想起了之前有一场 CF Div.1 的 D 就是这么做(CF1314 D)。

\(\mathcal{O}(Tn\log n)\) 做法:思想很巧妙:预处理出每个点 \(i\) 到特殊城市的最小距离 \(f_i\) 以及对应城市 \(to_i\),和所有特殊城市到该点的最小距离 \(g_i\) 以及对应城市 \(fr_i\)。对于每条边 \((u,v)\),若 \(fr_u\neq to_v\),则用 \(fr_u+w+to_v\) 更新答案。具体证明可以看洛谷上 s_r_f 的题解,没看懂

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define pll pair <ll,ll>
#define fi first
#define se second
#define mem(x,v) memset(x,v,sizeof(x))

#define gc getchar()
inline int read(){
	int x=0; char s=gc;
	while(!isdigit(s))s=gc;
	while(isdigit(s))x=(x<<1)+(x<<3)+s-'0',s=gc;
	return x;
}

const int N=1e5+5;

int cnt,hd[N],nxt[N<<3],to[N<<3],val[N<<3];
void add(int u,int v,int w){
	nxt[++cnt]=hd[u],hd[u]=cnt,to[cnt]=v,val[cnt]=w;
}

ll n,m,k,x[N],buc[N],dis[N];
priority_queue <pll> q;
ll dij(){
	while(!q.empty()){
		pll t=q.top(); q.pop();
		if(buc[t.se])return -t.fi;
		for(int i=hd[t.se],v=to[i];i;v=to[i=nxt[i]])
			if(dis[v]>-t.fi+val[i])
				q.push({-(dis[v]=-t.fi+val[i]),v});
	} return 1e18;
}

int main(){
	int T; cin>>T;
	while(T--){
		cnt=0,mem(hd,0),mem(nxt,0);
		n=read(),m=read(),k=read();
		for(int i=1,u,v,w;i<=m;i++)u=read(),v=read(),w=read(),add(u,v,w);
		for(int i=1;i<=k;i++)x[i]=read();
		ll ans=1e18;
		for(int i=0;(1<<i)<=k;i++){
			priority_queue <pll> ept,ept2; swap(q,ept);
			mem(buc,0),mem(dis,0x3f);
			for(int j=1;j<=k;j++)
				if((j>>i)&1)q.push({dis[x[j]]=0,x[j]});
				else buc[x[j]]=1;
			ans=min(ans,dij());
			mem(buc,0),mem(dis,0x3f),swap(q,ept2);
			for(int j=1;j<=k;j++)
				if(((j>>i)&1)==0)q.push({dis[x[j]]=0,x[j]});
				else buc[x[j]]=1;
			ans=min(ans,dij());
		} cout<<ans<<endl;
	}
	return 0;
}

II. P1462 通往奥格瑞玛的道路

显然答案满足可二分性,于是二分 + dijkstra 即可。

时间复杂度 \(n\log n\log c\)

#include <bits/stdc++.h>
using namespace std;

#define pii pair <int,int>
#define fi first
#define se second

const int N=1e4+5;

int n,m,b,f[N],dis[N];
vector <pii> e[N];
priority_queue <pii,vector<pii>,greater<pii> > q;

bool check(int v){
	memset(dis,0x3f,sizeof(dis)),q.push({dis[1]=0,1});
	while(!q.empty()){
		pii t=q.top(); q.pop();
		int id=t.se,d=t.fi;
		if(dis[id]<d)continue;
		for(pii it:e[id])
			if(f[it.fi]<=v&&dis[it.fi]>d+it.se)
				q.push({dis[it.fi]=d+it.se,it.fi});
	} return dis[n]<=b;
}

int main(){
	cin>>n>>m>>b;
	for(int i=1;i<=n;i++)cin>>f[i];
	for(int i=1;i<=m;i++){
		int a,b,c; cin>>a>>b>>c;
		e[a].push_back({b,c});
		e[b].push_back({a,c});
	} int l=f[1],r=1e9+7;
	while(l<r){
		int m=l+r>>1;
		if(check(m))r=m;
		else l=m+1;
	} if(l>1e9)puts("AFK");
	else cout<<l<<endl;
	return 0;
}

III. P4568 [JLOI2011]飞行路线

注意到 k 很小,于是跑最短路时记录当前用了几条免费边即可。

时间复杂度 \(\mathcal{O}(nk\log(nk))\)

不会分析 dij 复杂度怎么办?

#include <bits/stdc++.h>
using namespace std;

#define pii pair <int,int>
#define piii pair <pii,int>
#define fi first
#define se second
#define pb push_back

const int N=1e4+5;

int n,m,k,s,t,dis[N][11];
vector <pii> e[N];
priority_queue <piii,vector<piii>,greater<piii> > q;

int main(){
	cin>>n>>m>>k>>s>>t;
	for(int i=1;i<=m;i++){
		int u,v,w; cin>>u>>v>>w;
		e[u].pb({v,w}),e[v].pb({u,w});
	} memset(dis,0x3f,sizeof(dis));
	q.push({{dis[s][0]=0,s},0});
	while(!q.empty()){
		int d=q.top().fi.fi,id=q.top().fi.se,num=q.top().se;
		q.pop();
		if(dis[id][num]<d)continue;
		for(pii it:e[id]){
			int to=it.fi,ds=it.se;
			if(dis[to][num+1]>d&&num<k)q.push({{dis[to][num+1]=d,to},num+1});
			if(dis[to][num]>d+ds)q.push({{dis[to][num]=d+ds,to},num});
		}
	} int ans=1e9;
	for(int i=0;i<=k;i++)ans=min(ans,dis[t][i]);
	cout<<ans<<endl;
	return 0;
}

2. 差分约束

2.1. 算法介绍

要是早一点学,说不定就进队了呢。

给出若干个 \(x_a-x_b\leq c\) 的形式,求一组 \(x\) 的解:转化为 \(x_b+c\geq x_a\)(如果是 \(x_a-x_b\geq c\) 就转化为 \(x_a+(-c)\geq x_b\)),那么这就是三角形不等式啦!如果 \(b\to a\) 连一条长度为 \(c\) 的边,然后再从超级源点 \(0\) 向每个点连长度为 \(0\) 的边,跑最短路,每个点的最短路长度就是一组解。超级源点的作用就是防止图不连通。

因为一般这个 \(c\) 都会有负值(全是非负的话那所有数相等不就行了嘛),所以用 BF 算法即可。若出现负环则无解。时间复杂度是 \(\mathcal{O}(nm)\)

模板题 P5960 代码:使用了 BF 和 vector,常数奇大无比,擦着时限过的。

#include <bits/stdc++.h>
using namespace std;

const int N=5e3+5;

int n,m,dis[N];
vector <pair<int,int> > e[N];

int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++)e[0].push_back({i,0});
	for(int i=1,a,b,c;i<=m;i++)cin>>a>>b>>c,e[b].push_back({a,c});
	memset(dis,0x3f,sizeof(dis)),dis[0]=0;
	for(int i=0;i<=n;i++)for(int j=0;j<=n;j++)for(auto it:e[j])
		if(dis[it.first]>dis[j]+it.second){
			if(i==n)puts("NO"),exit(0);
			dis[it.first]=dis[j]+it.second;
		} for(int i=1;i<=n;i++)cout<<dis[i]<<' ';
	return 0;
}

2.2. 例题

*I. P5590 赛车游戏

hot tea!这种套路还是第一次见。

\(d_i\)\(1\to i\) 的最短路,我们只需保证对于所有边 \((u,v)\)\(w_{u,v}=d_v-d_u\) 即可保证任意一条简单路径长相等。于是 \(1\leq d_v-d_u\leq 9\),转化为 \(d_u+9\geq d_v\)\(d_v-1\geq d_u\),差分约束求解即可。

注意判断哪些边有用,即在 \(1\to n\) 的任意一条路径上。剩下的边随便标即可。

#include <bits/stdc++.h>
using namespace std;

#define pii pair <int,int>
#define fi first
#define se second

const int N=2e3+5;

int n,m,u[N],v[N],vis[N],ok[N],dis[N];
vector <int> e[N];
vector <pii> G[N];

void dfs(int id){
	vis[id]=1;
	if(id==n)return;
	for(int it:e[id]){
		if(!vis[it])dfs(it);
		ok[id]|=ok[it];
	}
}

int main(){
	cin>>n>>m;
	for(int i=1;i<=m;i++)cin>>u[i]>>v[i],e[u[i]].push_back(v[i]);
	ok[n]=1,dfs(1);
	if(!ok[1])puts("-1"),exit(0); 
	for(int i=1;i<=m;i++)if(ok[u[i]]&&ok[v[i]])
		G[u[i]].push_back({v[i],9}),G[v[i]].push_back({u[i],-1});
	memset(dis,0x3f,sizeof(dis));
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			for(pii it:G[j])
				if(dis[it.fi]>dis[j]+it.se){
					if(i==n)puts("-1"),exit(0);
					dis[it.fi]=dis[j]+it.se;
				}
	cout<<n<<" "<<m<<endl;
	for(int i=1;i<=m;i++)
		cout<<u[i]<<" "<<v[i]<<" "<<(ok[u[i]]&&ok[v[i]]?dis[v[i]]-dis[u[i]]:1)<<endl;
	return 0;
}

*II. P7515 [省选联考 2021 A 卷] 矩阵游戏

nice tea。

首先我们固定第一行第一列全都为 \(0\)​​,可以仅根据 \(b\)​​​ 的限制推出剩下来一整个矩阵 \(a_{i,j}\)​​。注意到对矩阵第 \(i\) 行的每个数进行 \(-r_i,+r_i,-r_i,+r_i,\cdots\) 操作后,仍然满足 \(b\) 的限制,列同理。因此我们设将 \(a_{i,j}\) 加上 \((-1)^jr_i+(-1)^ic_j\)​,那么有 \(0\leq a_{i,j}+(-1)^jr_i+(-1)^ic_j\leq 10^6\)。不过这样当 \(i,j\) 奇偶性相同时 \(r_i,c_j\) 前的符号相同,我们只需要\(r\) 的奇数行符号取反,\(c\)​​ 的偶数行符号取反,类似黑白染色即可。这样就是 \((-1)^{i+j+1}r_i+(-1)^{i+j}c_j\),不难发现 \(r_i\)\(c_j\) 前符号一定不同,可以使用差分约束求解,时间复杂度 \(\mathcal{O}(Tnm(n+m))\)。​

3. 最小生成树

3.1. Kruskal

将所有边按照边权从小到大排序并依次枚举每一条边,如果当前边两端点不连通则加入该边。连通性可以用并查集维护。时间复杂度 \(m\log m\)

3.2. 例题

I. P4180 [BJWC2010]严格次小生成树

首先 Kruskal 求出最小生成树,然后枚举所有边 \((u,v)\),用它替换最小生成树上 \(u,v\) 之间权值最大或严格第二大的边即可(之所以要第二大是因为最大值可能和 \(w_{u,v}\) 相等)。

时间复杂度 \(m\log n\)

#include <bits/stdc++.h>
using namespace std;

#define pb push_back
#define ll long long
#define pii pair <int,int>
#define fi first
#define se second

const int N=1e5+5;
const int inf=1e9+7;

int n,m,f[N];
int find(ll x){return f[x]==x?x:f[x]=find(f[x]);}

struct edge{
	ll u,v,w,tag;
}e[N<<2];
vector <pii> g[N];

ll tot,ans=1e18,dep[N],fa[N][17];
struct pr{
	int x,y;
	pr(){x=y=-1;}
	friend pr operator + (pr a,pr b){
		pr c; c.x=max(a.x,b.x);
		if(a.x!=c.x)c.y=a.x;
		if(a.y!=c.x)c.y=max(c.y,a.y);
		if(b.x!=c.x)c.y=max(c.y,b.x);
		if(b.y!=c.x)c.y=max(c.y,b.y);
		return c;
	}
}val[N][17];

void dfs(int id,int f,int d){
	dep[id]=d,fa[id][0]=f;
	for(pii it:g[id])if(it.fi!=f)
		val[it.fi][0].x=it.se,dfs(it.fi,id,d+1);
} pr LCA(int u,int v){
	pr ans;
	if(dep[u]<dep[v])swap(u,v);
	for(int i=16;~i;i--)if(dep[fa[u][i]]>=dep[v])
		ans=ans+val[u][i],u=fa[u][i];
	if(u==v)return ans;
	for(int i=16;~i;i--)if(fa[u][i]!=fa[v][i])
		ans=ans+val[u][i]+val[v][i],u=fa[u][i],v=fa[v][i];
	return ans+val[u][0]+val[v][0];
}


int main(){
	cin>>n>>m;
	for(int i=1;i<=m;i++)cin>>e[i].u>>e[i].v>>e[i].w;
	sort(e+1,e+m+1,[](edge a,edge b){return a.w<b.w;});
	for(int i=1;i<=n;i++)f[i]=i;
	for(int i=1;i<=m;i++){
		int u=find(e[i].u),v=find(e[i].v);
		if(u!=v){
			g[e[i].u].pb({e[i].v,e[i].w});
			g[e[i].v].pb({e[i].u,e[i].w});
			tot+=e[i].w,f[u]=v,e[i].tag=1;
		}
	} dfs(1,0,1);
	for(int i=1;1<<i<=n;i++)
		for(int j=1;j<=n;j++)
			fa[j][i]=fa[fa[j][i-1]][i-1],
			val[j][i]=val[j][i-1]+val[fa[j][i-1]][i-1]; 
	for(int i=1;i<=m;i++)
		if(e[i].u!=e[i].v&&!e[i].tag){
			pr c=LCA(e[i].u,e[i].v);
			if(e[i].w>c.x)ans=min(ans,e[i].w-c.x);
			else if(~c.y)ans=min(ans,e[i].w-c.y);
		} cout<<tot+ans<<endl;
	return 0;
}

4. 无向图连通性:割点与桥

以下两章内容均与 Tarjan 算法有关

关于无向图和有向图的联通性是非常重要的一部分,但我并不是很熟悉,所以学一下。

Tarjan 老爷子真是神。

4.1. 割点与桥

割点和桥的定义十分类似,分别是删去后使连通分量增加的节点和边,且都是无向图上的定义。显然,一个孤立点或一张仅有一条边构成的无向图唯二的端点都不是割点,但后者唯一的边是割边。

4.2. 从动态规划的角度理解 Tarjan 算法

相信大家应该都会 dfs 树与 dfs 序,此处不再赘述。

我主要想通过一种新的理解方式解释 Tarjan 算法。因为网上诸多博客讲解该算法的时候 low 数组仿佛凭空出现,抽象的定义让很多初学者摸不着头脑,而从提出问题到解决问题这样一个逻辑链的不完整性(甚至不存在,比如说 “为什么要设计 low 数组”)让我们无法感受到究竟是怎样的灵感促使了这一算法的诞生。注意,我们不仅要学算法,更要汲取算法内核的思维方式。

定义一个节点的子树表示该节点在 dfs 树上的子树,且包含节点本身

不难发现,对于一个节点 \(x\) 的子树,若其中存在节点 \(y\) 满足 \(y\) 不经过 \(x\) 能到达的所有点都被困在 \(x\) 的子树内(显然若存在则整棵子树都满足该条件),则 \(x\) 是割点。那么怎么表示 “不经过 \(x\) 能到达的所有点被困在 \(x\) 的子树内” 呢?

考虑应用动态规划的思想:注意到 \(x\) 的子树内所有点的 dfs 序 \(d_y\) 都不小于 \(d_x\),所以设计状态 \(f_x\) 表示 \(x\) 仅经过一条非树边所能到达的节点的 dfs 序的最小值(注意这与一般 tarjan 算法的状态设计不同,即 \(f\) 并不是 low 数组)。

  • 为什么是 “仅经过一条非树边”:由于一个割点可能存在于多个环中,对于处在一个 “8” 字形结构最底部的节点 \(y\) 来说,它可以通过一条非树边上翻到结构中间的节点 \(x\),然后从再走一条非树边上翻到结构顶部的节点 \(u\),这样在求 \(x\) 是否是割点的时候就不合法了,因为 \(f_y\)只有经过 \(x\) 才能到达的点 \(u\) 更新,不符合我们一开始 “不经过 \(x\)” 的假设。而只经过一条非树边至少保证了 我们求到的 \(f_y\)\(y\) 不需要经过中间点能够直接到达的节点的 dfs 序最小值

求出 \(f\) 后,我们仍无法快速(线性)判断一个点 \(x\) 是否是割点,但已经可以判断了:若存在一条树边 \(x\to y\),使得 \(y\) 子树内的所有节点 \(u\)\(\min f_u<d_x\),则 \(x\) 是割点。这很好理解,因为 \(y\) 子树内的所有节点可以互相到达,因此它们的 \(f\) 是共用的,也就是说,在考虑 \(x\) 是否是割点时,我们可以把 \(y\) 子树内的所有点看成一个点。同时,显然的是,\(y\) 子树内存在一个点使得它不经过 \(x\) 能到达不在 \(x\) 子树内的节点,那么它仅经过一条非树边就能到达不在 \(x\) 子树内的节点。加粗的两句话初读可能有些拗口,但清晰地阐述了我们使用上述方法判断割点的原因。

不难发现这样求出一张图上所有割点的时间复杂度为 \(\mathcal{O}(n^2)\)

能不能再给力一点啊?

当然可以,而且非常简单:注意到我们每次需要求出子树内 \(f_u\) 的最小值,即我们将问题转化为了:给出一棵带点权 \(f_u\) 的有根树,对于每个节点,求出以该节点为根的子树的权值最小值 \(g_u\)

这是一个非常基础的树形动态规划:从叶节点向上 DP,对于每个节点 \(u\),令 \(g_u=\min\left(f_u,\min_{v\in \mathrm{son}(u)}g_v\right)\) 即可。将树形动态规划 “嵌入” 本题,不难发现 \(g\) 就是 tarjan 算法中的 low 数组,即:

\[f_i=\min\left(d_i,\min_{j,(i,j)\in E}d_j\right)\\ g_i=\min\left(f_u,\min_{v\in \mathrm{son}(u)}g_v\right)\\ \Downarrow\\ g_i=\min\left(d_i,\min_{j,(i,j)\in E}d_j,\min_{v\in \mathrm{son}(u)}g_v\right) \]

三个由逗号分隔的柿子分别对应了:

  • 一开始将节点 \(x\) 的 low 设置为 dfn。
  • 对于被搜的点 \(y\ ((x,y)\in E)\),用 \(y\) 的 dfn 更新 \(x\) 的 low。
  • 对于没有被搜过的点 \(y\ ((x,y)\in E)\),搜索 \(y\) 后回溯,这意味着在 dfs 树上 \(y\)\(x\) 的子节点。因此可以类似树形 DP,用 \(y\) 的 low 更新 \(x\) 的 low。注意由于 \(y\) 的 low “包含” 了 \(y\) 的 dfn(即我们用 \(y\) 的 dfn 设置了 \(y\) 的 low 的初始值),所以我们保证了对于 \(x\) 的每一条出边 \((x,y)\),我们都用 \(y\) 的 dfn 去更新过了 \(x\) 的 low。

此外,如果一个节点 \(u\) 在 dfs 树上有超过一个儿子 \(s_i\),那么根据 dfs 树的性质,\(u\) 显然为割点:去掉该点后,各儿子 \(s_i\) 所形成的连通块不连通。时间复杂度为 \(\mathcal{O}(n)\)

综上,我们首先通过对问题的初始理解设计了一个较为直观的状态 \(f\),然后将其抽象为一个经典的树形 DP 问题,再通过对该问题的优化,自然地得出了 Tarjan 算法的 low 数组。希望这样一条清晰的逻辑链:\(d(dfn)\to f\to g(low)\) 能够帮助各位读者更好地理解本算法,只不过当年 Tarjan 老爷子很有可能一下就设计出了 low 数组,毕竟对于他来说这实在是有些简单了。

注意输出格式不然你会 WA 44。

#include <bits/stdc++.h>
using namespace std;

#define vint vector <int>
#define pb emplace_back
   
inline int read() {
    int x = 0, sgn = 0; char s = getchar();
    while(!isdigit(s)) sgn |= s == '-', s = getchar();
    while(isdigit(s)) x = x * 10 + s - '0', s = getchar();
    return sgn ? -x : x;
}

template <class T> void cmin(T &a, T b){a = a < b ? a : b;}
template <class T> void cmax(T &a, T b){a = a > b ? a : b;}

const int N = 1e5 + 5;

int n, m, dnum, cut, root, buc[N], dfn[N], low[N];
vector <int> e[N];
void dfs(int id) {
	low[id] = dfn[id] = ++dnum;
	int son = 0;
	for(int it : e[id])
		if(!dfn[it]) {
			dfs(it), son++, cmin(low[id], low[it]);
			if(low[it] >= dfn[id] && id != root) buc[id] = 1;
		} else cmin(low[id], dfn[it]);
	if(id == root && son >= 2) buc[id] = 1;
}
int main(){
	cin >> n >> m;
	for(int i = 1; i <= m; i++) {
		int x = read(), y = read();
		e[x].pb(y), e[y].pb(x);
	}
	for(int i = 1; i <= n; i++) if(!dfn[i]) root = i, dfs(i);
	for(int i = 1; i <= n; i++) cut += buc[i];
	cout << cut << endl;
	for(int i = 1; i <= n; i++) if(buc[i]) cout << i << " ";
    return 0;
}

5. 有向图连通性

5.1. 强联通分量

全称 Strong Connected Component,缩写 SCC。以下是一些定义:

  • 在有向图 \(G\) 中,若两个点 \(u,v\ (u\neq v)\) 能够相互到达,则称这两个点是 强联通的
  • 在有向图 \(G\) 中,若任意两个节点都是强联通的,则称 \(G\) 是一张 强连通图
  • 有向图 \(G\) 的极大强联通子图被称为 \(G\)强连通分量

强联通分量在求解一些与连通性有关的题目时很好用。


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM