深入剖析 RxSwift 中的 PriorityQueue:二叉堆的 Swift 实战

204 阅读9分钟

Swift 标准库 Array / Dictionary 在一般场景足够快,但面对 “多线程 + 频繁增删 + 元素很少” 的 RxSwift 调度负载时,仍会产生不可忽视的拷贝与锁开销。为了极致性能,RxSwift 作者实现了体积极小、针对性极强的 PriorityQueue —— 用 二叉堆 (O(1) 取堆顶,O(log n) 插入/删除) 支撑调度系统。

RxSwift 源码里隐藏着一个小而美的数据结构 —— PriorityQueue。它用二叉堆实现高效的任务优先级管理,为 RxSwift 多线程、频繁增删且元素较少的典型工作负载节省了拷贝与锁成本。本文基于源码和实践,梳理 完全二叉树 → 堆 → PriorityQueue 的理论脉络与实现细节。


目录

  1. 写在前面
  2. 完全二叉树与堆:理论奠基
    1. 完全二叉树定义
    2. 从完全二叉树到堆
  3. 为什么是二叉堆?
  4. PriorityQueue 源码解析
    1. 核心数据结构
    2. 入队 enqueue:上滤 bubble‑to‑higher‑priority
    3. 删除元素 & 出队:上滤 + 下滤
  5. 复杂度分析
  6. 实战演练与输出验证
  7. 总结与思考
  8. 参考链接

写在前面

先抛出需求:只做 3 件事

设想我们需要一种尽可能高效的数据结构,只暴露 3 个最常用的接口:

  1. 添加元素
  2. 获取最大值(或最小值)
  3. 删除最大值(或最小值)

这是一类在任务调度、消息优先级、定时器管理中随处可见的经典场景。

方案插入查最大删最大备注
动态数组O(1)O(n)O(n)查找最大需遍历;删除最大触发整体搬移
双向链表O(1)O(n)O(n)依旧需遍历,且无随机索引
二叉堆 (最大堆)O(log n)O(1)O(log n)牺牲轻微插删成本,换来极速查顶

显然,若"查最大值"是高频操作,前两者会沦为瓶颈。二叉堆凭借“父节点总 ≥ 子节点”的堆序性质,让最大值始终占据根位置,因此:

  • peek() 直接访问 elements[0] -> O(1)
  • enqueue / dequeue 仅需沿父链或子链局部调整 -> O(log n)

为什么要用二叉「树」?

  • 完全二叉树的数组映射让堆无需真实指针即可定位父/子节点,空间紧凑且 CPU 缓存友好;
  • 与 d‑ary 堆、Fibonacci 堆相比,二叉堆在小规模数据(RxSwift 典型场景 <100 个定时任务)里常数因子最小,实现也最简单。

因此,RxSwift 作者在调度器内部选择了 “数组 + 最大二叉堆” 组合,打造了轻量级 PriorityQueue,既满足接口需求,又把常用路径推到接近 O(1) 的极限性能。


完全二叉树与堆:理论奠基

完全二叉树定义

完全二叉树 (Complete Binary Tree):除最后一层外,其余各层节点数都达到最大值,且最后一层节点全部集中在最左侧。

这种布局保证了紧凑连续的数组映射

完全二叉树.png

完全二叉树有这样的性质:

一棵有n个节点的完全二叉树(n > 0),从上到下、从左到右对节点从0开始进行编号。

对任意i个节点:

  • 如果i = 0,它是根节点
  • 如果i > 0,它的父节点编号为floor((i-1)/2)向下取整
  • 如果2i+1 <= n-1, 它的左子节点编号为2i+1
  • 如果2i+1 > n-1,它无左子节点
  • 如果2i + 2 <= n-1, 它的右子节点编号为2i + 2

从完全二叉树到堆

二叉堆的逻辑结构是一棵完全二叉树,所以也叫完全二叉堆。 堆有一个重要的性质,任意节点的值总是大于等于(或者小于等于)子节点的值。

  • 如果任意节点的值总是>=子节点的值,成为最大堆
  • 如果任意节点的值总是<=子节点的值,成为最小堆

因此堆中的元素必须具备可比较性

