☰
Kruskal算法详解:C语言实现最小生成树的贪心与并查集技巧
2026/9/28 16:09:21 网站建设 项目流程

1. 从"连通所有站点"说起:Kruskal算法到底在解决什么问题

先说一个很实际的需求场景:假设你是某个乡镇的网络布线负责人,手里有N个村子的接入点,乡镇府批了一笔光纤铺设预算,要求把N个村子全部接入主干网,同时光纤总里程必须最短。村子之间有些山梁、河沟和现有道路,不同路段的铺设成本差异很大,怎么选线才能既保证所有村子连通,又把总成本压到最低?

这类问题在计算机科学里有一个规范的名字——最小生成树(Minimum Spanning Tree, MST)。它的定义很直白:在一个带权无向图中,找出一个包含全部N个顶点、由N-1条边构成的连通子图,并且这N-1条边的权值之和最小。如果这个图是"疏"的,即边数远小于顶点数的平方,那么Kruskal算法几乎是最直观、最容易用C语言手写的解法之一。

Kruskal算法的核心思想用一句话概括就是:贪心 + 并查集。先把所有边按权值从小到大排序,然后从最小的边开始,逐条尝试加入生成树的边集,每次加入前判断一下——如果这条边的两个端点已经在同一个连通块里,就放弃它,否则就把它纳入结果集,并合并这两个端点所在的连通块。循环直到选够了N-1条边为止。

这个算法之所以值得拿出来单独写一篇C语言实现的文章,不光是因为它逻辑清晰,更因为在具体编码时,你会遇到好几个"纸上谈兵容易、落地实现踩坑"的细节:并查集的路径压缩怎么写才不容易出错、边的排序用qsort还是自写排序、遇到权值相同的边怎么处理、结构体数组的大小怎么算边界……这篇文章会从原理到代码、从代码到踩坑,完整过一遍。适合刚学完C语言基础、想进阶图论算法的学生,也适合需要用C语言做课程设计或比赛刷题的人。

2. 动手前的准备:C语言环境和数据结构选型

2.1 编译器和项目组织

Kruskal算法本身没有平台相关性,用任何标准C编译器都可以,我在实际开发和调试中用的是**Visual Studio Code + GCC(MinGW-w64)**的组合,命令行编译命令很简单:

gcc -o kruskal kruskal.c -std=c11 -Wall -Wextra

其中-Wall -Wextra一定要开,因为Kruskal这种涉及数组下标和循环条件的代码,编译器给到的警告往往能提前暴露越界或未初始化的问题。如果你用的是Visual Studio,直接把源文件加入项目即可,不需要额外配置什么第三方库,整个实现全部基于C标准库。

2.2 图的数据结构设计

Kruskal算法处理的对象是"边",不是"邻接矩阵"也不是"邻接表"。这是它和Prim算法的一个明显差异:Prim更偏向于稠密图,通常用邻接矩阵或邻接表配合"维护候选边集"的思路;而Kruskal天然适合**边集数组(Edge List)**这种简洁的数据结构,直接用结构体数组把所有边存储起来。

我常用的边结构体定义如下:

#define MAXV 100 // 最大顶点数 #define MAXE 5000 // 最大边数(按无向图计算,每条边存储一次) typedef struct { int u; // 边的起点 int v; // 边的终点 int w; // 边的权值 } Edge;

这里要特别注意一个约定:在Kruskal算法中,图是有向还是无向,体现在输入边的存储方式上。如果是无向图,比如输入"1 3 8"表示顶点1和顶点3之间有一条权值为8的路径,那么我们在边集数组里只存一条{1, 3, 8}就够了,不需要另外存{3, 1, 8}。因为Kruskal的判断逻辑只关心两个端点是否连通,不关心方向,重复存储只会让排序和选边的效率白白降低。

2.3 并查集是Kruskal的灵魂

