最小生成树
最小生成树(Minimum Spanning Tree, MST)是图论中的一个重要概念,在网络设计、电路布线等实际问题中有广泛应用。
基本概念
【定义】给定一个带权无向连通图G=(V,E),图中每条边e都有一个权值w(e)。最小生成树是一个包含G中所有顶点的无环连通子图,且所有边的权值之和最小。
关键特性:
- MST包含图G的所有顶点
- MST是一棵树(无环连通图)
- MST的边权之和在所有生成树中最小
Kruskal算法
【算法】Kruskal算法是一种贪心算法,基于边的选择策略:按照边权从小到大的顺序选择边,如果加入该边不会形成环,则将其加入生成树。
算法步骤
- 将所有边按权值升序排序
- 初始时,每个顶点构成一个独立的连通分量
- 按排序后的顺序考察每条边(u,v):
- 如果u和v不在同一连通分量,则将该边加入生成树,并合并u和v所在的连通分量
- 否则,丢弃该边
- 重复步骤3,直到加入n-1条边(n为顶点数)或考察完所有边
代码实现
Kruskal算法通常使用并查集来判断两个顶点是否在同一连通分量:
struct Edge {
int u, v, weight;
Edge(int _u, int _v, int _w) : u(_u), v(_v), weight(_w) {}
// 重载小于运算符,用于边的排序
bool operator < (const Edge& other) const {
return weight < other.weight;
}
};
vector<Edge> edges; // 存储所有边
vector<Edge> mst; // 存储最小生成树的边
// 并查集数据结构
int parent[MAXN];
int rank[MAXN]; // 用于按秩合并
// 初始化并查集
void init(int n) {
for (int i = 1; i <= n; i++) {
parent[i] = i; // 每个节点的父节点初始为自己
rank[i] = 0; // 初始秩为0
}
}
// 查找节点所在集合的代表(带路径压缩)
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
// 合并两个集合(按秩合并)
void unionSets(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return;
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
}
// Kruskal算法实现
int kruskal(int n) {
// 初始化并查集
init(n);
// 对所有边按权值排序
sort(edges.begin(), edges.end());
int totalWeight = 0; // 最小生成树的总权值
int edgeCount = 0; // 已加入生成树的边数
// 按权值从小到大遍历所有边
for (const Edge& e : edges) {
int rootU = find(e.u);
int rootV = find(e.v);
// 如果加入这条边不会形成环
if (rootU != rootV) {
mst.push_back(e); // 将边加入MST
totalWeight += e.weight;
unionSets(rootU, rootV); // 合并集合
edgeCount++;
// 如果已经有n-1条边,MST构建完成
if (edgeCount == n - 1) {
break;
}
}
}
// 检查是否形成了一个完整的生成树
return (edgeCount == n - 1) ? totalWeight : -1; // -1表示图不连通
}
时间复杂度分析
- 排序:O(E log E)
- 并查集操作:近似O(E)(使用路径压缩和按秩合并)
- 总时间复杂度:O(E log E) 或 O(E log V),因为E最多为V²,所以log E = O(log V)
Prim算法
【算法】Prim算法也是一种贪心算法,但它基于顶点的选择策略:每次选择与已选顶点集合相邻的、权值最小的边所连接的顶点。
算法步骤
- 选择任意一个顶点作为起点,加入已选顶点集合S
- 初始化一个数组key,key[v]表示顶点v与集合S中顶点相连的最小边权
- 重复以下步骤直到所有顶点都被选择:
- 在未选择的顶点中找出key值最小的顶点u,将u加入S
- 更新所有与u相邻的未选择顶点v的key值
代码实现
Prim算法有朴素实现和堆优化两个版本,下面是堆优化的实现:
const int MAXN = 100005;
const int INF = 0x3f3f3f3f;
struct Edge {
int to, weight;
Edge(int _to, int _w) : to(_to), weight(_w) {}
};
vector<Edge> graph[MAXN]; // 邻接表表示的图
bool visited[MAXN]; // 记录顶点是否已加入MST
int key[MAXN]; // key[i]表示顶点i与已选顶点集合相连的最小边权
int prim(int n) {
// 初始化
for (int i = 1; i <= n; i++) {
key[i] = INF;
visited[i] = false;
}
// 从顶点1开始
key[1] = 0;
// 使用优先队列(小顶堆)
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
pq.push({0, 1}); // {key值, 顶点编号}
int totalWeight = 0;
int vertexCount = 0; // 记录已加入MST的顶点数
while (!pq.empty() && vertexCount < n) {
int u = pq.top().second;
int w = pq.top().first;
pq.pop();
if (visited[u]) continue;
visited[u] = true;
totalWeight += w;
vertexCount++;
// 更新u的所有邻接点的key值
for (const Edge& e : graph[u]) {
int v = e.to;
int weight = e.weight;
if (!visited[v] && weight < key[v]) {
key[v] = weight;
pq.push({key[v], v});
}
}
}
// 检查是否所有顶点都已加入MST
return (vertexCount == n) ? totalWeight : -1; // -1表示图不连通
}
时间复杂度分析
- 堆优化的Prim算法时间复杂度:O(E log V)
- 朴素Prim算法时间复杂度:O(V²)
Kruskal vs Prim:算法对比
算法 | 时间复杂度 | 适用情况 | 优点 | 缺点 |
---|---|---|---|---|
Kruskal | O(E log E) | 适合稀疏图 | 实现简单,易于理解 | 需要排序所有边 |
Prim | O(E log V)(堆优化) | 适合稠密图 | 不需要预先排序所有边 | 实现相对复杂 |
特殊情况处理
次小生成树
次小生成树是指权值和仅次于最小生成树的生成树。求解次小生成树的步骤:
- 找出最小生成树T
- 对于T中的每条边e:
- 暂时移除e
- 在不考虑e的情况下,找出连接e两端点的最小权边e'(e'不在T中)
- 计算T - e + e'的总权值
- 所有可能替换方案中,权值和最小的即为次小生成树
最小生成森林
当图不连通时,我们需要求解最小生成森林,即每个连通分量的最小生成树的集合。Kruskal算法自然支持求解最小生成森林。
应用场景
- 网络设计:设计成本最低的网络连接方案
- 电路布线:寻找最短的布线方式
- 聚类算法:如连通图的单链接聚类
- 近似算法:用于解决旅行商问题的近似算法
习题推荐
- POJ 1287 Networking(基础MST练习)
- POJ 1789 Truck History(Kruskal实践)
- POJ 2485 Highways(Prim算法实践)
- POJ 1679 The Unique MST(判断MST是否唯一)
- POJ 3723 Conscription(最大生成树的应用)
解题技巧
- 预处理:处理复杂问题时,可能需要先将问题转化为标准MST问题
- 完全图构造:有时需要构建完全图,然后求MST
- 离线处理:当边的信息不同时给出时,可以先存储所有信息再处理
- 最大生成树:只需将所有边权取负,然后求最小生成树即可