一:如何理解“堆”

  1. 堆是一个完全二叉树;
    完全二叉树要求除了最后一层,其他层的节点都是满的,最后一层的节点都靠左排列。
  2. 堆中每个节点都必须大于等于(或小于等于)其子树中每个节点的值。
    堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。
  3. 对于每个节点的值都大于等于子树中每个节点值的堆,叫作“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,叫“小顶堆”。

二:如何实现“堆”

要实现一个堆,要先知道堆都支持哪些操作,已及如何存储一个堆。

  1. 如何存储一个堆:
    完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。
  2. 往堆中插入一个元素
    往堆中插入一个元素后,需要继续满足堆的两个特性
    1. 如果把新插入的元素放到堆的最后,则不符合堆的特性了,于是需要进行调整,让其重新满足堆的特性,这个过程叫做 堆化(heapify)
    2. 堆化实际上有两种,从下往上和从上往下
    3. 从下往上的堆化方法:堆化非常简单,就是顺着节点所在的路径,向上或者向下,对比,然后交换。
  3. 删除堆顶元素
    1. 从堆的定义的第二条中,任何节点的值都大于等于(或小于等于)子树节点的值,则堆顶元素存储的就是堆中数据的最大值或最小值。
    2. 假设是大顶堆,堆堆顶元素就是最大的元素,但删除堆顶元素之后,就需要把第二大元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后在迭代地删除第二大节点,以此类推,直到叶子节点被删除。但这种方式会使堆化出来的堆不满足完全二叉树的特性。
    3. 可以把最后一个节点放到堆顶,然后利用同样的父子节点对比方法,对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止,这是从上往下的堆化方法。

一个包含n个节点的完全二叉树,树的高度不会超过log2n。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,即O(log n)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以往堆中插入一个元素和删除堆顶元素的时间复杂度都是O(log n)。

三:如何基于堆实现排序

  1. 排序方法有时间复杂度是O(n^2)的冒泡排序,插入排序,选择排序,有时间复杂度是O(nlogn)的归并排序,快速排序,线性排序。
  2. 借助堆这种数据结构实现的排序算法就叫作堆排序,这种排序方法的时间复杂度非常稳定,是O(nlogn),并且它还是原地排序算法。

堆排序的过程大致分解为两大步骤:建堆和排序

  1. 首先将数组原地建成一个堆。“原地”:是指不借助另一个数组,就在原地数组上操作。
  2. 建堆有两种思路:
    • 第一种:在堆中插入一个元素的思路。尽管数组中包含n个数据,但是可以假设起初堆中只包含一个数据,就是下标为1的数据。然后,调用插入方法,将将下标从2到n的数据依次插入到堆中,这样就将包含n个数据的数组,组织成了堆
    • 第二种:是从后往前处理数组,并且每个数据都是从上往下堆化。
      第二种和第一种思路截然相反,第一种建堆思路的处理过程是从前往后处理数据,并且每个数据插入堆中时,都是从下往上堆化。对下标从n/2开始到1的数据进行堆化,下标是n/2 + 1到n的节点,是叶子节点,不需堆化
  3. 建堆的时间复杂度
    每个节点堆化的时间复杂度是O(logn),则n/2+1个节点堆化的总时间复杂度是O(n)。因为叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始。每个节点堆化的过程中,需要比较和交换的节点个数,跟这个节点高度k成正比。
  4. 排序:
    建堆结束后,数组中的数据已是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。
    将它和最后一个元素交换,最大元素就放到了下标为n的位置
    这个过程有点类似“删除堆顶元素”的操作,当堆顶元素移除后,把下标为n的元素放到堆顶,然后在通过堆化的方法,将剩下的n-1个元素重新构建成堆。堆化完成之后,在取堆顶元素,放到下标是n-1的位置,一直重复这个过程,直到最后堆中只剩下标为1的一个元素,排序工作就完成了。
  5. 时间,空间复杂度,以及稳定性分析
    ①:整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。
    ②:堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是O(n),排序过程的时间复杂度是O(nlogn),所以堆排序的时间复杂度是O(nlogn)
    ③:堆排序不是稳定的排序算法,可能改变值相等的数据原始相对顺序。

四、思考题

  1. 在讲堆排序建堆的时候,我说到,对于完全二叉树来说,下标从 2n​+1 到 n 的都是叶子节点,这个结论是怎么推导出来的呢?
    n就是n/2的左子节点,所以n/2,并不是叶子节点。其实很简单,n是最后一个节点,那n的父节点n/2必定就是最后一个父节点了。那么自然n/2之后的节点都为叶子节点。使用数组存储表示完全二叉树时,从数组下标为1开始存储数据,数组下标为i的节点,左子节点为2i, 右子节点为2i + 1. 这个结论很重要(可以用数学归纳法证明),将此结论记为『原理1』,以下证明会用到这个原理。
    为什么,对于完全二叉树来说,下标从n/2 + 1 到 n的节点都是叶子节点? 使用反证法证明即可:
    如果下标为n/2 + 1的节点不是叶子节点,即它存在子节点,按照『原理1』,它的左子节点为:2(n/2 + 1) = n + 2,大家明显可以看出,这个数字已经大于n + 1,超出了实现完全二叉树所用数组的大小(数组下标从1开始记录数据,对于n个节点来说,数组大小是n + 1),左子节点都已经超出了数组容量,更何况右子节点。以此类推,很容易得出:下标大于n/2 + 1的节点肯定都是也叶子节点了,故而得出结论:对于完全二叉树来说,下标从n/2 + 1 到 n的节点都是叶子节点。备注下:用数组存储表示完全二叉树时,也可以从下标为0开始,只是这样做的话,计算左子节点时,会多一次加法运算

  2. 我们今天讲了堆的一种经典应用,堆排序。关于堆,你还能想到它的其他应用吗?

    • 从大数量级数据中筛选出top n 条数据; 比如:从几十亿条订单日志中筛选出金额靠前的1000条数据
    • 在一些场景中,会根据不同优先级来处理网络请求,此时也可以用到优先队列(用堆实现的数据结构);比如:网络框架Volley就用了Java中PriorityBlockingQueue,当然它是线程安全的
    • 可以用堆来实现多路归并,从而实现有序,leetcode上也有相关的一题:Merge K Sorted Lists