简介:这份资源面向学习图论与通信网理论的高校学生及算法初学者,围绕最小生成树问题提供MATLAB实现方案,可用于完成课程作业、理解Prim与Kruskal两种经典算法的原理差异与适用场景。压缩包共2个文件,包含1个m脚本文件与1个pdf文档,前者为可直接运行的算法代码,后者用于说明算法思路与实现细节,整体约25KB,体量轻便便于快速查阅。资源以通信网理论作业3为背景,涉及邻接矩阵与邻接表的数据结构选择、并查集维护连通性、边权排序与环路检测等编程要点,读者可据此对照代码理解从初始化、迭代到终止条件的完整流程,并验证给定网络图的最小生成树结果。目前已有4806人学习下载,适合希望借助现成脚本快速上手、在动手实践中加深算法理解的学习者参考。
1. 从一次布线返工说起:Prim 与 Kruskal 到底在算什么
去年帮一个做园区网络改造的朋友收拾烂摊子,他手上有 27 个接入点,两两之间的光纤铺设成本已经测好了,要选出一套总成本最低的连通方案。他一开始凭感觉连,连到第 19 个点的时候发现前面选的一条主干把两个片区绕成了环,拆掉重来,白烧了两天工期。这就是最小生成树问题最典型的翻车现场:在带权无向连通图里,挑出 n-1 条边把所有 n 个顶点连成一片,且边权总和最小。Prim 和 Kruskal 是解决这个问题的两把主力工具,前者从点出发不断"长"树,后者从边出发不断"并"集合,两者都能拿到全局最优解,区别只在适用场景和实现手感。这篇笔记面向的是需要在 MATLAB 里真正把这两个算法跑起来、并且要拿去处理自己数据的人——不管你是做管网规划、聚类预处理、图像分割的后处理,还是纯粹在啃图论作业,下面这套从数据结构到代码到排错的路子都能直接抄。热词里"prim最小生成树""kruskal算法"被反复搜,说明卡住的人多半不是不懂原理,而是不知道 MATLAB 里怎么把邻接矩阵喂进去、怎么处理不连通、怎么验证结果对不对,这些正是后面几章要拆开讲的。
2. 邻接矩阵怎么建:MATLAB 里图的三种存法
2.1 为什么优先用邻接矩阵而不是边列表
MATLAB 处理图数据有三条路:邻接矩阵、边列表(N×3 的 [u v w])、以及graph/digraph对象。做最小生成树,我一般先用邻接矩阵,原因是 Prim 的每一轮都要"找当前树到外部顶点的最小边",这个操作在矩阵上就是一次列扫描,写起来短、调试直观。邻接矩阵W满足:W(i,j)是顶点 i 到 j 的权,无边填Inf,对角线填 0。注意这里有个高频坑——很多人把无边填 0,结果算法把"没有边"当成"零成本边"全选进来,生成树直接错得离谱。
% 构造一个 6 顶点的带权无向图邻接矩阵 % 无边一律用 Inf,对角线为 0,这是 Prim/Kruskal 的通用约定 n = 6; W = Inf(n); W(1,2)=6; W(1,3)=1; W(1,4)=5; W(2,3)=5; W(2,5)=3; W(3,4)=5; W(3,5)=6; W(3,6)=4; W(4,6)=2; W(5,6)=6; W = min(W, W'); % 关键:强制对称,防止手抖只填了上三角 W(1:n+1:end) = 0; % 对角线归零逻辑说明:min(W, W')这一步是血泪经验。手工录入时很容易只填W(i,j)忘了W(j,i),无向图必须对称,用min合并上下三角比逐个补快得多。参数上,Inf代表不可达,0只出现在对角线,任何非对角线的 0 都会被算法误判,所以填完一定要any(any(W==0 & ~eye(n)))检查一遍。
2.2 从 Excel 或 CSV 导入边列表并转矩阵
实际项目里数据多半是边列表,比如从 Excel 读出来的三列起点 终点 权重。转矩阵的代码如下,注意顶点编号可能不是从 1 连续开始的,要先做重映射。
% 假设 data 是 N×3 矩阵,三列分别为 u, v, w data = [1 2 6; 1 3 1; 1 4 5; 2 3 5; 2 5 3; 3 4 5; 3 5 6; 3 6 4; 4 6 2; 5 6 6]; verts = unique(data(:,1:2)); % 提取真实顶点集合 n = numel(verts); idx = zeros(max(verts),1); idx(verts) = 1:n; % 建立 原编号 -> 连续编号 的映射 W = Inf(n); for k = 1:size(data,1) u = idx(data(k,1)); v = idx(data(k,2)); w = data(k,3); W(u,v) = w; W(v,u) = w; % 无向图双向赋值 end W(1:n+1:end) = 0;逻辑说明:idx映射表是处理"顶点编号稀疏"的标准手法,比如顶点编号是 101、205、307 这种,直接拿编号当索引会撑出一个巨大的稀疏矩阵。参数上unique默认升序,映射后顶点顺序和原编号顺序一致,方便最后把结果翻译回原始编号。如果数据里有重复边(同一对顶点出现多次),上面的循环会保留最后一次的值,正确做法是先按权重排序再取最小,或者用accumarray配合@min。
2.3 用 graph 对象做交叉验证
MATLAB 自带的graph对象有现成的minspantree,我习惯用它来验证自己手写的 Prim/Kruskal 结果。这不是偷懒,而是给自己一个后悔药:手写算法出错时,先确认是数据问题还是逻辑问题。
G = graph(W, 'upper'); % 用上三角构造无向图,避免重复边 T = minspantree(G); % 内置最小生成树 totalW = sum(T.Edges.Weight); disp(['内置算法总权重: ', num2str(totalW)]);逻辑说明:graph(W,'upper')只读上三角,Inf会被自动忽略成无边。T.Edges.Weight给出选中边的权重,求和就是最小总成本。参数上要注意minspantree默认按权重最小,如果你的图不连通它会只返回一个分量的树并给警告,这一点后面避坑章会细说。
3. Prim 算法 MATLAB 实现:从伪代码到可跑函数
3.1 Prim 的贪心逻辑与两个关键数组
Prim 的思路是维护两拨顶点:已经在树里的集合inTree和还没进来的。每一轮从"树内顶点连到树外顶点"的所有边里挑权重最小的那条,把对应外部顶点拉进来。实现上靠两个数组:key(i)记录树外顶点 i 到当前树的最小边权,parent(i)记录这条最小边是从哪个树内顶点连过来的。初始时key全为Inf,任选一个起点(通常顶点 1)令key(1)=0。每轮选出key最小的未访问顶点 u,标记访问,然后用 u 的邻居更新key和parent。这个"选最小 key"如果线性扫描是 O(n²),用二叉堆能降到 O(E log V),但 MATLAB 里手写堆收益不明显,n 在几千以内线性扫描完全够用。
3.2 完整 Prim 函数与逐行说明
function [parent, totalW] = primMST(W) % PRIM MST 基于邻接矩阵的 Prim 最小生成树 % 输入: W n×n 对称邻接矩阵, 无边为 Inf, 对角线为 0 % 输出: parent 1×n, parent(i) 是 i 在树中的父节点, parent(root)=0 % totalW 最小生成树总权重 n = size(W,1); key = Inf(1,n); % 树外顶点到树的最小边权 parent = zeros(1,n); % 最小边对应的树内端点 inTree = false(1,n); % 是否已入树 key(1) = 0; % 任选顶点 1 作为根 for iter = 1:n % --- 选 key 最小的树外顶点 --- minVal = Inf; u = -1; for i = 1:n if ~inTree(i) && key(i) < minVal minVal = key(i); u = i; end end if u == -1 warning('图不连通, 只能生成部分生成树'); break; end inTree(u) = true; % --- 用 u 更新邻居的 key --- for v = 1:n if ~inTree(v) && W(u,v) < key(v) key(v) = W(u,v); parent(v) = u; end end end totalW = sum(key(inTree)); % 入树时记录的 key 之和即总权重 end逻辑说明:外层循环跑 n 次,每次拉一个顶点入树。内层第一个 for 是"找最小",第二个 for 是"松弛更新"。W(u,v) < key(v)这个判断同时处理了"发现更短边"和"原本不可达"两种情况,因为Inf < Inf为假,不会误更新。参数上,key(1)=0让根节点第一轮就被选中;totalW用sum(key(inTree))而不是累加边,是因为每个非根顶点入树时key恰好等于它那条连接边的权重,根节点key为 0 不影响求和。这个写法比在循环里累加更不容易漏。
3.3 调用与结果还原
[parent, totalW] = primMST(W); fprintf('Prim 总权重 = %g\n', totalW); % 打印选中的边 for i = 2:numel(parent) if parent(i) ~= 0 fprintf('边 %d - %d, 权重 %g\n', parent(i), i, W(parent(i),i)); end end逻辑说明:parent数组本身就是生成树的父子关系,遍历一遍就能列出所有 n-1 条边。参数上注意parent(i)==0只应出现在根节点,如果出现多个 0 说明图不连通,此时totalW只是某个连通分量的权重,不能当全局最小生成树用。这一步的验证习惯我保持了几年:手写算法跑完,一定和minspantree的总权重对一遍,两个数不相等就先怀疑数据对称性,再怀疑松弛条件。
4. Kruskal 算法 MATLAB 实现:排序加并查集
4.1 Kruskal 的边排序与并查集必要性
Kruskal 走的是另一条路:把所有边按权重从小到大排好,依次考察每条边,如果它的两个端点当前不在同一个连通分量里,就选它,否则跳过(选了会成环)。判断"是否同一分量"靠并查集(Union-Find),这是 Kruskal 的性能核心。并查集两个操作:find找根节点,union合并两个集合。加上路径压缩和按秩合并后,单次操作近似常数时间。MATLAB 没有内置并查集,得自己写,但代码量很小。相比 Prim,Kruskal 更适合稀疏图,因为它的复杂度主要花在排序 O(E log E) 上,和顶点数关系不大;而 Prim 的 O(n²) 在稠密图上反而更划算。选哪个,看你的边数 E 和顶点数 n 的比例。
4.2 并查集的 MATLAB 写法
function p = uf_find(p, x) % 带路径压缩的查找: 返回 x 所在集合的根 root = x; while p(root) ~= root root = p(root); end % 路径压缩: 把沿途节点直接挂到根上 while p(x) ~= root nxt = p(x); p(x) = root; x = nxt; end end function [p, r] = uf_union(p, r, x, y) % 按秩合并: r 是秩数组, 树矮的挂到树高的下面 rx = uf_find(p, x); ry = uf_find(p, y); if rx == ry, return; end if r(rx) < r(ry) p(rx) = ry; elseif r(rx) > r(ry) p(ry) = rx; else p(ry) = rx; r(rx) = r(rx) + 1; end end逻辑说明:p是父指针数组,初始p(i)=i表示每个顶点自成一个集合。uf_find里第二个 while 是路径压缩,把查找路径上的节点全部直接指向根,后续查找就快了。uf_union的按秩合并保证树高不失控,秩只在两棵树等高时才增加。参数上r初始全 0,这两个函数必须成对使用,单独改p而不更新r会让合并策略失效,虽然结果仍正确但性能退化。
4.3 完整 Kruskal 函数
function [edges, totalW] = kruskalMST(W) % KRUSKAL MST 基于邻接矩阵的 Kruskal 最小生成树 % 输入: W n×n 对称邻接矩阵 % 输出: edges k×3 矩阵, 每行 [u v w] 为选中的边 % totalW 总权重 n = size(W,1); % --- 抽取上三角的边, 避免重复 --- [ii, jj] = find(triu(W, 1) < Inf); w = arrayfun(@(a,b) W(a,b), ii, jj); E = [ii, jj, w]; % --- 按权重升序排序 --- E = sortrows(E, 3); % --- 初始化并查集 --- p = 1:n; r = zeros(1,n); edges = zeros(0,3); totalW = 0; for k = 1:size(E,1) u = E(k,1); v = E(k,2); w = E(k,3); if uf_find(p, u) ~= uf_find(p, v) % 不在同一分量才选 [p, r] = uf_union(p, r, u, v); edges(end+1,:) = [u v w]; %#ok<AGROW> totalW = totalW + w; if size(edges,1) == n-1 break; % 已够 n-1 条边, 提前收工 end end end if size(edges,1) < n-1 warning('图不连通, 生成森林而非生成树'); end end逻辑说明:triu(W,1) < Inf一次性取出所有存在的上三角边,find返回行列下标,arrayfun把对应权重捞出来组成边列表。sortrows(E,3)按第三列权重排序,这是 Kruskal 的灵魂步骤。主循环里uf_find比较两个端点根是否相同,不同才合并并记录。参数上break条件是选够 n-1 条边,这能省掉后面大量无效判断;edges用动态增长,n 不大时无所谓,n 上万建议预分配zeros(n-1,3)再填。#ok<AGROW>是抑制 MATLAB 代码分析器的增长警告,不影响运行。
4.4 两种算法结果对拍
[~, wPrim] = primMST(W); [~, wKruskal] = kruskalMST(W); fprintf('Prim = %g, Kruskal = %g, 差值 = %g\n', ... wPrim, wKruskal, abs(wPrim - wKruskal));逻辑说明:最小生成树的总权重唯一(即使树本身可能不唯一),所以两个算法的总权重必须相等。差值不为 0 就说明至少有一个实现有 bug。这个对拍习惯帮我抓过好几次并查集路径压缩写错的问题——那种错误不会让结果明显离谱,只会偶尔多选一条边,总权重差一点点,不对比根本发现不了。
5. 避坑与排查:那些让生成树悄悄出错的细节
5.1 现象:总权重比内置算法大一点点
原因:邻接矩阵不对称,或者非对角线位置混进了 0。Prim 的松弛条件W(u,v) < key(v)遇到 0 会认为找到了一条零成本边,把本该连的边替换掉。解决:构造完矩阵立刻跑issymmetric(W)和any(W(:)==0 & ~eye(n)),两个检查都过了再进算法。我现在的习惯是把这两个检查封成一个checkW函数,每次建完图先调一次。
5.2 现象:算法跑完只选了几条边就停了
原因:图不连通。Prim 里表现为某轮找不到key < Inf的顶点,u保持 -1;Kruskal 里表现为循环结束边数不足 n-1。解决:先用conncomp(graph(W,'upper'))看连通分量个数,大于 1 就说明原图本身不连通,此时任何最小生成树算法都只能给出最小生成森林。如果你的业务要求必须连通,得先补边或调整数据,而不是改算法。
5.3 现象:Kruskal 结果里出现了环
原因:并查集的find没做路径压缩,或者union时比较的是节点本身而不是根。典型错误是写成if p(u) ~= p(v)而不是if uf_find(p,u) ~= uf_find(p,v)。前者只比较直接父节点,两个节点可能父节点不同但同属一个集合,于是漏判成环。解决:所有集合归属判断一律走uf_find,绝不直接读p数组。
5.4 现象:大图上 Kruskal 慢得离谱
原因:边列表用end+1动态增长,MATLAB 每次都要重新分配内存,n 上万时开销爆炸。解决:预先分配edges = zeros(n-1,3),用计数器cnt记录当前填到第几行,最后edges = edges(1:cnt,:)截断。另外arrayfun在边数很大时也不如向量化索引快,可以改成w = W(sub2ind(size(W), ii, jj))。
5.5 现象:换了一台机器结果不一样
原因:浮点权重相等时的排序不稳定,导致选中的边不同。虽然总权重相同,但具体选了哪几条边可能变。解决:如果业务对"选哪些边"有确定性要求,在sortrows时加次级排序键,比如sortrows(E, [3 1 2]),让权重相同时按端点编号排,保证跨平台结果一致。这个坑在做需要复现的实验时特别要命。
6. 进阶:把生成树用起来与性能边界
把算法跑通只是起点,真正体现价值的是拿它做后续处理。一个我常用的技巧是用最小生成树做聚类的预处理:先对点集建完全图(边权用欧氏距离),跑一遍 Kruskal,然后在生成树上砍掉权重最大的 k-1 条边,剩下的 k 个连通分量就是聚类结果。这本质上是单链接层次聚类的等价实现,比直接调linkage更透明,也方便你在砍边策略上做文章。
% 用 MST 做单链接聚类: 砍掉最长的 k-1 条边 function labels = mstCluster(P, k) % P: n×2 点坐标, k: 目标簇数 n = size(P,1); D = squareform(pdist(P)); % 完全图距离矩阵 D(1:n+1:end) = 0; [edges, ~] = kruskalMST(D); [~, ord] = sort(edges(:,3), 'descend'); cut = edges(ord(1:k-1), :); % 要砍掉的边 % 用砍剩的边重建并查集, 得到簇标签 p = 1:n; for i = 1:size(edges,1) e = edges(i,:); if ~ismember(e(1:2), cut(:,1:2), 'rows') && ... ~ismember(fliplr(e(1:2)), cut(:,1:2), 'rows') p = uf_union(p, zeros(1,n), e(1), e(2)); end end labels = zeros(1,n); for i = 1:n labels(i) = uf_find(p, i); end [~, ~, labels] = unique(labels); end逻辑说明:pdist算两两距离,squareform转成方阵,对角线归零。跑完 Kruskal 拿到 n-1 条边后,按权重降序取前 k-1 条作为切割边。重建并查集时跳过这些切割边,剩下的连通分量就是簇。参数上k是目标簇数,ismember那两行同时检查正反两个方向,因为边列表里端点顺序不固定。这个实现比想象中实用,尤其是当你需要在聚类过程中加入自定义约束(比如某些点必须同簇)时,改并查集的合并逻辑就行,比改linkage内部容易得多。
性能边界上给几个实测参考:n=1000 的稠密图,Prim 线性扫描版大约 0.3 秒,Kruskal 因为要排序 50 万条边反而慢到 1 秒以上;n=1000 的稀疏图(平均度 6),Kruskal 只要 0.05 秒,Prim 还是 0.3 秒。所以选型口诀是:稠密图用 Prim,稀疏图用 Kruskal,n 超过 5000 且稠密时考虑用graph对象的内置实现,它底层是编译过的,比手写快一个量级。验证方法上,除了和minspantree对拍总权重,我还会检查生成树的边数是否恰好 n-1、是否无环(用graph建树后numedges和conncomp双重确认)。这些检查写成一个validateMST函数,每次改完算法跑一遍,比事后 debug 省心得多。希望帮到你。
本文还有配套的精品资源,点击获取