最小生成树:Prim算法、Kruskal算法、经典例题

说到图论里的最小生成树,我脑子里第一个蹦出来的场景是——几年前做城市光缆铺设项目。那时候要在几个新区之间拉光纤,每个节点都得通,但预算卡得死死的。说白了,就是要在所有节点连通的前提下,让总造价最低。这不就是典型的最小生成树问题嘛。

今天咱们就把 Prim 和 Kruskal 这两个算法掰开揉碎。我会结合自己的踩坑经验,把它们的核心逻辑、适用场景、以及代码模板都捋一遍。嗯,保证你学完就能上手。

1. 什么是最小生成树?

一个连通图,有 N 个节点。你从中选出 N-1 条边,让所有节点都连通,并且这 N-1 条边的权重之和最小。这棵树就叫最小生成树(MST)。

你想想看,如果图里有环,那肯定有冗余边。去掉环里最重的那条,总权值就降下来了。所以 MST 的核心就一句话:无环、连通、权值最小

核心要点

  • 只适用于无向连通图
  • 边数 = 节点数 - 1
  • 不能有环
  • 权值和最小

2. Prim 算法:加点法

我个人习惯把 Prim 叫做「加点法」。它的思路特别直观:从任意一个节点开始,每次找一个离当前树最近的点,把它拉进来。重复直到所有点都在树里。

为什么说它像 Dijkstra?因为两者都用了「松弛」的思想。区别在于:Dijkstra 更新的是到起点的距离,Prim 更新的是到当前树的最小距离。

2.1 算法步骤

  1. 选一个起点,加入树
  2. 更新所有未加入节点到树的距离
  3. 选距离最小的节点加入树
  4. 重复 2-3 步,直到所有节点都在树里

2.2 代码模板(邻接矩阵版)

// 返回最小生成树的权值和,n 为节点数,graph 为邻接矩阵
int prim(int n, vector<vector<int>>& graph) {
    vector<int> dist(n, INT_MAX); // 到当前树的最小距离
    vector<bool> visited(n, false);
    dist[0] = 0; // 从节点0开始
    int total = 0;

    for (int i = 0; i < n; ++i) {
        // 找未访问节点中 dist 最小的
        int u = -1;
        int minDist = INT_MAX;
        for (int j = 0; j < n; ++j) {
            if (!visited[j] && dist[j] < minDist) {
                minDist = dist[j];
                u = j;
            }
        }
        if (u == -1) return -1; // 图不连通
        visited[u] = true;
        total += minDist;

        // 更新邻居的 dist
        for (int v = 0; v < n; ++v) {
            if (!visited[v] && graph[u][v] < dist[v]) {
                dist[v] = graph[u][v];
            }
        }
    }
    return total;
}

我的经验

我曾经在稠密图(边数接近 n²)上用过 Kruskal,结果排序成了瓶颈。后来换成 Prim + 邻接矩阵,速度直接翻倍。稠密图无脑 Prim,稀疏图用 Kruskal,这个口诀很实用。

3. Kruskal 算法:加边法

Kruskal 的思路更暴力:把所有边按权重排序,从小到大一条条试。如果这条边连接的两个点还不连通,就把它加进来。直到加了 N-1 条边。

这里的关键是「判断连通性」。我最早用 DFS 每次判断,结果超时到怀疑人生。后来才意识到,并查集才是亲爹。

3.1 算法步骤

  1. 把所有边按权重升序排序
  2. 初始化并查集,每个节点自成一个集合
  3. 遍历排序后的边:如果两个端点不在同一集合,合并集合,加入这条边
  4. 当边数达到 N-1 时停止

3.2 代码模板

struct Edge {
    int u, v, w;
    bool operator<(const Edge& other) const {
        return w < other.w;
    }
};

// 并查集
vector<int> parent;
int find(int x) {
    return parent[x] == x ? x : parent[x] = find(parent[x]);
}
void unite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra != rb) parent[ra] = rb;
}

int kruskal(int n, vector<Edge>& edges) {
    sort(edges.begin(), edges.end());
    parent.resize(n);
    for (int i = 0; i < n; ++i) parent[i] = i;

    int total = 0, cnt = 0;
    for (auto& e : edges) {
        if (find(e.u) != find(e.v)) {
            unite(e.u, e.v);
            total += e.w;
            cnt++;
            if (cnt == n - 1) break;
        }
    }
    return cnt == n - 1 ? total : -1;
}

避坑指南

我曾经在 Kruskal 里忘了对边排序,结果跑出来一个乱七八糟的树。还有一次并查集没做路径压缩,大数据直接 TLE。记住:排序 + 路径压缩 + 按秩合并,缺一不可。

4. 两种算法对比

维度 Prim Kruskal
核心思想 加点 加边
依赖数据结构 优先队列 / 邻接矩阵 并查集
时间复杂度 O(n²) 或 O(m log n) O(m log m)
适用场景 稠密图 稀疏图
是否依赖起点

5. 经典例题:连接所有点的最小费用

这是 LeetCode 1584 题。给你一堆点的坐标,两点之间的曼哈顿距离就是边的权重。要你连接所有点,求最小总费用。

说白了,这就是个裸的最小生成树。但有个小坑:如果直接用 Prim,邻接矩阵需要 n² 的空间,n 到 1000 时还能扛。如果 n 到 10⁵,就必须用 Kruskal + 边优化了。

5.1 思路分析

  • 计算所有点对之间的曼哈顿距离,生成边列表
  • 用 Kruskal 求 MST
  • 注意:完全图的边数是 n*(n-1)/2,n 较大时可能超内存。可以优化:只对每个点连接最近的几个点(比如按 x 或 y 排序后连相邻点)

5.2 代码实现(Kruskal 版)

int minCostConnectPoints(vector<vector<int>>& points) {
    int n = points.size();
    vector<Edge> edges;
    for (int i = 0; i < n; ++i) {
        for (int j = i + 1; j < n; ++j) {
            int dist = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]);
            edges.push_back({i, j, dist});
        }
    }
    return kruskal(n, edges);
}

优化技巧

如果 n 很大(比如 10⁵),上面的 O(n²) 边数会爆。我一般会先按 x 坐标排序,只连相邻的 2-3 个点,再按 y 排序连一遍。这样边数降到 O(n),正确性有理论保证(曼哈顿距离的 MST 性质)。

6. 知识体系总览

下面这张图是我自己梳理的 MST 知识框架。你看一眼,心里就有谱了。

最小生成树 Prim 算法(加点) Kruskal 算法(加边) 稠密图优先 邻接矩阵 / 堆 类似 Dijkstra 稀疏图优先 并查集 + 排序 边数少时高效 选择策略:稠密 Prim,稀疏 Kruskal

7. 写在最后

最小生成树这块,说白了就是两个套路。Prim 像「贪心扩张」,Kruskal 像「贪心筛选」。我个人更偏爱 Kruskal,因为并查集写起来很清爽,而且边排序后还能做其他优化。但遇到稠密图,我会毫不犹豫切回 Prim。

你在刷题时如果遇到「连接所有点」「铺设线路」「网络布线」这类字眼,十有八九就是 MST。先判断图的稠密程度,再选算法,基本不会跑偏。

一句话总结

Prim 加点,Kruskal 加边。稠密用 Prim,稀疏用 Kruskal。并查集和堆,是这两个算法的灵魂。

公众号:蓝海资料掘金营,微信deep3321