最小生成树详解

1. 定义

在连通的带权图的所有生成树中,权值和最小的那棵生成树(包含途中所有顶点的树),称作最小生成树。

举例说明

比如用微信群组的例子来解释最小生成树的逻辑。
假设有 5 个人(A、B、C、D、E)想建一个微信群组,每个人之间单独建群聊天会耗费一定的手机流量(权值)。现在要让这 5 个人都能在同一个聊天网络里,但又想让总流量耗费最小。
选起点 :先随便选一个人,比如选 A。A 目前在自己的 “小群” 里。
找最小连接 :看看和 A 相连的人(B、C)中,谁和 A 聊天流量耗费最少。假设 A 和 B 聊天流量耗费为 2,A 和 C 聊天流量耗费为 5,那选和 B 建立联系,把 B 拉进群。
重复找最小 :现在群里有 A 和 B。再看看和 A、B 相连还没进群的人(C、D)。A 和 C 聊天流量耗费 5,B 和 D 聊天流量耗费 3,那选拉 D 进来。
继续找最小 :现在群里有 A、B、D。看和这三个人相连还没进群的人(C、E)。假设 D 和 C 聊天流量耗费为 1,B 和 E 聊天流量耗费为 4,那选拉 C 进来。
重复直到全进群 :现在群里有 A、B、D、C。最后看和这四个人相连还没进群的人(E)。比如 D 和 E 聊天流量耗费为 6,C 和 E 聊天流量耗费为 2,那选拉 E 进来。
最终,就形成了一个把所有人都拉进群且总流量耗费最小的聊天网络,这就是最小生成树的逻辑。
 

2. 相关算法
 

2.1 Prim算法(普利姆算法)

从任意一个点开始,每次都把“离已选集合最近”的新点拉进来,直到所有点都在集合里。


🥞 生活比喻:Prim 算法就像拼 Wi-Fi 覆盖

想象你搬进了一栋 4 个房间的屋子,每个房间都要连上网。
你手上有几根不同长度的网线,目标是用最少的网线总长让所有房间都能上网。

  1. 随便挑一间房(比如客厅 A)先插上路由器,客厅就算“已覆盖”。

  2. 看所有能从已覆盖房间拉到未覆盖房间的网线,选最短的那根,把对应的房间也拉进来。
    客厅→卧室 B 这根最短?好,把卧室 B 也盖上。

  3. 现在客厅 + 卧室都覆盖了,再看新的“边界网线”:
    卧室→厨房 2 米,客厅→书房 4 米 → 选 2 米,把厨房拉进来。

  4. 最后只剩书房,再拉一根最短的网线把书房也接上。

最终拉的几根网线就是最小生成树——总长度最短,全屋 Wi-Fi 通!


Prim算法的三种实现策略

1️⃣ 邻接矩阵朴素版 O(V²)

  • V 是点数,E 是边数。

  • 矩阵 = 一个二维表,存“每两点之间有没有边”。

  • 过程
    每选 1 个点,就要把整个 V×V 的表扫一遍找最短边。
    共选 V 次,所以 V×V = V²。

  • 生活例子
    你要把全班 100 个同学的姓名、电话写满一张 100×100 的通讯录。
    每找 1 个人的号码,都得把整页翻完——慢,但简单直接。

  • 适用场景
    图很稠密(边很多,几乎每两点都有边),矩阵反而省内存。

    // 复杂度来源:两层 for 循环,每层最多 V 次,共 V*V
    public static int primMatrix(int[][] g) {
        int n = g.length;
        boolean[] inMST = new boolean[n];
        int[] minEdge = new int[n];            // 到已选集合的最小边权
        Arrays.fill(minEdge, Integer.MAX_VALUE);
        minEdge[0] = 0;
    
        for (int cnt = 0; cnt < n; cnt++) {
            int u = -1;
            // ① 每次扫整个数组找最小 => O(V)
            for (int i = 0; i < n; i++)
                if (!inMST[i] && (u == -1 || minEdge[i] < minEdge[u]))
                    u = i;
    
            if (u == -1) return -1;            // 图不连通
            inMST[u] = true;
    
            // ② 再扫一遍更新 => O(V)
            for (int v = 0; v < n; v++)
                if (!inMST[v] && g[u][v] != 0 && g[u][v] < minEdge[v])
                    minEdge[v] = g[u][v];
        }
        return Arrays.stream(minEdge).sum();   // 最小生成树权值和
    }

