【数据结构】图论基石:最小生成树(MST)实战精解与Prim/Kruskal算法详解
图的基本应用——最小生成树
- 导读:探索图的强大世界——从基础操作到核心应用
- 一、最小生成树
- 1.1 基本概念
- 1.2 概念解读
- 1.3 最小生成树的性质
- 1.4 核心算法
- 二、Prim算法
- 2.1 基本原理
- 2.2 算法逻辑
- 2.3 算法评价
- 三、Kruskal算法
- 3.1 基本原理
- 3.2 算法逻辑
- 3.3 算法评价
- 结语:掌握最小生成树,解锁最优连通之路
导读:探索图的强大世界——从基础操作到核心应用
大家好,很高兴又和大家见面啦!!!
在之前的博客中,我们一起揭开了图数据结构的神秘面纱,掌握了它表示复杂关系网络的能力。我们深入探讨了:
-
图的基本特性:了解了图的方向性(有向图、无向图)、连通性(连通图、连通分量)、路径(简单路径、回路)等核心概念。
-
图的表示方法:如何用邻接矩阵和邻接表来描绘顶点之间的连接?
-
图的遍历策略:如何运用广度优先搜索 (BFS) 和深度优先搜索 (DFS) 系统地“探索”图中的每一个角落?
这些基础操作就像是探索图世界的钥匙。掌握了它们,我们就可以更进一步,去解决那些更加激动人心、更具实际意义的难题!图论的应用领域极其广泛,本次内容我们将聚焦于一个基础且重要的方向:图的最优连通性问题。
想象一下,我们需要在多个城市之间架设通信网络,或者规划最经济的交通路线网——目标是用最少的成本(材料、距离、时间等)将所有关键点连接起来。
这就是最小生成树 (Minimum Spanning Tree, MST) 要解决的核心问题!
最小生成树是图论中的一个经典应用,它着眼于如何在带权连通无向图中,找到一棵包含所有顶点、且权值(边权重之和)最小的生成树。我们将详细解读其概念、性质,并学习构建它的两大核心算法:普里姆算法 (Prim) 和克鲁斯卡尔算法 (Kruskal)。
这仅仅是图论应用的起点!了解最小生成树为我们理解更复杂的应用奠定了基础。在后续的学习中,我们将继续深入图的广阔世界,探索:
-
最短路径问题:如何找到网络中两点间最快、最便宜的路径?(如:Dijkstra算法、Floyd算法)
-
有向无环图 (DAG) 与表达式求值:如何高效计算复杂的表达式?
-
拓扑排序:如何解决任务执行的依赖关系和顺序问题?
-
关键路径分析:如何在大型项目中识别和管理决定总工期的核心环节?
本次博客之旅,我们将集中火力,攻克最小生成树这一重要堡垒,学习其核心思想和两种高效的构建算法。理解它,是开启后面精彩应用的钥匙!准备好了吗?让我们开始这场探索“最优连通性”的奇妙旅程!
一、最小生成树
1.1 基本概念
生成树:连通图中的生成树是包含图中所有顶点的一个极小连通子图。
- 若图中有n个顶点,则它的生成树中含有n-1条边
生成森林:非连通图中,连通分量的生成树构成了非连通图的生成森林。
最小生成树:带权连通无向图中,其生成树不同,每棵树的权(树中所有边的权值之和)也可能不同。其权值最小的那颗生成树就是最小生成树(Minimum-Spanning-Tree, MST)
1.2 概念解读
首先我们需要明确一点:
- 生成树是无向连通图中独有的概念
对于有向图而言,它也存在生成树类似的概念,只不过有向图的生成树通常被称为有向生成树或树形图:
- 一个顶点的入度为0,其余顶点的入度均为1的有向图
有向树满足以下特点:
- 连通性:我们从根结点出发,可以通过有向路径找到其它所有结点
- 树结构:
- 包含图中的全部顶点
- 无有向环
- 除根结点外,每个顶点有且仅有一条入边,即父结点唯一
因此这里我们介绍的生成树,都是连通无向图的一个子图,或者说是极小连通子图。
当无向图为非连通图时,图中的每一个连通分量都可以获取一棵生成树,由这些生成树就构成了一个生成森林。因此生成森林是无向非连通图中的概念。
现在我们的目标就很明确了,我们此时的研究对象就是无向连通图。
当我们给图中的所有边赋予一个权值时,我们就得到了一个无向带权连通图。如下所示:
在上图中,我们可以得到权为10的树:
该树的权为: ( a , b ) + ( b , c ) + ( c , d ) + ( d , e ) = 1 + 2 + 4 + 3 = 10 (a, b) + (b, c) + (c, d) + (d, e) = 1 + 2 + 4 + 3 = 10 (a,b)+(b,c)+(c,d)+(d,e)=1+2+4+3=10
当然,我们还可以得到权为11的树:
该树的权为: ( a , b ) + ( b , c ) + ( a , d ) + ( d , e ) = 1 + 2 + 5 + 3 = 11 (a, b) + (b, c) + (a, d) + (d, e) = 1 + 2 + 5 + 3 = 11 (a,b)+(b,c)+(a,d)+(d,e)=1+2+5+3=11
可以看到,当我们在带权图中获取不同的生成树时,我们可以得到不同权值的图。如果我们得到的生成树的权值是我们在这个图中所有生成树中权值最小的一棵树时,那这棵树就是这个图的最小生成树。
1.3 最小生成树的性质
最小生成树具备以下性质:
- 若图G中存在权值相同的边,则图G的最小生成树可能不唯一,即最小生成树的树形不唯一。
- 当图G中的各边权值互不相等时,图G的最小生成树时唯一的;
- 当无向连通图G的边数比顶点数少1时,即顶点数为n,边数为 n - 1,此时的图G已经是一棵树了,也就是说图G的最小生成树就是他本身
- 最小生成树的树形可以不唯一,但权值一定唯一且最小
- 最小生成树的边数为顶点数减一,即 ∣ V ∣ = n , ∣ E ∣ = n − 1 |V| = n, |E| = n - 1 ∣V∣=n,∣E∣=n−1
这些性质实际上就是在说一件事:
- 同一个图的最小生成树可以有多个,但最小生成树的权值一定是最小权值。
有朋友可能不太理解为什么顶点数和边数的关系是 ∣ E ∣ = ∣ V ∣ − 1 |E| = |V| - 1 ∣E∣=∣V∣−1,这是因为在树中,一定不存在环路。
这里我们设想以下,如果两个顶点之间需要连通,那么这两个顶点之间需要几条边?
没错,就是一条边。当我们要在这棵树中加入一个新顶点,我们又需要加入几条边呢?
没错,还是一条边。那也就是说,往后不管我加多少个顶点,那么都是需要增加同等数量的边。这里我们将开头的两个顶点与后来加入的顶点视为两部分:
- 原始顶点与边数:
- 顶点:2
- 边:1
- 新增顶点与边数:
- 顶点:k
- 边:k
那也就是说顶点总数就为: ∣ V ∣ = k + 2 |V| = k + 2 ∣V∣=k+2 ,而边的总数为: ∣ E ∣ = k + 1 |E| = k + 1 ∣E∣=k+1
现在我们再来看顶点数与边数,是不是正好是: ∣ E ∣ = ∣ V ∣ − 1 |E| = |V| - 1 ∣E∣=∣V∣−1
理解了最小生成树后,下面我们就需要知道对于任意一个无向连通图G,我们应该如何获取它的最小生成树。
1.4 核心算法
要了解最小生成树的核心算法,我们就需要明确最小生成树的核心:
- 顶点
- 权值最小的边
在最小生成树中,包含图G的所有顶点以及将这些顶点连通的权值之和最小的边,这里就给我们提供了两种思路:
- 从顶点出发,每一次都选择加入树中的最小权值的边以及未加入树中的顶点
- 从边出发:每一次选择一条权值最小的边,当该边对应的顶点未被加入树中时,将该边连同顶点一起加入树中
上述的两种思路分别对应的就是最小生成树的两种算法:
- Prim算法:通过顶点构建最小生成树
- Kruskal算法:通过边构建最小生成树
接下来我们就来分别学习一下这两种算法的基本原理与算法逻辑;
二、Prim算法
2.1 基本原理
Prim(普里姆)算法在构造一棵最小生成树时,实际上就是将顶点逐一加入树中,每次加入时都选择权值最小的边。这里我们以下面这个图G为例,来展示整个过程:
在图G中包含6个顶点与10条边:
- 顶点集: ∣ V ∣ = { a , b , c , d , e , f } |V| = \{a, b, c, d, e, f\} ∣V∣={a,b,c,d,e,f}
- 边集: ∣ E ∣ = { ( a , b , 1 ) , ( a , f , 6 ) , ( a , e , 5 ) , ( b , c , 2 ) , ( b , f , 4 ) , ( c , d , 3 ) , ( c , f , 4 ) , ( d , e , 4 ) , ( d , f , 5 ) , ( e , f , 3 ) } |E| =\\\{(a, b, 1), (a, f, 6), (a, e, 5), \\(b, c, 2), (b, f, 4), \\(c, d, 3), (c, f, 4), \\(d, e, 4), (d, f, 5), \\(e, f, 3)\} ∣E∣={(a,b,1),(a,f,6),(a,e,5),(b,c,2),(b,f,4),(c,d,3),(c,f,4),(d,e,4),(d,f,5),(e,f,3)}
接下来我们就来通过Prim算法构建一棵最小生成树:
- 首先选择一个顶点作为生成树的根结点。这里我们选择顶点a作为树的根结点;
- 接下来,我们选择依附于该顶点的权值最小的边。在图中有3条边依附于顶点a:
- ( a , b , 1 ) , ( a , f , 6 ) , ( a , e , 5 ) (a, b, 1), (a, f, 6), (a, e, 5) (a,b,1),(a,f,6),(a,e,5)
- 在这三条边中,权值最小的为: ( a , b , 1 ) (a, b, 1) (a,b,1),其权值为1,因此我们将该边以及顶点b加入到树中
- 接下来,我们继续选择依附于顶点a与顶点b的权值最小的边。在图中有4条边依附于顶点a与顶点b:
- ( a , f , 6 ) , ( a , e , 5 ) , ( b , c , 2 ) , ( b , f , 4 ) (a, f, 6), (a, e, 5), (b, c, 2), (b, f, 4) (a,f,6),(a,e,5),(b,c,2),(b,f,4)
- 在这4条边中,权值最小的为: ( b , c , 2 ) (b, c, 2) (b,c,2),其权值为2,因此我们将该边以及顶点c加入到树中
- 下面我们继续选择依附于顶点a、顶点b与顶点c的权值最小的边。在图中有5条边依附于顶点a、顶点b与顶点c:
- ( a , f , 6 ) , ( a , e , 5 ) , ( b , f , 4 ) , ( c , d , 3 ) , ( c , f , 4 ) (a, f, 6), (a, e, 5), (b, f, 4), (c, d, 3), (c, f, 4) (a,f,6),(a,e,5),(b,f,4),(c,d,3),(c,f,4)
- 在这5条边中,权值最小的为: ( c , d , 3 ) (c, d, 3) (c,d,3),其权值为3,因此我们将改边以及顶点d加入到树中
- 下面我们继续选择依附于这4个顶点的权值最小的边。在图中有6条边依附于这4个顶点:
- ( a , f , 6 ) , ( a , e , 5 ) , ( b , f , 4 ) , ( c , f , 4 ) , ( d , e , 4 ) , ( d , f , 5 ) (a, f, 6), (a, e, 5), (b, f, 4), (c, f, 4), (d, e, 4), (d, f, 5) (a,f,6),(a,e,5),(b,f,4),(c,f,4),(d,e,4),(d,f,5)
- 在这6条边中,权值最小的边有3条: ( b , f , 4 ) , ( c , f , 4 ) , ( d , e , 4 ) (b, f, 4), (c, f, 4), (d, e, 4) (b,f,4),(c,f,4),(d,e,4),对于这三条边而言,其邻接点f, e,且都未加入树中,因此可以任选一条边加入到树中,这里我们选择: ( b , f , 4 ) (b, f, 4) (b,f,4)
- 最后我们继续选择依附于这5个顶点的权值最小的边。在图中有6条边依附于这4个顶点:
- ( a , f , 6 ) , ( a , e , 5 ) , ( c , f , 4 ) , ( d , e , 4 ) , ( d , f , 5 ) , ( e , f , 3 ) (a, f, 6), (a, e, 5), (c, f, 4), (d, e, 4), (d, f, 5), (e, f, 3) (a,f,6),(a,e,5),(c,f,4),(d,e,4),(d,f,5),(e,f,3)
- 在这6条边中,权值最小的为: ( e , f , 3 ) (e, f, 3) (e,f,3),且邻接点e并未加入树中,因此我们直接将点e加入到树中
现在我们就得到了该图的最小生成树,且该树的权值为: 1 + 2 + 3 + 4 + 3 = 13 1 + 2 + 3 + 4 + 3 = 13 1+2+3+4+3=13
2.2 算法逻辑
从上述过程中我们不难发现,整个Prim算法实际上就是在做一件事:
- 找到依附于树中顶点的权值最小的边
因此整个算法的逻辑实际上很简单:
- 先创建一棵空树: T = ∅ T = \emptyset T=∅
- 将任一顶点加入到树中: T = { v 1 } T = \{v_1\} T={v1}
- 通过循环逐一将权值最小的边以及对应的邻接点加入树中
- 当树中包含全部顶点时,循环结束
该逻辑的C语言描述为:
void Prim(graph* g, int x) {ALGraph* T = Creat_Empty_Tree(); // 创建空树T->ver_list[0] = g->algraph.ver_list[x]; // 将顶点x添加到树中T->ver_num = 1; // 顶点数量加1while (T->ver_num != g->algraph.ver_num) { // 存在未加入树的顶点VNode p = Get_Min_Info_Node(g, T); // 获取权值最小的边对应的邻接点T->ver_list[T->ver_num] = p; // 将顶点p加入到树中T->ver_num += 1; // 顶点数量加1}
}
该算法的具体实现在后续的内容中我们会详细介绍,这里先不再展开。
2.3 算法评价
在Prim算法中,每次在获取顶点的邻接点时,都需要对已加入到树中的顶点进行一次遍历,因此整个算法的时间复杂度为: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。
由于Prim算法不依赖与 ∣ E ∣ |E| ∣E∣ ,因此它适用于求解边稠密的图的最小生成树。
三、Kruskal算法
3.1 基本原理
Kruskal(克鲁斯卡尔)算法在构建一棵最小生成树时,则是通过边来依次选择合适的顶点加入树中。这里我们还是以图G为例给大家展示整个过程:
在图G中包含6个顶点与10条边:
- 顶点集: ∣ V ∣ = { a , b , c , d , e , f } |V| = \{a, b, c, d, e, f\} ∣V∣={a,b,c,d,e,f}
- 边集: ∣ E ∣ = { ( a , b , 1 ) , ( a , f , 6 ) , ( a , e , 5 ) , ( b , c , 2 ) , ( b , f , 4 ) , ( c , d , 3 ) , ( c , f , 4 ) , ( d , e , 4 ) , ( d , f , 5 ) , ( e , f , 3 ) } |E| =\\\{(a, b, 1), (a, f, 6), (a, e, 5), \\(b, c, 2), (b, f, 4), \\(c, d, 3), (c, f, 4), \\(d, e, 4), (d, f, 5), \\(e, f, 3)\} ∣E∣={(a,b,1),(a,f,6),(a,e,5),(b,c,2),(b,f,4),(c,d,3),(c,f,4),(d,e,4),(d,f,5),(e,f,3)}
由于 Kruskal 算法是以边为主体,因此我们按照边的权值从小到大依次记录到数组中:
数组下标 | 权值 | 顶点i | 顶点j |
---|---|---|---|
0 | 1 | a | b |
1 | 2 | b | c |
2 | 3 | c | d |
3 | 3 | e | f |
4 | 4 | b | f |
5 | 4 | c | f |
6 | 4 | d | e |
7 | 5 | a | e |
8 | 5 | d | f |
9 | 6 | a | f |
接下来我们就借助这个数组,并通过 Kruskal 算法构建一棵最小生成树:
- 首先生成一棵空树 T。在这棵树中,当我们遇到合适的顶点时,我们会逐一将其加入到树中。之后,我们会从上到下依次检查各边依附的两个顶点是否能够加入到树中:
- 在第一轮检查中,权值为1的边 ( a , b ) (a, b) (a,b) 所依附的两个顶点并未加入到树中,因此这两个顶点之间可以通过边 ( a , b ) (a, b) (a,b) 进行连通并加入到树 T 中:
- 在第二轮检查中,权值为2的边 ( b , c ) (b, c) (b,c) 所依附的两个顶点:b/c 中,顶点c未加入到树中,因此这两个顶点之间可以通过边 ( b , c ) (b, c) (b,c) 进行连通并加入到树 T中:
- 在第三轮检查中,权值为3的边 ( c , d ) (c, d) (c,d) 所依附的两个顶点:c/d 中,顶点d未加入到树中,因此这两个顶点之间可以通过边 ( c , d ) (c, d) (c,d) 进行连通并加入到树 T中:
- 第四轮检查中,权值为3的边 ( e , f ) (e, f) (e,f) 所依附的两个顶点并未加入到树中,因此这两个顶点之间可以通过边 ( e , f ) (e, f) (e,f) 进行连通并加入到树 T 中,此时树 T就变成了森林:
- 第五轮检查中,权值为4的边 ( b , f ) (b, f) (b,f) 所依附的两个顶点:b/f 均以加入到森林中,但两个顶点分别位于两棵树中,因此我们可以通过边 ( b , f ) (b, f) (b,f) 将其连通,使两棵树合并为同一棵树:
- 在之后的检查中,我们会发现所有的顶点均以加入树 T 中,因此我们不再需要其他的边。
现在我们就得到了该图G的最小生成树,且该树的权值为: 1 + 2 + 3 + 4 + 3 = 13 1 + 2 + 3 + 4 + 3 = 13 1+2+3+4+3=13
3.2 算法逻辑
从上述过程中,我们可以看到,Kruskal 算法实际上就做了一件事:
- 通过当前权值最小的边找到还未加入树的顶点
如果我们想快速的找到当前的最小权值边,我们则可以如上述过程所示,通过一个边数组来按照权值从小到大记录各边以及改边依附的顶点信息,之后,我们只需要在扫描数组的过程中判断当前的顶点是否加入树中即可,整个逻辑如下所示:
- 先创建一棵空树:T = ∅ \emptyset ∅ 以及一个边表:
edge_list[]
- 将图中的各边按照权值大小记录到边表
edge_list[]
中 - 依次扫描边表中的信息
- 当该边依附的顶点未加入到树中时,将该顶点加入到树中
- 当该边依附的顶点已加入到树中时,则不进行任何操作
- 当所有的顶点都加入到树中时,循环结束
该逻辑的C语言描述为:
void Kruskal(graph* g) {ALGraph* T = Creat_Empty_Tree(); // 创建空树ELNode* edge_list = Creat_Edge_Info_list(); // 创建边表Record_Edge_Info(g, edge_list); // 记录边权值信息for (int i = 0; i < g->algraph.edge_num; i++) { // 遍历边表int ver_i = edge_list[i].vertex_i; // 顶点iif (!In_Tree(T, g, ver_i)) { // 顶点i不在树中Union(T, g, ver_i); // 将顶点i合并到树T中}int ver_j = edge_list[i].vertex_j; // 顶点jif (!In_Tree(T, g, ver_j)) { // 顶点j不在树中Union(T, g, ver_j); // 将顶点j合并到树T中}}
}
该算法的具体实现我们也会在后续的内容中详细介绍,这里就不再继续展开。
3.3 算法评价
在Kruskal算法中,其时间消耗主要在边权值信息的记录上,在记录中会涉及到根据边权值对表中的信息进行排序,因此其时间复杂度由所使用的排序算法决定,因此时间复杂度在 O ( ∣ E ∣ l o g 2 ∣ E ∣ ) ~ O ( ∣ E ∣ 2 ) O(|E|log_2|E|)~O(|E|^2) O(∣E∣log2∣E∣)~O(∣E∣2)这个范围内。
其次,我们在对顶点进行判断时,可以借助并查集来进一步优化判断一个顶点是否位于树T中,使其判断的时间复杂度达到 O ( α ( ∣ V ∣ ) ) O(\alpha(|V|)) O(α(∣V∣))。在并查集中我们就有介绍过, O ( α ( ∣ V ∣ ) ) O(\alpha(|V|)) O(α(∣V∣)) 的增长十分缓慢,接近常数级。因此总的时间复杂度为 O ( ∣ E ∣ l o g 2 ∣ E ∣ ) ~ O ( ∣ E ∣ 2 ) O(|E|log_2|E|)~O(|E|^2) O(∣E∣log2∣E∣)~O(∣E∣2)
在Kruskal算法中,是以边为主导,算法不依赖与顶点 ∣ V ∣ |V| ∣V∣,因此Kruskal算法适合于边稀疏而顶点较多的图。
结语:掌握最小生成树,解锁最优连通之路
通过本篇的学习,我们深入探讨了图论中的重要应用——最小生成树(Minimum Spanning Tree, MST)。
-
我们首先明确了最小生成树的核心概念:
- 在带权连通无向图中寻找一棵包含所有顶点且权值之和最小的树结构。
- 最小生成树不仅具有权值唯一最小的性质(即使树形可能不唯一),还严格遵循 ∣ E ∣ = ∣ V ∣ − 1 |E| = |V| - 1 ∣E∣=∣V∣−1 的边数规则,完美诠释了树结构的无环特性。
-
更重要的是,我们揭示了构建最小生成树的两大核心算法:
-
Prim算法(顶点驱动)——通过逐顶点扩展,以贪心策略选取依附于当前树的最小权值边,逐步构建最优连通网络。
-
Kruskal算法(边驱动)——以全局最小边为起点,通过合并子树逐步形成最小生成树,高效解决最优连通问题。
-
这两种算法从不同视角出发,却殊途同归:
- Prim算法聚焦"点与树的连接",适合稠密图;
- Kruskal算法专注"全局最优边",擅长稀疏图。
掌握它们,就握住了解决实际优化问题(如通信网络铺设、交通规划)的金钥匙!
精彩预告:最短路径——探索图的最优寻路艺术
最小生成树解决了"全局最优连通",但若需精准规划两点间最短路径(如导航最短路线、网络数据传输优化),则需要更精妙的工具!在下一篇中,我们将深入:
-
Dijkstra算法:如何逐步逼近单源最短路径?
-
Floyd算法:如何动态计算任意两点间最小距离?
-
实战场景:从地图导航到路由协议,揭示最短路径的普适价值!
持续关注,解锁图论核心能力链:有向无环图优化表达式、拓扑排序解依赖关系、关键路径掌控项目周期——每一步都是算法能力的跃升!
您的支持是我持续创作的动力!
✨ 点赞鼓励,让我知道这篇内容对您有帮助
⭐ 收藏备用,随时回顾算法精髓
➡️ 转发分享,帮助更多同行一起成长
💬 评论区欢迎交流探讨学习心得
下期预告:《图的基本应用——最短路径》
点击关注,算法干货不迷路!我们下次见!🚀
继续探索,让代码改变世界! 💻🌍