一、为什么要谈最小生成树
在图论的工具箱里,最小生成树(Minimum Spanning Tree,MST)是少数几个「定义简洁、应用广泛、算法优美」三项全占的问题。给定一个连通无向加权图 G = (V, E),MST 要求找到一棵包含所有顶点的树 T,使得 T 中所有边的权重之和最小。
这个问题的实际背景随处可见:城市之间铺设光缆、电路板上的布线、聚类分析中的单链接聚类、甚至游戏地图的程序化生成。它的理论地位同样重要——MST 是贪心策略的经典成功案例,也是理解割、环、拟阵等概念的入口。
本文会完整覆盖三种经典 MST 算法:Kruskal、Prim 和 Boruvka,给出可编译运行的 C 实现,讨论它们各自适合的工程场景,最后延伸到 Steiner 树、可靠性设计等高级话题。
一个核心观点贯穿全文:MST 算法的选择,本质上是边排序和优先队列之间的权衡。 理解了这一点,面对具体工程需求时就不会选错算法。
二、基本定义与性质
2.1 生成树
对于连通图 G = (V, E),一棵生成树是 G 的一个子图 T = (V, E_T),满足:
- T 包含 G 的所有顶点;
- T 是一棵树(连通且无环);
- |E_T| = |V| - 1。
2.2 最小生成树
在边带权的连通图中,权重之和最小的生成树称为最小生成树。注意 MST 不一定唯一——当存在权重相同的边时,可能有多棵不同的 MST,但它们的总权重一定相同。
2.3 MST 的基本性质
环性质(Cycle Property):对于图中任意一个环,环上权重严格最大的边不属于任何 MST。
割性质(Cut Property):对于图的任意一个割 (S, V),跨越该割的权重严格最小的边必属于某棵 MST。
这两条性质是所有 MST 算法正确性的基石。Kruskal 算法利用的是割性质(每次加入的最小边恰好连接两个不同连通分量),Prim 算法也利用割性质(每次选择连接已选集合与未选集合的最小边),而环性质则给出了「哪些边一定不在 MST 中」的判据。
2.4 唯一性条件
如果图中所有边的权重互不相同,则 MST 唯一。证明很简单:假设存在两棵不同的 MST T1 和 T2,取 T1 中不在 T2 里的边 e,把 e 加入 T2 形成环,环上必有另一条跨越同一割的边 e’,由割性质和权重互异性,e 和 e’ 的大小关系确定,从而导出矛盾。
三、割性质的证明
割性质是整篇文章的理论根基,值得完整证明一次。
定理(Cut Property):设 G = (V, E, w) 是连通加权图,(S, V) 是 G 的任意一个割,e 是跨越该割的唯一最小权边。则 e 属于 G 的每一棵 MST。
证明:
设 T 是 G 的一棵 MST,假设 e = (u, v) 不在 T 中,其中 u 属于 S,v 属于 V。
- 因为 T 是生成树,T 中存在一条从 u 到 v 的唯一路径 P。
- 路径 P 从 S 出发到达 V,必然至少经过一条跨越割 (S, V) 的边,设这条边为 e’ = (x, y)。
- 由题设 e 是跨越该割的唯一最小权边,故 w(e) < w(e’)。
- 构造新树 T’ = T - {e’} + {e}。删除 e’ 后 T 断成两个连通分量,加入 e 重新连通,因此 T’ 仍然是生成树。
- w(T’) = w(T) - w(e’) + w(e) < w(T),与 T 是 MST 矛盾。
故 e 必在 T 中。当跨越割的最小权边不唯一时,结论弱化为「至少属于某棵 MST」。
下图展示了割性质的直觉:
图中紫色虚线表示割 C,将顶点分为蓝色集合 S 和绿色集合 V。跨越割的边中,橙色粗边(权重 1)是轻边,它必然出现在 MST 中。灰色虚线是其余的 crossing edges。
四、Kruskal 算法与 Union-Find
4.1 算法思想
Kruskal 算法是最直觉的贪心策略:
- 将所有边按权重从小到大排序;
- 依次考察每条边,如果这条边连接的两个顶点不在同一连通分量中,就将这条边加入 MST;
- 重复直到 MST 包含 V-1 条边。
正确性直接来自割性质:当我们选择一条连接两个不同分量的边时,这条边是跨越「这两个分量构成的割」的最小边(因为更小的边要么已被选入,要么连接的是同一分量内的顶点)。
4.2 Union-Find 数据结构
判断「两个顶点是否在同一连通分量」以及「合并两个分量」,正是 Union-Find(并查集)的经典用途。
Union-Find 支持两个操作:
- Find(x):返回 x 所在集合的代表元素(根节点);
- Union(x, y):将 x 和 y 所在的集合合并。
朴素实现下,这两个操作的时间复杂度可能退化到 O(n)。加上两个优化后,均摊复杂度降到 O(alpha(n)),其中 alpha 是反阿克曼函数,在实际中可以认为是常数。
4.3 路径压缩(Path Compression)
在 Find 操作中,将查找路径上的所有节点直接挂到根节点下:
int find(int parent[], int x) {
if (parent[x] != x)
parent[x] = find(parent, parent[x]);
return parent[x];
}这使得后续对同一路径上节点的 Find 操作接近 O(1)。
4.4 按秩合并(Union by Rank)
合并时,将较矮的树挂到较高的树下面,避免树的高度过快增长:
void union_sets(int parent[], int rank[], int x, int y) {
int rx = find(parent, x);
int ry = find(parent, y);
if (rx == ry) return;
if (rank[rx] < rank[ry]) {
parent[rx] = ry;
} else if (rank[rx] > rank[ry]) {
parent[ry] = rx;
} else {
parent[ry] = rx;
rank[rx]++;
}
}4.5 Kruskal 的时间复杂度
- 排序:O(E log E) = O(E log V)(因为 E <= V^2,所以 log E = O(log V));
- Union-Find 操作:O(E * alpha(V)),近似 O(E);
- 总计:O(E log E),瓶颈在排序。
对于稀疏图(E 接近 V),这几乎是线性的。对于稠密图(E 接近 V^2),排序的开销变大,此时 Prim 可能更优。
五、Prim 算法与优先队列
5.1 算法思想
Prim 算法从任意一个起点开始,维护一个「已选集合」S,每次从 S 到 V的所有边中选择权重最小的一条,将对应的顶点加入 S,直到所有顶点都被选入。
这也是割性质的直接应用:每一步选择的都是跨越割 (S, V) 的最小边。
5.2 基于二叉堆的实现
用最小堆维护每个未选顶点到已选集合的最小边权:
- 初始化:将起点的 key 设为 0,其余顶点设为正无穷,全部入堆;
- 每次取出堆顶(key 最小的顶点 u),将 u 加入 MST;
- 对 u 的每个邻居 v,如果 v 未被选且 w(u,v) < key[v],则更新 key[v] 并在堆中执行 decrease-key。
时间复杂度:
- 每个顶点出堆一次:V 次 extract-min,每次 O(log V);
- 每条边可能触发一次 decrease-key:E 次 decrease-key,每次 O(log V);
- 总计:O((V + E) log V)。
对于稠密图,E = O(V^2),此时用邻接矩阵 + 线性扫描更好,复杂度为 O(V^2)。
5.3 Fibonacci 堆优化
Fibonacci 堆的 decrease-key 是均摊 O(1),extract-min 是均摊 O(log V):
- V 次 extract-min:O(V log V);
- E 次 decrease-key:O(E);
- 总计:O(E + V log V)。
这是 Prim 算法的理论最优复杂度。当 E = omega(V log V) 时(即图足够稠密),这比 Kruskal 的 O(E log E) 更好。
不过实际工程中 Fibonacci 堆的常数因子很大,缓存不友好,实现也复杂。除非 V 超过百万级别且图确实稠密,否则二叉堆甚至配对堆(pairing heap)通常是更实际的选择。
5.4 Prim vs Dijkstra
Prim 和 Dijkstra 的代码结构几乎一模一样,区别仅在松弛条件:
- Dijkstra:
if dist[u] + w(u,v) < dist[v](到源点的距离) - Prim:
if w(u,v) < key[v](到已选集合的最小边权)
这种相似性不是巧合。两者都是「从已探索区域向外扩展」的贪心策略,只是优化目标不同:Dijkstra 最小化路径长度,Prim 最小化连接代价。
六、Boruvka 算法
6.1 历史背景
Boruvka 算法(1926 年)是已知最早的 MST 算法,比 Kruskal(1956)和 Prim(1957)都早了三十年。它由捷克数学家 Otakar Boruvka 提出,最初动机是为摩拉维亚地区设计最经济的电力网络。
6.2 算法思想
- 初始时每个顶点自成一个连通分量;
- 每一轮(phase)中,每个连通分量独立地找出连接自己到其他分量的最小权边;
- 将所有找到的边加入 MST,合并对应的分量;
- 重复直到只剩一个分量。
6.3 复杂度分析
每一轮至少将分量数减半(最差情况下两个分量合并成一个),因此最多 O(log V) 轮。每一轮需要扫描所有边找最小,耗时 O(E)。
总计:O(E log V)。
6.4 并行友好性
Boruvka 的独特优势在于天然适合并行计算。每一轮中,各连通分量寻找最小外边的操作是完全独立的,可以并行执行。这使得 Boruvka 成为 GPU 上和分布式环境中 MST 计算的首选框架。
实际上,许多现代并行 MST 算法都是以 Boruvka 为基础,在每一轮中用并行原语(如 parallel reduce)来寻找各分量的最小外边。
6.5 与其他算法的混合
一个常见的工程技巧是「Boruvka + Kruskal 混合」:先跑几轮 Boruvka 大幅减少分量数,然后切换到 Kruskal 处理剩余的少量分量。这在某些场景下比单独使用任何一种算法都快。
七、Union-Find 的均摊分析
Union-Find 的均摊复杂度 O(alpha(n)) 是算法分析中的一个经典结果,这里给出证明的核心思路。
7.1 阿克曼函数与反阿克曼函数
阿克曼函数 A(k, j) 的定义:
A(0, j) = j + 1
A(k, 0) = A(k-1, 1) 当 k >= 1
A(k, j) = A(k-1, A(k, j-1)) 当 k >= 1, j >= 1
它的增长速度极快:
- A(1, j) = j + 2
- A(2, j) = 2j + 3
- A(3, j) = 2^(j+3) - 3
- A(4, 1) = 2(2(2(22))) - 3 = 2^65536 - 3
反阿克曼函数 alpha(n) = min{k : A(k, 1) >= n}。对于宇宙中原子数量级别的 n(约 10^80),alpha(n) <= 4。因此在任何实际应用中,alpha(n) 都不超过 4,可以视为常数。
7.2 证明梗概(Tarjan-van Leeuwen 分析)
将节点按 rank 分成若干块(block),第 i 块包含 rank 在 [A(i-1,1)+1, A(i,1)] 范围内的节点。关键观察:
同块收缩:路径压缩使得一个节点的父指针不断上移,如果父节点和自己在同一块内,则该节点的「块内指针跳数」严格递减,最终指向块外节点。
块的数量:由于反阿克曼函数的定义,rank 最多到 log n,而块的数量恰好是 alpha(n)。
势能论证:定义势能函数 Phi,对每个节点 x 记录它在块内还能跳几次。每次路径压缩要么跨块(对应势能不变但块号推进),要么块内跳(势能下降 1)。
均摊:m 次操作的总代价 = 实际代价 + 势能变化。块间跳数不超过 m * alpha(n),块内跳的总势能下降不超过 n * alpha(n)。
最终得到 m 次操作(混合 Find 和 Union)的总时间为 O(m * alpha(n)),即单次操作均摊 O(alpha(n))。
7.3 工程启示
这个分析告诉我们:路径压缩和按秩合并缺一不可。
- 只有路径压缩,没有按秩合并:均摊 O(log n)(Tarjan 1975);
- 只有按秩合并,没有路径压缩:最坏 O(log n);
- 两者结合:均摊 O(alpha(n)),几乎常数。
实际测试中,Union-Find 操作的平均耗时通常不超过两到三次指针跳转,比理论分析的上界还要好得多。
八、完整 C 实现
8.1 Kruskal 算法实现
以下是一个完整的、可编译运行的 Kruskal 算法 C 实现,包含 Union-Find 数据结构:
/* kruskal.c — Kruskal's MST with Union-Find
* 编译: gcc -O2 -o kruskal kruskal.c
* 运行: ./kruskal < input.txt
*
* 输入格式:
* 第一行: V E
* 接下来 E 行: u v w (顶点从 0 开始编号)
*
* 输出: MST 的边和总权重
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_V 100000
#define MAX_E 500000
/* ========== Union-Find ========== */
typedef struct {
int parent[MAX_V];
int rank[MAX_V];
int n;
} UnionFind;
void uf_init(UnionFind *uf, int n) {
uf->n = n;
for (int i = 0; i < n; i++) {
uf->parent[i] = i;
uf->rank[i] = 0;
}
}
int uf_find(UnionFind *uf, int x) {
if (uf->parent[x] != x)
uf->parent[x] = uf_find(uf, uf->parent[x]); /* 路径压缩 */
return uf->parent[x];
}
/* 按秩合并,返回 1 表示成功合并,0 表示已在同一集合 */
int uf_union(UnionFind *uf, int x, int y) {
int rx = uf_find(uf, x);
int ry = uf_find(uf, y);
if (rx == ry) return 0;
if (uf->rank[rx] < uf->rank[ry]) {
uf->parent[rx] = ry;
} else if (uf->rank[rx] > uf->rank[ry]) {
uf->parent[ry] = rx;
} else {
uf->parent[ry] = rx;
uf->rank[rx]++;
}
return 1;
}
/* ========== Edge & Sorting ========== */
typedef struct {
int u, v;
long long w;
} Edge;
Edge edges[MAX_E];
Edge mst_edges[MAX_V];
int cmp_edge(const void *a, const void *b) {
long long diff = ((Edge *)a)->w - ((Edge *)b)->w;
if (diff < 0) return -1;
if (diff > 0) return 1;
return 0;
}
/* ========== Kruskal ========== */
long long kruskal(int V, int E, int *mst_count) {
qsort(edges, E, sizeof(Edge), cmp_edge);
UnionFind uf;
uf_init(&uf, V);
long long total_weight = 0;
*mst_count = 0;
for (int i = 0; i < E && *mst_count < V - 1; i++) {
int u = edges[i].u;
int v = edges[i].v;
long long w = edges[i].w;
if (uf_union(&uf, u, v)) {
mst_edges[*mst_count] = edges[i];
(*mst_count)++;
total_weight += w;
}
}
return total_weight;
}
/* ========== Main ========== */
int main(void) {
int V, E;
if (scanf("%d %d", &V, &E) != 2) {
fprintf(stderr, "错误: 无法读取 V 和 E\n");
return 1;
}
if (V > MAX_V || E > MAX_E) {
fprintf(stderr, "错误: 超出最大限制 V=%d E=%d\n", MAX_V, MAX_E);
return 1;
}
for (int i = 0; i < E; i++) {
if (scanf("%d %d %lld", &edges[i].u, &edges[i].v, &edges[i].w) != 3) {
fprintf(stderr, "错误: 读取第 %d 条边失败\n", i);
return 1;
}
}
int mst_count = 0;
long long total = kruskal(V, E, &mst_count);
if (mst_count < V - 1) {
printf("图不连通,无法生成 MST\n");
printf("已找到 %d 条边(需要 %d 条)\n", mst_count, V - 1);
return 1;
}
printf("MST 总权重: %lld\n", total);
printf("MST 边列表:\n");
for (int i = 0; i < mst_count; i++) {
printf(" %d -- %d (权重 %lld)\n",
mst_edges[i].u, mst_edges[i].v, mst_edges[i].w);
}
return 0;
}代码要点:
uf_find使用递归路径压缩,生产环境中如果栈深度受限可以改为迭代版本(两遍遍历);cmp_edge小心处理了 long long 比较,没有直接做减法返回 int(避免溢出);- 提前终止条件
*mst_count < V - 1避免不必要的 Find 操作; - 全局数组的大小可以根据实际需求调整,这里为了演示设置了固定上限。
8.2 Prim 算法实现
以下是基于二叉堆的 Prim 算法完整实现,使用邻接表存图:
/* prim.c — Prim's MST with binary heap
* 编译: gcc -O2 -o prim prim.c
* 运行: ./prim < input.txt
*
* 输入格式与 kruskal.c 相同
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#define MAX_V 100000
#define MAX_E 500000
#define INF LLONG_MAX
/* ========== 邻接表 ========== */
typedef struct AdjNode {
int to;
long long w;
int next;
} AdjNode;
AdjNode adj[MAX_E * 2]; /* 无向图,每条边存两次 */
int head[MAX_V];
int adj_cnt;
void adj_init(int V) {
adj_cnt = 0;
memset(head, -1, sizeof(int) * V);
}
void adj_add(int u, int v, long long w) {
adj[adj_cnt].to = v;
adj[adj_cnt].w = w;
adj[adj_cnt].next = head[u];
head[u] = adj_cnt++;
}
/* ========== 最小堆 ========== */
typedef struct {
int vertex;
long long key;
} HeapNode;
typedef struct {
HeapNode data[MAX_V + 1];
int pos[MAX_V]; /* pos[v] = v 在堆数组中的下标 */
int size;
} MinHeap;
void heap_init(MinHeap *h) {
h->size = 0;
memset(h->pos, -1, sizeof(h->pos));
}
void heap_swap(MinHeap *h, int i, int j) {
HeapNode tmp = h->data[i];
h->data[i] = h->data[j];
h->data[j] = tmp;
h->pos[h->data[i].vertex] = i;
h->pos[h->data[j].vertex] = j;
}
void heap_sift_up(MinHeap *h, int i) {
while (i > 0) {
int parent = (i - 1) / 2;
if (h->data[parent].key <= h->data[i].key) break;
heap_swap(h, i, parent);
i = parent;
}
}
void heap_sift_down(MinHeap *h, int i) {
int n = h->size;
while (2 * i + 1 < n) {
int child = 2 * i + 1;
if (child + 1 < n && h->data[child + 1].key < h->data[child].key)
child++;
if (h->data[i].key <= h->data[child].key) break;
heap_swap(h, i, child);
i = child;
}
}
void heap_insert(MinHeap *h, int v, long long key) {
int i = h->size++;
h->data[i].vertex = v;
h->data[i].key = key;
h->pos[v] = i;
heap_sift_up(h, i);
}
HeapNode heap_extract_min(MinHeap *h) {
HeapNode min_node = h->data[0];
h->pos[min_node.vertex] = -1;
h->size--;
if (h->size > 0) {
h->data[0] = h->data[h->size];
h->pos[h->data[0].vertex] = 0;
heap_sift_down(h, 0);
}
return min_node;
}
void heap_decrease_key(MinHeap *h, int v, long long new_key) {
int i = h->pos[v];
if (i < 0 || h->data[i].key <= new_key) return;
h->data[i].key = new_key;
heap_sift_up(h, i);
}
/* ========== Prim ========== */
long long key[MAX_V]; /* 到已选集合的最小边权 */
int parent[MAX_V]; /* MST 中的父节点 */
int in_mst[MAX_V]; /* 是否已加入 MST */
long long prim(int V, int src) {
MinHeap heap;
heap_init(&heap);
for (int i = 0; i < V; i++) {
key[i] = INF;
parent[i] = -1;
in_mst[i] = 0;
}
key[src] = 0;
for (int i = 0; i < V; i++)
heap_insert(&heap, i, key[i]);
long long total_weight = 0;
int mst_count = 0;
while (heap.size > 0) {
HeapNode min_node = heap_extract_min(&heap);
int u = min_node.vertex;
if (min_node.key == INF) break; /* 不连通 */
in_mst[u] = 1;
total_weight += min_node.key;
mst_count++;
/* 松弛邻居 */
for (int e = head[u]; e != -1; e = adj[e].next) {
int v = adj[e].to;
long long w = adj[e].w;
if (!in_mst[v] && w < key[v]) {
key[v] = w;
parent[v] = u;
heap_decrease_key(&heap, v, w);
}
}
}
if (mst_count < V) return -1; /* 图不连通 */
return total_weight;
}
/* ========== Main ========== */
int main(void) {
int V, E;
if (scanf("%d %d", &V, &E) != 2) {
fprintf(stderr, "错误: 无法读取 V 和 E\n");
return 1;
}
adj_init(V);
for (int i = 0; i < E; i++) {
int u, v;
long long w;
if (scanf("%d %d %lld", &u, &v, &w) != 3) {
fprintf(stderr, "错误: 读取第 %d 条边失败\n", i);
return 1;
}
adj_add(u, v, w);
adj_add(v, u, w);
}
long long total = prim(V, 0);
if (total < 0) {
printf("图不连通,无法生成 MST\n");
return 1;
}
printf("MST 总权重: %lld\n", total);
printf("MST 边列表:\n");
for (int i = 0; i < V; i++) {
if (parent[i] != -1) {
printf(" %d -- %d (权重 %lld)\n", parent[i], i, key[i]);
}
}
return 0;
}代码要点:
- 邻接表使用链式前向星(数组模拟链表),比
malloc链表缓存友好得多; - 堆维护了
pos数组,使得decrease_key可以直接定位元素,时间 O(log V); prim函数返回 -1 表示图不连通,调用方可据此处理;- 起点
src的key设为 0,其余设为 INF,这保证起点第一个被取出。
九、算法对比:何时用哪个
9.1 理论复杂度对比
| 算法 | 时间复杂度 | 空间复杂度 | 数据结构 |
|---|---|---|---|
| Kruskal | O(E log E) | O(V + E) | Union-Find |
| Prim(二叉堆) | O((V + E) log V) | O(V + E) | 二叉堆 + 邻接表 |
| Prim(Fibonacci 堆) | O(E + V log V) | O(V + E) | Fibonacci 堆 + 邻接表 |
| Prim(邻接矩阵) | O(V^2) | O(V^2) | 数组 |
| Boruvka | O(E log V) | O(V + E) | Union-Find |
9.2 工程选择指南
稀疏图(E 接近 V):Kruskal 最优。排序 O(E log E) 接近 O(V log V),Union-Find 操作几乎线性。代码也最简单。
稠密图(E 接近 V^2):Prim + 邻接矩阵(O(V^2))最优。此时 Kruskal 的 O(E log E) = O(V^2 log V),明显更慢。
中等密度图:Prim + 二叉堆通常表现最好。O((V + E) log V) 在大多数实际图上优于 Kruskal。
并行场景:Boruvka 首选。可以利用多核或 GPU 并行计算各分量的最小外边。
边已排序:如果输入的边已经排好序,Kruskal 的瓶颈消失,直接降为 O(E * alpha(V)),几乎线性。
动态图(增量):如果边是逐个到达的,可以用在线版 Kruskal(维护当前 MST 森林)。
9.3 实测经验
在我的实际项目中,对于 V < 10^5、E < 10^6 的图,Kruskal 和 Prim(二叉堆)的性能差距通常在 20% 以内,远不如理论分析暗示的那么大。瓶颈往往在 I/O 和内存访问模式上,而不是算法本身。
我的个人选择策略:默认用 Kruskal(代码短、不易出错),除非图确实稠密或者需要在线处理。竞赛场景下 Kruskal 几乎是首选,因为 Union-Find 的板子大家都背得滚瓜烂熟。
十、工程陷阱与注意事项
在实际工程中使用 MST 算法时,有一些经典的坑值得专门列出来。
10.1 常见陷阱一览
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
| 边权溢出 | 排序或求和时结果错误 | 用 long long 存边权和总权重 |
| 浮点边权比较 | MST 结果不稳定 | 用整数化(乘以精度因子)或容差比较 |
| 自环 | Kruskal 多加了无用边 | 预处理时过滤 u == v 的边 |
| 平行边 | 正确性不受影响但浪费时间 | 可选择性地保留最小权平行边 |
| 图不连通 | 算法输出不完整的树 | 检查 MST 边数是否等于 V-1 |
| Union-Find 未初始化 | 合并结果错误 | 确保 parent[i] = i 的初始化 |
| 堆中的过期条目 | Prim 用懒删除堆时重复处理 | extract 后检查 in_mst 标记 |
| 负权边 | MST 算法正确处理负权 | 不需要特殊处理(与最短路不同) |
| 边权相同时的非确定性 | 不同运行得到不同 MST | 如果需要确定性,对权重相同的边再按端点排序 |
| 大规模图的栈溢出 | 递归路径压缩 | 改用迭代版 Find(两遍遍历) |
10.2 迭代版路径压缩
当 V 很大(超过 10^5 级别)时,递归版
uf_find 可能导致栈溢出。以下是迭代版本:
int uf_find_iter(UnionFind *uf, int x) {
/* 第一遍:找到根 */
int root = x;
while (uf->parent[root] != root)
root = uf->parent[root];
/* 第二遍:路径压缩 */
while (uf->parent[x] != root) {
int next = uf->parent[x];
uf->parent[x] = root;
x = next;
}
return root;
}10.3 Prim 的懒删除堆变体
如果不想实现
decrease_key(确实比较麻烦),可以用「懒删除」策略:不更新堆中已有的条目,而是直接插入一个新的更小的条目。取出时检查是否已在
MST 中:
/* 懒删除版 Prim 的核心循环(伪代码风格的 C) */
while (heap.size > 0) {
HeapNode node = heap_extract_min(&heap);
if (in_mst[node.vertex]) continue; /* 跳过过期条目 */
in_mst[node.vertex] = 1;
total_weight += node.key;
/* 松弛邻居,直接 insert 新条目 */
for (each neighbor v of node.vertex) {
if (!in_mst[v] && w < key[v]) {
key[v] = w;
heap_insert(&heap, v, w); /* 不删旧的,直接插新的 */
}
}
}这种方法实现简单,但堆的大小可能膨胀到 O(E),空间和时间常数都会变大。竞赛中常用,工程中看情况。
10.4 浮点权重的处理
很多实际问题(如欧几里得 MST)中边权是浮点数。此时有两个选择:
- 整数化:如果精度允许,将所有权重乘以 10^k 转为整数;
- 容差比较:排序时用
fabs(a - b) < eps判断相等,但这会破坏排序的传递性,需要小心处理。
我的建议是尽量整数化。浮点比较是无底洞,除非你确切知道自己在做什么。
十一、应用场景与扩展问题
11.1 网络线缆布局
MST 最经典的应用就是网络布线:给定 n 个节点(数据中心、交换机、办公室),节点之间的布线成本已知,求最小成本的连通布线方案。
这正是 Boruvka 当年解决的问题——只不过那时连接的是变电站而非服务器。
实际布线中还要考虑可靠性(单点故障问题),这引出了下面的 Suurballe 算法。
11.2 聚类分析(单链接聚类)
单链接聚类(Single-Linkage Clustering)与 MST 有着精确的对应关系:
- 对数据点构建完全图,边权为点对之间的距离;
- 计算 MST;
- 删除 MST 中权重最大的 k-1 条边,得到 k 个聚类。
这等价于:MST 的构建过程就是单链接层次聚类的过程。Kruskal 算法按边权从小到大合并,每次合并对应层次聚类树中的一次合并操作。
这种方法的优势是能发现任意形状的聚类(不像 k-means 倾向于球形聚类),劣势是对噪声敏感。
11.3 Suurballe 算法与网络可靠性
在实际网络设计中,仅有 MST 是不够的——任何一条边的故障都会导致网络分裂。Suurballe 算法寻找两条边不相交的最短路径,可以用来增强网络的可靠性。
具体做法:在 MST 的基础上,为关键节点对找到备份路径,确保任意单边故障不会导致断连。这本质上是求最小权的 2-边连通子图,是一个比 MST 更难但实际中非常重要的问题。
11.4 Steiner 树问题
Steiner 树问题是 MST 的一个自然推广:给定图 G 和一个顶点子集 S(称为「终端」),找到连接 S 中所有顶点的最小权子树。与 MST 不同的是,Steiner 树允许使用不在 S 中的顶点(称为 Steiner 点)作为中继。
关键区别:
- MST 要求连接所有顶点,且最优解一定是一棵树;
- Steiner 树只要求连接指定子集,最优解也是一棵树但可能包含非终端顶点。
Steiner 树问题是 NP-hard 的。证明的核心思路是从集合覆盖(Set Cover)或精确覆盖(Exact Cover)归约。这意味着除非 P = NP,不存在多项式时间的精确算法。
实际中常用的近似方法:
- MST 近似:对终端集合 S 构建完全图(边权为原图中两点间最短路),求这个完全图的 MST,再映射回原图。近似比为 2 - 2/|S|。
- Dreyfus-Wagner 动态规划:O(3^|S| * V + 2^|S| * V^2),当 |S| 较小时可用。
- 迭代优化:基于局部搜索的启发式方法,实际效果往往很好。
11.5 欧几里得 MST
当顶点是平面上的点、边权是欧几里得距离时,可以利用几何性质加速。关键事实:欧几里得 MST 是 Delaunay 三角剖分的子图。因此可以:
- 先算 Delaunay 三角剖分:O(V log V);
- 在三角剖分的边上跑 Kruskal:边数 O(V),排序 O(V log V)。
总复杂度 O(V log V),比朴素的 O(V^2 log V)(对完全图跑 Kruskal)好得多。
11.6 最小瓶颈路径
MST 还有一个很好的性质:图中任意两点间的最小瓶颈路径(minimax path)恰好就是 MST 上这两点间的路径。
所谓最小瓶颈路径,是使得路径上最大边权最小的路径。这在网络带宽分析中很有用——如果边权表示带宽限制(取倒数变成最小化),MST 上的路径就是带宽最大的路径。
十二、个人思考与工程哲学
12.1 算法选择的本质
回到文章开头的论点:MST 算法的选择,本质上是边排序和优先队列之间的权衡。
Kruskal 说:「让我把所有边排好序,然后贪心地选。」这是一种全局视角。
Prim 说:「让我从一个点开始,逐步扩展,用优先队列维护边界。」这是一种局部视角。
两者殊途同归,但适合的场景不同。这种「全局排序 vs 局部优先队列」的对偶性在算法设计中反复出现——比较 Dijkstra(优先队列)和 Bellman-Ford(全局松弛)也能看到类似的味道。
12.2 理论与实践的鸿沟
Fibonacci 堆让 Prim 达到了 O(E + V log V) 的理论最优,但在实际中几乎没人用。原因很简单:
- 常数因子太大,在常见规模下反而比二叉堆慢;
- 实现复杂度高,容易出 bug;
- 缓存不友好,在现代 CPU 上表现不佳。
这教会我们一个道理:O 记号隐藏的常数在工程中可能比渐近量级更重要。 一个 O(n log n) 但缓存友好、SIMD 优化的算法,在 n < 10^7 的范围内轻松击败 O(n) 但缓存不友好的算法。
类似的例子还有:跳表(理论优雅,实际不如红黑树)、van Emde Boas 树(理论 O(log log n),实际不如 std::set)。
12.3 Union-Find 的普适性
Union-Find 是我最喜欢的数据结构之一。它的设计哲学很简洁:用两个简单的启发式(路径压缩 + 按秩合并)就把一个 O(log n) 的数据结构推到了 O(alpha(n)),接近理论下界。
而且它的应用范围远超 MST:
- 动态连通性;
- 等价类合并(编译器中的类型统一);
- 渗透问题(percolation);
- 图像处理中的连通区域标记;
- Tarjan 的离线 LCA 算法。
如果让我推荐一个「投入产出比最高的数据结构」,Union-Find 一定在前三名。
12.4 关于 Boruvka 的复兴
Boruvka 算法在很长时间里被教科书冷落,但随着并行计算的普及,它正在经历复兴。在 GPU 编程中,Boruvka 的每一轮都可以高效并行化——每个线程块负责一个连通分量,用 atomicMin 找最小外边。
我在一个网络拓扑分析项目中测试过 GPU 版 Boruvka,对于 10^7 级别的边数,比 CPU 上的 Kruskal 快了约 15 倍。当然,这包含了数据传输的开销——如果数据已经在 GPU 上(比如图计算流水线的中间结果),加速比会更大。
12.5 写给初学者
如果你刚开始学 MST,我的建议是:
- 先理解割性质,它是一切 MST 算法的理论根基;
- 先写 Kruskal,代码短、好调试,Union-Find 是必须掌握的基本功;
- 再写 Prim,感受「和 Dijkstra 只差一行」的优雅;
- 最后了解 Boruvka,体会「古老算法在新时代焕发生机」的趣味。
MST 问题本身不难,但它串联起来的知识图谱——贪心、拟阵、并查集、优先队列、均摊分析、NP-hard 归约——构成了算法学习中最有价值的一条线索。顺着这条线深挖下去,你会遇到越来越多有趣的东西。
12.6 延伸阅读建议
关于 MST 的理论深度,推荐以下材料:
- Cormen 等《算法导论》第 23 章,MST 的标准教科书讲解;
- Tarjan 的论文「Efficiency of a Good But Not Linear Set Union Algorithm」(1975),Union-Find 分析的原始文献;
- Chazelle 的论文「A Minimum Spanning Tree Algorithm with Inverse-Ackermann Type Complexity」(2000),目前已知最快的确定性 MST 算法,复杂度 O(E * alpha(E, V));
- Karger、Klein、Tarjan 的随机化线性时间 MST 算法(1995),一个理论上的突破;
- 如果对并行 MST 感兴趣,搜索「parallel Boruvka GPU」会找到大量现代文献。
上一篇: Bellman-Ford 与路由协议 下一篇: Tarjan 算法族 相关阅读: - Dijkstra 与 A* - 网络流与二分匹配