2️⃣ 二叉堆 + 邻接表 O(E log V)

  • 邻接表:只存“每个点真正连出去的边”,不存空边。

  • 二叉堆(优先队列):每次把“当前最短边”快速弹出来。

  • 过程
    每条边最多进堆一次,每次堆操作是 log V,所以 E 条边 × log V = E log V

  • 生活例子
    用微信的“附近的人”功能:只列出你周围 20 个好友,再用小顶堆(按距离排序)找出最近 1 个。
    比翻全班通讯录快多了。

  • 适用场景
    现实中 90% 的图都是稀疏图(边远少于 V²),这是最常用的写法

    // 复杂度来源:每条边最多进堆一次 => E 次 push/pop,每次 log V
    public static long primBinaryHeap(int n, List<List<Edge>> adj) {
        boolean[] inMST = new boolean[n];
        int[] minEdge = new int[n];
        Arrays.fill(minEdge, Integer.MAX_VALUE);
        PriorityQueue<Edge> pq = new PriorityQueue<>(Comparator.comparingInt(e -> e.w));
        minEdge[0] = 0;
        pq.add(new Edge(0, 0));
    
        long total = 0;
        while (!pq.isEmpty()) {
            Edge cur = pq.poll();
            int u = cur.v;
            if (inMST[u]) continue;
            inMST[u] = true;
            total += cur.w;
    
            for (Edge e : adj.get(u)) {
                int v = e.v, w = e.w;
                if (!inMST[v] && w < minEdge[v]) {
                    minEdge[v] = w;
                    pq.offer(new Edge(v, w));   // O(log V)
                }
            }
        }
        return total;
    }

3️⃣ 斐波那契堆 O(E + V log V)

  • 斐波那契堆是一个更高级的优先队列,支持“减小键值”操作均摊 O(1)。

  • 过程
    总操作 = 把 E 条边各插一次 + V 次弹出最小元素(每次 log V)
    E + V log V

  • 生活例子
    相当于你有一个“魔法抽屉”,能在 1 秒内把任何好友的距离改小,而普通抽屉每次改完都要重新排顺序。
    魔法抽屉更快,但造起来巨麻烦。

  • 适用场景
    写论文、刷比赛想冲极限性能;工程里几乎不用,因为代码复杂、常数大。

    /* 伪代码:斐波那契堆尚未进入 JDK 标准库。
     * 若真要用,可引入第三方库(如 algs4, fastutil)或自己实现。
     * 这里给出核心调用逻辑以展示复杂度来源:
     *
     * - decreaseKey 均摊 O(1)  => 总 E 次
     * - extractMin  均摊 O(log V) => 总 V 次
     * 于是总体 O(E + V log V)
     */
    public static long primFibonacci(int n, List<List<Edge>> adj) {
        // FibonacciHeap<Node> heap = new FibonacciHeap<>();
        // 其余逻辑与二叉堆版完全一致,只是把 PriorityQueue 换成 FibonacciHeap
        // 并调用 heap.decreaseKey(...) 而非 offer(...)
        throw new UnsupportedOperationException("需要外部 FibonacciHeap 实现");
    }

统一测试入口

public static void main(String[] args) {
    /* 同一组数据,三种实现跑出的结果都是 6 */
    int n = 4;
    int[][] matrix = new int[n][n];
    matrix[0][1] = matrix[1][0] = 1;
    matrix[1][2] = matrix[2][1] = 2;
    matrix[2][3] = matrix[3][2] = 3;
    matrix[0][3] = matrix[3][0] = 4;

    List<List<Edge>> adj = new ArrayList<>();
    for (int i = 0; i < n; i++) adj.add(new ArrayList<>());
    addEdge(adj, 0, 1, 1);
    addEdge(adj, 1, 2, 2);
    addEdge(adj, 2, 3, 3);
    addEdge(adj, 0, 3, 4);

    System.out.println("Matrix  O(V²)        = " + primMatrix(matrix));
    System.out.println("Binary  O(E log V)   = " + primBinaryHeap(n, adj));
    // System.out.println("FibHeap O(E+V log V) = " + primFibonacci(n, adj));
}

static void addEdge(List<List<Edge>> adj, int u, int v, int w) {
    adj.get(u).add(new Edge(v, w));
    adj.get(v).add(new Edge(u, w));
}

static class Edge {
    int v, w;
    Edge(int v, int w) { this.v = v; this.w = w; }
}

🧩 复杂度对照小结(代码级)

实现方式关键循环复杂度公式代码体现
邻接矩阵朴素版两层 for (int i=0;i<n;i++)O(V²)每次扫整行/整列
二叉堆版PriorityQueue.poll/offerO(E log V)每边最多一次 log V
斐波那契堆版decreaseKey + extractMinO(E + V log V)均摊 O(1) 改键 + V 次 O(log V)

🧩 为什么 Prim 会有这三种实现?

Prim 的核心思想永远不变:

“每次把离已选集合最近的顶点拉进来”

但怎么“找到最近的顶点”有三种工具:

  1. 暴力扫整张表 → 用 邻接矩阵 → 复杂度 O(V²)
    (每个点进来后,把剩下所有点距离扫一遍)

  2. 用二叉堆(优先队列) → 复杂度 O(E log V)
    (把边扔到堆里,每次 log V 弹出最小)

  3. 用斐波那契堆 → 复杂度 O(E + V log V)
    (堆支持 O(1) 的“减小键值”,更精细)


 一句话总结

Prim 算法就是“扩张领土法”:已占的城邦不断吞并最近的邻国,直到一统天下且修路总成本最低。

2.2 Kruskal算法(克鲁斯卡尔算法/贪心算法)

