Skip to content

Lec 8 优先队列 & 二叉堆

介绍另外一种类似树的数据结构称为二叉堆,他给了我们排序的另外一种思路

  • 优先队列接口
  • 优先队列的排序
  • 二叉堆
  • 堆排序

优先队列接口

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

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

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

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

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

  • 主要关注insertdelete_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(logn)时间完成
    • 因此优先队列排序需要O(nlogn)时间
  • 可以通过子树增强(subtree augmentation)将 find_min()find_max() 的时间复杂度提升至 O(1)
  • 但是这种数据结构非常复杂,最终的排序不是原地排序
  • 有没有更加简单的数据实现优先队列,并且实现原地O(n·logn)排序?
    • 有的!二叉堆和堆排序

优先队列的排序

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

完全二叉树

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

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

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

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

截屏2024-07-24 17.48.25

数组n个元素的完全二叉树视角的高度为log2n(国外教材是, 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]
  • 最大堆的是一个满足所有节点最大堆性质的数组
  • 推断: 在最大堆中,每个节点i, 对于其子树中所有节点j,都满足Q[i]Q[j]
    • 截屏2024-07-24 18.05.46

插入操作

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

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

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

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

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

  • 运行时间:树的高度,故时间复杂度为 Θ(logn)

删除操作

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

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

  • 向下堆化 max_heapify_down(i)

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

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

正确性

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

运行时间:树的高度,故时间复杂度为 Θ(logn)​。

构建操作

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

python
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(nlogn)​的成本。我们可以用Stirlig‘s 渐进 n!=Θ(n(n/e)e)

T(n)<i=1n(lognlogi)=lognnn!=O(log(en/n))=O(nlogelogn)=O(n)

堆排序

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

原地优先队列排序

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