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

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

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


目录

  1. 写在前面
  2. 完全二叉树与堆:理论奠基
    1. 完全二叉树定义
    2. 从完全二叉树到堆
  3. 为什么是二叉堆?
  4. [PriorityQueue 源码解析](#PriorityQueue 源码解析 "#priorityqueue-%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90")
    1. 核心数据结构
    2. [入队 enqueue:上滤](#入队 enqueue:上滤 "#%E5%85%A5%E9%98%9F-enqueue%E4%B8%8A%E6%BB%A4-bubble-to-higher-priority")bubble‑to‑higher‑priority
    3. [删除元素 & 出队:上滤 + 下滤](#删除元素 & 出队:上滤 + 下滤 "#%E5%88%A0%E9%99%A4%E5%85%83%E7%B4%A0--%E5%87%BA%E9%98%9F%E4%B8%8A%E6%BB%A4--%E4%B8%8B%E6%BB%A4")
  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):除最后一层外,其余各层节点数都达到最大值,且最后一层节点全部集中在最左侧。

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

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

一棵有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

从完全二叉树到堆

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

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

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

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

0 1 2 3 4 5 6 7 8 9
72 68 50 43 38 47 21 14 40 3

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

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

为什么是二叉堆?

需求 操作 目标复杂度
插入元素 enqueue O(log n)
查询堆顶 peek O(1)
删除堆顶 dequeue O(log n)

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


PriorityQueue 源码解析

核心数据结构

swift 复制代码
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)

swift 复制代码
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 的元素可能大于父节点或小于子节点,需分两段恢复堆序:

swift 复制代码
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

swift 复制代码
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)

为什么先上滤再下滤?

放入新元素后:

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

复杂度分析

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

实战演练与输出验证

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

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

swift 复制代码
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]

运行输出:

makefile 复制代码
堆顶: 55
完整堆: [55, 44, 28, 14, 1, 3, 9, 1, 2]

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

markdown 复制代码
          55
       /      \
     44        28
    /  \      /  \
  14    1    3    9
 /  \
1    2

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

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

运行输出:

makefile 复制代码
新堆顶: 44
完整堆: [44, 14, 28, 2, 1, 3, 9, 1]

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

markdown 复制代码
          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 GitHub -- PriorityQueue.swift 源码

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

相关推荐
星沁城23 分钟前
149. 直线上最多的点数
java·算法·leetcode
Digitally32 分钟前
如何轻松地将联系人从 iPhone 转移到 iPhone?
ios·cocoa·iphone
皮蛋瘦肉粥_1211 小时前
代码随想录day10栈和队列1
数据结构·算法
金融小师妹1 小时前
黄金价格触及3400美元临界点:AI量化模型揭示美元强势的“逆周期”压制力与零售数据爆冷信号
大数据·人工智能·算法
李元豪2 小时前
强化学习所有所有算法对比【智鹿ai学习记录】
人工智能·学习·算法
岁忧2 小时前
(LeetCode 面试经典 150 题) 169. 多数元素(哈希表 || 二分查找)
java·c++·算法·leetcode·go·散列表
YuTaoShao2 小时前
【LeetCode 热题 100】15. 三数之和——排序 + 双指针解法
java·算法·leetcode·职场和发展
逛逛GitHub2 小时前
和 DeepSeek 扳扳手腕?这个国产开源 AI 大模型绝了。
算法·github
gohacker2 小时前
Python 量化金融与算法交易实战指南
python·算法·金融
满分观察网友z2 小时前
从删库到跑路?后序遍历如何优雅地解决资源释放难题!(145. 二叉树的后序遍历)
算法