并行与分布式系统中的并行单源最短路径:基于优先队列的并行Dijkstra算法
题目描述
在一个加权有向图(或加权无向图)中,给定一个源点(Source Vertex),单源最短路径(Single-Source Shortest Path, SSSP)问题旨在计算从该源点到图中所有其他顶点的最短路径距离。图的边权通常为非负值。Dijkstra算法是解决此问题的经典串行算法,其时间复杂度为O((V+E) log V)。但在大规模图(例如,社交网络、网页链接图、交通网络)中,使用并行计算加速最短路径计算至关重要。本题旨在设计并讲解一种基于优先队列的并行Dijkstra算法。该算法需要在共享内存(多核)或分布式内存环境中,高效地并行化Dijkstra算法的核心操作——从“未确定最短距离的顶点集合”中选出当前距离最小的顶点,并“松弛”(Relax)其邻边,同时避免因并发更新带来的数据竞争和同步开销,从而获得显著的加速比。
解题过程
Dijkstra算法的核心思想是“贪心”:维护一个集合S,包含已确定最终最短距离的顶点;以及一个优先队列Q,包含尚未确定的顶点及其当前已知的从源点到该顶点的最佳距离估计。算法每次从Q中取出距离估计最小的顶点u,将其加入S,然后“松弛”其所有出边(u, v)。松弛操作会检查:如果经过u到达v的路径比v当前的已知距离更短,则更新v的距离估计,并相应调整Q中v的位置。
并行化的挑战在于:
- 顶点选择的串行性:经典Dijkstra中,每次只能从Q中提取一个最小距离顶点。这一步难以直接并行。
- 更新的冲突:多个不同的顶点u可能试图同时松弛同一个顶点v的距离,导致对
dist[v]的并发写冲突。 - 优先队列的并发:需要支持多线程或多进程同时进行“取出最小元素”和“减少键值”操作的高效并发优先队列。
一个经典的并行化思路是放宽“严格按全局最小顺序处理顶点”的限制,允许一批“足够小”距离的顶点被并行处理。下面是基于“优先级队列”和“桶”思想的并行Δ-Stepping算法的精髓,但这里我们讲解一个更直接、适用于共享内存环境的并行Dijkstra变体。
步骤1:问题形式化与数据结构设计
- 输入:有向图G=(V, E),权重函数w: E → ℝ⁺(非负),源点s ∈ V。
- 目标:计算数组
dist[0..|V|-1],使得dist[v]为从s到v的最短路径长度。 - 核心数据结构:
dist[]:存储当前最短距离估计。初始时,dist[s] = 0,其余为无穷大。visited[]或processed[]:标记顶点是否已被处理(即其最短距离已最终确定,并从优先队列中删除)。初始全为false。- 并发优先队列 (Q):这是一个支持并发操作的优先级队列。常用实现包括:
- 基于锁的二叉堆:粗粒度锁(性能差)或更精细的锁策略。
- 无锁(Lock-Free)或基于事务内存的优先队列:更优的并发性能,但实现复杂。
- 桶式结构 (Buckets):适用于整数权重或权重在一定范围的情况。可以划分为多个桶,每个桶内距离值相同(或在一个范围内)。这允许并行处理同一桶内的所有顶点,是实现高效并行的关键。
- 在并行Dijkstra的许多高效实现中,原子操作(如CAS, Compare-And-Swap)用于安全地更新
dist[v]。
步骤2:基本并行算法框架
一个典型的共享内存并行Dijkstra算法伪代码框架如下:
并行 Dijkstra (共享内存,P个线程)
输入: 图 G(V, E, w), 源点 s
输出: 距离数组 dist[]
1. 初始化: dist[v] = ∞ for all v ∈ V; dist[s] = 0
2. 初始化: visited[v] = false for all v ∈ V
3. 初始化: 并发优先队列 Q; Q.insert(s, 0)
4. 并行段:
5. while Q 非空:
6. // 阶段1: 并行获取一批候选顶点
7. 候选集合 C = 空集
8. 临界区或使用原子操作: // 从Q中“批量”取元素
9. 重复直到 C 大小达到一个阈值 BATCH_SIZE 或 Q 为空:
10. u = Q.extract_min() // 取出并删除最小元素
11. if u 为有效顶点: // 防止重复处理
12. 将 u 加入 C
13. // 阶段2: 并行处理候选顶点
14. for 所有顶点 u in C 并行执行 (由P个线程处理):
15. if visited[u] 已被其他线程设置为 true: // 双重检查
16. continue
17. 原子操作地将 visited[u] 设置为 true // 确保唯一处理
18. for 每个邻居 v of u:
19. 计算 new_dist = dist[u] + w(u, v)
20. 使用原子比较并交换 (CAS) 操作:
21. if new_dist < dist[v]:
22. 成功将 dist[v] 更新为 new_dist
23. Q.insert_or_decrease_key(v, new_dist) // 并发插入/降键
步骤详解:
- 初始化:与串行算法相同。
- 批量获取候选顶点(阶段1):为了增加并行度,不严格每次只取一个顶点,而是从并发优先队列Q中取出一个批次的、当前“最小”的顶点(例如,BATCH_SIZE个),放入候选集合C。这一步通常需要某种形式的同步(如一个临界区,或一个原子地、从队列前端移除多个元素的操作),但开销被批量操作分摊。
- 并行松弛(阶段2):多个线程并行地处理C中的不同顶点u。对于每个u:
- 唯一性检查:由于C是批量获取的,可能存在多个顶点距离估计相近。用
visited标记和原子操作确保每个顶点只被处理一次。一个顶点被标记为visited意味着它的出边将被松弛(或已被松弛)。 - 遍历邻边:每个线程遍历分配给它的顶点u的所有出边。
- 原子松弛:对于边(u, v),计算新的距离估计
new_dist。使用原子比较并交换 (CAS) 操作来尝试更新dist[v]。如果new_dist < dist[v],则CAS成功地将dist[v]更新为new_dist。CAS操作保证了在并发写入时,dist[v]始终得到最小的更新值,而不会因竞态条件导致数据损坏。 - 更新优先队列:如果
dist[v]被成功更新,则需要将v以新的距离值插入优先队列Q,或者如果v已在Q中,则减少其键值。insert_or_decrease_key必须是线程安全的。
- 唯一性检查:由于C是批量获取的,可能存在多个顶点距离估计相近。用
步骤3:关键技术:原子操作与并发优先队列
-
原子比较并交换 (CAS):这是并行Dijkstra算法的核心同步原语。它用于安全地更新
dist[v]。伪代码如下:bool atomic_compare_and_swap(int* addr, int expected, int new_val): 原子地执行: if *addr == expected: *addr = new_val return true else: return false在松弛操作中:
int old_dist = dist[v]; while new_dist < old_dist: if CAS(&dist[v], old_dist, new_dist) 成功: // 更新成功,需要将v加入/更新到Q Q.insert_or_decrease_key(v, new_dist); break; else: // 更新失败,说明其他线程已更新了dist[v]为更小的值 old_dist = dist[v]; // 重新读取当前值 -
并发优先队列 (Concurrent Priority Queue):
- 需求:支持并发的
extract_min(阶段1用)和insert_or_decrease_key(阶段2用)。 - 常见实现:
- Fine-Grained Locking Heap:对堆的每个节点或每层使用不同的锁,减少锁争用。
- Skip Lists with Locking:基于跳表的优先队列,可以设计为锁住搜索路径上的部分节点。
- Buckets (Δ-Stepping思想):将距离值范围划分为多个桶(例如,桶宽度为Δ)。所有距离在
[kΔ, (k+1)Δ)的顶点放入第k个桶。算法按桶顺序处理。处理一个桶时,可以并行松弛该桶内所有顶点的出边。这天然地允许大量顶点在同一阶段被并行处理,是获得高性能的重要方法。但需要注意,松弛后产生的新顶点(距离在下一个桶范围)需要被放入相应的桶。
- 需求:支持并发的
步骤4:性能考量与优化
- 批处理大小 (BATCH_SIZE):批大小影响并行粒度。太小则阶段1同步开销大;太大则可能处理了许多“非最小”顶点,增加计算量,可能违反Dijkstra的正确性保证(但对于非负权重,最终结果仍然正确,只是中间计算量可能增加)。
- 图的数据结构:使用邻接表存储图,便于并行遍历邻居。确保内存访问友好(缓存局部性)。
- 负载均衡:在阶段2,C中顶点的度数可能差异很大,导致线程间负载不均。可以采用动态调度(如OpenMP的
schedule(dynamic)),让线程动态地从C中领取顶点处理。 - “已访问”标记策略:
visited数组的原子操作可能成为瓶颈。有时可采用“延迟”策略,或结合距离值判断(如果dist[u]是最终确定的,则其出边已被松弛),但需仔细设计以保证正确性。
总结
并行Dijkstra算法的核心在于通过原子操作安全地更新距离估计,以及设计高效的并发数据结构(特别是优先队列) 来管理待处理的顶点。通过批量获取候选顶点和并行松弛,算法能有效利用多核计算资源。虽然严格的顶点处理顺序被放宽,但只要保证松弛操作是安全的(通过CAS),并且每个顶点最终都被处理,算法就能计算出正确的最短路径距离。在实际的高性能图计算库(如Gunrock, Ligra)中,常采用类似思想,并结合Δ-Stepping等算法来处理大规模图数据。