20. 最小生成树:普里姆算法与克鲁斯卡尔算法

聊到图论,有个问题我当年面试时被问过不下五次——最小生成树。说白了,就是在一个带权无向连通图里,找一棵树,把所有顶点都连起来,并且边的总权重最小。

你想想看,现实里这玩意儿能干嘛?铺光缆、修铁路、建电网……核心就一句话:怎么花最少的钱,把所有点都连上

解决这个问题,有两个经典算法:普里姆(Prim)克鲁斯卡尔(Kruskal)。它们思路完全不同,但都能给出正确答案。我个人习惯根据图的稠密程度来选,后面我会细说。

20.1 普里姆算法(Prim)

普里姆算法的思路,有点像“从一颗种子长成一棵树”。

具体做法:

  • 随便选一个顶点作为起点,把它放进“已选集合”里。
  • 每次从“已选集合”到“未选集合”的所有边里,挑一条最短的。
  • 把这条边连着的那个新顶点,拉进“已选集合”。
  • 重复,直到所有顶点都进来。

嗯,这里要注意:每次只扩展一个顶点,而且永远不形成环。

20.1.1 代码实现(邻接矩阵版)

#define INF 99999
#define V 5

int prim(int graph[V][V]) {
    int parent[V];      // 记录每个顶点的父节点
    int key[V];         // 记录到已选集合的最小边权
    int visited[V] = {0};

    for (int i = 0; i < V; i++) key[i] = INF;
    key[0] = 0;
    parent[0] = -1;

    for (int count = 0; count < V - 1; count++) {
        // 找当前 key 值最小的未访问顶点
        int u = -1, min = INF;
        for (int i = 0; i < V; i++) {
            if (!visited[i] && key[i] < min) {
                min = key[i];
                u = i;
            }
        }

        visited[u] = 1;

        // 更新邻居的 key 值
        for (int v = 0; v < V; v++) {
            if (graph[u][v] && !visited[v] && graph[u][v] < key[v]) {
                parent[v] = u;
                key[v] = graph[u][v];
            }
        }
    }

    // 打印结果
    int total = 0;
    for (int i = 1; i < V; i++) {
        printf("边 %d - %d  权重 %d\n", parent[i], i, graph[i][parent[i]]);
        total += graph[i][parent[i]];
    }
    return total;
}
我的习惯:如果图比较稠密(边数接近 n²),我会优先用 Prim。因为它的时间复杂度是 O(n²),跟边数无关,稠密图下反而比 Kruskal 快。

20.1.2 时间复杂度

实现方式时间复杂度
邻接矩阵 + 普通查找O(V²)
邻接表 + 二叉堆O(E log V)
邻接表 + 斐波那契堆O(E + V log V)

我在项目中遇到过用邻接矩阵写 Prim 的情况——顶点数只有几十个,邻接矩阵写起来最省事,性能也完全够用。没必要为了“高级”而用堆。

20.2 克鲁斯卡尔算法(Kruskal)

克鲁斯卡尔的思路完全不同。它不看顶点,只看边。

做法:

  • 把所有边按权重从小到大排序。
  • 从小到大依次取边,如果这条边连接的两个顶点不在同一个集合里,就把它加入生成树。
  • 否则跳过(避免成环)。
  • 直到选了 V-1 条边为止。

这里的关键是判断两个顶点是否连通——用并查集(Union-Find)最合适。

20.2.1 代码实现

typedef struct {
    int src, dest, weight;
} Edge;

typedef struct {
    int V, E;
    Edge* edges;
} Graph;

// 并查集:找根
int find(int parent[], int i) {
    if (parent[i] != i)
        parent[i] = find(parent, parent[i]);  // 路径压缩
    return parent[i];
}

// 并查集:合并
void unionSet(int parent[], int x, int y) {
    int rootX = find(parent, x);
    int rootY = find(parent, y);
    parent[rootX] = rootY;
}

// 比较函数,用于 qsort
int cmp(const void* a, const void* b) {
    return ((Edge*)a)->weight - ((Edge*)b)->weight;
}

void kruskal(Graph* graph) {
    int V = graph->V;
    Edge result[V];
    int e = 0, i = 0;

    qsort(graph->edges, graph->E, sizeof(Edge), cmp);

    int parent[V];
    for (int v = 0; v < V; v++) parent[v] = v;

    while (e < V - 1 && i < graph->E) {
        Edge next = graph->edges[i++];

        int x = find(parent, next.src);
        int y = find(parent, next.dest);

        if (x != y) {
            result[e++] = next;
            unionSet(parent, x, y);
        }
    }

    // 打印结果
    for (i = 0; i < e; i++)
        printf("边 %d - %d  权重 %d\n", result[i].src, result[i].dest, result[i].weight);
}
我曾经踩过的坑:写 Kruskal 时忘了做路径压缩,结果 find 函数退化成 O(n),在大图上直接超时。记住:并查集一定要做路径压缩 + 按秩合并,否则性能会崩。

20.2.2 时间复杂度

步骤复杂度
边排序O(E log E)
并查集操作O(E α(V))
总复杂度O(E log E)

α(V) 是阿克曼函数的反函数,增长极慢,可以近似看作常数。所以 Kruskal 的瓶颈在排序上。

20.3 两种算法对比

对比项普里姆(Prim)克鲁斯卡尔(Kruskal)
核心思路从顶点出发,逐步扩展从边出发,从小到大选
依赖数据结构优先队列 / 数组并查集
时间复杂度O(V²) 或 O(E log V)O(E log E)
适用场景稠密图(边多)稀疏图(边少)
是否依赖起点

你想想看,如果图里顶点多但边很少,比如一个城市群只有几条主干道,那 Kruskal 排序边的代价就很小,反而 Prim 要维护一个很大的 key 数组,不划算。

20.4 知识体系图

下面这张图,帮你把两个算法的核心逻辑串起来:

最小生成树算法体系 最小生成树 普里姆算法 (Prim) 克鲁斯卡尔算法 (Kruskal) 从顶点出发 逐步扩展 优先队列 边排序 从小到大选边 并查集判环 稠密图 → Prim | 稀疏图 → Kruskal 两者都能得到全局最优解(贪心策略)

20.5 选型建议

  • 图比较稠密(边数接近 V²):用 Prim,邻接矩阵版就够。
  • 图比较稀疏(边数接近 V):用 Kruskal,排序 + 并查集效率很高。
  • 边权可能为负:两个算法都要求边权非负,否则不保证正确。
  • 需要实时加边:Kruskal 更灵活,因为它是离线算法,可以随时加入新边重新排序。

核心要点:

  • Prim 是“点扩展”,Kruskal 是“边选择”。
  • 两者都是贪心算法,且都能得到全局最优解。
  • 并查集是 Kruskal 的灵魂,路径压缩一定要写。
  • Prim 的堆优化版本在稀疏图上也有不错表现,但代码量会大一些。

好了,最小生成树就聊到这儿。这两种算法你最好都手写一遍,光看是记不住的。我当年就是看完觉得懂了,结果面试让写 Kruskal,并查集写错了,面完才反应过来……嗯,别学我。


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