從零開始的簡單圖論


掐指一算,上一次學圖論可能還是在學 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