在图论中,最小生成树(MST,Minimum Spanning Tree)是一个包含图中所有顶点且边的总权重最小的树。Prim 算法是求解最小生成树的一种经典算法,它通过逐步选择图中最小的边来构造树。接下来,我们将详细介绍 Prim 算法的工作原理、实现步骤及其复杂度分析。
1. 什么是最小生成树(MST)?
在一个加权无向图中,生成树是一个包含所有顶点的子图,并且该子图没有环。在所有的生成树中,最小生成树是总边权最小的生成树。
最小生成树的特点:
- 每个顶点都必须在树中出现。
- 每条边的权重都要尽可能小。
- 树中没有环路。
最小生成树的问题可以通过不同的算法来求解,常见的算法包括 Prim 算法 和 Kruskal 算法。
2. Prim 算法的基本思想
Prim 算法是通过逐步扩展生成树来构建最小生成树的。具体的过程如下:
- 从一个任意的节点出发,将其标记为生成树的一个节点。
- 每次选择一条最小的边,将其对应的节点加入到生成树中。
- 重复步骤2,直到所有的节点都被包含在生成树中。
简而言之,Prim 算法通过逐步选取当前生成树外与生成树内的最小边,将未连接的节点添加到生成树中,直到所有节点都被包含。
3. Prim 算法的步骤
假设我们有一个加权无向图,V 表示图中的所有顶点集合,E 表示图中的所有边集合。Prim 算法的具体步骤如下:
-
初始化:
- 选择一个任意的起始顶点,将它加入最小生成树(MST)。
- 为每个顶点保持一个“最小边权”值。初始时,将起始顶点的边权值设为0,其余顶点的边权值设为∞。
-
选择最小的边:
- 在所有连接生成树中的顶点和生成树外的顶点的边中,选择边权值最小的边。
- 将该边对应的顶点加入生成树。
-
更新边权:
- 更新所有与新加入的顶点相邻的、未加入生成树的顶点的边权值。
- 选择新的最小边,继续执行。
-
重复上述步骤:
- 重复步骤2和步骤3,直到所有顶点都被加入到生成树中。
4. 复杂度分析
-
时间复杂度:
- 如果使用 邻接矩阵 存储图,Prim 算法的时间复杂度是 O(V²),因为在每一步都要扫描所有的顶点。
- 如果使用 邻接表 存储图并利用 二叉堆 或 斐波那契堆,时间复杂度可以优化到:
- 二叉堆:O(E log V)
- 斐波那契堆:O(E + V log V)
其中,V 是图中的顶点数,E 是图中的边数。
-
空间复杂度: O(V + E),主要是存储图的结构和辅助数据(如最小边权、父节点)。
5. Prim 算法的实现
接下来,我们用 C++ 实现一个基于 邻接表 和 优先队列(二叉堆)的 Prim 算法。
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
using namespace std;
typedef pair<int, int> pii; // (边权, 目标顶点)
const int INF = INT_MAX;
const int MAX_V = 1000; // 假设图中最多1000个顶点
vector<pii> adj[MAX_V]; // 邻接表表示图
int dist[MAX_V]; // 保存每个顶点到生成树的最短边权
int parent[MAX_V]; // 记录生成树中每个顶点的父节点
void Prim(int start, int V) {
priority_queue<pii, vector<pii>, greater<pii>> pq; // 最小优先队列
fill(dist, dist + V, INF); // 初始化所有顶点的边权为INF
dist[start] = 0; // 起始点的边权为0
pq.push({0, start}); // 将起始点加入队列
while (!pq.empty()) {
int u = pq.top().second; // 当前顶点
pq.pop();
// 遍历与u相邻的所有顶点
for (auto& edge : adj[u]) {
int v = edge.second; // 目标顶点
int weight = edge.first; // 边的权值
// 如果v还未加入生成树,并且通过u到v的边能缩小v的边权
if (dist[v] > weight) {
dist[v] = weight;
parent[v] = u; // 更新v的父节点为u
pq.push({dist[v], v}); // 将v加入队列
}
}
}
}
int main() {
int V, E; // 顶点数和边数
cin >> V >> E;
// 输入图的边
for (int i = 0; i < E; ++i) {
int u, v, w;
cin >> u >> v >> w;
adj[u].push_back({w, v});
adj[v].push_back({w, u});
}
int start = 0; // 从顶点0开始
Prim(start, V);
// 输出生成树的结果
cout << "Minimum Spanning Tree edges:" << endl;
for (int i = 1; i < V; ++i) {
cout << parent[i] << " - " << i << " with weight " << dist[i] << endl;
}
return 0;
}
6. Kruskal 算法的基本思想
Kruskal 算法的核心思想是:
- 从图中选择边的权重最小的边,逐步构建生成树。
- 每次选择未在生成树中的两顶点之间的最小权重边,且选择的边不会形成环。
具体步骤如下:
- 排序:首先将图中的所有边按权重从小到大排序。
- 初始化:为每个顶点建立一个集合(或者用并查集结构表示),开始时每个顶点自成一个集合。
- 逐边选择:按排序后的顺序,逐一检查每条边:
- 如果这条边的两个端点属于不同的集合,则将其加入最小生成树,并将这两个集合合并。
- 如果这条边的两个端点已经在同一个集合中,则跳过这条边,防止形成环。
- 停止条件:直到最小生成树包含了 V−1V-1V−1 条边(其中 VVV 为图的顶点数)时,算法终止。
7. Kruskal 算法的步骤
- 初始化:初始化一个空的最小生成树 MST,准备存储最终的边集。对所有边进行排序。
- 遍历所有边:按权重从小到大遍历所有边,对于每一条边:
- 判断这条边连接的两个顶点是否属于不同的集合。
- 如果属于不同的集合,则将这条边加入 MST,并将这两个集合合并。
- 如果已经在同一个集合中,则跳过这条边,避免形成环。
- 结束条件:当 MST 中的边数等于 V−1V-1V−1 时,算法结束。
8. Kruskal 算法的实现
接下来,我们通过 C++ 实现 Kruskal 算法。为了方便处理集合合并和查找,我们使用 并查集(Union-Find)数据结构来帮助判定是否形成环。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Edge {
int u, v, weight; // 边的两个顶点u和v,边的权重
bool operator<(const Edge& other) const {
return weight < other.weight; // 按权重排序
}
};
int parent[1000]; // 父节点数组
int rank[1000]; // 记录树的深度,优化并查集
// 并查集的查找操作,路径压缩
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
// 并查集的合并操作,按秩合并
void union_sets(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
}
}
// Kruskal算法求最小生成树
vector<Edge> Kruskal(int V, vector<Edge>& edges) {
// 初始化并查集
for (int i = 0; i < V; ++i) {
parent[i] = i;
rank[i] = 0;
}
// 按权重排序所有的边
sort(edges.begin(), edges.end());
vector<Edge> MST; // 存放最小生成树的边
for (const auto& edge : edges) {
int u = edge.u;
int v = edge.v;
// 如果u和v不在同一个集合中,说明没有环,可以加入MST
if (find(u) != find(v)) {
MST.push_back(edge);
union_sets(u, v); // 合并集合
}
}
return MST;
}
int main() {
int V, E; // 顶点数和边数
cin >> V >> E;
vector<Edge> edges(E);
// 输入所有边
for (int i = 0; i < E; ++i) {
cin >> edges[i].u >> edges[i].v >> edges[i].weight;
}
// 调用Kruskal算法求解最小生成树
vector<Edge> MST = Kruskal(V, edges);
// 输出生成树的结果
cout << "Minimum Spanning Tree edges:" << endl;
for (const auto& edge : MST) {
cout << edge.u << " - " << edge.v << " with weight " << edge.weight << endl;
}
return 0;
}
5. 复杂度分析
-
时间复杂度:
- 排序边:排序所有 E 条边的时间复杂度为 O(ElogE)。
- 并查集操作:每次查询和合并的时间复杂度为 O(α(V)),其中 α 是反阿克曼函数(非常缓慢增长)。因此总的并查集操作复杂度为 O(Eα(V))。
所以,Kruskal 算法的总时间复杂度为:
O(ElogE+Eα(V))≈O(ElogE)其中,V 是图的顶点数,E 是图的边数。
-
空间复杂度:
- 存储图的边需要 O(E) 空间,
- 存储并查集需要 O(V)空间,因此空间复杂度为 O(V+E)。
6. Kruskal 算法与 Prim 算法的对比
特性 | Kruskal 算法 | Prim 算法 |
---|---|---|
算法类型 | 基于边的选择 | 基于顶点的扩展 |
数据结构 | 并查集(Union-Find) | 最小堆或优先队列(Priority Queue) |
适用情况 | 边较少的稀疏图 | 顶点较少的稠密图 |
处理方式 | 按边排序后逐条选边 | 从一个点出发逐步选择与已选树相连的最小边 |
时间复杂度 | O(ElogE) | O(ElogV)(用优先队列实现) |
空间复杂度 | O(E+V) | O(V) |
7. 总结
Kruskal 算法通过对图中的边进行排序,并使用并查集(Union-Find)来逐步选择不形成环的最小边,从而构建最小生成树。它与 Prim 算法 相比,主要的不同在于处理方式:Kruskal 算法是基于边的选择,而 Prim 算法是基于顶点的扩展。两者各有优势,Kruskal 算法通常适用于稀疏图,而 Prim 算法适用于稠密图。
Prim 算法是求解最小生成树的经典算法,它通过逐步构建生成树的方式,确保每一步选择的边都是当前生成树与未加入顶点之间的最小边,最终得到一个权重最小的生成树。该算法适用于加权无向图,并且通过合理的数据结构(如优先队列)优化了效率。
与 Kruskal 算法 不同,Prim 算法逐步扩展生成树,而 Kruskal 算法则是通过将边排序后按顺序选择最小的边来构建生成树。两者的适用场景有所不同,但都可以有效地求解最小生成树问题。