【计算机算法与设计(8)】最小生成树算法(Kruskal 算法和 Prim 算法)

AgenticCoding·十二月创作之星挑战赛 10w+人浏览 260人参与

要点8:掌握最小生成树算法(Kruskal 算法和 Prim 算法)

📌 适合对象:算法学习者、计算机科学学生
⏱️ 预计阅读时间:70-80分钟
🎯 学习目标:掌握两种最小生成树算法(Kruskal和Prim),理解它们的原理、实现和复杂度
📚 参考PPT:第 11 章-PPT-N2 v3.1(最小生成树算法)- Kruskal算法、Prim算法相关内容


📚 学习路线图

最小生成树MST
通用贪心策略
Generic-MST
Kruskal算法
合并森林
Prim算法
生长树
复杂度:O(m log n)
复杂度:O(n²)或O(m log n)

本文内容一览(快速理解)

  1. 最小生成树(MST):加权图的一棵生成树,其所有边的总权值最小
  2. 通用贪心策略:每次选择一条安全边加入集合A
  3. Kruskal算法:按边权从小到大,合并不同的连通分支
  4. Prim算法:从单个顶点开始,每次添加距离树最近的顶点
  5. 算法比较:Kruskal适合稀疏图,Prim适合稠密图

一、最小生成树问题(Minimum Spanning Tree Problem):问题定义

这一章要建立的基础:理解最小生成树是图算法中的经典问题,有多种应用场景。

核心问题:给定一个带权连通图,如何找出一棵生成树,使得所有边的总权值最小?


[!NOTE]
📝 关键点总结:连通图 G G G的一个连通子图如果包含图中所有顶点则称为 G G G的一个支撑子图(spanning subgraph)。图 G G G的一个支撑子图如果是一棵树,则称其为图 G G G的一棵生成树(Spanning Tree)。加权图 G G G的一棵生成树 T T T,如果它的所有边的总权值,记作 W ( T ) W(T) W(T),是所有生成树中最小的,则称其为最小生成树(MST,Minimal Spanning Tree)。

1.1 问题定义(Problem Definition):理解MST

概念的本质

最小生成树问题的定义:

  • 输入:带权连通无向图 G ( V , E , w ) G(V, E, w) G(V,E,w)
  • 输出:一棵生成树 T T T,使得 W ( T ) = ∑ e ∈ T w ( e ) W(T) = \sum_{e \in T} w(e) W(T)=eTw(e)最小

树的特性

  • 树是一个连通的、无环的无向图
  • 一个可能不连通的无向无环图称为森林
  • 树中任意两顶点由唯一简单路径连接
  • 树是连通的,且 ∣ E ∣ = ∣ V ∣ − 1 |E| = |V| - 1 E=V1

图解说明

带权连通图G
生成树T
包含所有顶点
最小生成树MST
总权值最小

💡 说明:很多应用问题可建模为找MST的问题,比如电子电路设计中的多管角连接线路总长度最小化、通信网络中的最小代价全网广播等。有时,最小生成树未必唯一,有些网络中可能存在多棵权重相同的最小生成树。

类比理解

就像连接城市:

  • 顶点:城市
  • :连接城市的道路
  • 权重:道路的长度或成本
  • MST:用最少的成本连接所有城市

实际例子

最小生成树示例:

图:a --5-- b
    |       |
    3       2
    |       |
    c --1-- d

可能的生成树:
1. a-b-d-c:5+2+1 = 8
2. a-c-d-b:3+1+2 = 6 ✓(MST)
3. a-b-d:5+2 = 7(不连通,不是生成树)

MST:a-c-d-b,总权值6

 


二、通用贪心策略(Generic Greedy Strategy):安全边

这一章要建立的基础:理解Kruskal和Prim算法都遵循同一个通用贪心策略。

核心问题:如何用贪心法构造最小生成树?


