图的最小生成树

7 阅读12分钟

最小生成树(Minimum Spanning Tree)

什么是最小生成树?

给定一个连通无向带权图 G = (V, E),其中 V 是顶点集合,E 是边集合。G 的生成树是一棵包含 V 中所有顶点的树,它是 G 的一个子图,包含全部 n 个顶点和 n-1 条边。

在 G 的所有生成树中,边权总和最小的那一棵生成树称为最小生成树

关键性质

  • 包含全部 n 个顶点
  • 恰好包含 n-1 条边
  • 是一棵树(连通且无环)
  • 边权和在所有生成树中最小

应用场景

  • 城市间铺设光缆/公路,使所有城市连通且总造价最小
  • 电网布线设计
  • 网络聚类分析

克鲁斯卡尔(Kruskal)算法

Kruskal 算法是一种使用贪婪方法的最小生成树算法。该算法初始将图视为森林,图中的每一个顶点视为一棵单独的树。一棵树只与它的邻接顶点中权值最小且不违反最小生成树属性(不构成环)的树之间建立连边。

算法步骤

  1. 把所有边按权值从小到大排序
  2. 按权值从小到大的顺序依次选择边,判断能否加入最小生成树
    • 判断方法:利用并查集,检查边的两个端点是否在同一个连通分量中
    • 如果不在同一分量,则选择这条边,合并两个分量
    • 如果在同一分量,则跳过(否则会形成环)
  3. 当已选择 n-1 条边时,最小生成树构建完成

代码实现

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* 给定一个带权无向连通图:
 * n(10 <= n <= 100) 个点,m 条边,顶点编号为 0 ~ n-1
 * 求该图的最小生成树的边权和 */

int n, m;
typedef struct {
    int u, v;  // 两个端点
    int w;     // 权值
} Edge;

Edge e[10005];    // 边集数组
int f[105];       // 并查集中的父亲数组

// 按权值对边排序(选择排序实现)
void Sortt(int l, int r) {
    int minn;
    Edge t;
    for (int i = l; i <= r - 1; i++) {
        minn = i;
        for (int j = i; j <= r; j++) {
            if (e[j].w < e[minn].w) {
                minn = j;
            }
        }
        t = e[minn];
        e[minn] = e[i];
        e[i] = t;
    }
}

// 并查集查找(带路径压缩)
int Find(int x) {
    if (f[x] == x) {
        return x;
    } else {
        return f[x] = Find(f[x]);
    }
}

void Kruskal() {
    // 初始化并查集:每个顶点自成一个集合
    for (int i = 0; i < n; i++) {
        f[i] = i;
    }

    int cnt = 0;   // 已选择的边数
    int sum = 0;   // 边权和
    int fu = 0, fv = 0;

    for (int i = 1; i <= m; i++) {
        // 判断这条边的两个端点是否属于同一个集合
        fu = Find(e[i].u);
        fv = Find(e[i].v);

        if (fu != fv) {
            // 不属于同一集合 → 可选,不会构成环
            printf("选 %d 和 %d 之间 边权为 %d 的边\n", e[i].u, e[i].v, e[i].w);
            sum += e[i].w;
            cnt++;
            f[fv] = fu;   // 合并两个集合
        }

        if (cnt == n - 1) {
            break;   // 已选够 n-1 条边,最小生成树形成
        }
    }
    printf("最小生成树的边权和为:%d\n", sum);
}

int main() {
    scanf("%d %d", &n, &m);
    int x, y, w;
    for (int i = 1; i <= m; i++) {
        scanf("%d %d %d", &x, &y, &w);
        e[i].u = x;
        e[i].v = y;
        e[i].w = w;
    }
    Sortt(1, m);   // 按边权排序
    Kruskal();
    return 0;
}

/*
输入样例:
9 15
0 1 3
0 5 4
1 6 6
6 5 7
1 2 8
1 8 5
2 8 2
2 3 12
8 3 11
6 3 14
6 7 9
5 4 18
3 7 6
7 4 1
3 4 10
*/

关键代码解析

1. 边集数组 Edge e[]

typedef struct {
    int u, v;   // 两个端点
    int w;      // 权值
} Edge;
Edge e[10005];

Kruskal 算法是对边操作,因此用边集数组存储图。每条边记录两个端点和权值即可,不需要邻接矩阵或邻接表。

2. 排序函数 Sortt

void Sortt(int l, int r) {
    int minn;
    Edge t;
    for (int i = l; i <= r - 1; i++) {
        minn = i;
        for (int j = i; j <= r; j++) {
            if (e[j].w < e[minn].w) {
                minn = j;
            }
        }
        t = e[minn];
        e[minn] = e[i];
        e[i] = t;
    }
}
  • 使用选择排序对边按权值从小到大排序
  • 外层循环从 lr-1,内层循环找到 [i..r] 中权值最小的边的下标 minn
  • 找到后将 e[i]e[minn] 交换
  • 排序是整个 Kruskal 算法的前提——只有边有序,才能按贪心策略依次选取

3. 并查集查找 Find

int Find(int x) {
    if (f[x] == x) {
        return x;
    } else {
        return f[x] = Find(f[x]);  // 路径压缩
    }
}
  • 递归查找顶点 x 所在集合的根节点("祖先")
  • 路径压缩:在查找过程中,将沿途所有节点直接挂到根节点下,加速后续查找
  • 返回 x 所属集合的代表元素

4. Kruskal 核心逻辑

for (int i = 1; i <= m; i++) {
    fu = Find(e[i].u);
    fv = Find(e[i].v);
    if (fu != fv) {
        sum += e[i].w;
        cnt++;
        f[fv] = fu;   // 合并
    }
    if (cnt == n - 1) break;
}
  • 按权值从小到大遍历每条边
  • Find(e[i].u) != Find(e[i].v) 说明 u 和 v 当前不在同一连通分量中——选这条边不会形成环
  • 选择边后,f[fv] = fu 将两个分量合并
  • cnt 记录已选边数,达到 n-1 时提前退出

5. 为什么选这条边不会形成环?

初始:每个顶点独立
  0    1    2    3    4
  ↓    ↓    ↓    ↓    ↓
 [0]  [1]  [2]  [3]  [4]

选择边 0-1(权 3):01 不在同一集合 → 合并
 [0]──[1]   2    3    4
  ↓    ↓
 {0, 1}

选择边 0-5(权 4):05 不在同一集合 → 合并
 [0]──[1]  [2]  [3]  [4]  [5]
  ↓    ↓                   ↓
 {0,1,5}

假设之后遇到边 1-5(权 7):
  Find(1) = 0, Find(5) = 0 → 祖先相同 → 跳过(否则成环)

时间复杂度分析

1. 排序函数 Sortt

  • 两条嵌套循环,执行次数为 (m-1) + (m-2) + ... + 1 = m(m-1)/2
  • 时间复杂度:O(m²)

若改用快速排序或归并排序,排序部分可降至 O(m log m),是实际工程中的标准做法。

2. 并查集操作

  • 路径压缩后,单次 Find 的均摊时间复杂度接近 O(α(n)),其中 α 为 Ackermann 函数的反函数,可认为是一个极小的常数(通常 ≤ 4)
  • Kruskal 主循环中最多执行 m 次 Find 和 n-1 次合并操作
  • 总时间复杂度:接近 O(m)

3. 整体复杂度

步骤操作复杂度(当前实现)复杂度(优化后)
排序选择排序 / 快速排序O(m²)O(m log m)
主循环m 次 Find + 合并O(m α(n)) ≈ O(m)O(m α(n))
总计O(m²)O(m log m)
  • 当 m 较大时,排序是 Kruskal 的主要瓶颈
  • 当前代码使用选择排序,对于 m = 10000 时性能尚可接受,但建议工程中改用 qsort

空间复杂度

  • 边集数组 e[m+1]O(m)
  • 并查集数组 f[n]O(n)
  • 总空间复杂度:O(m + n)

普里姆(Prim)算法

Prim 算法在找最小生成树时,将顶点分为两类:一类是在查找的过程中已经包含在生成树中的顶点(假设为 A 类),剩下的为另一类(假设为 B 类)。

算法步骤

  1. 任选一个顶点作为生成树的起点(并标记),将其到生成树的距离设为 0,其他顶点到生成树的距离初始化为无穷大
  2. 更新起点所有邻接点到生成树的距离
  3. 在未加入生成树的顶点中,选择距离生成树最近的那个顶点,加入生成树并标记
  4. 更新新加入顶点的邻接点到生成树的距离(取原有值与新边权值的较小值)
  5. 重复步骤 3-4,直到所有 n 个顶点都加入生成树

代码实现

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define inf 10001

/* 给定一个带权无向连通图:
 * n(<=100) 个点,m 条边,顶点编号为 0 ~ n-1
 * 边权都小于 10000
 * 输出最小生成树的边权和 */

int n, m;
int dist[105];     // 各顶点到生成树的距离
int flag[105];     // 标记顶点是否已加入生成树
int g[105][105];   // 邻接矩阵

int mymin(int a, int b) {
    return a < b ? a : b;
}

void Prim() {
    int s = 1;         // 任选一点(此处选顶点 1)作为起点
    dist[s] = 0;       // 起点到生成树的距离为 0
    int minn;          // dist 最小的顶点
    int t;             // 保存 dist[minn]
    int sum = 0;       // 边权和

    for (int i = 1; i <= n; i++) {   // 循环 n 次,每次加入一个顶点
        // 找未加入生成树中 dist 最小的顶点
        t = inf;
        minn = -1;
        for (int j = 0; j < n; j++) {
            if (flag[j] == 0 && dist[j] < t) {
                minn = j;
                t = dist[j];
            }
        }

        // 将 minn 加入生成树
        printf("点 %d 通过边权为 %d 的边加入到生成树中\n", minn, t);
        flag[minn] = 1;      // 标记
        sum += t;

        // 更新 minn 的邻接点到生成树的距离
        for (int j = 0; j < n; j++) {
            if (flag[j] == 0 && g[minn][j] < inf) {
                dist[j] = mymin(dist[j], g[minn][j]);
            }
        }
    }
    printf("边权总和为 %d\n", sum);
}

int main() {
    scanf("%d %d", &n, &m);

    // 初始化邻接矩阵和 dist 数组
    for (int i = 0; i < n; i++) {
        dist[i] = inf;
        for (int j = 0; j < n; j++) {
            g[i][j] = inf;
        }
    }

    int x, y, w;
    for (int i = 1; i <= m; i++) {
        scanf("%d %d %d", &x, &y, &w);
        g[x][y] = g[y][x] = w;    // 无向图
    }

    Prim();
    return 0;
}

/*
输入样例:
9 15
0 1 3
0 5 4
1 6 6
6 5 7
1 2 8
1 8 5
2 8 2
2 3 12
8 3 11
6 3 14
6 7 9
5 4 18
3 7 6
7 4 1
3 4 10
*/

关键代码解析

1. 核心数据结构

int dist[105];     // 顶点到生成树的距离
int flag[105];     // 是否已加入生成树
int g[105][105];   // 邻接矩阵存图
  • dist[j]:顶点 j 到当前生成树的最短距离,若 j 已在树中则为 0
  • flag[j]:标记顶点是否已被选入生成树
  • g[i][j]:邻接矩阵存储图中边 i-j 的权值,无直接连边则为 inf

2. Prim 核心逻辑

// 找未加入生成树中 dist 最小的顶点
t = inf;
minn = -1;
for (int j = 0; j < n; j++) {
    if (flag[j] == 0 && dist[j] < t) {
        minn = j;
        t = dist[j];
    }
}

// 将 minn 加入生成树
flag[minn] = 1;
sum += t;

// 更新 minn 的邻接点到生成树的距离
for (int j = 0; j < n; j++) {
    if (flag[j] == 0 && g[minn][j] < inf) {
        dist[j] = mymin(dist[j], g[minn][j]);
    }
}

每一步做三件事:

  1. 选点:遍历所有未加入的顶点,找出 dist 最小的那个(离生成树最近)
  2. 加入:标记该顶点已加入,累加边权
  3. 更新:扫描新顶点的所有邻接点,若通过新顶点到生成树距离更短,则更新 dist

3. 执行流程示例

初始:以顶点 1 为起点

顶点:    0    1    2    3    4    5    6    7    8
dist:   ∞    0    ∞    ∞    ∞    ∞    ∞    ∞    ∞
flag:   0    1    0    0    0    0    0    0    0

第1步:选 dist 最小的未标记点 → 顶点 1(t=0,起点无代价)
  更新邻接点 dist:
  dist[0]=3, dist[2]=8, dist[6]=6, dist[8]=5

第2步:选 dist 最小的未标记点 → 顶点 0(t=3)
  更新邻接点:dist[5]=4

第3步:选 dist 最小的未标记点 → 顶点 5(t=4)
  更新邻接点:dist[4]=18, dist[6]=7(原6→新7,不变)

第4步:选 dist 最小的未标记点 → 顶点 8(t=5)
  更新邻接点:dist[2]=2(原8→新2), dist[3]=11

第5步:选 dist 最小的未标记点 → 顶点 2(t=2)
  更新邻接点:dist[3]=11→12(不变,保留11)

... 依次进行,直到所有顶点加入

4. 与 Dijkstra 算法的关系

Prim 算法的代码结构与 Dijkstra 最短路径算法非常相似,核心区别在于 dist 的含义:

算法dist[j] 的含义更新方式
Primj 到生成树的最短距离dist[j] = min(dist[j], g[minn][j])
Dijkstraj 到源点的最短路径dist[j] = min(dist[j], dist[minn] + g[minn][j])

Prim 只关心顶点到树的距离(单边权值),Dijkstra 关心到源点的累积距离(路径和)。

时间复杂度分析

1. 查找最小 dist 顶点

for (int j = 0; j < n; j++) {
    if (flag[j] == 0 && dist[j] < t) { ... }
}
  • 每次循环需要扫描 n 个顶点来找到 dist 最小且未标记的顶点
  • 外层循环执行 n 次,内层扫描 n 个顶点
  • 时间复杂度:O(n²)

2. 更新邻接点 dist

for (int j = 0; j < n; j++) {
    if (flag[j] == 0 && g[minn][j] < inf) {
        dist[j] = mymin(dist[j], g[minn][j]);
    }
}
  • 每次加入新顶点后,扫描 n 个顶点更新 dist
  • 外层循环 n 次,内层扫描 n 个顶点
  • 时间复杂度:O(n²)

3. 整体复杂度

步骤操作复杂度
初始化邻接矩阵和 dist 数组O(n²)
主循环每次找最小 dist(n 次循环 × n 次扫描)O(n²)
更新 dist每次更新邻接点(n 次循环 × n 次扫描)O(n²)
总计O(n²)
  • 对于邻接矩阵实现的 Prim,时间复杂度始终为 O(n²),与边数 m 无关
  • 因此 Prim 算法特别适合稠密图(m 接近 n²)

4. 优化方向

优化方式查找最小值更新总复杂度
邻接矩阵(当前实现)O(n) 遍历O(n) 遍历O(n²)
二叉堆 + 邻接表O(log n)O(log n)O((n+m) log n)
斐波那契堆 + 邻接表O(log n)O(1) 均摊O(n log n + m)

对于稀疏图,使用二叉堆优化的 Prim(O((n+m) log n))通常优于邻接矩阵版本。

空间复杂度

  • 邻接矩阵 g[n][n]O(n²)
  • 距离数组 dist[n]O(n)
  • 标记数组 flag[n]O(n)
  • 总空间复杂度:O(n²)

两种算法对比

对比项Kruskal 算法Prim 算法
出发点的角度出发顶点的角度出发
核心操作选权值最小的边,用并查集判环选离生成树最近的顶点加入
数据结构边集数组 + 并查集邻接矩阵 / 邻接表
时间复杂度O(m log m)(排序优化后)O(n²)(邻接矩阵)
空间复杂度O(m + n)O(n²)
适用场景稀疏图(m 远小于 n²)稠密图(m 接近 n²)
实现复杂度较低(只需边排序 + 并查集)中等(需维护 dist 数组)

如何选择?

图很稀疏(m ≈ n)    →  Kruskal 算法(O(m log m))
图很稠密(m ≈ n²)   →  Prim 算法(O(n²))
顶点数很小(n ≤ 200) →  两者均可,Prim 实现更直接
边权有负数          →  Kruskal 可用,Prim 需谨慎

两种算法都是贪心算法的正确典范——Kruskal 在全局边集上贪心,Prim 在当前顶点集合的邻接边上贪心,两者均能得到全局最优解(最小生成树)。