二叉堆.png

鉴于完全二叉堆的一些特性,二叉堆的底层一般用数组实现即可。做法是将节点按 Breadth‑First(自上而下、从左到右)顺序依次写入数组,确保索引连续且无空洞。

0123456789
7268504338472114403

从这张表可以直观看出 “下标 → 路径” 的规律

parent = (i - 1) / 2   // i为节点编号,也为数组中的索引
left   = 2 * i + 1
right  = 2 * i + 2

为什么是二叉堆?

需求操作目标复杂度
插入元素enqueueO(log n)
查询堆顶peekO(1)
删除堆顶dequeueO(log n)

在满足上述三点的所有数据结构里,二叉堆同时具备 实现简单空间紧凑常数因子小 三大优势,对 RxSwift 这种“小规模高频”场景再合适不过。


PriorityQueue 源码解析

核心数据结构

struct PriorityQueue<Element> {
    private let hasHigherPriority: (Element, Element) -> Bool // 比较闭包
    private let isEqual:          (Element, Element) -> Bool // 判等闭包
    private var elements = [Element]()                       // 顺序表存储
}
  • 底层数据结构使用数组。
  • 外部注入“大于”语义,元素要具有可比较性。
  • 支持随机删除 (RxSwift 需取消调度的场景)。

入队 enqueue:上滤 bubble‑to‑higher‑priority

添加新元素时,首先将其放在数组末尾 (保证仍是完全二叉树),但此时它可能大于自己的父节点,破坏了最大堆“父 ≥ 子” 的堆序性质。为恢复堆序,我们必须沿着父链向上比较并交换,直至以下两种情况之一发生:

  1. 到达根节点 — 没有父节点可以比较。
  2. 重新满足最大堆性质。

这一过程被称为 上滤 (sift‑up / bubble‑up)

mutating func enqueue(_ element: Element) {
    // ① 先尾插——保证完全二叉树结构 O(1)
    elements.append(element)

    // ② 再上滤——恢复最大堆性质 O(log n)
    bubbleToHigherPriority(elements.count - 1)
}

private mutating func bubbleToHigherPriority(_ index: Int) {

    var unbalancedIndex = initialUnbalancedIndex

    while unbalancedIndex > 0 {
        let parentIndex = (unbalancedIndex - 1) / 2
        guard self.hasHigherPriority(elements[unbalancedIndex], elements[parentIndex]) else { break }
        elements.swapAt(unbalancedIndex, parentIndex)
        unbalancedIndex = parentIndex
     }
}

索引公式复习
parent = (child - 1)/2 父节点编号的计算上一节已提及。

在while循环里做了以下操作

  • 如果 node > 大于父节点,与父节点交换位置
  • 如果 node <= 父节点,或者node没有父节点,退出循环 时间复杂度为O(logn)

删除元素 & 出队:上滤 + 下滤

无论是 dequeue() 删除堆顶,还是 remove(element) 随机删除,最终都会调用同一私有方法 removeAt(i)。为了避免在数组中间做 remove(at:) 带来的 O(n) 整体搬移,RxSwift 采用了经典的 “首尾交换 + popLast” 策略:

  1. 交换:将最后一个元素与要删除的位置 i 对调。
  2. 尾删popLast() 常量时间真正删除末尾元素。

做完第 1 步后,新放到位置 i 的元素可能大于父节点或小于子节点,需分两段恢复堆序:

private mutating func removeAt(_ index: Int) {
    let lastIndex = elements.count - 1
    if index != lastIndex {
        elements.swapAt(index, lastIndex)   // 首尾交换 O(1)
    }
    _ = elements.popLast()                  // 删除尾元素 O(1)

    // 第一次尝试:它会不会“太大”?如果比父还大,让它向上漂
    bubbleToHigherPriority(index)

    // 第二次尝试:它会不会“太小”?如果比子还小,让它向下沉
    bubbleToLowerPriority(index)
}

下滤 bubble‑to‑lower‑priority

