Archipelago

Lec 8优先队列与二叉堆

讲义完成更新于 2026-09-16

Lec 8 优先队列与二叉堆

资料依据:课程视频与 transcript · Lecture notes

TL;DR

  • 优先队列关心插入和取出最值;不同数据结构在这两个操作之间分配不同成本。
  • 二叉堆用紧凑数组表示完全二叉树,并以堆序不变量保证根为最值;上浮和下沉的成本为 O(log⁡n)O(\log n)。
  • 自底向上建堆是 O(n)O(n) 而非 O(nlog⁡n)O(n\log n);反复删除最值得到原地堆排序,总时间为 O(nlog⁡n)O(n\log n)。

优先队列接口

Priority Queue 提供了一个用于排序通用的框架,这里将提供三种方式,区别仅仅在实现中的数据结构不同。在这之前,先介绍下优先队列接口。

  • 通过以优先级作为 key,对元素进行排序,因此是 Set 接口(而不是顺序接口)

  • 跟踪所有的元素,快速访问和移除最重要的一个,举几个应用

    • 在受限带宽下路由,必须优先处理某种类型的消息
    • 在 OS 内核下调度进程
    • 离散事件仿真
    • 图算法
  • 优先队列针对集合操作进行了优化

    操作描述
    build(X)从可迭代对象 X 构建优先队列
    insert(x)将项 x 添加到数据结构中
    delete_max()移除并返回具有最大键值的存储项
    find_max()返回具有最大键值的存储项
  • 优先队列通常针对最大值或最小值进行优化,而不是同时针对两者进行优化。

  • 主要关注insert和delete_max操作:build 操作可以重复 insert 实现;find_max()可以通过 insert(delete_min())实现

无序数组

  • 存储元素:在无序的动态数组中存储元素。

  • 插入(insert(x)):将 x 附加到末尾,摊销时间复杂度为 O(1)。

    删除最大值(delete_max()):在 O(n) 时间内找到最大值,将最大值交换到末尾并移除。

  • 插入操作快,但删除最大值操作慢。

  • 优先队列排序是选择排序(加上一些复制操作)

有序数组

  • 存储元素:在有序的动态数组中存储元素。

  • 插入(insert(x)):将 x 附加到末尾,并在 O(n) 时间内交换到正确的位置。

  • 删除最大值(delete_max()):在摊销 O(1)时间内从末尾删除。

  • 删除最大值操作快,但插入操作慢。

  • 优先队列排序是插入排序(加上一些复制操作)。

AVL 树

  • insert(x), find_min(), find_max(), delete_min()和delete_max() 操作能在O(log⁡n)O(\log{n})时间完成
    • 因此优先队列排序需要O(nlog⁡n)O(n\log{n})时间
  • 可以通过子树增强(subtree augmentation)将 find_min() 和 find_max() 的时间复杂度提升至 O(1)
  • 但是这种数据结构非常复杂,最终的排序不是原地排序
  • 有没有更加简单的数据实现优先队列,并且实现原地O(n⋅log⁡n)O(n·\log{n})排序?
    • 有的!二叉堆和堆排序

优先队列的排序

  • 任何优先级队列数据结构都可以转变为一个排序算法。
    • Build(A), e.g。, 可以通过一个接一个顺序 insert 数据项
    • 重复执行delete_min()(或者delete_max()) 来确定排序顺序
  • 所有复杂的工作都发生在数据结构内部
  • 运行时间Tbuild+n⋅Tdelete_max≤n⋅Tinsert+n⋅Tdelete_maxT_{build} + n · T_{delete\_max} \le n · T_{insert} + n · T_{delete\_max}
  • 很多排序算法我们可以看作是优先队列排序。

完全二叉树

思路: 将数组解释为一个完全二叉树,在深度 i 处最多有 2^i 个节点,除了在最大深度处,所有节点都是左对齐的

1 d0 ______O____
2 d1 ____O____ __O__
3 d2 __O__ __O O O
4 d3 O O O

等价地,完全二叉树按照读取顺序密集填充:从根到叶,从左到右。

**视角:**数组与完全二叉树之间的双射。

优先队列与二叉堆图示 1

数组 n 个元素的完全二叉树视角的高度为:⌈log⁡2n⌉\lceil\log_2{n}\rceil(国外教材是, lgn),所以它是一棵平衡的二叉树

隐式完全二叉树

完全二叉树的结构可以是隐式的,而不是存储指针。

  • 根节点在索引 0 处。
  • 通过索引运算计算相邻节点:
    • 左子节点:left(i) = 2i + 1
    • 右子节点:right(i) = 2i + 2
    • 父节点:parent(i) = (i - 1) / 2

二叉堆

