定义与性质

堆是一种具有特殊性质的树,特殊在于:

  • 它是一棵完全二叉树(除了最后一层的节点,其他节点都有两个子节点。
  • 它的任意一个节点的值都比它的子节点(若存在子节点)的值大或小(据此分为最大堆和最小堆)。

堆有一个别名:优先队列(为何详见堆的应用)。

一个最大堆如图所示:
image.png
接下来讲解堆的相关内容均以最大堆为例。
观察可以发现,堆还有一些其他性质:

  • 第 i 层的节点可能比第 i-1 层的大,但一定比第 i-2 层的小(记住它,等下理解堆的两个重要操作:上推和下拉时,遇到问题就想想它)。

    堆的实现和基本操作

    一般堆的基本操作包括:

  • 建堆。有交换建堆法插入建堆法

  • 删除一个元素。
  • 插入一个元素。

数组能够很好的实现堆,因为通过数组的下标可以很快地找到节点的父子关系,以最大堆为例:
image.png
图1

假设某个节点在数组中下标为 i,那么它的两个子节点的下标分别为 2*i+12*i+2(如果有的话)。

插入、删除和建堆

为什么不先讲如何建堆呢?
因为堆有两个非常重要的操作:上推和下拉。
上推:用于插入元素时,维护堆的性质。
下拉:用于删除元素时,维护堆的性质。
建堆就是通过这两个方法实现的!

上推实现插入和建堆

以图 1 为例,假如现在插入一个元素 9,如何通过上推维护堆的性质呢?

上推的思想: 将插入的元素与它的父节点比较,若小于等于,则说明新元素已处于正确的位置;否则将它与父节点交换。 交换后可能导致破坏了它所处的子树的堆性质,继续采用上述方法比较,直至它处于正确位置或到达根节点。

image.png
例子比较特殊,其实大多数时候不需要到达根节点就可以找到合适的位置。
由此可以得到启发,**插入建堆法**:如果把元素依次插入,插入后使用上推维护堆的性质,就可以构建堆了!
但是这种方法的时间代价是 堆 - 图4 的,不够 堆 - 图5 的交换建堆法好。

下拉实现删除和建堆

以图 1 为例,如何删除元素 8 呢,删除后如何维护堆的性质呢?

下拉的思想: 将要删除的元素和末尾元素 x 交换,然后 x 与 x 的子节点比较,若 x 不是最大的,则与较大的子节点交换。 交换后可能导致破坏了它所处的子树的堆性质,继续采用上述方法比较,直至它处于正确位置或到达底层。

image.png
由此又获得启发,**交换建堆法**:对于任意一个数组,依次对末尾元素到首元素采用下拉法,即可构建堆!

  • 顺序不能错,一定是从末尾元素到首元素。
  • 按顺序的情况下,可以不处理叶子节点,因为叶子节点已经无法下拉,这可以大大提高性能,因为在一棵完全二叉树中,叶子节点是所有节点的一半以上。

堆的遍历和查找

用数组实现堆,有什么好遍历和查找的,直接扫描一遍就行了= =!
堆的性质并不能给查找带来性能提升!

代码实现

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func main() {
  6. nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
  7. buildHeap(nums)
  8. fmt.Println(nums)
  9. }
  10. // 上推
  11. func pushup(nums []int, valIdx int) {
  12. // val 已经推到根节点
  13. if valIdx == 0 {
  14. return
  15. }
  16. rootIdx := int((valIdx - 1) / 2)
  17. // val 已经推到正确位置
  18. if nums[valIdx] <= nums[rootIdx] {
  19. return
  20. }
  21. // val 还没推到正确位置,继续往上推
  22. nums[rootIdx], nums[valIdx] = nums[valIdx], nums[rootIdx]
  23. // 由于发生上推,可能破坏局部最大堆性质
  24. // 所以要尝试继续往上推
  25. pushup(nums, rootIdx)
  26. }
  27. // 插入
  28. // 借助上推实现
  29. func insert(nums []int, val int) []int {
  30. nums = append(nums, val)
  31. pushup(nums, len(nums)-1)
  32. return nums
  33. }
  34. // 下拉
  35. func pulldowm(nums []int, i int, size int) {
  36. largest, left, right := i, 2*i+1, 2*i+2
  37. if left < size && nums[left] > nums[largest] {
  38. largest = left
  39. }
  40. if right < size && nums[right] > nums[largest] {
  41. largest = right
  42. }
  43. if largest != i {
  44. nums[i], nums[largest] = nums[largest], nums[i]
  45. pulldowm(nums, largest, size)
  46. }
  47. }
  48. // 交换建堆法
  49. // 借助下拉实现
  50. func buildHeap(nums []int) {
  51. for i := int(len(nums) / 2); i >= 0; i-- {
  52. pulldowm(nums, i, len(nums))
  53. }
  54. }
  • 代码中采用了交换建堆法,有兴趣的朋友可以自己试试插入建堆法(毕竟 insert() 都在那了= =)。
  • 代码中没有写删除操作,因为删除涉及细节很多。比如说是否允许堆中含有同值元素?如果允许,删除法给出的参数就不能是值,而应该是数组下标。

堆的应用

堆排序

容易想到,如果我们对一个最大堆每次删除堆顶元素,删完整个堆就可以获得一个升序序列。
代码实现如下:

func heapSort(nums []int) {
    buildHeap(nums)
    last := len(nums) - 1                    // 尾元素指针
    for i := last; i >= 0; i-- {            
        nums[0], nums[i] = nums[i], nums[0]    // 与末尾元素交换
        pulldowm(nums, 0, last)                // 下拉维持堆性质
        last--                                // 减少一个元素
    }
}

细节:

  • 删除并不是真的删除,而是通过一个尾指针 last 控制参与形成堆的元素个数,从而使得被移到数组后面的元素不再参与下拉操作。

正因为如此,每次删除堆顶元素后,都可以重新形成一个新的堆,因此堆又被称为优先队列,即优先权最高的最先出队。