private mutating func bubbleToLowerPriority(_ initialUnbalancedIndex: Int) {

    var unbalancedIndex = initialUnbalancedIndex
    while true {
            let leftChildIndex = unbalancedIndex * 2 + 1
            let rightChildIndex = unbalancedIndex * 2 + 2

            var highestPriorityIndex = unbalancedIndex
            // 1️⃣ 找出更大的子节点 (最大堆)
            if leftChildIndex < elements.count && self.hasHigherPriority(elements[leftChildIndex], elements[highestPriorityIndex]) {
                highestPriorityIndex = leftChildIndex
            }

            if rightChildIndex < elements.count && self.hasHigherPriority(elements[rightChildIndex], elements[highestPriorityIndex]) {
                highestPriorityIndex = rightChildIndex
            }
            // 2️⃣ 若 parent 已经是最大,结束
            guard highestPriorityIndex != unbalancedIndex else { break }
            // 3️⃣ 否则与更大的子交换,继续下一层
            elements.swapAt(highestPriorityIndex, unbalancedIndex)

            unbalancedIndex = highestPriorityIndex
     }
}

索引公式复习
对当前节点索引 i,其左、右子节点分别是 2*i+12*i+2。(节点计算上一节已提及) 在bubbleToLowerPriority的while循环中做了以下事情:

  • 如果node < 子节点,与最大的子节点交换位置
  • 如果node >= 子节点,或者node没有子节点,退出循环。

时间复杂度为O(logn)

为什么先上滤再下滤?
放入新元素后:

  • 上滤 能快速处理 “值过大” 的情况;若它不比父大,循环立即终止,不影响整体复杂度。
  • 之后再做 下滤 处理 “值过小” 的情况,防止漏掉对子节点的校验。

复杂度分析

操作时间复杂度说明
enqueueO(log n)尾插 + 上滤
peekO(1)直接取 elements[0]
dequeueO(log n)首尾换 + 上/下滤
removeO(n + log n)线性定位 + removeAt
空间O(n)顺序表存储

实战演练与输出验证

Demo代码放在:github.com/wutao23yzd/…

下面通过 Int 最大堆 演示上滤 / 下滤全过程,并用 ASCII‑Tree 直观展示堆形态变化。

var queue = PriorityQueue<Int>(hasHigherPriority: >, isEqual: ==)
[1, 3, 9, 2, 1, 28, 44, 55, 14].forEach(queue.enqueue)

print("堆顶:", queue.peek()!)        // 55
print("完整堆:", queue)              // [55,44,28,14,1,3,9,1,2]

运行输出:

堆顶: 55
完整堆: [55, 44, 28, 14, 1, 3, 9, 1, 2]

ASCII 树示意:上滤完成后的最大堆

          55
       /      \
     44        28
    /  \      /  \
  14    1    3    9
 /  \
1    2

接下来删除堆顶 55,触发 首尾交换 → popLast → 上滤 → 下滤

_ = queue.dequeue() // 移除 55
print("新堆顶:", queue.peek()!)
print("完整堆:", queue)

运行输出:

新堆顶: 44
完整堆: [44, 14, 28, 2, 1, 3, 9, 1]

ASCII 树示意:下滤完成后的最大堆

          44
       /       \
     14         28
    /  \       /  \
   2    1     3    9
  /
 1

可以清晰看到:

  1. 55 → 44:最大值删除后,44 通过上滤顶替堆顶。
  2. 元素重新分布:下滤将 44 的子树调整到满足“父 ≥ 子”性质。

总结与思考

  • 实力担当:PriorityQueue 以 <30 行核心代码,在 RxSwift 高并发场景下提供了极高性价比的任务调度支撑。
  • 设计之美:完全依赖数组存储 + 闭包注入顺序规则,让实现既轻量又灵活。
  • 可借鉴处
    1. 闭包策略模式:在不引入协议/泛型约束的前提下,提供“高优先级”与“判等”两种可插拔策略。
    2. 删除优化:用“首尾交换 + popLast”把原本 O(n) 删除化归 O(1) + O(log n)。
    3. 视觉化调试:打印堆树状图,直观展示上/下滤效果。

参考链接

  • RxSwift GitHubPriorityQueue.swift 源码

,感谢阅读!如有疑问或改进建议,欢迎评论~