堆排序

完全二叉树

完全二叉树complete binary tree )是一种特别的二叉树,它具有以下特性:

  1. 树的每一层都是完整的,只有最后一层可能例外;
  2. 就算最后一层不完整,节点也是从左到右依次排列的;

从根节点开始,逐层给每个节点依次编号,我们发现节点编号是有规律的。以编号为 $i$ 的节点为例:

  • 它的父节点编号是:$\frac{i-1}{2}$
  • 它的左子节点编号是:$i \times 2 + 1$
  • 它的右子节点编号是:$(i + 1) \times 2$

因此,我们可以用数组来保存完全二叉树。根据某个节点在数组中的下标,我们可以通过公式计算其父节点和子节点的下标。这样就不用额外保存子节点指针,可以节约很多内存,存储效率极高。

如果完全二叉树父节点的值,总是小于等于(或大于等于)子节点,那么它就称为 ( heap )。

  • 如果父节点总是 小于等于 子节点,那么称为 最小堆min heap ),又可称为 小顶堆
  • 如果父节点总是 大于等于 子节点,那么称为 最大堆max heap ),又可称为 大顶堆

堆的性质决定了其最小值(或最大值)一定在 堆顶(根节点),可以应用到很多场景,例如:

  • 优先队列
  • 排行榜
  • 数据排序

我们最好保证堆是一棵完全二叉树,这样做有两个好处:

  • 完全二叉树形态是固定的,因此可以用数组来保存,效率极高;
  • 完全二叉树高度最低,因此操作效率也很高(插入弹出等操作的效率跟树的高度线性相关);

构建堆(逐个插入法)

既然堆这么有用,我们如何构建一个堆呢?最简单的做法是构建一个空堆,然后将数据逐一插进去。

假设 push_heap 函数负责将一个新元素插入堆,那么建堆函数 build_heap 可以这样写:

插入新元素

那么,现在问题就变成——如何将一个新元素插入一个已有的堆?

  1. 将新元素插到堆的尾部(作为最后一个叶子节点);
  2. 再视情况上浮新节点,使其满足堆的性质(向上整理);

push_heap 函数先将新元素追加到数组尾部,再调用 shift_heap_item_up 执行向上整理:

shift_heap_item_up 函数比较新元素跟父节点的大小,新元素更大则交换位置,直到根节点。如此一来,新元素每次不断循环上浮,最终到达正确位置。

弹出堆顶

堆的性质决定了 堆顶(根节点)就是最大值或最小值,因此将堆顶元素从堆中弹出是一个很有用的操作。举个例子,堆可以用来实现优先任务队列,弹出堆顶即可取出优先级最高的任务。

那么,如何将堆顶元素从堆中弹出呢?

  1. 将堆顶元素取出;
  2. 将最后一个叶子节点挪到堆顶;
  3. 重新调整根节点,使它满足堆的性质(向下整理);

注意到,我们之所以用最后一个叶子节点来填补根节点的空缺,就是为了保证堆是完全二叉树。如果我们是拿它的最大子节点来填补,最终堆的形态很可能就不是完全二叉树了。

构建堆(两两合并)

温馨提示:向下整理的步骤请参考弹出堆顶部分,这里两个场景本质是一样的:对不满足堆性质的根节点进行调整,使得树重新满足堆的性质。

堆排序

掌握堆的性质后,我们知道可以利用它来排序数据:

  1. 将待排序数据构建成一个堆;
  2. 循环弹出堆顶(最大值或最小值),直到堆为空;
  3. 堆顶元素组成的序列就是有序序列;
    • 如果构建的堆是最小堆,弹出的序列是 升序 的(从大到小排列);
    • 如果构建的堆是最大堆,弹出的序列是 降序 的(从小到大排列);

小菜学算法】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学算法】系列文章首发于公众号【小菜学编程】,敬请关注: