C语言图论:最小生成树算法

本文献给:

已掌握图论基础,希望理解如何在带权连通图中找到最小生成树的C语言学习者。本文将系统讲解两种经典的最小生成树算法。

你将学到:

  1. 最小生成树问题的定义与核心概念
  2. Prim算法:从顶点出发,逐步扩张生成树
  3. Kruskal算法:按边权排序,逐步合并连通分量
  4. 算法对比与实战应用

@toc

第一部分:问题定义与核心概念

1. 什么是最小生成树?

在带权连通无向图 G=(V,E,w)G = (V, E, w)G=(V,E,w) 中,w:E→Rw: E \rightarrow \mathbb{R}w:E→R 为边权函数。生成树 是GGG的一个子图,它是一棵包含GGG中所有顶点的树。最小生成树是所有生成树中边权之和最小的生成树。

关键术语:

  • 连通图:图中任意两个顶点都有路径相连。
  • 生成树 :包含所有顶点的树,有∣V∣−1|V|-1∣V∣−1条边。
  • 边权:通常为非负实数,表示距离、成本等。
  • 最小生成树性质:最小生成树不一定唯一,但边权之和唯一。

第二部分:图的存储(带权图)

与最短路径相同,我们使用带权图的存储方式。

c 复制代码
#define MAX_V 100
#define INF 0x3f3f3f3f // 表示"无穷大"的一个较大数值

// 邻接矩阵(带权)
typedef struct {
    int matrix[MAX_V][MAX_V]; // 存储权值,INF表示无边
    int vertex_count;
} GraphMatrixWeighted;

void init_graph_weighted(GraphMatrixWeighted* g, int n) {
    g->vertex_count = n;
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            g->matrix[i][j] = (i == j) ? 0 : INF; // 自身距离为0,但生成树中不考虑自环
        }
    }
}

void add_edge_weighted(GraphMatrixWeighted* g, int u, int v, int weight) {
    if (u >= g->vertex_count || v >= g->vertex_count) return;
    g->matrix[u][v] = weight;
    g->matrix[v][u] = weight; // 无向图
}

第三部分:Prim算法

1. 核心思想

贪心策略。从任意一个顶点开始,每次选择连接已选顶点集合和未选顶点集合的最小权值边,并将该边连接的未选顶点加入已选集合。直到所有顶点都被选中。

算法正确性依赖:最小生成树的局部最优选择性质。

2. 算法步骤(朴素 O(∣V∣2)O(|V|^2)O(∣V∣2) 实现)

  1. 初始化:选择一个起始顶点,设置visited[src]=1,初始化min_edge[v]为从起始顶点到v的边权(无边则为INF)。
  2. 循环|V|-1次:
    a. 从未访问的顶点中选择min_edge值最小的顶点u
    b. 将顶点u加入生成树(标记visited[u]=1),累加生成树权重。
    c. 更新min_edge数组:对于每个未访问的顶点v,如果matrix[u][v] < min_edge[v],则更新min_edge[v] = matrix[u][v]
  3. 循环结束,得到最小生成树的总权重。

3. C语言实现

c 复制代码
// Prim算法 (邻接矩阵,朴素实现)
int prim(GraphMatrixWeighted* g) {
    int visited[MAX_V] = {0};
    int min_edge[MAX_V]; // 记录当前已选顶点集合到未选顶点的最小边权
    int total_weight = 0;
    
    // 初始化,从顶点0开始
    visited[0] = 1;
    for (int i = 0; i < g->vertex_count; i++) {
        min_edge[i] = g->matrix[0][i];
    }
    
    // 还需要选择 |V|-1 个顶点
    for (int count = 1; count < g->vertex_count; count++) {
        // 找到未访问的顶点中 min_edge 最小的顶点
        int u = -1;
        int min_weight = INF;
        for (int v = 0; v < g->vertex_count; v++) {
            if (!visited[v] && min_edge[v] < min_weight) {
                min_weight = min_edge[v];
                u = v;
            }
        }
        if (u == -1) {
            // 图不连通,无法形成生成树
            return -1;
        }
        
        visited[u] = 1;
        total_weight += min_weight;
        
        // 更新 min_edge 数组
        for (int v = 0; v < g->vertex_count; v++) {
            if (!visited[v] && g->matrix[u][v] < min_edge[v]) {
                min_edge[v] = g->matrix[u][v];
            }
        }
    }
    
    return total_weight;
}

算法复杂度:

  • 时间复杂度 :O(∣V∣2)O(|V|^2)O(∣V∣2),适合稠密图。
  • 空间复杂度 :O(∣V∣)O(|V|)O(∣V∣)。
  • 优化方向 :使用**优先队列(最小堆)**可将时间复杂度降为 O((∣V∣+∣E∣)log⁡∣V∣)O((|V|+|E|) \log |V|)O((∣V∣+∣E∣)log∣V∣),适合稀疏图。

第四部分:Kruskal算法

1. 核心思想

贪心策略 。将图中的所有边按权值从小到大排序,然后从权值最小的边开始,如果这条边连接的两个顶点不在同一个连通分量中,则选择这条边,并将两个连通分量合并。直到选择了∣V∣−1|V|-1∣V∣−1条边。

算法正确性依赖:最小生成树的全局最优选择性质。

2. 算法步骤

  1. 将图中所有边按权值从小到大排序。
  2. 初始化一个并查集,每个顶点自成一个集合。
  3. 依次考察每条边(按权值从小到大):
    • 如果该边连接的两个顶点属于不同的集合(即不连通),则选择该边,并将两个集合合并。
    • 如果属于同一个集合,则选择这条边会形成环,因此舍弃。
  4. 当选择的边数达到∣V∣−1|V|-1∣V∣−1时,算法结束。

3. 并查集(Disjoint Set)辅助数据结构

Kruskal算法需要并查集来快速判断两个顶点是否属于同一个连通分量。

c 复制代码
// 并查集实现
typedef struct {
    int parent[MAX_V];
    int rank[MAX_V];
} DisjointSet;

void make_set(DisjointSet* ds, int n) {
    for (int i = 0; i < n; i++) {
        ds->parent[i] = i;
        ds->rank[i] = 0;
    }
}

int find(DisjointSet* ds, int x) {
    if (ds->parent[x] != x) {
        ds->parent[x] = find(ds, ds->parent[x]); // 路径压缩
    }
    return ds->parent[x];
}

void union_set(DisjointSet* ds, int x, int y) {
    int root_x = find(ds, x);
    int root_y = find(ds, y);
    if (root_x != root_y) {
        // 按秩合并
        if (ds->rank[root_x] < ds->rank[root_y]) {
            ds->parent[root_x] = root_y;
        } else if (ds->rank[root_x] > ds->rank[root_y]) {
            ds->parent[root_y] = root_x;
        } else {
            ds->parent[root_y] = root_x;
            ds->rank[root_x]++;
        }
    }
}

4. Kruskal算法实现

为了便于操作,我们使用边列表来存储图。

c 复制代码
// 边结构体
typedef struct {
    int u, v;
    int weight;
} Edge;

// 图(边列表)
typedef struct {
    Edge edges[MAX_V * MAX_V];
    int edge_count;
    int vertex_count;
} GraphEdgeList;

// 比较函数,用于排序
int compare_edges(const void* a, const void* b) {
    Edge* edge_a = (Edge*)a;
    Edge* edge_b = (Edge*)b;
    return edge_a->weight - edge_b->weight;
}

