最小生成树:Prim算法的堆优化版本
字数 2980 2025-12-13 13:58:11
最小生成树:Prim算法的堆优化版本
一、问题描述
给定一个无向连通带权图 \(G = (V, E)\),其中 \(V\) 是顶点集,\(E\) 是边集,每条边有一个非负权值。
目标是找到图 \(G\) 的一棵最小生成树(Minimum Spanning Tree, MST),即包含所有顶点的、总边权最小的连通无环子图。
Prim算法的基本思想是从一个顶点开始,逐步扩展生成树,每次添加一条当前可选的、权值最小的边(这条边连接已选顶点集和未选顶点集)。
堆优化版本(常称为Prim-Dijkstra优化)的核心改进在于:用优先队列(最小堆)高效地维护和获取“当前可选的最小边”。
二、核心概念与基本Prim算法回顾
-
基本Prim算法(邻接矩阵实现)步骤
- 任意选一个起点(如顶点0),加入集合 \(S\)(已在MST中的顶点集),其余顶点在集合 \(T\) 中。
- 维护一个数组
minDist[v],记录每个未选顶点 \(v\) 到当前已选集合 \(S\) 的最短边权(即从已选顶点到 \(v\) 的所有边中的最小权值)。 - 每次从 \(T\) 中选出
minDist最小的顶点 \(u\),将其加入 \(S\),并将 \(u\) 连接到已选顶点集的那条边加入MST。 - 更新 \(u\) 的所有邻接点 \(v\) 的
minDist[v]:若边 \((u, v)\) 的权值小于minDist[v],则更新。 - 重复直到所有顶点加入 \(S\)。
-
时间复杂度
- 邻接矩阵实现:\(O(V^2)\),适合稠密图。
- 当图为稀疏图(边数 \(E\) 远小于 \(V^2\))时,此方法效率较低。
三、堆优化版本的思路
-
目标
将“每次从未选顶点中找出minDist最小者”的操作,用最小堆(优先队列)实现,从而降低时间复杂度。 -
数据结构
- 优先队列(最小堆)
heap:存储(dist, v)对,其中dist是顶点 \(v\) 到已选集合的当前最短边权,\(v\) 是顶点编号。 - 数组
inMST[v]:标记顶点 \(v\) 是否已加入MST。 - 数组
minDist[v]:意义同基本Prim,记录当前最短边权。
- 优先队列(最小堆)
-
操作优化
- 初始时,将起点 \((0, 0)\) 入堆(
dist=0表示从已选集合到自身的距离为0)。 - 每次从堆中弹出
dist最小的顶点 \(u\),若它已inMST[u]为真,则跳过(避免重复处理)。 - 否则将其加入MST,并遍历其邻接边,如果边 \((u, v)\) 的权值 \(w\) 小于
minDist[v],则更新minDist[v] = w,并将(w, v)加入堆中(允许重复入堆,通过inMST标记过滤)。 - 这样做的好处是:不必每次在未选顶点中线性扫描找最小,而是从堆顶快速获取。
- 初始时,将起点 \((0, 0)\) 入堆(
四、逐步详细示例
考虑下图(顶点数 \(V=5\),边数 \(E=7\)):
顶点: 0-1-2-3-4
边与权值:
0-1: 2
0-3: 6
1-2: 3
1-3: 8
1-4: 5
2-4: 7
3-4: 9
用邻接表存储图。
步骤1:初始化
- 起点选0。
inMST = [False, False, False, False, False],minDist = [0, ∞, ∞, ∞, ∞]。 - 堆初始:
[(0, 0)]。
步骤2:第一次弹出堆顶
- 弹出
(0, 0),inMST[0] = False,将其加入MST:inMST[0] = True。此时MST包含顶点0,总边权=0。 - 遍历0的邻接边:
- 邻接点1,边权2。
minDist[1] = ∞ > 2,更新minDist[1] = 2,入堆(2, 1)。 - 邻接点3,边权6。
minDist[3] = ∞ > 6,更新minDist[3] = 6,入堆(6, 3)。
- 邻接点1,边权2。
- 堆状态:
[(2,1), (6,3)]。
步骤3:第二次弹出堆顶
- 弹出
(2, 1),inMST[1] = False,加入MST,MST边加入(0-1:2),总边权=2。 - 遍历顶点1的邻接边:
- 邻接点0,已
inMST[0]=True,跳过。 - 邻接点2,边权3。
minDist[2]=∞ > 3,更新minDist[2]=3,入堆(3,2)。 - 邻接点3,边权8。当前
minDist[3]=6,8>6,不更新。 - 邻接点4,边权5。
minDist[4]=∞ > 5,更新minDist[4]=5,入堆(5,4)。
- 邻接点0,已
- 堆状态:
[(3,2), (6,3), (5,4)]。
步骤4:第三次弹出堆顶
- 弹出
(3, 2),inMST[2]=False,加入MST,MST边加入(1-2:3),总边权=5。 - 遍历顶点2的邻接边:
- 邻接点1,已
inMST[1]=True,跳过。 - 邻接点4,边权7。当前
minDist[4]=5,7>5,不更新。
- 邻接点1,已
- 堆状态:
[(5,4), (6,3)]。
步骤5:第四次弹出堆顶
- 弹出
(5, 4),inMST[4]=False,加入MST,MST边加入(1-4:5),总边权=10。 - 遍历顶点4的邻接边:
- 邻接点1,已
inMST[1]=True,跳过。 - 邻接点2,已
inMST[2]=True,跳过。 - 邻接点3,边权9。当前
minDist[3]=6,9>6,不更新。
- 邻接点1,已
- 堆状态:
[(6,3)]。
步骤6:第五次弹出堆顶
- 弹出
(6, 3),inMST[3]=False,加入MST,MST边加入(0-3:6),总边权=16。 - 遍历顶点3的邻接边:邻接点0、1、4均已在MST中,跳过。
- 堆为空。
最终MST:包含边 (0,1)、(1,2)、(1,4)、(0,3),总权值=2+3+5+6=16。
五、堆优化版本的复杂度分析
- 每个顶点最多入堆一次(但实际可能因更新而重复入堆,但每个顶点最多被弹出并处理一次)。
- 堆操作:
- 每个顶点出堆一次,复杂度 \(O(V \log V)\)。
- 每条边可能触发一次入堆(当发现更小的
minDist时),复杂度 \(O(E \log V)\)。
- 总时间复杂度:\(O((V+E) \log V)\)。
- 空间复杂度:\(O(V+E)\) 用于存图,\(O(V)\) 用于堆。
六、代码实现要点
import heapq
def prim_mst_heap(graph, start=0):
"""
graph: 邻接表,graph[u] = [(v, w), ...]
start: 起始顶点
"""
V = len(graph)
inMST = [False] * V
minDist = [float('inf')] * V
minDist[start] = 0
heap = [(0, start)] # (dist, vertex)
mst_edges = []
total_weight = 0
while heap:
dist, u = heapq.heappop(heap)
if inMST[u]:
continue
inMST[u] = True
total_weight += dist
if u != start: # 不记录起点的“边”
# 实际记录边需要额外保存连接信息,这里省略
pass
for v, w in graph[u]:
if not inMST[v] and w < minDist[v]:
minDist[v] = w
heapq.heappush(heap, (w, v))
return total_weight
七、对比与总结
- 堆优化Prim在稀疏图中效率高于基本Prim的 \(O(V^2)\) 方法。
- 与Kruskal算法对比:
- Kruskal按边排序,复杂度 \(O(E \log E)\),适合边数较少的稀疏图。
- 堆优化Prim适合边数较多的稠密图吗?当 \(E\) 接近 \(V^2\) 时,堆优化Prim的 \(O(V^2 \log V)\) 可能略慢于基本Prim的 \(O(V^2)\),但实际中通常仍用堆优化,因为它编码简单且对多数图高效。
- 关键优化点:允许重复入堆,用
inMST标记避免重复加入MST。