最小生成树(Minimum Spanning Tree, MST)问题是图论中的一个经典问题,其目标是在一个带权无向图中选出一棵生成树,使得所有顶点都连通,并且边的权值之和最小。常见的最小生成树算法有 Kruskal 算法、Prim 算法 和 Borůvka 算法。下面将详细介绍这几种算法的原理、实现、使用场景以及注意事项。
1. 最小生成树的概念
1.1、定义
对于一个无向连通图,设图中每条边都有一个非负权值,一个生成树是包含图中所有顶点的无环连通子图。最小生成树就是所有生成树中边权和最小的那棵树。可以描述为给定一个无向图 (G = (V, E)),其中:
- (V) 是顶点集合
- (E) 是边集合,每条边都有一个权重 (w(e))
目标是从 (E) 中挑选一组边 (T),使得:
- (T) 连通 (V) 中的所有顶点(也就是说形成一棵树)
- (T) 中边的总权重 (\sum_{e \in T} w(e)) 最小
注意:
- 如果图不连通,则无法构成包含所有顶点的生成树;
- 最小生成树一般不是唯一的,但所有最小生成树的总权值都是相同的。
1.2、性质
- MST 是唯一的(当边权互异时);
- MST 满足“剪枝性”:任何生成树的子图也是它的部分最优解;
- 如果添加一条边后形成环,则环中权值最大的边不可能在最优解中出现(贪心选择性质)。
2. 常见算法
2.1 Kruskal 算法
算法原理:
Kruskal 算法是基于贪心思想工作的。其核心思路为:
- 将图中的所有边按权重从小到大排序。
- 从权值最小的边开始选边,判断该边加入是否形成环(用并查集判断);若不形成环,则将该边加入生成树。
- 重复步骤 2,直到生成树中含有 (n-1) 条边(其中 (n) 是顶点数)。
主要步骤:
- 排序所有边
- 初始化并查集,每个顶点自成一集
- 依次选择边,若边两端属于不同集合,则合并集合,并加入结果中
伪代码示例:
struct Edge {
int u, v;
int weight;
};
bool cmp(const Edge &a, const Edge &b) {
return a.weight < b.weight;
}
int find(vector<int>& parent, int x) {
if (parent[x] != x)
parent[x] = find(parent, parent[x]);
return parent[x];
}
void unionSets(vector<int>& parent, vector<int>& rank, int x, int y) {
int rootX = find(parent, x);
int rootY = find(parent, y);
if (rootX != rootY) {
if (rank[rootX] < rank[rootY])
swap(rootX, rootY);
parent[rootY] = rootX;
if (rank[rootX] == rank[rootY])
rank[rootX]++;
}
}
vector<Edge> kruskal(int n, vector<Edge>& edges) {
vector<Edge> mst;
sort(edges.begin(), edges.end(), cmp);
vector<int> parent(n), rank(n, 0);
for (int i = 0; i < n; i++) {
parent[i] = i;
}
for (auto &edge : edges) {
int u = edge.u;
int v = edge.v;
if (find(parent, u) != find(parent, v)) {
mst.push_back(edge);
unionSets(parent, rank, u, v);
}
}
return mst;
}
复杂度分析:
- 排序边的时间复杂度为 (O(E \log E))
- 并查集操作近似为 (O(1))(使用路径压缩和按秩合并),整体时间复杂度为 (O(E \log E))
适用场景:
- 边数较少(稀疏图)时表现较好。
2.2 Prim 算法
算法原理:
Prim 算法同样遵循贪心思想,从一个顶点开始,逐步扩展最小生成树:
- 任意选择一个起始顶点,将其加入集合 (S)。
- 找到所有与集合 (S) 中顶点相连的边,从中选择权值最小、且另一端顶点不在 (S) 中的边,将该边和顶点加入生成树和集合 (S)。
- 重复步骤 2,直到所有顶点都被包含。
主要步骤:
- 使用优先级队列来维护当前候选边,提升查找最小边的效率。
- 对于新加入的顶点,将其相连的边加入优先队列。
伪代码示例:
#include <queue>
#include <vector>
using namespace std;
struct Edge {
int v, weight;
bool operator>(const Edge& other) const {
return weight > other.weight;
}
};
vector<pair<int, int>> adj[1000]; // 邻接表,假设图中最多 1000 个顶点
vector<Edge> prim(int start, int n) {
vector<bool> visited(n, false);
vector<Edge> mst;
priority_queue<Edge, vector<Edge>, greater<Edge>> minHeap;
// 将起始顶点的所有边加入优先队列
visited[start] = true;
for (auto &p : adj[start]) {
minHeap.push({
p.first, p.second});
}
while (!minHeap.empty() && mst.size() < n - 1) {
Edge cur = minHeap.top();
minHeap.pop();
int v = cur.v;
// 如果该顶点已经在生成树中,跳过
if (visited[v]) continue;
visited[v] = true;
mst.push_back(cur);
// 将该顶点的所有边加入队列
for (auto &p : adj[v]) {
if (!visited[p.first])
minHeap.push({
p.first, p.second});
}
}
return mst;
}
复杂度分析:
- 使用优先级队列的 Prim 算法,对于稠密图来说复杂度为 (O(E \log V))。
- 针对稀疏图和稠密图均可适用,但实现时需要调整数据结构。
适用场景:
- 当图以邻接表形式给出时,Prim 算法通常更适合;对于稠密图还可以采用 Fibonacci 堆进一步改善复杂度。
2.3 Borůvka 算法
算法原理:
Borůvka 算法比较适合于并行处理问题,其主要思想为:
- 每个连通分量(初始时每个顶点都是一个分量)都选取与外部连通的最小边。
- 合并所有由这些最小边连通的分量,并重复这一过程,直到整个图合并为一个连通分量。
Borůvka 算法 C++ 实现
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Edge {
int u, v, w;
};
class DSU {
vector<int> parent, rank;
public:
DSU(int n) : parent(n), rank(n, 0) {
for (int i = 0; i < n; ++i) parent[i] = i;
}
int find(int x) {
return x == parent[x] ? x : parent[x] = find(parent[x]);
}
bool unite(int x, int y) {
int fx = find(x), fy = find(y);
if (fx == fy) return false;
if (rank[fx] < rank[fy]) swap(fx, fy);
parent[fy] = fx;
if (rank[fx] == rank[fy]) rank[fx]++;
return true;
}
};
int boruvkaMST(int n, const vector<Edge>& edges) {
DSU dsu(n);
int components = n;
int totalWeight = 0;
vector<Edge> mst;
while (components > 1) {
// 每个组件的最小边
vector<int> cheapest(n, -1);
// 找出每个组件的最便宜的边
for (int i = 0; i < edges.size(); ++i) {
int uRoot = dsu.find(edges[i].u);
int vRoot = dsu.find(edges[i].v);
if (uRoot == vRoot) continue;
if (cheapest[uRoot] == -1 || edges[i].w < edges[cheapest[uRoot]].w)
cheapest[uRoot] = i;
if (cheapest[vRoot] == -1 || edges[i].w < edges[cheapest[vRoot]].w)
cheapest[vRoot] = i;
}
// 添加所有最便宜的边
for (int i = 0; i < n; ++i) {
int edgeIdx = cheapest[i];
if (edgeIdx == -1) continue;
int u = edges[edgeIdx].u;
int v = edges[edgeIdx].v;
if (dsu.unite(u, v)) {
totalWeight += edges[edgeIdx].w;
mst.push_back(edges[edgeIdx]);
components--;
}
}
}
cout << "MST edges:\n";
for (auto& e : mst) {
cout << e.u << " - " << e.v << " : " << e.w << "\n";
}
return totalWeight;
}
示例使用
int main() {
int n = 4;
vector<Edge> edges = {
{
0, 1, 10},
{
0, 2, 6},
{
0, 3, 5},
{
1, 3, 15},
{
2, 3, 4}
};
int mstWeight = boruvkaMST(n, edges);
cout << "Total weight of MST: " << mstWeight << endl;
return 0;
}
输出示例:
MST edges:
2 - 3 : 4
0 - 3 : 5
0 - 1 : 10
Total weight of MST: 19
特点:
- 算法步骤可以并行化,每次选边操作独立。
- 通常在实践中不如 Kruskal 和 Prim 常用,但在理论上和某些特定的应用场景中有优势。
3. 注意事项
- 图的连通性检查: 如果图不连通,则不存在包含所有顶点的生成树。在实现中应考虑这一问题,或者通过算法得到森林结构(每个连通部分各自生成一个最小生成树)。
- 边权相等的处理: 当图中存在多条权值相同的边时,可能会有多棵不同的最小生成树。算法实现应保证在确定性需求下有固定输出(例如,使用稳定排序)。
- 数据结构选择:
- Kruskal 算法依赖高效的并查集,特别在使用路径压缩和按秩合并时能达到较高性能。
- Prim 算法中优先级队列(堆)的选择和初始化对性能影响较大,稠密图时可考虑邻接矩阵实现,但通常邻接表配合堆更高效。
- 输入规模与稀疏/稠密图:
- 对于边数较少的稀疏图,Kruskal 算法较为高效。
- 对于边数较多的稠密图,Prim 算法的使用更为普遍,同时注意堆优化。
4. 综合示例:一个完整的 Kruskal 算法实现
下面提供一个较为完整的 C++ 示例,展示如何使用 Kruskal 算法计算最小生成树:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Edge {
int u, v, weight;
};
bool cmp(const Edge &a, const Edge &b) {
return a.weight < b.weight;
}
// 并查集——路径压缩查找
int find(vector<int>& parent, int x) {
if (parent[x] != x)
parent[x] = find(parent, parent[x]);
return parent[x];
}
// 并查集合并:按秩合并(简单版)
void unionSets(vector<int>& parent, vector<int>& rank, int x, int y) {
int rootX = find(parent, x);
int rootY = find(parent, y);
if (rootX == rootY) return;
if (rank[rootX] < rank[rootY])
swap(rootX, rootY);
parent[rootY] = rootX;
if (rank[rootX] == rank[rootY])
rank[rootX]++;
}
vector<Edge> kruskalMST(int n, vector<Edge>& edges) {
// 初始化并查集
vector<int> parent(n), rank(n, 0);
for (int i = 0; i < n; i++) {
parent[i] = i;
}
vector<Edge> mst;
sort(edges.begin(), edges.end(), cmp);
for (auto &edge : edges) {
if (find(parent, edge.u) != find(parent, edge.v)) {
mst.push_back(edge);
unionSets(parent, rank, edge.u, edge.v);
}
}
return mst;
}
int main() {
// 设图有 4 个顶点,0 ~ 3
int n = 4;
vector<Edge> edges = {
{
0, 1, 10},
{
0, 2, 6},
{
0, 3, 5},
{
1, 3, 15},
{
2, 3, 4}
};
vector<Edge> mst = kruskalMST(n, edges);
cout << "Minimum Spanning Tree (Kruskal):" << endl;
for (auto &edge : mst) {
cout << edge.u << " - " << edge.v << " : " << edge.weight << endl;
}
return 0;
}
输出示例:
Minimum Spanning Tree (Kruskal):
2 - 3 : 4
0 - 3 : 5
0 - 1 : 10
5. 总结
- Kruskal 算法: 适用于稀疏图,依赖边排序和并查集,时间复杂度 (O(E \log E))。
- Prim 算法: 适合图以邻接表存储,使用堆优化,复杂度 (O(E \log V))。
- Borůvka 算法: 适合并行处理,理论上能在一些特定场景下取长补短。
- 实现时应注意图的连通性、数据结构选择以及边权相等时的处理等问题。