// Kruskal算法
int kruskal(GraphEdgeList* g) {
    // 1. 将边按权值排序
    qsort(g->edges, g->edge_count, sizeof(Edge), compare_edges);
    
    // 2. 初始化并查集
    DisjointSet ds;
    make_set(&ds, g->vertex_count);
    
    int total_weight = 0;
    int edges_selected = 0;
    
    // 3. 遍历每条边
    for (int i = 0; i < g->edge_count; i++) {
        int u = g->edges[i].u;
        int v = g->edges[i].v;
        int weight = g->edges[i].weight;
        
        // 如果u和v不在同一个集合中
        if (find(&ds, u) != find(&ds, v)) {
            union_set(&ds, u, v);
            total_weight += weight;
            edges_selected++;
            if (edges_selected == g->vertex_count - 1) {
                break;
            }
        }
    }
    
    // 如果选出的边数不足 |V|-1,则图不连通
    if (edges_selected != g->vertex_count - 1) {
        return -1;
    }
    
    return total_weight;
}

算法复杂度:

  • 时间复杂度 :O(∣E∣log⁡∣E∣)O(|E| \log |E|)O(∣E∣log∣E∣),主要开销在排序。
  • 空间复杂度 :O(∣V∣+∣E∣)O(|V| + |E|)O(∣V∣+∣E∣)。

第五部分:总结与对比

算法对比表

特性 Prim算法 Kruskal算法
适用图类型 连通图(通常稠密图) 连通图(通常稀疏图)
核心思想 从顶点出发,逐步扩张生成树 按边权排序,逐步合并连通分量
时间复杂度 O(∣V∣2)O(|V|^2)O(∣V∣2) 或 O((∣V∣+∣E∣)log⁡∣V∣)O((|V|+|E|)\log|V|)O((∣V∣+∣E∣)log∣V∣) O(∣E∣log⁡∣E∣)O(|E|\log|E|)O(∣E∣log∣E∣)
空间复杂度 O(∣V∣)O(|V|)O(∣V∣) O(∣V∣+∣E∣)O(|V|+|E|)O(∣V∣+∣E∣)
优点 适合稠密图,实现简单 适合稀疏图,边排序后操作简单
缺点 稠密图时效率高,稀疏图不如Kruskal 稀疏图时效率高,稠密图排序开销大
存储结构 邻接矩阵或邻接表 边列表

选择指南

  1. 稠密图 :优先使用 Prim算法(朴素实现即可)。
  2. 稀疏图 :优先使用 Kruskal算法,因为其时间复杂度与边数有关,排序开销相对较小。
  3. 图存储结构:如果图本身是边列表形式,使用Kruskal算法更方便;如果是邻接矩阵或邻接表,Prim算法可能更方便。

觉得文章有帮助?别忘了:
👍 点赞 👍 - 给我一点鼓励
⭐ 收藏 ⭐ - 方便以后查看
🔔 关注 🔔 - 获取更新通知


标签: #C语言 #图论 #最小生成树 #Prim算法 #Kruskal算法 #算法

相关推荐
Yzzz-F2 小时前
算法竞赛进阶指南 进阶搜索
算法·深度优先
weixin_437546332 小时前
注释文件夹下脚本的Debug
java·linux·算法
月明长歌3 小时前
【码道初阶】【LeetCode 572】另一棵树的子树:当“递归”遇上“递归”
算法·leetcode·职场和发展
月明长歌3 小时前
【码道初阶】【LeetCode 150】逆波兰表达式求值:为什么栈是它的最佳拍档?
java·数据结构·算法·leetcode·后缀表达式
C雨后彩虹3 小时前
最大数字问题
java·数据结构·算法·华为·面试
java修仙传3 小时前
力扣hot100:搜索二维矩阵
算法·leetcode·矩阵
喵了meme3 小时前
C语言实战3
c语言·开发语言
浅川.253 小时前
xtuoj 字符串计数
算法
天`南3 小时前
【群智能算法改进】一种改进的金豺优化算法IGJO[1](动态折射反向学习、黄金正弦策略、自适应能量因子)【Matlab代码#94】
学习·算法·matlab