[!NOTE]
📝 关键点总结:Kruskal和Prim算法都遵循这一策略。假设带权连通图 G ( V , E ) G(V, E) G(V,E)中,顶点的数目 ∣ V ∣ = n |V| = n V=n,边的数目 ∣ E ∣ = m |E| = m E=m。在每次循环之前, A A A是某棵最小生成树的一个(边)子集;而每次循环之后 A ← A ∪ ( u , v ) A \leftarrow A \cup (u,v) AA(u,v)仍是某棵MST的(边)子集。

2.1 通用算法框架(Generic Algorithm Framework)

概念的本质

通用算法的步骤:

  1. 初始化:边的集合 A ← ∅ A \leftarrow \emptyset A(含 n n n个孤立顶点,即 n n n棵树)
  2. 找安全边:在 E E E中找出一条边 e e e使得集合 A ∪ { e } A \cup \{e\} A{e}属于某棵MST中
  3. 添加边 A ← A ∪ { e } A \leftarrow A \cup \{e\} AA{e}
  4. 判断终止:如果当前的边集合 A A A构成一棵生成树,算法停止,否则goto第二步

算法伪码

Generic-MST (G, w)
A ← ∅
while A does not form a spanning tree    //只要A还不是一棵生成树
    find a safe edge (u, v) for A        //找一条安全的边(u, v)
    A ← A ∪ {(u, v)}                     //把边(u, v)加到集合A中
endwhile    
return A
End

图解说明

A = ∅
n个孤立顶点
找安全边(u,v)
A = A ∪ {(u,v)}
A是生成树?
返回A

💡 说明:随着生成树构造过程的推进,集合 A A A总是保持在无环状态,否则包含集合 A A A的MST将包含一个环路,与树的定义相矛盾。算法执行的任何时刻, T ( V , A ) T(V, A) T(V,A)是一个森林, T T T的每一个连通分支是一棵树。Generic-MST算法中的while循环总执行次数为 ∣ V ∣ − 1 |V|-1 V1

实际例子

通用算法执行过程:

初始:A = ∅,有4个孤立顶点

第1轮:选择安全边(a,b),A = {(a,b)}
第2轮:选择安全边(c,d),A = {(a,b), (c,d)}
第3轮:选择安全边(b,c),A = {(a,b), (c,d), (b,c)}

结果:A构成生成树,算法结束

 


2.2 安全边(Safe Edge):割的最小交叉边

概念的本质

割(Cut):图的一个顶点分割 C = ( P , V − P ) C = (P, V-P) C=(P,VP),就是把图的顶点集合 V V V分成两个非空子集 P P P V − P V-P VP

交叉边(Cross Edge):给定一个割 C = ( P , V − P ) C = (P, V-P) C=(P,VP),如果一条边 ( u , v ) (u, v) (u,v)的两端, u u u v v v分属于两边,即 u ∈ P u \in P uP v ∈ V − P v \in V-P vVP,那么我们说,割 C C C与边 ( u , v ) (u, v) (u,v)相交,而边 ( u , v ) (u, v) (u,v)是一条交叉边。

割尊重集合A:如果图中一个割 C = ( P , V − P ) C = (P, V-P) C=(P,VP),与一个边的集合 A ⊆ E A \subseteq E AE中每一条边都不相交,那么,我们说这个割尊重(respect)集合 A A A

最小交叉边:给定一个割 C = ( P , V − P ) C = (P, V-P) C=(P,VP),其所有交叉边构成的集合,称为这个割的交集,记为 B ( C ) B(C) B(C)。交集 B ( C ) B(C) B(C)中权值最小的边称为最小交叉边。

定理9.1:对于一个带权连通图 G ( V , E , w ) G(V, E, w) G(V,E,w) A ⊆ E A \subseteq E AE E E E的一个子集且包含在某棵MST之中。设 C = ( P , V − P ) C = (P, V-P) C=(P,VP)是图 G G G中任意一个与 A A A不相交的割,则这个割的最小交叉边对于 A A A来说是一条安全边。

图解说明

