最小生成树(MST)原理以及 Kruskal、Prim和Borůvka算法的C++实现

最小生成树(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 算法是基于贪心思想工作的。其核心思路为:

  1. 将图中的所有边按权重从小到大排序。
  2. 从权值最小的边开始选边,判断该边加入是否形成环(用并查集判断);若不形成环,则将该边加入生成树。
  3. 重复步骤 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 算法同样遵循贪心思想,从一个顶点开始,逐步扩展最小生成树:

  1. 任意选择一个起始顶点,将其加入集合 (S)。
  2. 找到所有与集合 (S) 中顶点相连的边,从中选择权值最小、且另一端顶点不在 (S) 中的边,将该边和顶点加入生成树和集合 (S)。
  3. 重复步骤 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 算法比较适合于并行处理问题,其主要思想为:

  1. 每个连通分量(初始时每个顶点都是一个分量)都选取与外部连通的最小边。
  2. 合并所有由这些最小边连通的分量,并重复这一过程,直到整个图合并为一个连通分量。
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 算法: 适合并行处理,理论上能在一些特定场景下取长补短。
  • 实现时应注意图的连通性、数据结构选择以及边权相等时的处理等问题。