高级数据结构-12:最小生成树
预备知识
条件变量
std::mutex 用于在多个线程访问同一共享变量时提供互斥保护。一个线程持有锁时,其他尝试获取该锁的线程会阻塞,直到锁被释放。互斥量可以隔离访问,但不能单独表达线程之间的执行依赖。
条件变量用于协调多个线程的执行顺序。例如,消费者线程需要等待生产者准备好数据后再处理。条件变量提供等待和通知两类操作,是线程同步的常用机制。
std::condition_variable 提供了实现线程间状态通知的基础。为了避免竞态条件,它必须与互斥量(通常结合 std::unique_lock)配合使用。wait 操作的底层执行逻辑包含三个关键动作:
- 释放并睡眠:当线程调用
wait时,它会原子性地释放当前持有的互斥锁,并将当前线程置于阻塞队列中等待。这确保了其他工作线程能够获取互斥锁进而修改共享数据。 - 唤醒与重加锁:当条件变量接收到唤醒通知后,线程结束睡眠。但在
wait函数真正返回之前,该线程会自动尝试重新获取与之关联的互斥锁。 - 防范虚假唤醒:操作系统在极少数情况下可能会在没有收到明确唤醒信号时将等待线程唤醒,这被称为“虚假唤醒”。因此,
wait调用必须嵌套在条件判断的循环(如while循环)中,线程被唤醒后需立刻复核共享变量的状态是否符合预期,若不符合则继续进入等待状态。
当共享资源完成更新时,执行更新的线程需要通过条件变量发送通知:
notify_one:该接口仅唤醒阻塞队列中的一个线程。具体唤醒哪一个通常由底层的操作系统调度器决定。这种方式适用于资源的增加仅能满足单一线程处理需求的场景(如任务队列中新增了一个任务),能够有效节约系统开销。notify_all:该接口会唤醒所有阻塞在该条件变量上的线程。它适用于全局状态发生改变,且该改变对所有子线程都有意义的场景(例如主控线程完成基础配置后,通知所有工作线程同步启动计算)。
实际应用中应谨慎使用 notify_all。所有被唤醒线程都会重新竞争同一把互斥锁,除一个线程成功获锁外,其余线程可能再次阻塞。这种集中唤醒引发的锁竞争和上下文切换称为“惊群效应”。
POSIX 线程库提供了底层原语来构建消息队列同步机制。以下代码展示了如何在 C 语言环境下利用 POSIX 线程库实现基于条件变量的消息队列管理。
1 | /* POSIX 线程库,提供互斥锁和条件变量。 */ |
在上述实现中,消费者线程获取互斥锁后必须循环检查工作队列是否为空。原因包括虚假唤醒,以及线程被唤醒后重新获取锁前,消息已被其他消费者取走。循环检查配合条件变量等待,可以避免无效轮询。
条件变量的标准读法是“锁保护状态,条件变量负责睡眠和唤醒”。wait 本身不代表条件一定满足,它只是让线程在条件可能改变时醒来。因此工程代码通常写成 wait(lock, predicate) 或 while (!predicate) wait(...),把真正的业务条件放在谓词中反复检查。
并查集
并查集用于管理一组互不相交的等价类,适合快速判断元素归属和合并集合。在最小生成树构造中,判断一条新边是否会成环,本质上就是判断该边两个端点是否属于同一连通分量。
并查集的核心操作是查找和合并。查找返回元素所属等价类的代表元素;合并将两个不同等价类合并。初始状态下,每个元素各自构成一个单元素集合。

最直接的实现称为快速查找/慢速合并。系统维护一个数组,索引代表元素,数组值代表所属集合标识。查找可通过索引直接完成,时间复杂度为常数;但合并需要遍历整个数组,将一个集合的所有标识更新为另一个集合标识,因此合并为线性时间。
1 | # 快速查找版本并查集:find 为 O(1),union 为 O(n)。 |


为提升合并效率,可以将结构从扁平数组改为森林模型。系统不再记录集合标识,而是维护父节点索引数组。初始时,每个元素是一棵单节点树,父节点指向自身。集合代表元素变为树根。

基于树形结构的策略称为快速合并。合并两个集合时,算法先沿父节点数组找到两棵树的根,再将其中一个根的父指针改为另一个根。这样用少量指针修改替代全数组遍历。

单纯快速合并可能在特定合并顺序下退化为链表,使查找路径过长。加权优化额外维护树规模或秩。合并时,将规模较小的树挂到规模较大的树根上,从而限制树高增长。