割C = (P, V-P)
交叉边集合B(C)
最小交叉边(u,v)
安全边
可加入A

💡 说明:定理9.1的证明思路:假设 T T T是一棵包含集合 A A A的MST,而边 ( u , v ) (u, v) (u,v)是割 C C C的最小交叉边。如果 ( u , v ) ∉ T (u, v) \notin T (u,v)/T,则 T T T中必有一条连接 u u u v v v的路径 p p p,路径 p p p上必有另外一条交叉边 ( x , y ) (x, y) (x,y)。把边 ( x , y ) (x, y) (x,y) T T T中刪去,把边 ( u , v ) (u, v) (u,v)加进去,得到新的生成树 T ′ T' T。因为 ( u , v ) (u, v) (u,v)是最小交叉边, w ( u , v ) ≤ w ( x , y ) w(u, v) \leq w(x, y) w(u,v)w(x,y),所以 W ( T ′ ) ≤ W ( T ) W(T') \leq W(T) W(T)W(T) T ′ T' T也是一棵MST。

实际例子

安全边示例:

图:a --5-- b
    |       |
    3       2
    |       |
    c --1-- d

当前A = {(a,c)}(权值3)

割C = ({a,c}, {b,d})
交叉边:{(a,b), (c,d), (c,b)}
最小交叉边:(c,d),权值1

根据定理9.1,(c,d)是安全边,可以加入A

 


三、Kruskal算法:合并森林

这一章要建立的基础:理解Kruskal算法通过合并不同的连通分支来构造MST。

核心问题:如何通过合并森林来构造最小生成树?


[!NOTE]
📝 关键点总结:在Kruskal算法中,集合 A A A是一个森林,每次加入到集合 A A A中的安全边永远都是连接两个不同分支(即:子树)的所有边中,权重最小的一个。Kruskal算法要的是怎么合并 ∣ V ∣ |V| V棵树。

3.1 Kruskal算法的基本思想(Basic Idea):按边权排序

概念的本质

Kruskal算法的策略:

  1. 初始化:集合 A A A初始化为空集,图 T T T含有 n n n个孤立顶点
  2. 排序:把图 G G G中的边按权值排序使得 e 1 ≤ e 2 ≤ … ≤ e m e_1 \leq e_2 \leq \ldots \leq e_m e1e2em
  3. 选择:按序【从小到大】逐条边检查并做选择
  4. 判断:如果 A ∪ { e i } A \cup \{e_i\} A{ei}不产生回路,那么就把 e i e_i ei选上并加到 A A A
  5. 终止:当 A A A包含 n − 1 n-1 n1条边时,算法结束

算法伪码

MST-Kruskal(G(V, E))        //G是一个带权连通图
1.   A ← ∅                   //集合A初始化为空集
2.   Construct graph T(V, A) //初始时,图T含有n个孤立顶点
3.   把图G中的边按权值排序使得e1 ≤ e2 ≤ … ≤ em    
4.   for i ← 1 to m          //按序【从小到大】逐条边检查并做选择
5.       if A∪{ei} does not form a cycle    //把边ei加到A中不产生回路
6.       then A ← A ∪ {ei}   //那么就把ei 选上并加到A中
7.       endif 
8.   endfor
9.   return graph T(V, A)    //由V和A中边组成的图就是解
10. End

图解说明

初始化:A=∅
n个孤立顶点
边按权值排序
按序检查每条边
加入边会
形成回路?
跳过
加入边到A
还有边?
返回MST

💡 说明:总结来说,Kruskal算法每次都把图中距离最近的两个分支联起来,一直持续这一进程,直到网络中只剩一下一个分支(即一棵最小生成树)。每次所选的连通相距最近两个分支的边,就可以理解成当时所有穿越边中最短的那条。贪心法的精要所在。

实际例子

Kruskal算法执行过程:

图:a --5-- b
    |       |
    3       2
    |       |
    c --1-- d

边排序:(c,d,1), (b,d,2), (a,c,3), (a,b,5)

第1轮:检查(c,d,1)
  - 不形成回路,加入A
  - A = {(c,d)}

第2轮:检查(b,d,2)
  - 不形成回路,加入A
  - A = {(c,d), (b,d)}

第3轮:检查(a,c,3)
  - 不形成回路,加入A
  - A = {(c,d), (b,d), (a,c)}

第4轮:检查(a,b,5)
  - 会形成回路(a-c-d-b-a),跳过

结果:MST = {(c,d), (b,d), (a,c)},总权值6

 


3.2 Kruskal算法的实现(Implementation):Union-Find数据结构

概念的本质

如何检测 A ∪ { e i } A \cup \{e_i\} A{ei}是否有回路?

Union-Find算法

  • A A A中每个连通分支中的顶点组织为一个集合,并分配一个分支号
  • 初始时刻,每个顶点 u ∈ V ( G ) u \in V(G) uV(G)各自形成一个集合,用 M a k e − s e t ( u ) Make-set(u) Makeset(u)表示
  • 每检查一条边 ( u , v ) (u, v) (u,v)时:
    1. 找出 u u u v v v的分支号: F i n d ( u ) Find(u) Find(u) F i n d ( v ) Find(v) Find(v)
    2. 如果 u u u v v v的分支号相同,边 ( u , v ) (u, v) (u,v)的加入会形成回路,不选这条边
    3. 否则,把这条边加到子图 A A A中,用 U n i o n ( u , v ) Union(u, v) Union(u,v)合并两个集合

改进的算法伪码

MST-Kruskal(G(V, E))
A ← ∅
Construct graph T(V, A)         //图T有顶点集合V,边的集合A
Sort edges such that e1 ≤ e2 ≤ … ≤ em    //按从小到大排序..
for each vertex v ∈ V
    Make-Set(v)                 //初始化T中每个分支
endfor
for i ← 1 to m
    Let ei = (u, v) 
    if Find(u) ≠ Find(v)
    then    A ← A ∪ {ei}        // ei 是一条安全边
            Union(u, v)         //把u和v所在子树合并
    endif
endfor
return graph T(V, A)
End

图解说明

检查边(u,v)
Find(u)和Find(v)
Find(u) == Find(v)?
形成回路
跳过
加入边
Union(u,v)

💡 说明:Union-Find对 m m m条边的操作复杂度是 O ( m α ( n ) ) O(m \alpha(n)) O(mα(n)) α ( n ) \alpha(n) α(n)是Ackermann函数的反函数,增长极慢,可认为常数。Kruskal算法复杂度是 O ( m log ⁡ n + m α ( n ) ) = O ( m log ⁡ n ) O(m \log n + m \alpha(n)) = O(m \log n) O(mlogn+mα(n))=O(mlogn)

实际例子

Union-Find示例:

初始:每个顶点一个集合
{a}, {b}, {c}, {d}

检查边(c,d):
  Find(c) = c, Find(d) = d(不同)
  加入边,Union(c,d)
  集合:{a}, {b}, {c,d}

检查边(b,d):
  Find(b) = b, Find(d) = c(不同)
  加入边,Union(b,c,d)
  集合:{a}, {b,c,d}

检查边(a,c):
  Find(a) = a, Find(c) = b(不同)
  加入边,Union(a,b,c,d)
  集合:{a,b,c,d}

检查边(a,b):
  Find(a) = a, Find(b) = a(相同)
  形成回路,跳过

 


3.3 Kruskal算法的正确性证明(Correctness Proof)

概念的本质

Kruskal算法的正确性证明:

归纳法证明:算法选中的任何一条边 e i e_i ei都使 A ∪ { e i } A \cup \{e_i\} A{ei}包含在某棵MST中。

归纳基础

  • 在for循环开始前, T ( V , A ) T(V, A) T(V,A)只含 ∣ V ∣ |V| V个孤立的顶点,不含边
  • 显然 A A A是包含在任意一棵MST之中

归纳步骤

  • 假设算法对前 i − 1 i-1 i1条边做了正确选择,这时的集合 A A A包含在某棵MST中
  • 我们证明算法对边 e i e_i ei的决定也是正确的

两种情况

  1. e i e_i ei加到子图 A A A中后产生回路

    • 这是不可能发生的,因为第5行代码已经排除了这种可能
  2. e i e_i ei加到子图 A A A中以后没有产生回路

    • 假设 e i = ( u , v ) e_i = (u, v) ei=(u,v)
    • 因为无回路,所以在加进 e i e_i ei之前, u u u v v v A A A中属于不同的连通分支
    • 构造割 C = ( P , V − P ) C = (P, V-P) C=(P,VP),其中 P P P含有 A A A中所有与顶点 u u u连通的顶点
    • e i e_i ei是一条交叉边,而且是最小交叉边(因为所有权值比 e i e_i ei小的边都已检查过)
    • 根据定理9.1, e i = ( u , v ) e_i = (u, v) ei=(u,v)是一个安全边

图解说明

归纳假设:
前i-1条边正确
检查边ei
ei形成回路?
跳过
正确
构造割C
ei是最小交叉边
根据定理9.1
ei是安全边
加入ei
正确

💡 说明:所以,Kruskal算法结束时, T ( V , A ) T(V, A) T(V,A)是一棵MST。这时, T ( V , A ) T(V, A) T(V,A)必定是一个连通图,否则由于原图 G G G是连通的,运算中一定丢弃了某条连结两个不同分支的边,这与算法矛盾。

 


四、Prim算法:生长树

这一章要建立的基础:理解Prim算法通过从单个顶点开始逐步生长树来构造MST。

核心问题:如何通过生长树来构造最小生成树?


[!NOTE]
📝 关键点总结:在Prim算法中,集合 A A A则一直就是一棵树,每次加入到 A A A的安全边永远是连接 A A A A A A之外节点的所有边中,权重最小的一个。Prim算法要的是怎么生长一棵树。

4.1 Prim算法的基本思想(Basic Idea):从单个顶点开始

概念的本质

Prim算法的策略:

  1. 初始化:随机或指定图中某个顶点 s ∈ V s \in V sV作为树根,并设置边的集合 A A A为空集
  2. 与Kruskal不同:子图 A A A永远只是一棵树
  3. 找安全边:每次找的安全边必须与当前的子图 A A A中的某个顶点相联
  4. 割的定义:每次使用的割是把边集 A A A所关联的顶点(仍记为 A A A)放在割的一边,而其余顶点则放在割的另一边,即 C = ( A , V − A ) C = (A, V-A) C=(A,VA)
  5. 最小交叉边:最小交叉边就是联结 A A A以外的顶点和 A A A里面的顶点的边中权值最小的那条

找最小交叉边算法

  • 为每个 A A A以外的顶点 v v v,找出 v v v A A A的最小交叉边 ( u , v ) (u, v) (u,v)
  • 记为 d ( v ) = w ( u , v ) d(v) = w(u, v) d(v)=w(u,v) π ( v ) = u \pi(v) = u π(v)=u u u u A A A中的顶点,称为 v v v的父亲)
  • 初始置 d ( v ) = ∞ d(v) = \infty d(v)= π ( v ) = n i l \pi(v) = nil π(v)=nil ∀ v ∈ V \forall v \in V vV,但 d ( s ) = 0 d(s) = 0 d(s)=0
  • 一条安全边就是所有 A A A以外的顶点中对应的一条最小交叉边

图解说明

初始化:选择顶点s
A = ∅
计算每个树外顶点v
到树的最小距离d(v)
选择d(v)最小的顶点u
加入边(π(u), u)到A
更新u的邻居的距离
所有顶点
都在树中?
返回MST

💡 说明:Prim算法是每次把距离已经构造的树最近的【树外】顶点加到树上来,一直持续这一进程,直到图中所有顶点都加到树上来,就可以得到一棵最小生成树。每次所选的连接最近树外顶点所用的边,就是树上顶点和树外顶点所形成的割的一条最小交叉边。