Kruskal算法的"判断加入某条边是否会形成环"这一步,靠的就是并查集(Union-Find Set)。它的模型理解起来像一个班级里的"小团体":每个顶点一开始都只属于自己,当选中一条边时,就把两个端点所在的团体合并成一个;如果某条边的两个端点已经属于同一个团体,那加上这条边必然形成环,必须跳过。

并查集通常用两个数组实现:

int parent[MAXV]; // parent[i] 表示顶点i的父节点 int rank[MAXV]; // rank[i] 表示以i为根的树的高度(近似值)

初始化时让parent[i] = i,表示每个顶点孤立成团。查找操作要带路径压缩,合并操作要按**秩(rank)**合并,目的是把并查集的查找均摊复杂度降到近O(1),让整个算法的时间瓶颈几乎只剩排序。

3. Kruskal算法的完整代码实现:从边排序到环路判断

3.1 按权值给所有边排序

既然贪心策略要求"从小到大依次尝试",那排序就是Kruskal的第一道工序。在C语言里,最省事且最不容易写错的方式是使用标准库的qsort函数,它需要我们自己写一个比较函数:

int cmpEdge(const void *a, const void *b) { Edge *ea = (Edge *)a; Edge *eb = (Edge *)b; return ea->w - eb->w; // 按权值升序排列 }

然后这样调用:

qsort(edges, m, sizeof(Edge), cmpEdge);

其中m是边数,edges是我们的边数组。这里有个小坑值得提醒:qsort的比较函数返回值是int,如果你直接用return a->w - b->w;,当权值最大值和最小值差距超过int的表数范围时会产生溢出,不过实际题目里边的权值一般不超过10^5级别,不会出问题;但更稳妥的习惯是写成return (a->w > b->w) - (a->w < b->w);,这个写法在任何情况下都绝对安全。

如果你不想依赖标准库,也可以自己写一个堆排序或归并排序,实现方式也不难。但既然C标准库已经把高效的排序封装好了,没必要重复造轮子,除非是学习目的想看内部实现。

3.2 并查集的查找与合并

接下来是关键部分:并查集基础函数。

int find(int x) { // 查找x所属的集合根节点,同时做路径压缩 if (parent[x] != x) { parent[x] = find(parent[x]); } return parent[x]; } void unionSet(int x, int y) { int rx = find(x); int ry = find(y); if (rx == ry) return; // 按秩合并:把矮树根接到高树根下,防止并查集退化成链表 if (rank[rx] < rank[ry]) { parent[rx] = ry; } else if (rank[rx] > rank[ry]) { parent[ry] = rx; } else { parent[ry] = rx; rank[rx]++; } }

find里的parent[x] = find(parent[x]);就是路径压缩:递归找到根之后,顺手把路径上经过的所有节点的父节点直接指向根。这样下次再找这些节点的根,就只需要一步。这里的递归深度在并查集中不会成为问题,因为有了按秩合并,树高最多是O(logN)量级,加上路径压缩后基本是常数级别。

3.3 选边主循环

现在排序有了、并查集有了,主循环的逻辑就非常顺了:

int kruskal(int n, int m, Edge *edges, Edge *result) { qsort(edges, m, sizeof(Edge), cmpEdge); for (int i = 0; i < n; i++) { parent[i] = i; rank[i] = 0; } int cnt = 0; // 已选边数 int weight = 0; // 生成树总权值 int index = 0; // result数组的下标 for (int i = 0; i < m && cnt < n - 1; i++) { int ru = find(edges[i].u); int rv = find(edges[i].v); if (ru != rv) { // 两端点不在同一个集合中,这条边可以选 result[index++] = edges[i]; weight += edges[i].w; unionSet(ru, rv); // 注意这里直接传根节点 cnt++; } } if (cnt != n - 1) { // 说明图不连通,没有覆盖全部顶点 return -1; } return weight; }

这里有一个很容易被忽视的细节:unionSet(ru, rv)里我传的是已经find出来的根节点,而不是原始端点。这是因为unionSet内部还会再调find一次,但如果我们已经知道根了,就可以省掉这次重复查找。反过来,如果你直接在循环里写unionSet(edges[i].u, edges[i].v);,逻辑也没错,只是多了一次查找开销。代码风格上我倾向于循环里先取根、后合并,读者看起来更清楚。

3.4 为什么"加入不会形成环"的判断条件就是ru != rv

可能有人会问:为什么只要两个顶点的根不同,就一定能加这条边?这里有个几何直觉:如果u和v已经在同一个连通块里,那么它们之间已经存在一条路径,再加一条直接边就会在图中形成一个闭合回路,而最小生成树的定义里要求"无环";反过来,如果它们不在同一个连通块,这条边就相当于是"连接两座孤岛的一座桥",加入它既能扩大连通范围,又不会形成环。

这个过程其实就是一种特殊的贪心策略。Kruskal算法的正确性呢,有严格的数学证明(主要依赖"割属性"和"环路属性"),但理解了"不连通则必为桥"这一点,已经足够你在代码中放心使用。

4. 完整可运行的示例代码与测试用例

4.1 整合一个可直接跑的程序

为避免读者在拼接代码时出错,我给出一个完整可直接编译运行的版本。这个程序会读取顶点数n和边数m,然后逐行读取每条边的u v w,最后输出最小生成树的总权值以及选中的每条边。

#include <stdio.h> #include <stdlib.h> #define MAXV 100 #define MAXE 5000 typedef struct { int u, v, w; } Edge; Edge edges[MAXE]; Edge result[MAXE]; int parent[MAXV]; int rank[MAXV]; int cmpEdge(const void *a, const void *b) { Edge *ea = (Edge *)a; Edge *eb = (Edge *)b; return (ea->w > eb->w) - (ea->w < eb->w); } int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); } return parent[x]; } void unionSet(int x, int y) { if (rank[x] < rank[y]) { parent[x] = y; } else if (rank[x] > rank[y]) { parent[y] = x; } else { parent[y] = x; rank[x]++; } } int kruskal(int n, int m, Edge *edges, Edge *result) { qsort(edges, m, sizeof(Edge), cmpEdge); for (int i = 0; i < n; i++) { parent[i] = i; rank[i] = 0; } int cnt = 0, weight = 0, index = 0; for (int i = 0; i < m && cnt < n - 1; i++) { int ru = find(edges[i].u); int rv = find(edges[i].v); if (ru != rv) { result[index++] = edges[i]; weight += edges[i].w; unionSet(ru, rv); cnt++; } } if (cnt != n - 1) { return -1; } return weight; } int main() { int n, m; printf("请输入顶点数和边数(用空格分隔): "); scanf("%d %d", &n, &m); printf("请输入 %d 条边,每行格式: 起点 终点 权值\n", m); for (int i = 0; i < m; i++) { scanf("%d %d %d", &edges[i].u, &edges[i].v, &edges[i].w); } int total = kruskal(n, m, edges, result); if (total == -1) { printf("图不连通,无法构成最小生成树\n"); } else { printf("最小生成树总权值: %d\n", total); printf("选中的边:\n"); for (int i = 0; i < n - 1; i++) { printf("(%d, %d, %d)\n", result[i].u, result[i].v, result[i].w); } } return 0; }

4.2 用一个小图来验证

我经常用下面这个经典例子来验证Kruskal是否正确。有5个顶点、7条边:

0 1 10 0 2 6 0 3 5 1 3 15 2 3 4 1 4 3 3 4 8

手算一下:按权值排序后依次尝试,权值3的边(1,4)入,权值4的边(2,3)入,权值5的边(0,3)入,此时已经连通了0、2、3、1、4五个节点中的4个,但顶点0和1还不在同一集合……再看权值6的边(0,2),但0和2已经通过3连通了,跳过;权值8的边(3,4),同集合跳过;权值10的边(0,1),此时0和1已经连通,跳过;权值15的边(1,3),同样跳过。最终选中3条边,总权值=3+4+5=12,这就是最小生成树的正确答案。

在程序里运行的结果应该和手算完全一致。如果你得到不同的结果,可以先检查scanf的输入格式是否对得上,再看排序方向是不是写反了。我初学的时候就在这里栽过一次——比较函数写成了降序,导致Kruskal直接变成了"最大生成树",输出结果完全不对。

4.3 关于"权值相同的边"如何处理

这是个很容易纠结的问题。如果排序后有多条边权值相等,比如上面例子里的(1,4)和(2,3)如果权值一样,那到底先选哪条?答案是:顺序不影响最终总权值的正确性。因为Kruskal算法只需要保证"从小到大尝试",相同权值的边无论先尝试哪一条,最终得到的最小生成树总权值都一样,只是选中的边集可能有多种合法组合而已。这个性质叫"最小生成树不唯一",但不影响总权值的最小性。

5. 实测中的性能与边界问题:不连通图、大边数、数组越界

5.1 图不连通时怎么办

程序里我用cnt != n - 1来判断是否覆盖了所有顶点。如果循环结束后选边数不到N-1,说明图中至少有一个顶点处在孤立的连通分量之外,这时候就不存在包含全部顶点的生成树,返回-1是一种常见的做法。实际比赛中,有些题目会保证图连通,但做通用工具时一定要加上这个判断,否则后续逻辑可能因为result数组未填满而出错。

5.2 多组输入与数组大小的选择

很多练习题会要求"多组测试数据直到EOF",这时要注意在每组输入前重新初始化edges和parent数组。特别是edges数组,不需要清空,因为后面会重新用前m个位置;但parent和rank必须在每组数据开始时重新初始化。我写过一版漏了rank数组的初始化,结果在合并时出现随机行为,排查了很久才发现是上一组数据残留导致的。

5.3MAXE的上限到底怎么定

如果用边集数组存无向图,添加每条无向边时其实在逻辑上相当于"双向可达",但Kruskal只需要存一条。如果题目输入给的是"每行两个顶点表示存在一条边",那MAXE直接按题目给的边数上限来定即可。有一个常见的错误是:输入一个完全图,比如50个顶点,理论上边数上限是50*49/2=1225,有人却习惯性地把MAXE设成50*50=2500,虽然不越界,但浪费了内存。反过来如果顶点数是500,完全图边数是124750,MAXE设小了会导致数组越界,这在C语言里可是轻则逻辑错误、重则段错误的隐患。一个稳妥的习惯是:先看数据范围,再定义数组,边数不足时宁多勿少。

6. Kruskal的实际应用与下一步提升方向

6.1 应用场景并不只是"铺网线"

最小生成树的应用绝对不限于题目和课本。举个现实的例子:聚类分析里的单链接聚类法就等价于求最小生成树后砍掉权值较大的边;图像分割里的某些算法也会先构造最小生成树;网络设计中要在多个节点之间用最少电缆连接所有节点,更是直接对应Kruskal或Prim。还有交通规划中,要在多个城市之间修公路且总里程最小,同样是个经典MST问题。

如果你以后要参与一些物联网网关的选点设计,比如在多个传感终端之间铺设数据总线,Kruskal的思路照样可以直接套用——节点作为顶点,两个节点之间的数据线成本作为边的权值,最小生成树给出的就是总布线成本最低的方案。这也是为什么我建议学数据结构时不要只背代码,而是把算法当成一个"工具",遇到实际问题时先抽象成图,再选择合适算法。

6.2 和Prim算法怎么选择

在实现完Kruskal之后,很多人会问:那Prim呢?两者的核心差别在策略上:

对比项Kruskal算法Prim算法
核心思路按全局边的权值排序,从小到大选边从一个顶点出发,逐步扩展"树外最小边"
数据结构边集数组 + 并查集邻接矩阵或邻接表 + 优先队列/数组
时间复杂度O(E log E),主要开销在排序邻接矩阵O(V^2),二叉堆优化后O(E log V)
适合场景稀疏图(E接近V)稠密图(E接近V^2)
代码量较少,逻辑直观稍多,需要维护候选边集合

简而言之:边少用Kruskal,点少用Prim。这只是一个参考,实际上两种算法在小规模数据上都很快,我自己在比赛中通常会看题目给出的点数范围来决定。如果V <= 500且是稠密图,直接邻接矩阵Prim更省事;如果V=10000、E=20000这种稀疏图,Kruskal显然更合适。

6.3 进阶:堆优化的Kruskal

如果想再进一步优化Kruskal,可以把"排序所有边"改成"把所有边放进最小堆,每轮弹出堆顶"。这样虽然时间复杂度没有本质变化,但在某些需要在线加点或动态加边的场景下,堆比全局排序更灵活。实现时可以先用qsort快速通过,再用二叉堆优化来应对大数据量。不过如果是初学者,我建议先把基础版本彻底吃透,再研究堆优化,否则容易一次接收太多概念反而混乱。

7. 踩坑实录:我在写Kruskal时经历过的几个迷之Bug

这一节算是我自己的"血泪总结",希望能帮读者少走一些弯路。

坑一:qsort比较函数返回值的溢出问题。我第一次写直接用return a->w - b->w;,测试数据小没事,后来数据加强后惊奇地发现结果错了。排查半天发现是因为某两条边的权值一个极大一个极小,差值溢出了,导致比较函数返回了错误符号。从此我养成了习惯,比较函数一律写成(a->w > b->w) - (a->w < b->w)或者显式分情况if-else,绝不直接做减法。

坑二:并查集find函数没有路径压缩,导致超时。初学并查集的时候,我写的查找是:

int find(int x) { while (parent[x] != x) x = parent[x]; return x; }

这个版本逻辑没错,但没有路径压缩,在大量查找操作下并查集树可能退化成长链,复杂度逼近O(N)一次查找。当边数达到几十万级别时,程序直接TLE。改成递归路径压缩后,速度提升非常明显。事实上,路径压缩和按秩合并是并查集性能的两个关键,缺一不可。

坑三:数组下标从0开始还是从1开始没统一。我的边结构体里顶点编号习惯从0开始,但有的题目输入从1开始,这时候如果忘了统一转换,很容易出现访问parent[0]和parent[n]混乱的情况。我的习惯是:读入后立刻把u-1、v-1,保证内部处理统一从0开始,输出时再补回+1。

坑四:无向图边重复存储。初学时我把无向图的边在数组里存了正反两条,即{u,v,w}和{v,u,w}都存。结果Kruskal在选边时,因为先选了其中一条,另一条正反边就成了"同集合内边"被跳过,逻辑虽然最终结果没错,但浪费了一半的存储和排序开销,数据量大时白白多耗时间。搞清楚了无向图的语义之后,就没再犯过这个错。

8. 我的个人调试技巧与最终建议

最后再分享几个我平时调试Kruskal算法的"小窍门"。第一,小数据手算验证是最有效的手段。不论是自己写测试样例还是看题目样例,先手算一遍最小生成树,再有意识地模拟程序执行,能提前发现大量逻辑问题。第二,中间结果打印,尤其是每次选边前后的并查集状态。可以在unionSet之后加一行printf("select edge (%d,%d) weight=%d\n", ...),观察选边过程是否符合预期。第三,边界测试要带上最小数据:一个顶点0条边、两个顶点1条边、三个顶点三条边但不成环的三角形等。很多同学只测常规数据,结果n=2这种最小边界直接越界或死循环。

说实话,Kruskal算法本身并不难,难的是把"并查集 + 排序"这两个基本功扎实地用C语言表达出来。如果你能把上面的完整代码亲手敲一遍,再改造成用malloc动态分配边数组的版本,甚至加一个堆优化的版本,你对C语言内存管理、结构体数组、函数指针、标准库排序的理解都会上一个台阶。这个练习过程带来的收获,远不止会写一个算法那么简单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询