最小生成树 算法解析+例题

最小生成树


基本

给出一张 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 n1 条边将所有点连通组成的树。

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)) disvmin(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;
}
  • 36
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值