路径压缩优化查找操作。查找某个元素根节点时,算法沿父指针向上遍历,并将路径上的中间节点直接连接到最终根节点。一次查找后,该路径被扁平化,后续查找成本降低。若配合按秩或按规模合并,并查集操作的均摊复杂度可达到近似常数的 ,其中 是增长极慢的反 Ackermann 函数。

并查集的不同版本可以按“哪个操作快”来记忆。快速查找用数组直接存集合编号,find 很快但 union 要全表改编号;快速合并用父指针森林表示集合,union 只改根指针但 find 可能走长链;按秩合并和路径压缩则分别控制树高、扁平化访问路径,是工业实现中最常搭配使用的两项优化。
串行最小生成树
并行化设计前,先回顾串行最小生成树算法。最经典的贪心策略是 Prim 算法和 Kruskal 算法,二者都通过局部最优选择逐步构造全局最优解。
Prim 算法
Prim 算法基于顶点扩张。算法可从任意起点开始,逐步将相邻顶点纳入生成树。执行过程中维护两个顶点集合:已纳入生成树的顶点和尚未纳入的顶点。每一步在跨越两个集合的边中选择最小权重边,将其连接的未纳入顶点加入生成树,并把该边加入结果集,直到所有顶点被纳入。
以下是基于邻接矩阵实现的 Prim 算法逻辑。利用邻接矩阵存储图时,二维数组中的元素直接记录了顶点间的边权重。若两个顶点间不存在直接连接,则将其权重初始化为无穷大。
1 | // 任选一个起始顶点。 |
上述实现中,距离数组维护未纳入顶点到已纳入顶点集合的最短单边距离。第一列存权重,第二列存提供该权重的树内顶点索引。标志数组记录顶点是否已纳入生成树。主循环扫描距离数组找到全局最小值对应顶点;新顶点加入后,再遍历未纳入顶点并更新其最短接入边。由于每轮都要线性扫描,时间复杂度为顶点数平方,适合稠密图,在大规模稀疏图上效率较低。
Prim 算法维护的是一个不断长大的连通块。dis[i][0] 不是源点到 i 的路径距离,而是“从当前生成树边界接到 i 的最便宜单条边”。这一点容易和 Dijkstra 混淆:Prim 优化的是整棵树的总接入成本,不关心从起点到某个顶点的路径是否最短。
Kruskal 算法
Kruskal 算法从边的视角构造最小生成树。初始状态是一片森林,每个顶点自成一棵树。算法不断选择合法边合并不同树,直到形成唯一生成树。
算法首先将图中所有的边按权重以非降序排列,然后构建一个仅包含顶点不包含任何边的森林。接下来重复从排序表中取出权值最小的边,如果这条边加入森林后不会在其中形成环路,那么将此边加入结果集,否则直接丢弃这条边。向结果集中加完顶点数减一条边后算法结束,此时的结果集即为图的最小生成树。
在代码实现层面,边结构的定义通常通过重载小于运算符来实现基于权重的排序。为解决环路判定与集合合并的性能瓶颈,标准实现均依赖于前文所述的并查集数据结构。
1 | // Kruskal 使用的边记录。 |
Kruskal 首先对全体边排序,因此排序主导整体复杂度。若使用高效并查集管理连通分量,合并与查询均摊成本很低,整体复杂度主要由排序决定。该特性使 Kruskal 适合边数较少的稀疏图。
Kruskal 的视角和 Prim 相反。Prim 始终保持“一棵树”向外扩张;Kruskal 一开始有很多棵小树,每加入一条合法边就合并两个连通分量。并查集在这里承担的就是“这条边的两个端点是否已经在同一棵树里”的快速环路判定。
在现实生活中,社交关系网通常具有动态特征,例如两人建立或解除好友关系对应着边的增减,通信成本变化对应着边权重的改变。维护动态图的最小生成树结果一般需要使用如 B 树等高级索引结构对图中的边和点信息建立索引。现有的理论表明,基于索引的动态维护算法可以在立方根对数级别的时间内完成维护工作。
并行最小生成树
图的划分
网络规模增长后,串行 MST 算法可能成为瓶颈。并行算法通常先划分图,将原图拆成若干不相交分区,交由不同进程或线程处理。各分区完成局部计算后,将结果交给全局进程继续合并,得到完整最小生成树。
为支持并行处理,图通常从邻接矩阵改为更节省内存的邻接表。数据结构中还需要记录多线程状态标识。
1 | // 并行图结构中的边节点。 |
在这里,边的类定义中包含了一个记录处理该边的线程编号的变量,该变量在后续的并发归约过程中用于追踪数据来源。图的构建函数负责读取输入数据并建立顶点数组与链表结构的映射关系。
分配任务前需要确定图划分规则。划分要求每个顶点只属于一个子图,保证无遗漏且无重叠。MST 并行算法通常希望各分区负载均衡,因此可用哈希或取模方式划分顶点,例如对顶点索引按线程数取模,将顶点分散到不同线程分区。
1 | // 按顶点编号把图划分给不同工作线程。 |
基于边并行
基于边的并行最小生成树算法也可称作并行 Kruskal 算法,其主体思想与非并行化的 Kruskal 算法大致相同。整个算法解耦为由各并行进程完成的部分算法与由一个全局进程完成的仲裁算法。
在部分算法中,各并行子线程独立扫描分配给自身的子图分区。为快速提取具有最小权重的边,子线程内部借助基于红黑树实现的有序多重映射容器来组织辖区内的边数据。遍历所有分配到的顶点及其邻接链表,将边权重作为键值插入映射容器中,从而完成局部的非降序排列。
1 | // 工作线程函数:维护本分区的局部有序边流。 |
当局部边集排序完成后,子线程与全局仲裁进程建立交互。子线程提取当前权重最小的一条边,并在互斥锁的保护下将其写入全局共享的消息队列中。若当前分区内的所有边已处理完毕,线程将更新全局状态数组宣告自身任务终结。随后,线程唤醒等待条件变量的仲裁进程,并使自身进入休眠状态,等待仲裁进程在处理完当前边后发送补充新边的请求指令。
这套并行 Kruskal 不是让每个线程独立产出一棵 MST,而是让每个线程维护一个“局部有序边流”。仲裁线程每次只从各个边流的当前最小边里挑全局最小值,类似多路归并排序中的最小头元素选择。这样可以避免一次性把所有边都集中到全局队列中。
仲裁算法
全局进程所执行的仲裁算法扮演着收集、判定与拼装的职责。全局进程首先向所有并行进程发送消息获取各分区最小权重边构成队列。接下来循环取出队列中权值最小的边,并向提供该边的进程发送消息请求补充新的最小权重边至队列中。这种消耗一条并补充一条的按需拉取机制有效控制了全局共享队列的内存占用,同时保障了全局进程总是能够在来自各个分区的局部最优边中筛选出全局最优解。
如果取出的候选边加入到结果集中不会构成环路则保留此边,若会构成环路则将其丢弃。当结果集中的边的数量达到顶点总数减一或全局队列为空时算法结束,同时通知各进程结束算法。
1 | // 仲裁线程执行的并行 Kruskal 主循环。 |
在该并行实现中,边数据按需异步提取,仲裁进程用双向映射字典替代标准数组并查集进行环路判定。一个字典记录顶点到连通分量标识的映射,另一个记录每个连通分量包含的顶点列表。加入候选边时,若两个端点都无归属,则创建新连通分量;若只有一个端点有归属,则将另一个端点并入;若两个端点属于不同分量,则将较小分量并入较大分量,并同步更新顶点映射。该逻辑用于追踪局部生成树合并过程。
仲裁线程的职责可以拆成三步:取当前全局最轻候选边、判断它是否连接两个不同连通分量、通知提供该边的分区补充下一条边。条件变量在这里不是为了保护数据本身,而是为了协调“子线程供边”和“仲裁线程消费边”的节奏。
性能评估
并行改造的有效性可以通过控制变量法下的执行时间来量化验证。在相同硬件环境下,以不同的图拓扑规模及线程数量为参数对串行与并行方案进行了基准测试。
以下表格展示了固定图规模并更改线程数量时的性能演变情况。数值代表毫秒级别的处理耗时。
| 顶点数,边数 | 串行算法 | 双线程 | 三线程 | 四线程 |
|---|---|---|---|---|
| 2000,10000 | 239.020 | 131.375 | 103.655 | 81.243 |
| 2000,50000 | 134.275 | 311.214 | 178.475 | 105.061 |
| 2000,100000 | 175.896 | 426.832 | 215.936 | 142.368 |
| 5000,10000 | 319.596 | 516.655 | 343.403 | 313.784 |
| 5000,50000 | 261.326 | 640.914 | 379.314 | 234.928 |
| 5000,100000 | 278.058 | 750.968 | 421.656 | 236.461 |
| 10000,10000 | 477.118 | 1108.250 | 651.409 | 378.871 |
| 10000,50000 | 544.600 | 1143.080 | 706.321 | 397.773 |
| 10000,100000 | 537.772 | 1451.580 | 677.460 | 386.310 |
测试中,双线程配置受上下文切换、互斥锁竞争和条件变量调度开销影响,在部分用例中慢于串行。线程数增加到四线程后,多核处理收益超过同步开销,四线程配置在各数据规模下均优于串行。
针对更大规模的数据集,测试分别记录了顶点数在八千至三万两千区间,边数在八万至八百万区间的耗时情况。单位为秒。
| 顶点数 \ 边数 | 80000 (串行) | 800000 (串行) | 8000000 (串行) |
|---|---|---|---|
| 8000 | 0.110000 | 1.490000 | 19.150000 |
| 16000 | 0.090000 | 1.670000 | 20.060000 |
| 32000 | 0.110000 | 1.520000 | 20.740000 |
以下表格展示了相同网络规模下,配置为十六个并发线程的并行算法耗时表现。单位为秒。
| 顶点数 \ 边数 | 80000 (并行) | 800000 (并行) | 8000000 (并行) |
|---|---|---|---|
| 8000 | 0.120000 | 0.400000 | 2.190000 |
| 16000 | 0.210000 | 0.560000 | 2.870000 |
| 32000 | 0.430000 | 0.770000 | 3.420000 |
对比上述数据可以看出,随着图边密度上升,串行算法中对千万级数组的全局排序操作成为制约性能的主要瓶颈,其执行时间呈现超线性增长。当处理三万两千个顶点、八百万条边的密集图时,并行方案利用多线程分散规约和有序拉取的优势,耗时不到三点五秒,相对于串行方案约二十秒的耗时取得了接近六倍的加速比。
测试中还涉及了部分极高负载组合,例如一万个顶点与四万条边并行耗时超过一万五千毫秒,而四万个顶点与八万条边的组合耗时接近四万五千毫秒。这些边缘用例的数据反映出对于特定分布的拓扑结构,哈希划分策略可能导致部分线程分配到大量高权值的冗余边,进而在全局队列同步时引发剧烈的锁冲突。
基于顶点并行
基于边的并行 Kruskal 虽然可多线程完成局部排序,但仍依赖中心化全局队列进行仲裁。该模型在中等规模下有效,但在更大规模下会受到全局队列锁竞争限制。因此,可以采用基于顶点的并行算法:各并行进程执行分区 Prim,全局进程再执行 Boruvka 合并。
非并行的 Boruvka 算法在起始阶段构造一个仅包含全图所有顶点但不包含任何边的空结果集,此时每一个顶点都属于一个独立的连通分量。算法在每一次迭代中对于每个连通分量,找出具有最小权重的邻接边,即连接该分量内部顶点与外部顶点的权重最小的边。将所有连通分量找出的最小邻接权重边统一并入结果集中。重复此探测并合并的过程直到图中只存在一个连通分量为止。由于在同一轮迭代内,对不同连通分量的邻接边查找操作相互独立,Boruvka 算法具有天然的并行亲和性。
分区 Prim 算法中,系统为当前进程分配特定的顶点子集及其相关的边集。初始状态下,认为分区内的所有顶点均不属于任何连通分量,并构建空的局部结果集。