思路: 将较大的元素保持在树的较高位置,但仅在局部。

  • 最大堆性质,在节点 i 处: 对于j∈{left(i),right(i)},有Q[i]≥Q[j]j \in \{left(i), right(i)\},有 Q[i] \ge Q[j]
  • 最大堆的是一个满足所有节点最大堆性质的数组
  • 推断: 在最大堆中,每个节点 i, 对于其子树中所有节点 j,都满足Q[i]≥Q[j]Q[i] \ge Q[j]
    • 优先队列与二叉堆图示 2

插入操作

  • 将新元素 x 附加到数组末尾,摊销时间复杂度为 O(1),使其成为读取顺序中的下一个叶节点 i。

  • max_heapify_up(i):与父节点交换,直到满足最大堆性质。

    • 检查 Q[parent(i)≥Q[i]]Q[parent(i) \ge Q[i]](这是在父节 parent(i) 处的最大堆性质的一部分)。
    • 如果不满足,则交换 Q[i] 和 Q[parent(i)],并递归地调用 max_heapify_up(parent(i)) 进行向上堆化。
  • 正确性:

    • 最大堆性质保证所有节点都大于等于其后代,除了 Q[i] 可能大于其某些祖先(除非 i 是根节点,这种情况下已经满足)。

    • 如果需要交换,交换后 Q[parent(i)] 代替 Q[i]保证了相同的性质。

  • 运行时间:树的高度,故时间复杂度为 Θ(log⁡n)\Theta(\log{n})。

删除操作

  • 动态数组只能轻松移除最后一个元素,但最大关键字在树的根节点。

  • 所以将根节点i=0i = 0 处的项目与堆数组中最后一个节点 i=n−1i = n - 1 处的项交换。

  • 向下堆化 max_heapify_down(i)\text{max\_heapify\_down}(i)

    :与更大的子节点交换,直到满足最大堆性质。

    • 检查 Q[i]≥Q[j]Q[i] \geq Q[j] 对于 j∈{left(i),right(i)}j \in \{\text{left}(i), \text{right}(i)\}(这是在节点 i 处的最大堆性质的一部分)。
    • 如果不满足,则将 Q[i]Q[i] 与关键字最大的子节点 j 交换,并递归地对 j 进行向下堆化。

正确性:

  • 最大堆性质保证所有节点都大于等于其后代,除了 Q[i]可能小于某些后代(除非 i 是叶节点,这种情况下已经满足)。
  • 如果需要交换,交换后 Q[j] 代替 Q[i] 保证了相同的性质。

运行时间:树的高度,故时间复杂度为 Θ(log⁡n)\Theta(\log n)。

构建操作

通过重复的 insert 实现最大堆优先队列是需要花费时间∑i=0nlog⁡i=log⁡n!=O(nlog⁡n)\sum^n_{i=0}\log{i} = \log{n!} = O(n\log{n})。最大堆可以从一个无序数组中构建。方法是从最后一个非叶节点开始,向上逐个进行 max heapify,直到根节点。这种方法保证堆的构建是高效的,可以在线性时间内构建最大堆。

def build_max_heap(A):
  n = len(A)
  for i in range(n // 2, -1, -1):	# O(n) loop backward over arry
    max_heapify_down(A, n, i)     # O(log n - log i) fix max heap

通过这样处理能够花费 O(n)而不是O(nlog⁡n)O(n\log{n})的成本。我们可以用 Stirlig‘s 渐进 n!=Θ(n(n/e)e)n! = \Theta(\sqrt{n}(n/e)^e)

T(n)<∑i=1n(log⁡n−log⁡i)=log⁡nnn!=O(log⁡(en/n))=O(nlog⁡e−log⁡n)=O(n)T(n) < \sum^n_{i=1}(\log{n}-\log{i}) = \log{\frac{n^n}{n!}=O(\log(e^n/\sqrt{n})) = O(n\log{e}-\log{\sqrt{n})=O(n})}

堆排序

  • 将最大堆插入到优先队列排序中,得到一种新的排序算法。
  • 运行时间为 O(nlog⁡n)O(n \log n),因为每次插入和删除最大值的操作都需要 O(log⁡n)O(\log n) 时间。
  • 但通常包括对该排序算法的两种改进:

原地优先队列排序

  • 最大堆 Q 是较大数组 A 的前缀,记住有多少项目|Q| 属于堆。
  • |Q|最初为零,最终为|A|(插入完成后),然后再次为零(删除完成后)。
  • insert() 在数组中的索引|Q| 处将下一个项目吸收到堆中。
  • delete max() 将最大项目移到末尾,然后通过减少|Q|来放弃它。
  • 使用数组进行原地优先队列排序就是选择排序。
  • 使用有序数组进行原地优先队列排序就是插入排序。
  • 使用二叉最大堆进行原地优先队列排序就是堆排序。

我的理解

我的理解

建堆的线性界提醒我们,不能用“每个节点最多走树高”直接乘出松界:绝大多数节点靠近叶子,能下沉的距离很短,按高度分组求和才能看到 O(n)O(n)。