堆排序是本博文介绍的几个排序算法中唯一引入了抽象数据结构(ADT)的排序方式。要完全理解堆排序的实现方式,首先需要理解堆这种数据结构的实现方式。
堆
提到堆,首先会引入优先队列(Priority Queues)的概念,优先队列不同于队列,队列是FIFO,在优先级队列中,队列内元素的逻辑顺序由其优先级决定。 优先级最高的元素位于队列的前面,最低优先级的元素位于后面。那么该如何应用这种数据结构呢?这里我们引入二叉树这种数据结构,运用二叉树的堆可以称为二叉堆。二叉堆的实现通过完全二叉树。二叉树及平衡二叉树,完全二叉树,完美二叉树的概念可以参考*。
构造堆(二叉堆)
引入
首先通过一个例子来说明如何构造二叉堆,以及构造二叉堆的时间复杂度。
假设我们有一个未排序数组[16, 14, 10, 8, 7, 9, 3, 2, 4, 1]。我们将其构造为下图所示的二叉堆。
如图所示,此处我们构造的是Max-Heap:每个节点的值都>=其子节点的值。现在我们给出以下性质:
- 根节点:数组的第一个元素,i = 1(i为索引,这里以1开始)。
- parent(i) = i / 2:任意节点i的父节点的索引值。
- left(i) = 2i:任意节点的左子节点的索引值。
- right(i) = 2i + 1:任意节点的右节点的索引值。
并且,一个完全二叉树的高度(即节点的层次数量)为\(O(lgn)\)。
消除错误位置
为了保证对于任意节点,该节点的值要>=其左右子节点的值,我们可以写出以下的伪代码,假设当前位置为i:
do max_heapify(A, i)
l = left(i)
r = right(i)
if l < heap_size(A) and A[l] > A[i]
then: largest = l
else: largest = i
if r < heap_size(A) and A[r] > A[largest]
then: largest = r
if largest != i
then: exchange A[i] with A[largest]
then: do max_heapify(A, largest)
A为数组,i为当前节点的索引。我们将当前节点的值分别与其左右子节点进行比较,确定最大的那个节点的位置,若largest != i,说明子节点更大,我们将当前节点的值与最大的那个子节点的值进行交换,然后递归调用max_heapify(max_heapify为修复指定错误位置的节点的方法),直到i处没有子节点(即i为叶子节点)。
指定i处的算法时间复杂度显然为\(O(logn)\)。
构造堆算法实现
这里主要分析其算法的时间复杂度,故不会使用代码的形式实现,重在分析。
对于一个数组A[1...n](数字为索引值),构造max heap的算法如下:
Build_Max_Heap(A):
for i = n / 2 downto 1:
do Max_Heapify(A, i)
这里引入一个最为重要的问题,为啥要从n/ 2 开始遍历到根节点?原因是A[n/2 + 1...n]处的元素全部为叶子节点,这些元素没有任何子节点可以进行比较。故我们只需要对有子节点的元素进行Max_Heapify操作。这里我们会很简单的想到其时间复杂度为\(O(nlogn)\)。但结果不一定正确。
我们知道叶子节点有n/2个,那么叶子节点上方一层的节点有 n/4个,叶子节点上上方的节点有n/8个,依此类推,则根节点有1个。我们知道,对于叶子节点上方第一层的节点,有n/4个,其只需要进行一次Max_Heapify就能得到正确位置。对于叶子节点上方第二层的节点,有 n/8个,其只需要进行两次Max_Heapify就能得到正确位置。依次类推。我们可以得到:n/4(1c) + n/8(2c) + n/16(3c) + 1(lgnc)。这里引入常量c为了确保可以存在的其他计算操作。
我们假设n/4 = \(2^k\)。则我们可以将刚才的算式变为c\(2^k\)(1/\(2^0\) + 2/\(2^1\) + 3/\(2^2\) + (k + 1)/\(2^k\)),即为\(c2^k\sum_{k=0}^n(k+1)/2^k\)。我们可以通过简单的归纳法得出后面的求和展开式位于3和4之间的一个数,总之,求和运算的结果为常量。而\(2^k\)=n/4,则整个的时间复杂度为O(n)。
所以,我们通过优化运算和分析,可以将之前得到的\(O(nlogn)\)的时间复杂度降至O(n)。
堆排序
解决完构造堆的过程后,现在进行堆排序的算法分析。排序策略:
- 首先将待排序数组构造成二叉堆。
- 找到最大的元素,其为A[i]。
- 交换A[n]和A[i]的值,此时最大值在数组的末尾n处。
- 将索引为n的节点从二叉堆中删除,此时堆的大小为n - 1。
- 此时,在根节点位置的值可以不是最大的,需要进行Max_Heapify操作来fix。
- 递归到第二步,直到heap的大小为0(空)。
时间复杂度分析,数组为大小为n,则堆大小为n,迭代n次堆大小为0。每次迭代后会调用Max_Heapify方法进行根节点修正,需要的时间复杂度为\(O(logn)\)。
则总的时间复杂度为\(O(nlogn)\)。
总结
未排序数组构造二叉堆,其时间复杂度为O(n);堆排序的时间复杂度为\(O(nlogn)\)。堆排序算法不是稳定的排序算法。