执行过程中,进程在分区内随机选取一个不属于任何连通分量的游离顶点,为其创建专属的连通分量,并以该组件为起点在全图上执行一步 Prim 算法逻辑。具体而言,算法寻找连接该组件内部顶点与外部顶点的具有最小权重的边。
当找到满足条件的最小连接边后,依据该边指向的外部目标顶点的位置和状态决定后续操作。如果目标顶点存在于本分区的管理范围内且尚处于游离状态,则将此边并入局部结果集中,同时将目标顶点吸纳进当前连通分量,并继续在该扩大的连通分量上循环执行下一步的连接边探测。如果目标顶点在本分区内但已经被划入其他已存在的连通分量,则同样保留此边,将当前的连通分量与目标顶点所在的连通分量合并,并立刻终止当前连通分量的 Prim 探测逻辑。最关键的防冲突机制在于,如果最小连接边指向的外部顶点不在本分区的管辖范围内,为避免产生跨线程的分布式锁与通讯开销,进程将直接放弃向外扩张,停止针对该组件的探测。随后,进程再次在分区内寻找其他游离顶点作为新的探测起点,直到分区内所有顶点都被分配到某个连通分量内。此时生成的局部结果集即为该进程阶段性输出的边集。
各分区先在局部范围内消除大量顶点和边,将原图压缩为由少量跨区连通分量组成的粗粒度网络。局部计算结束后,全局进程收集并合并局部结果,构建包含原始顶点和已确认边的新图。随后在该图上执行串行 Boruvka 算法,通过全局收缩与组件合并,最终得到完整最小生成树边集。
基于顶点的并行方案把工作分成“局部先缩小问题”和“全局再合并组件”两段。局部 Prim 尽量在分区内部先确定容易处理的边,减少全局阶段需要处理的碎片;Boruvka 则适合全局阶段,因为每个连通分量寻找自己的最轻外连边这一动作天然可以并行。