实际例子

Prim算法执行过程(从a开始):

图:a --5-- b
    |       |
    3       2
    |       |
    c --1-- d

初始化:A = ∅,选择a
  d[a] = 0, d[b] = ∞, d[c] = ∞, d[d] = ∞

第1轮:选择a(d[a]最小)
  - 更新邻居:d[b] = 5, d[c] = 3
  - 加入边:无(a是根)
  - A = ∅(树只有a)

第2轮:选择c(d[c] = 3最小)
  - 加入边(a,c)
  - 更新邻居:d[d] = min(∞, 1) = 1
  - A = {(a,c)}

第3轮:选择d(d[d] = 1最小)
  - 加入边(c,d)
  - 更新邻居:d[b] = min(5, 2) = 2
  - A = {(a,c), (c,d)}

第4轮:选择b(d[b] = 2最小)
  - 加入边(d,b)
  - A = {(a,c), (c,d), (d,b)}

结果:MST = {(a,c), (c,d), (d,b)},总权值6

 


4.2 Prim算法的实现(Implementation):伪码

概念的本质

Prim算法的伪码:

MST-Prim(G(V, E), w, s)
1  for each v ∈ V                //初始化
2      d[v] ← ∞;
3      π[v] ← nil
4  endfor
5  d[s] ← 0;
6  A ← ∅                         //边的集合初始为空
7  Construct graph T(V, A)       //构造一个只有顶点V但没有边的图
8  Q ← V                         //用数据结构Q把V中点组织起来
9  while Q ≠ ∅
10     u ← Extract-Min(Q)        //找到Q中d(·)值最小的,并将其从Q中剥离
11     for each v ∈ Adj[u]
12         if v ∈ Q and d[v] > w(u, v)    //检查新交叉边 并更新d[v]
13         then    d[v] ← w(u, v)
14                 π[v] ← u
15         endif 
16     endfor                     
17 endwhile     
18 A ← {(π[u], u): u ∈ V – {s}}  //为树添加边
19 return T(V, A)
End

关键点

  1. 每次循环更新得到的 d [ v ] d[v] d[v] π [ v ] \pi[v] π[v]只是临时值,还有可能在后续循环中更新(降低)
  2. 只有当一个顶点从第10行 E x t r a c t − M i n ( ) Extract-Min() ExtractMin()操作从 Q Q Q中取出的时候,那时的 d [ ] d[] d[] π [ ] \pi[] π[]值才永久化

图解说明

初始化
d[s]=0, d[v]=∞
Q = V
Q非空?
u = Extract-Min(Q)
对u的每个邻居v
v∈Q且
d[v] > w(u,v)?
d[v] = w(u,v)
π[v] = u
跳过
继续下一个邻居
还有邻居?
构造MST

💡 说明:Prim算法与Dijkstra算法非常相似,主要区别在于:Prim算法中 d [ v ] d[v] d[v]表示顶点 v v v到当前树 A A A的距离(一跳距离),而Dijkstra算法中 d [ v ] d[v] d[v]表示从源点 s s s到顶点 v v v的距离(累计距离)。

实际例子

Prim算法详细执行:

图:s --1--> a
    |        |
    4        3
    |        |
    v        v
    b --2--> c

初始化:d[s]=0, d[a]=∞, d[b]=∞, d[c]=∞
Q = {s, a, b, c}

第1轮:u = s
  - 更新:d[a] = 1, d[b] = 4
  - Q = {a, b, c}

第2轮:u = a(d[a]=1最小)
  - 加入边(s,a)
  - 更新:d[c] = 3
  - Q = {b, c}

第3轮:u = c(d[c]=3最小)
  - 加入边(a,c)
  - 更新:d[b] = min(4, 2) = 2
  - Q = {b}

第4轮:u = b(d[b]=2最小)
  - 加入边(c,b)
  - Q = ∅

