FEATURED · 精选文章

C语言实现Prim算法:从贪心策略到最小生成树构建详解

发布时间 / 2026/8/12 14:55:41
来源 / 创域科博编辑部
栏目 / 资讯中心
C语言实现Prim算法:从贪心策略到最小生成树构建详解 1. 项目概述从理论到代码手把手实现普利姆算法最近在整理一些经典算法的实现发现很多朋友对图论算法特别是最小生成树Minimum Spanning Tree, MST的实现感到头疼。普利姆Prim算法作为求解MST的经典算法之一其思想直观但用C语言实现时在数据结构的选择、边权重的处理以及循环终止条件的把控上常常会让人踩坑。今天我就结合自己多次实现和教学的经验抛开教科书上晦涩的描述用最“接地气”的方式带你从零开始用纯C语言实现一个健壮、高效的普利姆算法。无论你是正在学习《数据结构》的学生还是需要复习算法应对面试的开发者这篇文章都将提供一份可以直接“抄作业”的代码和一份详尽的“避坑指南”。普利姆算法的核心目标很简单在一个带权的连通无向图中找出一棵包含所有顶点的树使得树上所有边的权重之和最小。你可以把它想象成要在几个城市之间铺设光纤网络要求网络连通所有城市即形成一棵树并且总的光纤长度即权重和最短。普利姆算法采用了一种“贪心”的策略从一个起点开始像“生长”一样每次都将当前已连接部分我们称之为“已访问集合”和未连接部分之间权重最小的那条边“吸收”进来直到所有顶点都被纳入集合。这个过程中我们需要持续追踪每个未访问顶点到已访问集合的“最短距离”并动态更新这正是实现的关键和难点所在。2. 核心思路与数据结构设计为什么这么选在动手写代码之前花点时间把思路和数据结构理清楚能省去后面大量的调试时间。普利姆算法虽然思想简单但不同的数据结构实现在代码复杂度和运行效率上差异巨大。2.1 算法流程再梳理让我们先抛开代码用人话再过一遍普利姆算法的步骤初始化任选一个顶点作为起点放入“已访问集合”记为集合U。同时初始化一个数组比如叫lowCost记录图中每个顶点到集合U的“最短距离”。对于起点这个距离是0因为它已经在U里了对于其他顶点这个距离初始化为它们与起点直接相连的边的权重如果不直接相连则记为“无穷大”用一个很大的数表示如INT_MAX。循环生长重复以下步骤直到所有顶点都加入U a.找最小从那些**还未加入U**的顶点中找出lowCost值最小的那个顶点假设是顶点k。这个顶点k和当前集合U之间的那条边就是导致lowCost[k]取当前值的边就是本次要加入生成树的边。 b.收录将顶点k加入集合U。此时生成树的边数和总权重相应更新。 c.更新因为集合U新加入了k所以所有未访问顶点到U的距离可能需要更新。具体方法是遍历所有未访问顶点j检查如果顶点k到j的边权重小于lowCost[j]当前记录的值那么就用这个更小的权重更新lowCost[j]。同时记录下是哪个顶点这里是k带来的这次更新这个信息用于最后回溯构建生成树。这个过程就像滚雪球U集合是雪球每次把粘在雪球表面“最紧”边权重最小的那点雪顶点滚进来然后因为雪球变大了它和周围雪的距离lowCost可能又发生了变化需要重新评估。2.2 关键数据结构选型与理由明确了流程我们来看C语言实现必须解决的几个问题图怎么存lowCost数组和“已访问标记”怎么维护如何高效地“找最小”1. 图的存储邻接矩阵 vs 邻接表邻接矩阵用一个二维数组graph[V][V]表示graph[i][j]的值就是顶点i到j的边权重。如果不连通可以用INT_MAX或一个自定义的无穷大值表示。优点实现极其简单直观检查任意两顶点间是否有边、权重多少是O(1)的操作。在更新lowCost的步骤2.c中我们需要频繁查询新加入顶点k到所有其他顶点j的权重用邻接矩阵直接graph[k][j]即可非常快。缺点空间复杂度O(V²)对于稀疏图边数远小于V²浪费严重。选择理由普利姆算法中更新操作需要遍历所有顶点并查询权重邻接矩阵的随机访问优势明显。对于教学、一般性实现或顶点数不是特别大比如几百个的情况邻接矩阵的简单性带来的收益远大于其空间开销。因此本文选择邻接矩阵作为图的存储结构让代码更清晰聚焦于算法本身。2. 辅助数组设计我们需要三个一维数组长度都是顶点数V。visited[V]布尔数组标记顶点是否已加入集合U。visited[i] 1表示已访问在U中0表示未访问。lowCost[V]整型数组存储每个顶点到当前集合U的最小权重。这是算法的核心。parent[V]整型数组记录最小生成树的边。parent[i]表示在最终的最小生成树中顶点i是从哪个顶点连接过来的即(parent[i], i)是一条生成树边。对于起始顶点其parent可以设为-1。3. “找最小”操作的实现步骤2.a要求我们从未访问顶点中找到lowCost最小的那个。最直接的方法是每次遍历整个lowCost数组忽略已访问的顶点找出最小值。这需要O(V)的时间。由于外层的“循环生长”要执行V次每次加入一个顶点所以总的时间复杂度是O(V²)。为什么不直接用优先队列堆学过算法的同学知道使用最小堆可以将“找最小”和“更新键值”的效率提高使算法整体达到O(E log V)的复杂度对于稀疏图更优。这确实是更高效的实现。但在纯C语言中实现一个支持decrease-key操作的堆需要自己构建代码复杂度会急剧上升容易让初学者迷失在数据结构的细节里偏离了理解普利姆算法本身的主线。选择理由为了最大化代码的清晰度和教学价值本文采用朴素的O(V²)遍历方法来实现“找最小”。这能让你最直观地看到算法每一步在做什么。当你彻底理解了这个版本后将其优化为堆版本将是一个很好的练习。注意这个O(V²)的实现在顶点数很多例如上万时确实会成为瓶颈。但在大多数课程作业、笔试面试或中小规模应用中它完全够用且易于理解和调试。先弄懂再优化是更稳妥的学习路径。3. 代码实现与逐行解析理论说再多不如一行代码。接下来我们结合一个具体的例子实现完整的普利姆算法。假设我们有5个顶点编号0-4图结构如下我们目标是找到其最小生成树。顶点: 0-1-2-3-4 边与权重: (0,1,2), (0,3,6), (1,2,3), (1,3,8), (1,4,5), (2,4,7), (3,4,9)为了方便我们假设图是无向的所以graph[i][j] graph[j][i]。3.1 头文件与常量定义#include stdio.h #include limits.h // 用于INT_MAX #define V 5 // 图中顶点的数量 #define INF INT_MAX // 定义无穷大limits.h中的INT_MAX代表了整型最大值我们用它表示两个顶点之间没有直接边相连。V定义为常量方便修改。在实际应用中你可能需要通过参数传递或动态内存分配来处理任意大小的图。3.2 核心函数Prim算法实现这是整个程序的心脏请结合注释仔细阅读。// 打印最小生成树的函数 void printMST(int parent[], int graph[V][V]) { printf(Edge \tWeight\n); // 注意从1开始循环因为0号顶点是根其parent为-1 for (int i 1; i V; i) { printf(%d - %d \t%d \n, parent[i], i, graph[i][parent[i]]); } } // Prim算法主函数 void primMST(int graph[V][V]) { int parent[V]; // 存储生成树的结构 int lowCost[V]; // 存储顶点到当前MST的最小权重 int visited[V]; // 标记顶点是否已在MST中 // 步骤1初始化 for (int i 0; i V; i) { lowCost[i] INF; // 初始距离设为无穷大 visited[i] 0; // 所有顶点初始未访问 parent[i] -1; // 所有顶点暂无父节点 } // 从第0个顶点开始构建MST lowCost[0] 0; // 起点到自身的距离为0确保它第一个被选中 parent[0] -1; // 第一个顶点是树的根没有父节点 // 步骤2循环生长需要构建包含V个顶点的MST所以循环V-1次因为第一个顶点已默认加入 for (int count 0; count V - 1; count) { // 2.a 从未访问顶点中选取lowCost最小的顶点u int min INF; int u -1; // 用于记录选中的顶点索引 for (int v 0; v V; v) { if (visited[v] 0 lowCost[v] min) { min lowCost[v]; u v; } } // 如果u还是-1说明没找到图可能不连通但根据算法前提连通图这里不应发生。 // 为健壮性考虑可以在这里检查并处理。 if (u -1) { printf(Graph is not connected!\n); return; } // 2.b 将选中的顶点u标记为已访问 visited[u] 1; // 2.c 更新所有未访问顶点v的lowCost值 for (int v 0; v V; v) { // 三个条件必须同时满足 // 1. v未访问 // 2. u和v之间有边graph[u][v] ! INF // 3. 这条边的权重小于v当前记录的到MST的最小权重 if (visited[v] 0 graph[u][v] ! INF graph[u][v] lowCost[v]) { parent[v] u; // 更新v的父节点为u lowCost[v] graph[u][v]; // 更新v到MST的最小权重 } } } // 步骤3打印构建好的最小生成树 printMST(parent, graph); // (可选) 计算并打印总权重 int totalWeight 0; for (int i 1; i V; i) { totalWeight graph[i][parent[i]]; } printf(Total weight of MST: %d\n, totalWeight); }逐段解析与避坑点初始化部分将lowCost全部初始化为INF是安全的做法。随后我们将起点0号的lowCost[0]设为0这保证了在第一次循环时它一定会因为lowCost最小而被选中。parent[0] -1是一个约定表示它是生成树的根。主循环 (for (int count 0; count V - 1; count))为什么是V-1次因为最小生成树有V个顶点必然有V-1条边。我们已经默认将顶点0“加入”了集合通过设置lowCost[0]0接下来只需要再找V-1条边即可。每次循环找到一条边和一个顶点。寻找最小lowCost的顶点 (u)这是一个简单的线性查找复杂度O(V)。注意判断条件visited[v] 0只考虑未访问的顶点。踩坑提醒min的初始值必须是INF并且要在每次寻找u之前重置为INF代码中它在循环外声明在每次迭代开始时由于u被找到后min会被更新所以下一次循环前min依然是上次的值不对仔细看min和u的查找是在内层for循环中完成的min的初始值在查找开始前设定为INF这是正确的。关键在于min和u的声明和查找逻辑必须在外层循环的每次迭代内部完成。上面代码的写法是正确的min和u在每次外层循环开始时通过int min INF; int u -1;重新初始化。更新lowCost数组这是最容易出错的地方。更新条件是对于顶点v如果它未访问、且与新加入的顶点u有边相连、且这条边的权重小于v当前记录的lowCost[v]才需要更新。graph[u][v] lowCost[v]这个小于判断是贪心算法的精髓它保证了我们始终维护的是“到当前MST集合的最小权重”。重要理解lowCost[v]记录的不是到某个特定顶点的距离而是到整个已访问集合U的最短距离。当u加入U后U扩大了那么v到U的距离就有可能通过u变得更短所以需要用graph[u][v]去尝试更新它。3.3 主函数与测试用例现在我们构建一个邻接矩阵来表示上面的示例图并调用primMST函数。int main() { // 用邻接矩阵表示图INF表示两点间无边 int graph[V][V] { {0, 2, INF, 6, INF}, // 0: 连接1(权重2), 3(权重6) {2, 0, 3, 8, 5}, // 1: 连接0,2,3,4 {INF, 3, 0, INF, 7}, // 2: 连接1,4 {6, 8, INF, 0, 9}, // 3: 连接0,1,4 {INF, 5, 7, 9, 0} // 4: 连接1,2,3 }; printf(Constructing Minimum Spanning Tree using Prims algorithm:\n); primMST(graph); return 0; }运行结果预期Constructing Minimum Spanning Tree using Prims algorithm: Edge Weight 0 - 1 2 1 - 2 3 0 - 3 6 1 - 4 5 Total weight of MST: 16这棵生成树的总权重是236516。你可以手动验证这确实是最小生成树。4. 算法执行过程模拟与调试技巧为了加深理解我们模拟一下算法前几步的执行这能帮你更好地调试未来可能遇到的复杂问题。假设从顶点0开始初始化后visited [0, 0, 0, 0, 0]lowCost [0, INF, INF, INF, INF]parent [-1, -1, -1, -1, -1]将lowCost[0]设为0。第一次循环 (count0)找u未访问顶点中lowCost最小的是顶点0值为0。所以u 0。标记visited[0] 1。更新检查所有v1,2,3,4。v1: 未访问graph[0][1]22 INF更新parent[1]0,lowCost[1]2。v2:graph[0][2]INF不更新。v3:graph[0][3]66 INF更新parent[3]0,lowCost[3]6。v4:graph[0][4]INF不更新。此时状态visited [1, 0, 0, 0, 0],lowCost [0, 2, INF, 6, INF],parent [-1, 0, -1, 0, -1]。生成树第一条边确定(0,1)。第二次循环 (count1)找u未访问顶点(1,2,3,4)中lowCost最小的是顶点1值为2。u 1。标记visited[1] 1。更新检查未访问顶点v2,3,4。v2:graph[1][2]33 INF更新parent[2]1,lowCost[2]3。v3:graph[1][3]88 lowCost[3]6不更新。这是关键虽然从1到3有边但当前lowCost[3]6代表通过0连接已经比8更小所以保留更优解。v4:graph[1][4]55 INF更新parent[4]1,lowCost[4]5。此时状态visited [1, 1, 0, 0, 0],lowCost [0, 2, 3, 6, 5],parent [-1, 0, 1, 0, 1]。生成树第二条边确定(1,2)。后续循环依此类推。通过这个模拟你可以清晰地看到lowCost数组如何动态地维护每个顶点到“生长中”的MST的最短距离以及parent数组如何一步步构建出整棵树。调试技巧 当你的程序输出结果不对时不要慌张。最好的调试方法就是打印中间状态在primMST函数的主循环内部每次找到u和每次更新完lowCost后都打印出visited,lowCost,parent三个数组。就像我们上面模拟的那样。对比你的输出和手动模拟的预期差异点往往就是bug所在。检查边界条件确保你的INF值足够大大于图中任何可能路径的权重和但又不会在运算中溢出。使用INT_MAX是标准做法。验证图对称性对于无向图务必检查你的邻接矩阵是否对称即graph[i][j]是否等于graph[j][i]。一个常见的错误是只初始化了矩阵的一半。5. 性能分析与优化方向我们实现的这个版本时间复杂度是O(V²)因为它有双层循环外层循环V-1次内层“找最小”和“更新”各需要遍历V个顶点。对于稠密图边数接近V²这个复杂度是可以接受的因为无论如何你至少需要检查每条边而检查每条边在邻接矩阵中也需要O(V²)的时间。优化思路针对稀疏图如果你的图非常稀疏比如V很大但每个顶点只连接少量边O(V²)的算法就显得太慢了。此时优化的核心在于加速“找最小”和“更新”操作。使用优先队列最小堆思路将未访问顶点按其lowCost值组织成一个最小堆。这样每次“找最小”操作就是取出堆顶元素时间复杂度降为O(log V)。“更新”操作即decrease-key也需要调整堆复杂度也是O(log V)。实现难点在C语言中需要自己实现堆数据结构并维护顶点索引与堆中位置的映射关系以支持高效的decrease-key操作。代码复杂度会显著增加。复杂度使用二叉堆整个算法复杂度可降至O((VE) log V)对于稀疏图E远小于V²优势明显。使用Fibonacci堆这是理论上的最优实现可以将“更新”操作的摊还代价降为O(1)从而使总复杂度达到O(E V log V)。但实现极其复杂通常只在算法竞赛库或高级图算法库中见到实际工程中较少自己实现。对于大多数场景的建议学习与理解务必掌握本文的O(V²)邻接矩阵版本。它是基础清晰易懂。作业与面试如果题目没有特别强调性能实现这个版本完全足够并能清晰展示你对算法过程的理解。实际项目如果顶点数较多成千上万且图是稀疏的应该考虑使用邻接表存储图并搭配一个可靠的优先队列库如C的priority_queue或在C中使用第三方库或自己实现来优化。你可以将本文的代码作为基准理解原理后再着手进行优化。6. 常见问题与解决方案实录在实际编写和调试普利姆算法时我遇到过不少典型问题。这里总结一下希望能帮你快速排雷。问题1程序运行后输出的总权重异常大或者包含了INF值。原因排查邻接矩阵初始化错误这是最常见的原因。检查你的graph数组确保不连通的顶点之间用INF表示且INF的值足够大例如INT_MAX。同时对角线自己到自己的权重应为0。图不连通普利姆算法要求输入图是连通的。如果你的图本身就不连通算法在某一轮可能找不到lowCost值不为INF的未访问顶点即u保持为-1但我们的基础版本可能没处理这个情况导致后续用INF参与计算。可以在寻找u的循环后检查if(u -1)然后打印错误信息并退出。lowCost数组更新逻辑错误仔细检查更新lowCost[v]的条件必须是graph[u][v] lowCost[v]而不是。虽然用有时也能得到正确结果但它可能掩盖一些逻辑问题且不符合算法严格定义。问题2生成树打印的边其权重和手动计算的对不上。原因排查parent数组理解错误printMST函数中我们打印的是parent[i]和i以及权重graph[i][parent[i]]。确保你是在用graph[i][parent[i]]取权重而不是graph[parent[i]][i]。对于无向图两者虽然相等但保持一致性是好习惯。顶点编号从0还是1开始这是一个经典的“差一错误”off-by-one error。我们的代码默认顶点从0开始编号。如果你的测试用例习惯从1开始需要在输入、存储和输出时进行转换否则会导致数组越界或逻辑混乱。在printMST循环中我们从i1开始因为parent[0]是-1。问题3对于大规模图程序运行非常慢。解决方案首先确认你的图是否是稠密图。如果是O(V²)是预期行为。如果图是稀疏的考虑使用邻接表存储图并将“找最小”操作优化为使用最小堆。这是性能提升的关键。在优化前可以用性能分析工具如gprof确认时间主要消耗在哪个步骤通常是内层的两个O(V)循环。问题4如何输出生成树的具体边而不仅仅是总权重解决方案我们的代码已经通过parent数组实现了。printMST函数做的就是这件事。parent[i]记录了顶点i在生成树中是从哪个顶点连接过来的。遍历parent数组从1到V-1每对(parent[i], i)就是生成树的一条边。如果你想以其他格式比如边列表输出修改printMST函数即可。问题5如果想从不同的起点开始怎么办解决方案算法本身可以从任意顶点开始。修改非常简单在初始化部分不要固定从顶点0开始。可以将起始顶点start作为参数传给primMST函数。然后将初始化代码lowCost[0]0; parent[0]-1;改为lowCost[start]0; parent[start]-1;即可。注意最终生成树的边和总权重是唯一的假设边权重互不相同但parent数组的表示方式即以谁为根会因起点不同而不同。7. 扩展思考与变种应用掌握了基础版本后我们可以思考一些更有挑战性的问题这能帮助你更深入地理解普利姆算法和图论。1. 处理等权边如果图中存在多条权重相等的边普利姆算法还能得到最小生成树吗答案是肯定的但最小生成树可能不唯一。我们的算法在“找最小”时如果遇到多个lowCost相等的顶点会选择第一个找到的。这会导致最终构建的生成树是众多可能的最小生成树之一。算法依然正确因为总权重相同。2. 与克鲁斯卡尔Kruskal算法的对比另一个经典的最小生成树算法是克鲁斯卡尔算法。它不再以顶点为中心“生长”而是对所有边按权重排序然后从小到大尝试加入如果加入的边不会形成环就采纳它直到选够V-1条边。普利姆顶点驱动适合稠密图用邻接矩阵O(V²)实现简单或稀疏图用邻接表优先队列优化。克鲁斯卡尔边驱动适合稀疏图因为其复杂度主要来自排序O(E log E)使用并查集判断环非常高效。 选择哪种算法取决于图的结构和你的具体需求。3. 应用于最大生成树如果需要找权重之和最大的生成树最大生成树只需将算法中的“找最小”改为“找最大”并将更新条件graph[u][v] lowCost[v]改为graph[u][v] lowCost[v]即可。同时初始化时lowCost应设为负无穷或一个很小的数而不是正无穷。4. 动态图下的最小生成树维护这是一个高级话题。如果图上的边权重会动态增加或减少如何高效地维护最小生成树有专门的动态MST算法但非常复杂。一个朴素的思路是权重变化后重新运行一次普利姆算法。如果变化不频繁这是可以接受的。最后我个人在实现和教学过程中最大的体会是理解lowCost数组的动态含义是掌握普利姆算法的钥匙。它不是一个静态的距离表而是随着已访问集合U的扩张而不断更新的、每个顶点到当前“生长前沿”的最短距离。把这个概念印在脑子里无论是写代码还是调试都会清晰很多。另一个实用的技巧是在纸上画一个小图比如5个顶点手动模拟一遍算法的执行记录每一步三个数组的变化这个练习抵得上读十遍代码。当你能够不参考任何资料在白板上流畅地写出这个算法的C语言实现并解释清楚每一行时你就真正掌握它了。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