算法复习——图算法篇之最小生成树之Prim算法
以下内容主要参考中国大学MOOC《算法设计与分析》,墙裂推荐希望入门算法的童鞋学习!
1. 问题背景
道路修建:需要修建道路连通城市,各道路花费不同,如下图所示。
上图中给出了一些道路修建方案,现要求连通各城市的最小花费。
这个问题本质上就是求解权重最小的连通生成子图。子图和生成子图的概念可参考算法复习——图算法篇之图的基本概念。更进一步,可以给出生成树的定义,即图 T ′ = < V ′ , E > T'=<V', E> T′=<V′,E>是无向图 G G G的一个生成子图,并且是连通、无环路的(树)。其实,我们要求的权重最小的连通生成子图一定是一棵生成树,因为如果存在环路,则至少两个城市之间连通的路至少有两条,也就是说在已经通过一条路连通的情况下再通过另一条路连通,一定产生了冗余的花费,这是没必要的。正是由于最小性的条件,我们最终的任务就变成了求解权重最小的生成树。
同时,通过上图可知,权重最小的生成树可能不唯一。
2. 问题定义
最小生成树问题(Minimum Spanning Tree Problem)
输入:
- 连通无向图 G = < V , E , W > G=<V, E, W> G=<V,E,W>,其中 w ( u , v ) ∈ W w(u, v) \in W w(u,v)∈W表示边 ( u , v ) (u, v) (u,v)的权重
输出:
- 图 G G G的最小生成树 T = < V T , E T > T=<V_T, E_T> T=<VT,ET>
m i n ∑ e ∈ E T w ( e ) s . t . V T = V , E T ⊆ E min\sum_{e \in E_T}w(e)\\ s.t.\ \ V_T=V, E_T \subseteq E mine∈ET∑w(e)s.t. VT=V,ET⊆E
3. 通用框架
需注意,通用框架不是一个具体的算法,而是求解这个最小生成树问题的总的方针策略。这个方针策略不会细节到具体的实现步骤,但是是具体算法背后最本质的思想。Prim算法和Kruskal算法都是基于这个方针策略和总体思想衍生出来的
3.1 总体框架分析
生成树是一个无向图中的连通、无环的生成子图。也就是说图中的所有顶点都会包含在这个最小生成树中,问题其实只是讨论哪些边该选择,哪些边不该选择。因此,
- 新建一个空边集A,边集A可逐步扩展为最小生成树;
- 每次向边集A中新增加一条边,并有以下约束条件:
- 需保证边集A仍是一个无环图(通用框架没有阐述如何去检验无环性,只是说应该去检验无环性);
- 需保证边集A仍是最小生成树的子集;
大家看第二个约束条件,可能会觉得这是句废话orzzz(我如果已经知道了最小生成树有那些边,我还求它干啥呢qwq)。那么问题就变成了如何保证边集A仍是最小生成树的子集呢?
3.2 相关概念
- 安全边(Safe Edge)
- A A A是某棵最小生成树T边的子集, A ⊆ T A \subseteq T A⊆T
- A ∪ ( u , v ) A \cup {(u, v)} A∪(u,v)仍是T边的一个子集,则称 ( u , v ) (u, v) (u,v)是 A A A的安全边
也就是说若每次向边集A中新增安全边,可保证边集A是最小生成树的子集。(内心os:这不是就换了一个概念,重复了一遍废话嘛嘤嘤嘤)(emmm其实真的不是,不急,且听下面分解)
于是,就可以生成通用的求解最小生成树的框架:
Generic-MST(G)
A <- 0
while 没有形成最小生成树 do
寻找A的安全边(u, v)
A <- A | (u, v) // 取并集
end
return A
那么,问题就变成了如何有效辨识安全边?
继续给出如下定义:
-
割(Cut)
- 图 G = < V , E > G=<V, E> G=<V,E>是一个连通无向图,割 ( S , V − S ) (S, V-S) (S,V−S)将图 G G G的顶点集 V V V划分为两部分。
-
横跨(Cross)
- 给定割 ( S , V − S ) (S, V-S) (S,V−S)和边 ( u , v ) (u, v) (u,v), u ∈ S u \in S u∈S, v ∈ V − S v \in V - S v∈V−S,称边 ( u , v ) (u, v) (u,v)横跨割 ( S , V − S ) (S, V-S) (S,V−S)。图中绿色的边都横跨割。
-
轻边(Light Edge)
- 横跨割的所有边中,权重最小的称为横跨这个割的一条轻边。
-
不妨害(Respect)
- 如果一个边集A中没有边横跨某割,则称该割不妨害边集A。
3.3 安全边辨识定理
给定图 G = < V , E > G=<V, E> G=<V,E>是一个带权的连通无向图,令 A A A为边集 E E E的一个子集,且 A A A包含在图 G G G的某棵最小生成树中。若割 ( S , V − S ) (S, V-S) (S,V−S)是图 G G G中不妨害边集 A A A的任意割,且 ( u , v ) (u, v) (u,v)是横跨该割的轻边,则对于边集 A A A,边 ( u , v ) (u, v) (u,v)是其安全边。
证明:
-
若 ( u , v ) ∈ T (u, v) \in T (u,v)∈T,由于 A ⊆ T A \subseteq T A⊆T,则 A ∪ { ( u , v ) } ⊆ T A \cup \{(u, v)\} \subseteq T A∪{(u,v)}⊆T,由安全边定义可证 ( u , v ) (u, v) (u,v)为安全边;
-
若 ( 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中,横跨割 ( S , V − S ) (S, V-S) (S,V−S)的一条边为 ( x , y ) (x, y) (x,y)。
边 ( 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);将边 ( u , v ) (u, v) (u,v)加入到 T T T中会形成环路,再去掉边 ( x , y ) (x,y) (x,y)会形成另一棵树 T ′ T' T′, w ( T ′ ) ≤ w ( T ) w(T') \leq w(T) w(T′)≤w(T), T ′ T' T′也是最小生成树,则 A ∪ { ( u , v ) } ⊆ T ′ A \cup \{(u, v)\} \subseteq T' A∪{(u,v)}⊆T′,边 ( u , v ) (u, v) (u,v)是安全边。
3.4 通用框架
- 生成树是一个连通、无环的生成子图
- 新建一个空边集 A A A,边集 A A A可逐步扩展为最小生成树
- 每次向边集
A
A
A中新增加一条边
- 需保证边集 A A A仍是一个无环图
- 需保证边集 A A A仍是最小生成树的子集(即每次新添加的边为一条轻边)
4. Prim算法
Prim算法和Kruskal算法最大的不同是,Prim算法始终维持边集 A A A一棵树,规避了无环图的判断,且整个过程中只有两个割集,每次都要去寻找这两个割集的轻边,并加入图中;而Kruskal算法中,每一步中可能有多个割集,按顺序加入边,但每次加入边都需要判断是否为无环图,只要不是无环图,就可以确保加入的边一定是轻边。
-
算法思想
- 步骤1:选择任意一个顶点,作为生成树的起始顶点;
- 步骤2:保持边集A始终为一棵树(若为树,一定为无环图,自然就规避了判断无环的问题),选择割 ( V A , V − V A ) (V_A, V-V_A) (VA,V−VA);
- 步骤3:选择横跨割 ( V A , V − V A ) (V_A, V-V_A) (VA,V−VA)的轻边,添加到边集 A A A中;
- 步骤4:重复步骤2和步骤3,直至覆盖所有顶点。
-
辅助数组
-
c
o
l
o
r
color
color表示顶点状态
- 黑色顶点 u u u已覆盖, u ∈ V A u \in V_A u∈VA
- 白色顶点 u u u未覆盖, u ∈ V − V A u \in V - V_A u∈V−VA
-
d
i
s
t
dist
dist记录横跨
(
V
A
,
V
−
V
A
)
(V_A, V-V_A)
(VA,V−VA)边的权重
- 顶点集 V A V_A VA到顶点 u u u的最短距离, d i s t [ u ] = m i n { w ( x , u ) } , ∀ x ∈ V A dist[u]=min\{w(x, u)\}, \forall x \in V_A dist[u]=min{w(x,u)},∀x∈VA
-
p
r
e
d
pred
pred表示前驱顶点
- ( p r e d [ u ] , u ) (pred[u], u) (pred[u],u)为最小生成树的边
-
c
o
l
o
r
color
color表示顶点状态
5. 伪代码
MST-Prim(G)
输入:图 G = < V , E , W > G=<V, E, W> G=<V,E,W>
输出:最小生成树 T T T
新建一维数组color[1..|V|], dist[1..|V|], pred[1..|V|]
// 初始化
for u ∈ V do
color[u] ← WHITE
dist[u] ← ∞
pred[u] ← NULL
end
dist[1] ← 0
// 执行最小生成树算法
for i ← 1 to |V| do
minDist ← ∞
rec ← 0
// 记录新增的安全边
for j ← 1 to |V| do
if color[j] ≠ BLACK and dist[j] < minDist then
minDist ← dist[j]
rec ← j
end
end
// 更新dist数组
for u ∈ G.Adj[rec] do
if w(rec, u) < dist[u] then
dist[u] ← w(rec, u)
pred[u] ← rec
end
end
color[rec] ← BLACK
end
时间复杂度分析:
初始化的时间复杂度是 O ( ∣ V ∣ ) O(|V|) O(∣V∣);记录新增的安全边的时间复杂度是 O ( ∣ V ∣ ) O(|V|) O(∣V∣),更新 d i s t dist dist数组的时间复杂度是 O ( d e g ( u ) ) O(deg(u)) O(deg(u)),所以执行最小生成树算法的时间复杂度是 O ( ∣ V ∣ ∗ ∣ V ∣ ) + O ( ∑ u ∈ V d e g ( u ) ) = O ( ∣ V ∣ 2 ) + O ( 2 ∣ E ∣ ) = O ( ∣ V ∣ 2 + ∣ E ∣ ) O(|V|*|V|)+O(\sum_{u \in V}deg(u))=O(|V|^2)+O(2|E|)=O(|V|^2+|E|) O(∣V∣∗∣V∣)+O(∑u∈Vdeg(u))=O(∣V∣2)+O(2∣E∣)=O(∣V∣2+∣E∣);所以最终整体算法的时间复杂度是 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)(无论是稠密图还是稀疏图, ∣ E ∣ < ∣ V ∣ 2 |E|<|V|^2 ∣E∣<∣V∣2)。
6. 优先队列优化
优先队列:
- 队列中每个元素有一个关键字,依据关键字大小离开队列
- 通过二叉堆来实现优先队列
- 要求父节点的关键字小于两个子节点的关键字(不要求左右子节点的大小关系)
- 除了最底层,第 h h h层有 2 h − 1 2^{h-1} 2h−1个顶点;且最底层的节点都是从最左往右放置,无空叶子结点,除非已放置了所有顶点
- 整个二叉堆的 O ( l o g n ) O(log n) O(logn)层
- 相关操作
- 插入操作 Q . I n s e r t ( ) Q.Insert() Q.Insert():需要插入新的节点,初始化是放在堆的最后,但这样可能破坏了堆的性质,即子节点的关键字小于父节点的关键字。为了维护堆的性质,将新插入的节点和父节点比大小,如果违背了堆的性质(即子节点的关键字小于父节点的关键字),就交换子节点和父节点;重复刚刚的操作,不断地比较交换,直至其符合堆的性质。该操作的时间复杂度是 O ( l o g n ) O(log n) O(logn)。
- 提取最小节点操作 Q . E x t r a c t M i n ( ) Q.ExtractMin() Q.ExtractMin():即提取堆顶的元素,但是这样的话,堆顶就空了,就把堆的最后一个元素强行放到堆顶,当这样可能违背了堆的性质。为了维护堆的性质,将堆顶(即根节点)和与其子节点比大小(左右孩子均可),如果违背了堆的性质,就交换子节点和父节点;重复刚刚地操作,不断地比较交换,直至其符合堆的性质。该操作的时间复杂度是 O ( l o g n ) O(log n) O(logn)。
- 更新操作 Q . D e c r e a s e K e y ( ) Q.DecreaseKey() Q.DecreaseKey():如果其中一个节点的关键字发生了变化,破坏了堆的性质。为了维护堆的性质,如果关键字变小了,就将其不断地和父节点比较交换,直至其符合堆的性质;如果关键字变大了,就将其不断地和子节点比较交换,直至其符合堆的性质。该操作的时间复杂度是 O ( l o g n ) O(log n) O(logn)。
因此,可以使用优先队列,高效查找安全边。伪代码如下:
MST-Prim-PriQueue(G)
输入:图 G = < V , E , W > G=<V, E, W> G=<V,E,W>
输出:最小生成树 T T T
新建一维数组color[1..|V|], dist[1..|V|], pred[1..|V|]
新建空优先队列Q
// 初始化
for u ∈ V do
color[u] ← WHITE
dist[u] ← ∞
pred[u] ← NULL
end
dist[1] ← 0
// 初始化优先队列
Q.Insert(V, dist)
// 执行最小生成树算法
while 优先队列Q非空 do
v ← Q.ExtractMin()
for u ∈ G.Adj[v] do
if color[u] == WHITE and w(v, u) < dist[u] then
dist[u] ← w(v, u)
pred[u] ← v
Q.DecreaseKey((u, dist[u]))
end
end
color[v] ← BLACK
end
时间复杂度分析:
初始化的时间复杂度是 O ( ∣ V ∣ ) O(|V|) O(∣V∣);寻找安全边的时间复杂度是 O ( l o g ∣ V ∣ ) O(log|V|) O(log∣V∣),更新 d i s t dist dist数组的时间复杂度是 O ( d e g ( u ) l o g ∣ V ∣ ) O(deg(u)log|V|) O(deg(u)log∣V∣),因此执行最小生成树的时间复杂度是 O ( ∣ V ∣ ∗ ( l o g ∣ V ∣ + d e g ( u ) l o g ∣ V ∣ ) ) = O ( ∣ V ∣ l o g ∣ V ∣ + ∣ E ∣ l o g ∣ V ∣ ) = O ( ∣ E ∣ l o g ∣ V ∣ ) O(|V|*(log|V|+deg(u)log|V|))=O(|V|log|V|+|E|log|V|)=O(|E|log|V|) O(∣V∣∗(log∣V∣+deg(u)log∣V∣))=O(∣V∣log∣V∣+∣E∣log∣V∣)=O(∣E∣log∣V∣)(因为在最小生成树问题中, ∣ V ∣ < ∣ E ∣ |V|<|E| ∣V∣<∣E∣);所以最终整体算法的时间复杂度是 O ( ∣ E ∣ l o g ∣ V ∣ ) O(|E|log|V|) O(∣E∣log∣V∣)。
7. 小结
通用框架 | Prim算法 |
---|---|
成环判断 | 始终保持一棵树 |
轻边发现 | 依赖于优先队列 |