结果:MST = {(s,a), (a,c), (c,b)},总权值6

 


4.3 Prim算法的复杂度(Complexity):取决于数据结构

概念的本质

Prim算法的复杂度取决于用什么数据结构来存储 d [ u ] d[u] d[u]

  1. 采用数组

    • 找出最小 d [ u ] d[u] d[u]值需要 O ( n ) O(n) O(n)时间
    • 完成第一件事所要的总时间为 n × O ( n ) = O ( n 2 ) n \times O(n) = O(n^2) n×O(n)=O(n2)
    • 检查和更新顶点 u u u的每一个邻居只需 O ( 1 ) O(1) O(1)时间
    • 第二件事所需总时间与图中边的个数成正比,为 O ( m ) O(m) O(m)
    • 总复杂度 O ( n 2 ) O(n^2) O(n2)
  2. 采用基于堆的优先队列

    • 最小 d [ u ] d[u] d[u]可以立即在根结点那里得到,删除并修复堆需要 O ( log ⁡ n ) O(\log n) O(logn)时间
    • 完成第一件事所要的总时间为 n × O ( log ⁡ n ) = O ( n log ⁡ n ) n \times O(\log n) = O(n \log n) n×O(logn)=O(nlogn)
    • 更新顶点 u u u的一个邻居 v v v需要 O ( log ⁡ n ) O(\log n) O(logn)时间
    • 第二件事所需总时间为 O ( m log ⁡ n ) O(m \log n) O(mlogn)
    • 总复杂度 O ( m log ⁡ n ) O(m \log n) O(mlogn)
  3. 采用斐波那契堆(Fibonacci Heap)

    • 在更新点 u u u的一个邻居时,我们并不立刻对堆进行修复,只是在这个点上打上记号,需要 O ( 1 ) O(1) O(1)
    • 等到下一个循环做"第一件事"的时候,进行大的修复工作
    • 用平摊分析的方法可以证明这第二件事所需总时间为 O ( m ) O(m) O(m)
    • 总复杂度 O ( n log ⁡ n + m ) O(n \log n + m) O(nlogn+m)

图解说明

Prim算法复杂度
数组
O(n²)
最小堆
O(m log n)
斐波那契堆
O(n log n + m)

💡 说明:对于稠密图( m m m接近 n 2 n^2 n2),使用数组的Prim算法复杂度为 O ( n 2 ) O(n^2) O(n2),与使用堆的 O ( n 2 log ⁡ n ) O(n^2 \log n) O(n2logn)相比更优。对于稀疏图( m m m接近 n n n),使用堆的Prim算法复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn),比数组的 O ( n 2 ) O(n^2) O(n2)更优。

实际例子

复杂度比较:

图:n = 1000个顶点

情况1:稠密图(m = 500,000)
- 数组:O(n²) = O(1,000,000)
- 堆:O(m log n) = O(500,000 × 10) = O(5,000,000)
- 数组更优

情况2:稀疏图(m = 2000)
- 数组:O(n²) = O(1,000,000)
- 堆:O(m log n) = O(2000 × 10) = O(20,000)
- 堆更优

 


五、两种算法的比较(Comparison):理解它们的区别

这一章要建立的基础:理解Kruskal和Prim两种算法的区别,知道在什么情况下使用哪种算法。

核心问题:两种算法有什么相同点和不同点?如何选择合适的方法?


[!NOTE]
📝 关键点总结:Kruskal算法和Prim算法是构造最小生成树的两个经典算法,它们都基于上面的通用算法框架。在Kruskal算法中,集合 A A A是一个森林,每次加入到集合 A A A中的安全边永远都是连接两个不同分支的所有边中,权重最小的一个。在Prim算法中,集合 A A A则一直就是一棵树,每次加入到 A A A的安全边永远是连接 A A A A A A之外节点的所有边中,权重最小的一个。

5.1 相同点(Similarities):都使用贪心策略

概念的本质

