Swift 标准库
Array
/Dictionary
在一般场景足够快,但面对 "多线程 + 频繁增删 + 元素很少" 的 RxSwift 调度负载时,仍会产生不可忽视的拷贝与锁开销。为了极致性能,RxSwift 作者实现了体积极小、针对性极强的 PriorityQueue ------ 用 二叉堆 (O(1) 取堆顶,O(log n) 插入/删除) 支撑调度系统。RxSwift 源码里隐藏着一个小而美的数据结构 ------ PriorityQueue 。它用二叉堆实现高效的任务优先级管理,为 RxSwift 多线程、频繁增删且元素较少的典型工作负载节省了拷贝与锁成本。本文基于源码和实践,梳理 完全二叉树 → 堆 → PriorityQueue 的理论脉络与实现细节。
目录
- 写在前面
- 完全二叉树与堆:理论奠基
- 为什么是二叉堆?
- [PriorityQueue 源码解析](#PriorityQueue 源码解析 "#priorityqueue-%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90")
- 核心数据结构
- [入队 enqueue:上滤](#入队 enqueue:上滤 "#%E5%85%A5%E9%98%9F-enqueue%E4%B8%8A%E6%BB%A4-bubble-to-higher-priority")bubble‑to‑higher‑priority
- [删除元素 & 出队:上滤 + 下滤](#删除元素 & 出队:上滤 + 下滤 "#%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")
- 复杂度分析
- 实战演练与输出验证
- 总结与思考
- 参考链接
写在前面
先抛出需求:只做 3 件事
设想我们需要一种尽可能高效的数据结构,只暴露 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
添加新元素时,首先将其放在数组末尾 (保证仍是完全二叉树) ,但此时它可能大于自己的父节点,破坏了最大堆"父 ≥ 子" 的堆序性质。为恢复堆序,我们必须沿着父链向上比较并交换,直至以下两种情况之一发生:
- 到达根节点 --- 没有父节点可以比较。
- 重新满足最大堆性质。
这一过程被称为 上滤 (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" 策略:
- 交换 :将最后一个元素与要删除的位置
i
对调。 - 尾删 :
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+1
与 2*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
可以清晰看到:
- 55 → 44:最大值删除后,44 通过上滤顶替堆顶。
- 元素重新分布:下滤将 44 的子树调整到满足"父 ≥ 子"性质。
总结与思考
- 实力担当:PriorityQueue 以 <30 行核心代码,在 RxSwift 高并发场景下提供了极高性价比的任务调度支撑。
- 设计之美:完全依赖数组存储 + 闭包注入顺序规则,让实现既轻量又灵活。
- 可借鉴处 :
- 闭包策略模式:在不引入协议/泛型约束的前提下,提供"高优先级"与"判等"两种可插拔策略。
- 删除优化:用"首尾交换 + popLast"把原本 O(n) 删除化归 O(1) + O(log n)。
- 视觉化调试:打印堆树状图,直观展示上/下滤效果。
参考链接
- RxSwift GitHub --
PriorityQueue.swift
源码
完,感谢阅读!如有疑问或改进建议,欢迎评论~