Bootstrap

最小生成树的构造

在图论中,最小生成树(MST,Minimum Spanning Tree)是一个包含图中所有顶点且边的总权重最小的树。Prim 算法是求解最小生成树的一种经典算法,它通过逐步选择图中最小的边来构造树。接下来,我们将详细介绍 Prim 算法的工作原理、实现步骤及其复杂度分析。

1. 什么是最小生成树(MST)?

在一个加权无向图中,生成树是一个包含所有顶点的子图,并且该子图没有环。在所有的生成树中,最小生成树是总边权最小的生成树。

最小生成树的特点:

  • 每个顶点都必须在树中出现。
  • 每条边的权重都要尽可能小。
  • 树中没有环路。

最小生成树的问题可以通过不同的算法来求解,常见的算法包括 Prim 算法Kruskal 算法

2. Prim 算法的基本思想

Prim 算法是通过逐步扩展生成树来构建最小生成树的。具体的过程如下:

  1. 从一个任意的节点出发,将其标记为生成树的一个节点。
  2. 每次选择一条最小的边,将其对应的节点加入到生成树中。
  3. 重复步骤2,直到所有的节点都被包含在生成树中。

简而言之,Prim 算法通过逐步选取当前生成树外与生成树内的最小边,将未连接的节点添加到生成树中,直到所有节点都被包含。

3. Prim 算法的步骤

假设我们有一个加权无向图,V 表示图中的所有顶点集合,E 表示图中的所有边集合。Prim 算法的具体步骤如下:

  1. 初始化:

    • 选择一个任意的起始顶点,将它加入最小生成树(MST)。
    • 为每个顶点保持一个“最小边权”值。初始时,将起始顶点的边权值设为0,其余顶点的边权值设为∞。
  2. 选择最小的边:

    • 在所有连接生成树中的顶点和生成树外的顶点的边中,选择边权值最小的边。
    • 将该边对应的顶点加入生成树。
  3. 更新边权:

    • 更新所有与新加入的顶点相邻的、未加入生成树的顶点的边权值。
    • 选择新的最小边,继续执行。
  4. 重复上述步骤:

    • 重复步骤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 算法的核心思想是:

  • 从图中选择边的权重最小的边,逐步构建生成树。
  • 每次选择未在生成树中的两顶点之间的最小权重边,且选择的边不会形成环。

具体步骤如下:

  1. 排序:首先将图中的所有边按权重从小到大排序。
  2. 初始化:为每个顶点建立一个集合(或者用并查集结构表示),开始时每个顶点自成一个集合。
  3. 逐边选择:按排序后的顺序,逐一检查每条边:
    • 如果这条边的两个端点属于不同的集合,则将其加入最小生成树,并将这两个集合合并。
    • 如果这条边的两个端点已经在同一个集合中,则跳过这条边,防止形成环。
  4. 停止条件:直到最小生成树包含了 V−1V-1V−1 条边(其中 VVV 为图的顶点数)时,算法终止。
7. Kruskal 算法的步骤
  1. 初始化:初始化一个空的最小生成树 MST,准备存储最终的边集。对所有边进行排序。
  2. 遍历所有边:按权重从小到大遍历所有边,对于每一条边:
    • 判断这条边连接的两个顶点是否属于不同的集合。
    • 如果属于不同的集合,则将这条边加入 MST,并将这两个集合合并。
    • 如果已经在同一个集合中,则跳过这条边,避免形成环。
  3. 结束条件:当 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(Elog⁡E)。
    • 并查集操作:每次查询和合并的时间复杂度为 O(α(V)),其中 α 是反阿克曼函数(非常缓慢增长)。因此总的并查集操作复杂度为 O(Eα(V))。

    所以,Kruskal 算法的总时间复杂度为:

    O(Elog⁡E+Eα(V))≈O(Elog⁡E)

    其中,V 是图的顶点数,E 是图的边数。

  • 空间复杂度:

    • 存储图的边需要 O(E) 空间,
    • 存储并查集需要 O(V)空间,因此空间复杂度为 O(V+E)。
6. Kruskal 算法与 Prim 算法的对比
特性Kruskal 算法Prim 算法
算法类型基于边的选择基于顶点的扩展
数据结构并查集(Union-Find)最小堆或优先队列(Priority Queue)
适用情况边较少的稀疏图顶点较少的稠密图
处理方式按边排序后逐条选边从一个点出发逐步选择与已选树相连的最小边
时间复杂度O(Elog⁡E)O(Elog⁡V)(用优先队列实现)
空间复杂度O(E+V)O(V)
7. 总结

Kruskal 算法通过对图中的边进行排序,并使用并查集(Union-Find)来逐步选择不形成环的最小边,从而构建最小生成树。它与 Prim 算法 相比,主要的不同在于处理方式:Kruskal 算法是基于边的选择,而 Prim 算法是基于顶点的扩展。两者各有优势,Kruskal 算法通常适用于稀疏图,而 Prim 算法适用于稠密图。

Prim 算法是求解最小生成树的经典算法,它通过逐步构建生成树的方式,确保每一步选择的边都是当前生成树与未加入顶点之间的最小边,最终得到一个权重最小的生成树。该算法适用于加权无向图,并且通过合理的数据结构(如优先队列)优化了效率。

Kruskal 算法 不同,Prim 算法逐步扩展生成树,而 Kruskal 算法则是通过将边排序后按顺序选择最小的边来构建生成树。两者的适用场景有所不同,但都可以有效地求解最小生成树问题。

;