算法简介:割舍怜悯之心

最小生成树,就是一张图一直删边,直到删成一颗树为止,这叫做生成树,如果这个生成树的边权之和是所有生成树中最小的,那么他被称为最小生成树,前面我们讲过了一个叫做 Boruvka 的算法,虽然他确实是求最小生成树的方法之一,但是他过于远古,不太常用,代码也不如 kruskal 简单,所以说,我们这里将介绍两种新的最小生成树算法,请看副标题 —— Prim & kruskal 。

算法演示:割舍侥幸之心

那么最小生成树,生成树生成树,总归就是树,有两种组成部分,一是点,二是边,而 Prim 和 kruskal 算法分别就是代表了基于点的贪心和基于边的贪心,我们来一个逐个击破。

点贪心:割舍痛苦之心

首先就是 Prim 算法,他之所以被称为点贪心,是因为这样一个思路:

生成树,必须要把一个图的所有点连起来,变成一个树,也就是说,所有点,我们都将出现在最终的点集合里。所以,我们一开始随便选择一个点,加入最小生成树点集合,然后每次去寻找和我们最小生成树点集合最近的一个点,这样就可以保证每次选出来的边是最短的,然后就一直循环往复,直到所有点都选完。

然后,我们就发现,这样的一个过程,和一个最短路算法极其神似,对,就是 Dijstra 算法。Dijstra 算法的原理,就是每次从图里面拿出来一个最近的点去不断更新最短路。而 Prim 算法也是如此。我们可以看个图示:

那么首先根据第一个规则,我们随便选择一个点,比如 1 ,那么接下来挑选一个最近的点,显而易见是 2 边权只有 1 ,那么现在我们的集合里面有 1,2 然后最小生成树的大小是 1

接下来,我们扩张,发现从二出去的唯一一条边 10 太长了,不如从 13 的那条边,所以说我们扩张到 3 。现在点集里面有 1,2,3 然后最小生成树的大小是 4

以此类推,我们会接着扩张到 6 ,然后扩张到 4 然后扩张到 5 。然后最小生成树就建完了,总大小应该是 15

这样的话,我们就可以写出一个朴素版本的 Prim 代码。

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int dis[N], vis[N], ans;
inline void Prim() {
dis[1] = 0; vis[1] = 1; ans = 0; // 记录距离,以及是否在点集里
dis[0] = 1e9; // 方便后面的处理
for(int i = 2; i <= n; i++) dis[i] = 1e9;
for(int i = G.head(1); i; i = G.nt(i)) {
dis[G.to(i)] = G.wt(i);
}
for(int i = 1; i < n; i++) { // 这里代表要扩张 n-1 次
int mi = 0; // 找到最短的路径
for(int j = 1; j <= n; j++) {
if(!vis[j] && dis[mi] > dis[j]) mi = j;
// 必须选择未选择的点经行扩张
}
vis[mi] = 1; // 他是最小的,纳入点集
ans += dis[mi];
for(int j = G.head(mi); j; j = G.nt(j)) {
int v = G.to(j);
if(!vis[v] && dis[v] > G.wt(j)) {
dis[v] = G.wt(j);
}
}
}
printf("%d\n", ans);
}

然后这样要优化的话,也非常简单,依照 Dijstra 的方式优化即可,也就是用堆,优化掉求最小值的部分等。代码绝对不难写,留给读者。那么那个代码写出来的结果的话,一般来讲的复杂度就是 O((n+m)logm) ,其中 n 为节点数, m 为边的数量,不难发现,这个算法在对于稠密图的时候非常吃亏,因为稠密图的时候,m 就将近与 n2 了,这样再加上一个 n 复杂度绝对的爆炸存在。

边贪心:割舍封闭之心

其次又是万众瞩目的 kruskal 算法。kruskal 算法的优点在于,代码简短,复杂度相较于 Prim 略优。他被称为边贪心的原因也是因为他的思路,可是他的思路与刚刚的 Prim 恰恰相反。首先我们需要知道一个定理:

一个图中最短的边(如果唯一)一定在最小生成树中。

那么我们的发明者就开始想了,一共就只有 n1 条边,那么我是不是选出来 n1 条最短的边就可以了?不然。因为试想一下,如果我们 n1 条最短的边无法把 n 个点连通,自然称不上最小生成树,也就是说,我们要在满足联通的情况下,尽可能地边权小。

那么想法也很简单,我们直接对于边权排序,每次合并一条最短的,两个端点原来不连通的相连通。这样合并 n1 次,就是最小生成树。而这个未选点的判定,不难就想到用并查集。因为所谓原来不连通,是因为我们每次只挑最短的边,并不能保证顺序,也就是说,在合并结束前,我们的图一直处于四分五裂的状态。从来都不完整,这时候用并查集判断是不是在同一个连通块,再合适不过。

代码的话,我愿称之为这几个算法中最好写的:

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int fa[N];
int find(int x) { return (fa[x] == x ? x : fa[x] = find(fa[x])); }

struct Edge {
int u, v, w;
}e[M];

inline int kruskal() {
int ans = 0, cnt = 0;
for(int i = 1; i <= n; i++) fa[i] = i;
sort(e + 1, e + m + 1, [](Edge a, Edge b) {
return a.w < b.w;
});
for(int i = 1; i <= m; i++) {
int u = find(e[i].u);
int v = find(e[i].v);
if(u == v) continue;
ans += e[i].w;
fa[u] = v;
cnt++;
if(cnt == n - 1) break;
}
return ans;
}

这里统计一下,可以略略提升一点点的复杂度,这样的代码复杂度应该是 O(mlogm+n×α(n)) 其中 α(n) 是并查集的复杂度。

算法实战:割舍逢迎之心

查看相关算法标签喵!