最小生成树
基本
给出一张 n n n 个点 m m m 条边的无向连通图 G G G,每条边 ( u , v ) (u,v) (u,v) 有边权 w ( u , v ) w(u,v) w(u,v)。顾名思义,在 G G G 的所有生成树中找一棵边权之和最小的树,这棵边权和最小的树就被称为 G G G 的最小生成树 MST(记为 T T T)。生成树即为在 G G G 上的 m m m 条边中选择 n − 1 n-1 n−1 条边将所有点连通组成的树。
Kruskal
基于边的 MST 算法。设当前计算出的 MST 为 G ′ G' G′,初始 G ′ G' G′ 为空集。使用贪心思想,将 m m m 条边按照边权从小到大进行枚举。对于 ( u , v ) (u,v) (u,v) 这条边,如果 u , v u,v u,v 两个点在当前 G ′ G' G′ 中还未连通(不属于同一个连通块),就将 ( u , v ) (u,v) (u,v) 加入 G ′ G' G′ 中,否则不加入 G ′ G' G′。使用并查集维护两点是否处于同一连通块内。
struct Edge { int u,v,w; } e[maxn];
int m,n,fa[maxn];
int find(int x) { return fa[x] == x ? x ? fa[x] = find(fa[x]); }
int Kruskal() {
for (int i = 1;i <= n;i ++) // 并查集初始化
fa[i] = i;
// 先按照边权从小到大进行排序。
sort(e + 1,e + m + 1,[&](const Edge &x,const Edge &y) { return x.w < y.w; });
int ans = 0;
for (int i = 1;i <= m;i ++) {
int u = find(e[i].u), v = find(e[i].v), w = e[i].w;
if (u == v) continue; // 两点已经处于同一个连通块内,跳过。
fa[u] = v, ans += w; // 合并两个连通块并统计答案。
}
return ans;
}
时间复杂度 O ( m log m ) O(m\log m) O(mlogm),瓶颈在于对边进行排序。
为什么这个贪心是正确的?
采用归纳法证明。初始时 G ′ G' G′ 为空,显然正确。在 Kruskal 的某一轮,决定将边 e e e 加入 G ′ G' G′ 中(但还没加入),且在这之前 G ′ G' G′ 被 T T T 包含:
- 如果 e e e 在 T T T 中,那么将 e e e 加入 G ′ G' G′ 后 G ′ G' G′ 仍然被 T T T 包含,正确。
- 否则,即
e
e
e 不在
T
T
T 中,令将
e
e
e 加入
T
T
T 后的图为
T
′
T'
T′,
T
′
T'
T′ 中一定会有一个环。在这个环上,除了
e
e
e 之外,有且仅有另一条边
f
f
f 也不在
G
′
G'
G′ 中(可以认为将
e
e
e 加入
G
′
G'
G′ 后,
G
′
G'
G′ 中
e
e
e 的作用起到了
T
T
T 中
f
f
f 的作用)。那么:
- w f w_f wf 一定不会比 w e w_e we 小,否则按照 Kruskal 的逻辑 f f f 会在 e e e 之前早就被加入 G ′ G' G′ 中。
- w f w_f wf 一定不会比 w e w_e we 大,否则 T ′ T' T′ 去掉 f f f 形成的生成树的答案会更优,即 T T T 就不是 G G G 的 MST。
- 所以 w f = w e w_f=w_e wf=we,说明最终的 G ′ G' G′ 是与 T T T 答案相同的另一棵 MST。
Prim
基于点的 MST 算法,与 Dijkstra 很像。我们令 d i s u dis_u disu 表示当前 G ′ G' G′ 中可能连接 u u u 的边。初始化 d i s u = + ∞ dis_u=+\infty disu=+∞。特别地,对于起点 S S S 令 d i s S = 0 dis_S=0 disS=0。每轮选择出一个 d i s dis dis 最小的点 u u u,类似于 Dijkstra 的松弛操作,对于每条连接 u u u 的边 ( u , v ) (u,v) (u,v), d i s v ← min ( d i s v , w ( u , v ) ) dis_v\gets\min(dis_v,w(u,v)) disv←min(disv,w(u,v))。意思为尝试用 ( u , v ) (u,v) (u,v) 这条边替换原本连接 v v v 的边(如果 G ′ G' G′ 中没有连接 v v v 的边则将 ( u , v ) (u,v) (u,v) 设为这条边)。
因为每一轮需要选择一个 d i s dis dis 最小的点,所以 Prim 也有堆优化写法。
// 这里给出堆优化 Prim 的模板。
void Prim() {
memset(dis, 0x3f, sizeof(dis)); dis[1] = 0;
q.push((node) { 1, 0 });
while (!q.empty() && k < n) {
int x = q.top().pos, w = q.top().dis; q.pop();
if (vis[x]) continue;
k ++, vis[x] = 1, ans += w;
for (int i = he[x]; i; i = to[i])
if (ww[i] < dis[vv[i]]) {
dis[vv[i]] = ww[i];
q.push((node) { vv[i], ww[i] });
}
}
}
暴力复杂度 O ( n 2 + m ) O(n^2+m) O(n2+m),堆优化后时间复杂度 O ( ( n + m ) log n ) O((n+m)\log n) O((n+m)logn)。与 Dijkstra 类似,点少的时候还是建议用暴力写法。
同上,该算法如何保证正确性?
仍然使用归纳法。初始时 G ′ G' G′ 中只有起点( d i s S = 0 dis_S=0 disS=0)而没有任何边,显然被 T T T 包含。在 Prim 中的某一轮, d i s dis dis 最小的点为 u u u,并且把 ( u , v ) (u,v) (u,v) 这条边确定为了 MST 中的边(每一轮都会至少确定一条,且每一条边都会被考虑到,所以最终会确定所有 MST 中的边),决定将其纳入 G ′ G' G′ 中,且在这之前 G ′ G' G′ 被 T T T 包含:
- 如果 ( u , v ) (u,v) (u,v) 在 T T T 中,那么将 ( u , v ) (u,v) (u,v) 加入 G ′ G' G′ 后 G ′ G' G′ 仍然被 T T T 包含,正确。
- 否则,即
(
u
,
v
)
(u,v)
(u,v) 不在
T
T
T 中,令将
(
u
,
v
)
(u,v)
(u,v) 加入
T
T
T 后的图为
T
′
T'
T′,
T
′
T'
T′ 中一定会有一个环。在这个环上,除了
(
u
,
v
)
(u,v)
(u,v) 之外,有且仅有另一条边
(
u
,
v
′
)
(u,v')
(u,v′) 也不在
G
′
G'
G′ 中(即在
G
G
G 中与
u
u
u 连接的点中
v
,
v
′
v,v'
v,v′ 两个点)。那么:
- w ( u , v ′ ) w(u,v') w(u,v′) 一定不会比 w ( u , v ) w(u,v) w(u,v) 小,否则被确定的就应该是 ( u , v ′ ) (u,v') (u,v′) 这条边。
- w ( u , v ′ ) w(u,v') w(u,v′) 一定不会比 w ( u , v ) w(u,v) w(u,v) 大,否则 T ′ T' T′ 去掉 ( u , v ′ ) (u,v') (u,v′) 形成的生成树的答案会更优,即 T T T 就不是 G G G 的 MST。
- 所以 w ( u , v ) = w ( u , v ′ ) w(u,v)=w(u,v') w(u,v)=w(u,v′),说明最终的 G ′ G' G′ 是与 T T T 答案相同的另一棵 MST。
Boruvka
比较冷门的一个点边相结合的 MST 算法。相较于前两种算法,Boruvka 只能求解边权互不相同的图,但是它能求无向图的最小生成森林(对于无向连通图而言求出的就是 MST)。
初始 G ′ G' G′ 为空,令 G ′ G' G′ 中的一个连通块 a a a 的最小边 E a E_a Ea 的意思为该连通块与其他连通块之间的边中边权最小的边。令点 u u u 所在的连通块编号为 A u A_u Au。算法中的每一轮,对于每个连通块 a a a(初始时每个点各自为一个连通块),令 E a E_a Ea 为 「无」;遍历 G G G 中的 m m m 条边,对于 ( u , v ) (u,v) (u,v) 这条边,如果 A u ≠ A v A_u\ne A_v Au=Av,则用 w ( u , v ) w(u,v) w(u,v) 更新 E A u E_{A_u} EAu 和 E A v E_{A_v} EAv;最后如果每个 E E E 都没有值,则说明 G ′ G' G′ 已经是 MST,退出算法,否则将每个 E E E 对应的边加入 G ′ G' G′ 中。
void Boruvka() {
init(N); int ans = 0;
bool flag;
do {
flag = 0;
memset(link, -1, sizeof(link));
memset(val, 0x3f, sizeof(val));
for(int x = 1; x <= N; x++) {
int fx = find(x);
for(auto &tmp : v[x]) {
int to = tmp.fi, w = tmp.se, fy = find(to);
if(fx == fy || (w > val[fx])) continue;
link[fx] = fy; val[fx] = w;
}
}
for(int x = 1; x <= N; x++) {
int fx = find(x);
if((~link[fx]) && find(fx) != find(link[fx]))
unionn(fx, link[fx]), ans += val[fx], flag = 1;
}
}while(flag);
int f1 = find(1);
for(int i = 2; i <= N; i++)
if(find(i) != f1)
return (void) puts("IMPOSSIBLE");
cout << ans;
}
算法每一轮至少会使连通块数量减半,所以复杂度 O ( m log n ) O(m\log n) O(mlogn)。证明可以参考 Kruskal 与 Prim。
例题
简单题/模板题
裸的 MST 模板题,以上三个模板爱用哪个用哪个。
中等题
不要被冗长的题面吓到了,仔细想想可以发现这就是 Boruvka 的算法流程但是形象化。然后就没有难点了。
我们可以直接把哪条边是最大边定下来,每次跑 Kruskal 的时候只要遇到比钦定的最大边的边权大的边,直接跳过即可;Kruskal 的停止条件即为 s s s 和 t t t 首次连通。如果发现连完 ( u , v ) (u,v) (u,v) 后 s s s 和 t t t 连通了,因为是按照边权从大到小枚举的,所以 ( u , v ) (u,v) (u,v) 就是当前的最小边。最后套个分数板子即可。复杂度 O ( m 2 ) O(m^2) O(m2)。
#include<bits/stdc++.h>
using namespace std;
const int maxn = 505,maxm = 5005;
int n,m,s,t;
struct Edge{
int u,v,w;
} e[maxm];
struct Frac {
int p,q; // p/q
void set(int x,int y) {
p = x / __gcd(x,y);
q = y / __gcd(x,y);
}
} ans, tmp;
bool cmp(Frac x,Frac y) {
int lcm = x.q * y.q / __gcd(x.q,y.q);
x.p *= lcm / x.q;
y.p *= lcm / y.q;
return x.p < y.p;
}
int fa[maxn];
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
int Kruskal(int mx) {
for (int i = 1;i <= n;i ++)
fa[i] = i;
for (int i = 1;i <= m;i ++) {
if (e[i].w > mx) continue;
int u = find(e[i].u), v = find(e[i].v);
if (u == v) continue;
fa[u] = v;
if (find(s) == find(t)) return e[i].w;
}
return -1;
}
int main() {
scanf("%d%d",&n,&m);
for (int i = 1;i <= m;i ++)
scanf("%d%d%d",&e[i].u,&e[i].v,&e[i].w);
scanf("%d%d",&s,&t);
sort(e + 1,e + m + 1,[&](const Edge &x,const Edge &y) {
return x.w > y.w;
});
ans.set(1000000000,1);
for (int i = 1,p,q;i <= m;i ++) {
p = e[i].w, q = Kruskal(p);
if (q == -1) break;
tmp.set(p,q);
if (cmp(tmp,ans)) ans.set(p,q);
}
if (ans.p == 1000000000) puts("IMPOSSIBLE");
else if (ans.q == 1) printf("%d",ans.p);
else printf("%d/%d",ans.p,ans.q);
return 0;
}
难题
这两道题都牵扯到了最小斯坦纳树,即只要求连通给定的 k k k 个点的生成树中边权之和最小的树。MST 相当于连通所有 n n n 个点的最小斯坦纳树。采用的是状态压缩 dp,洛谷上模板题的题解已经讲的非常清晰了,在此仅放模板题代码。
#include<bits/stdc++.h>
#define mk make_pair
#define ll long long
using namespace std;
const int maxn = 1e5 + 5,maxk = 10;
int n,m,k,im[maxn];
ll f[maxn][(1 << maxk) + 5];
vector<pair<int,ll> > mp[maxn];
void addEdge(int u,int v,ll w) {
mp[u].push_back(mk(v,w));
}
priority_queue<pair<ll,int> > q;
bool vis[maxn];
void Dijkstra(int now) {
memset(vis,false,sizeof(vis));
while (!q.empty()) {
int u = q.top().second; q.pop();
if (vis[u]) continue;
vis[u] = true;
for (auto V : mp[u]) {
int v = V.first; ll w = V.second;
if (f[v][now] > f[u][now] + w) {
f[v][now] = f[u][now] + w;
if (!vis[v])
q.push(mk(-f[v][now],v));
}
}
}
}
int main() {
scanf("%d%d%d",&n,&m,&k); ll w;
for (int i = 1,u,v;i <= m;i ++) {
scanf("%d%d%lld",&u,&v,&w);
addEdge(u,v,w); addEdge(v,u,w);
}
for (int i = 1;i <= n;i ++)
for (int s = 0;s <= (1 << k) - 1;s ++)
f[i][s] = 1e18;
for (int i = 1;i <= k;i ++) {
scanf("%d",&im[i]);
f[im[i]][1 << (i - 1)] = 0;
}
for (int s = 0;s <= (1 << k) - 1;s ++) {
for (int i = 1;i <= n;i ++) {
for (int t = s & (s - 1);t;t = (t - 1) & s)
f[i][s] = min(f[i][s],f[i][t] + f[i][s ^ t]);
if (f[i][s] < 1e18) q.push(mk(-f[i][s],i));
}
Dijkstra(s);
}
printf("%lld",f[im[1]][(1 << k) - 1]);
return 0;
}