算法复习——图算法篇之最小生成树之Prim算法

算法复习——图算法篇之最小生成树之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 mineETw(e)s.t.  VT=V,ETE

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 AT
    • 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,VS)将图 G G G的顶点集 V V V划分为两部分。
  • 横跨(Cross)

    • 给定割 ( S , V − S ) (S, V-S) (S,VS)和边 ( u , v ) (u, v) (u,v) u ∈ S u \in S uS v ∈ V − S v \in V - S vVS,称边 ( u , v ) (u, v) (u,v)横跨割 ( S , V − S ) (S, V-S) (S,VS)。图中绿色的边都横跨割。
  • 轻边(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,VS)是图 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 AT,则 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,VS)的一条边为 ( 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,VVA)

    在这里插入图片描述

    • 步骤3:选择横跨割 ( V A , V − V A ) (V_A, V-V_A) (VA,VVA)的轻边,添加到边集 A A A中;
    • 步骤4:重复步骤2和步骤3,直至覆盖所有顶点。
  • 辅助数组

    • c o l o r color color表示顶点状态
      • 黑色顶点 u u u已覆盖, u ∈ V A u \in V_A uVA
      • 白色顶点 u u u未覆盖, u ∈ V − V A u \in V - V_A uVVA
    • d i s t dist dist记录横跨 ( V A , V − V A ) (V_A, V-V_A) (VA,VVA)边的权重
      • 顶点集 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)},xVA
    • p r e d pred pred表示前驱顶点
      • ( p r e d [ u ] , u ) (pred[u], u) (pred[u],u)为最小生成树的边

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(VV)+O(uVdeg(u))=O(V2)+O(2E)=O(V2+E);所以最终整体算法的时间复杂度是 O ( ∣ V ∣ 2 ) O(|V|^2) O(V2)(无论是稠密图还是稀疏图, ∣ E ∣ < ∣ V ∣ 2 |E|<|V|^2 E<V2)。

6. 优先队列优化

优先队列

  • 队列中每个元素有一个关键字,依据关键字大小离开队列
  • 通过二叉堆来实现优先队列
    • 要求父节点的关键字小于两个子节点的关键字(不要求左右子节点的大小关系)
    • 除了最底层,第 h h h层有 2 h − 1 2^{h-1} 2h1个顶点;且最底层的节点都是从最左往右放置,无空叶子结点,除非已放置了所有顶点
    • 整个二叉堆的 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(logV),更新 d i s t dist dist数组的时间复杂度是 O ( d e g ( u ) l o g ∣ V ∣ ) O(deg(u)log|V|) O(deg(u)logV),因此执行最小生成树的时间复杂度是 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(logV+deg(u)logV))=O(VlogV+ElogV)=O(ElogV)(因为在最小生成树问题中, ∣ V ∣ < ∣ E ∣ |V|<|E| V<E);所以最终整体算法的时间复杂度是 O ( ∣ E ∣ l o g ∣ V ∣ ) O(|E|log|V|) O(ElogV)

7. 小结

通用框架Prim算法
成环判断始终保持一棵树
轻边发现依赖于优先队列
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值