掐指一算,上一次學圖論可能還是在學 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 數組,即:
三個由逗號分隔的柿子分別對應了:
- 一開始將節點 \(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\) 的 強連通分量。
強聯通分量在求解一些與連通性有關的題目時很好用。