两种算法的相同点:

  1. 都使用贪心策略:每次选择一条安全边加入集合 A A A
  2. 都基于通用算法框架:都遵循Generic-MST的框架
  3. 都利用定理9.1:都通过找割的最小交叉边来找到安全边
  4. 都保证正确性:都能正确构造最小生成树

图解说明

相同点
贪心策略
通用框架
定理9.1
正确性保证

 


5.2 不同点(Differences):策略、数据结构、复杂度

概念的本质

两种算法的不同点:

特性Kruskal算法Prim算法
集合A的性质森林(多个连通分支)树(单个连通分支)
选择策略连接两个不同分支的最小边连接树与树外顶点的最小边
数据结构Union-Find优先队列(堆)
时间复杂度 O ( m log ⁡ n ) O(m \log n) O(mlogn) O ( n 2 ) O(n^2) O(n2) O ( m log ⁡ n ) O(m \log n) O(mlogn) O ( n log ⁡ n + m ) O(n \log n + m) O(nlogn+m)
适用场景稀疏图稠密图(数组)或稀疏图(堆)
实现难度需要Union-Find需要优先队列

图解说明

算法比较
Kruskal
合并森林
O(m log n)
Prim
生长树
O(n²)或O(m log n)

💡 说明:Kruskal算法每次都把图中距离最近的两个分支联起来,一直持续这一进程,直到网络中只剩一下一个分支。Prim算法是每次把距离已经构造的树最近的【树外】顶点加到树上来,一直持续这一进程,直到图中所有顶点都加到树上来。

实际例子

算法选择示例:

场景1:稀疏图(m接近n)
- 推荐:Kruskal算法
- 理由:O(m log n) = O(n log n),实现简单

场景2:稠密图(m接近n²)
- 推荐:Prim算法(数组实现)
- 理由:O(n²) vs O(n² log n),Prim更快

场景3:需要在线算法(边动态添加)
- 推荐:Prim算法
- 理由:可以逐步生长树

场景4:边已经排序
- 推荐:Kruskal算法
- 理由:不需要排序,直接O(m α(n))

 


📝 本章总结

核心要点回顾

  1. 最小生成树(MST)

    • 加权图的一棵生成树,其所有边的总权值最小
    • 应用:电子电路设计、通信网络、近似算法
  2. 通用贪心策略

    • 每次选择一条安全边加入集合 A A A
    • 安全边:割的最小交叉边(定理9.1)
  3. Kruskal算法

    • 策略:合并森林,按边权从小到大选择
    • 复杂度: O ( m log ⁡ n ) O(m \log n) O(mlogn)
    • 适用:稀疏图
  4. Prim算法

    • 策略:生长树,从单个顶点开始
    • 复杂度: O ( n 2 ) O(n^2) O(n2)(数组)或 O ( m log ⁡ n ) O(m \log n) O(mlogn)(堆)或 O ( n log ⁡ n + m ) O(n \log n + m) O(nlogn+m)(斐波那契堆)
    • 适用:稠密图(数组)或稀疏图(堆)
  5. 算法比较

    • 相同点:都使用贪心策略,都基于通用框架
    • 不同点:集合 A A A的性质、选择策略、数据结构、复杂度不同

知识地图

最小生成树MST
通用贪心策略
安全边
Kruskal算法
合并森林
Prim算法
生长树
O(m log n)
O(n²)或O(m log n)

关键决策点

  • 何时使用Kruskal:稀疏图,边已经排序,实现简单
  • 何时使用Prim:稠密图(数组),需要在线算法,与Dijkstra类似
  • 如何优化Kruskal:使用高效的Union-Find数据结构
  • 如何优化Prim:根据图的特点选择合适的数据结构(数组/堆/斐波那契堆)

💡 延伸学习:最小生成树算法是图算法的基础,掌握它们有助于我们:

  1. 理解网络设计问题(如通信网络、电路设计)
  2. 解决实际的最优化问题
  3. 为学习更复杂的图算法打下基础
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

roman_日积跬步-终至千里

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值