按权重从小到大选边,避环合并,直至n-1条边连起所有顶点。


🥞 生活比喻:装修布线
  • 房间 = 顶点

  • 电线 = 边,长度 = 权值

  • 目标:用最少总长度把所有房间连成一个电网

  • 规则:

    1. 把所有电线从短到长排好队。

    2. 一根一根往屋里放。

    3. 如果这根电线会把已经连通的房间再连一次(出现圈),就丢掉。

    4. 直到所有房间都通电,剩下的电线就是最小生成树


算法步骤(图例)
顶点:0  1  2  3
边:
0–1 1
1–2 2
2–3 3
0–3 4
步骤动作选中边连通分量累计长度
初始排序{0},{1},{2},{3}0
取最短 0–10–1{0,1},{2},{3}1
取下一条 1–21–2{0,1,2},{3}1+2=3
再取下一条 2–32–3{0,1,2,3}3+3=6
0–3 会成圈丢弃

结果:最小生成树边 = {0–1, 1–2, 2–3},总长度 = 6


核心数据结构
  • 并查集(Union-Find)
    判断“两点是否已经连通”,以及合并两个集合。


Java 完整代码(含复杂度标注)
import java.util.*;

/**
 * Kruskal 算法:最小生成树
 * 复杂度:O(E log E) = O(E log V)  (排序 + 并查集)
 */
public class KruskalMST {

    /* ================= 边 ================= */
    static class Edge implements Comparable<Edge> {
        int u, v, w;
        Edge(int u, int v, int w) {
            this.u = u;
            this.v = v;
            this.w = w;
        }
        @Override
        public int compareTo(Edge o) {
            return Integer.compare(this.w, o.w); // 按权值升序
        }
    }

    /* ================= 并查集 ================= */
    static class UnionFind {
        int[] parent, rank;
        UnionFind(int n) {
            parent = new int[n];
            rank = new int[n];
            for (int i = 0; i < n; i++) parent[i] = i;
        }
        int find(int x) {
            if (parent[x] != x) parent[x] = find(parent[x]); // 路径压缩
            return parent[x];
        }
        boolean union(int x, int y) {
            int rx = find(x), ry = find(y);
            if (rx == ry) return false; // 已连通
            // 按秩合并
            if (rank[rx] < rank[ry]) parent[rx] = ry;
            else if (rank[rx] > rank[ry]) parent[ry] = rx;
            else {
                parent[ry] = rx;
                rank[rx]++;
            }
            return true;
        }
    }

    /* ================= Kruskal ================= */
    public static long kruskal(int n, List<Edge> edges) {
        // 1. 排序所有边 O(E log E)
        Collections.sort(edges);

        UnionFind uf = new UnionFind(n);
        long total = 0;
        int cnt = 0;

        // 2. 依次取最短边
        for (Edge e : edges) {
            if (uf.union(e.u, e.v)) { // 不会成圈
                total += e.w;
                cnt++;
                if (cnt == n - 1) break; // 已选 n-1 条边
            }
        }
        return cnt == n - 1 ? total : -1; // 不连通返回 -1
    }

    /* ================= 测试 ================= */
    public static void main(String[] args) {
        int n = 4;
        List<Edge> edges = new ArrayList<>();
        edges.add(new Edge(0, 1, 1));
        edges.add(new Edge(1, 2, 2));
        edges.add(new Edge(2, 3, 3));
        edges.add(new Edge(0, 3, 4));

        System.out.println("Kruskal MST 总权值 = " + kruskal(n, edges)); // 6
    }
}

 一句话总结

Kruskal
“排好队,一根一根拿,不成圈就连,直到连完所有点!”

2.3 Prim 和 Kruskal 在稠密图和稀疏图的应用场景有什么不同?

稠密图 → Prim(邻接矩阵 O(V²) 或二叉堆 O(E log V))更快;
稀疏图 → Kruskal(O(E log V))写起来更简单。


为什么?

图类型特点推荐算法原因
稠密图(边多,接近 V²)E ≈ V²Prim 邻接矩阵 O(V²)常数极小,无需排序全部边
Prim 二叉堆 O(E log V)在 E ≈ V² 时 ≈ V² log V,略慢于矩阵,但代码通用
稀疏图(边少,E ≈ V 或 V log V)E ≪ V²Kruskal O(E log V)只需排序少量边,并查集操作极快;代码短、易写
Prim 二叉堆 O(E log V)复杂度同 Kruskal,但实现稍长,可用

实战口诀

  • 边多到爆炸(稠密)→ Prim(矩阵或堆)。

  • 边少到可怜(稀疏)→ Kruskal(排序 + 并查集)。

复杂度对照表(Prim vs Kruskal)

算法核心操作时间复杂度适用场景
Prim 邻接矩阵暴力找最小O(V²)稠密图
Prim 二叉堆二叉堆O(E log V)稀疏图
Prim 斐波那契堆高级堆O(E + V log V)理论极限
Kruskal排序 + 并查集O(E log E) = O(E log V)稀疏图/边可排序
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sevenlumos

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值