掐指一算,上一次学图论可能还是在学 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\) 的 强连通分量。
强联通分量在求解一些与连通性有关的题目时很好用。
