1.1 堆的概念
堆这个概念最早是威廉姆斯在堆排序中被提出来的,堆是一种基于完全二叉树构建的数据结构(这种基于完全二叉树建立的堆我们称为二叉堆),因此对具有完全二叉树的所有性质。不过堆也有自己特有的性质,我们通常把堆分为最大堆和最小堆。在最大堆中,对于任意的节点k一定有k节点的子节点上存储的值不大于k节点上存储的值(如果是结构体数据需要依据某些变量来进行比较)。根据最大堆的性质我们可以得出最小堆的性质:对于任意的节点k一定有k节点的子节点上存储的值不小于k节点上存储的值。
1.2 完全二叉树的性质
在我们了解堆之前,我们必须要先熟悉一下完全二叉树的性质。虽然有些性质并不是完全二叉树特有的,但是由于这里为了更好的熟悉完全二叉树,故将完全二叉树与普通二叉树共有的性质也归纳进来。要格外注意的是,这里我们认为完全二叉树的root节点的高度为0,并且root节点的下标为1。
性质1:在完全二叉树中至多有2^k个高度为k的节点。
性质2:高度为k的完全二叉树上最多有2^(k+1) – 1个节点。
性质3:有n个节点的完全二叉树,这个二叉树的高度为logn(文中的所有的log指的是以2为底的对数)。
性质4:在完全二叉树中对于任意节点k,k的左右子节点的下标分别为2*k和2*k+1。(若k的左右子节点存在)节点k的父节点的下标为k/2(注意下标值是要向下取整)。
性质 5:对于含有n个节点的完全二叉树,下标为 (n/2, n]的节点一定是完全二叉树的叶子节点。
1.3 堆性质的维护
堆的建立与堆排序都依赖于堆的性质的维护,我们要维护最大堆或者是最小堆的性质,下面我们以最大堆为例。维护最大堆的性质,我们就要去维护这个堆以保证堆中任意节点k中存储的值不小于其左右子节点上存储的值,对于维护最大堆的操作我们如下描述:
1、对于堆中任意节点k,将节点k 存储的值与其left 节点处存储的值进行比较,若节点k 的left 节点存在且其中存储的值大于节点k 处存储的值,那么标记其left 节点为largest 节点,否则标记k 节点为largest 节点。
2、将k 节点的right 节点处存储的值与largest 节点处存储的值比较,若大于largest 节点处的值,将节点k 的right 节点标记为largest 节点。
3、若largest 节点与k 节点不同,那么交换两节点中存储的值,并将largest 节点作为新的k 节点,返回第一步。否则算法结束。
函数实现过程的伪代码:
Fuction: Max_heap(heap, x)
Left=x+x
Right=left+1
max_num = x
if left<=size_heap && heap[left]>heap[x]
then max_num = left
if right<=size_heap && heap[right]>heap[max_num]
then max_num = right
if x != max_num
then Change heap[x] heap[max_num]
Max_heap(heap, max_num)
对于堆的性质维护的时间复杂度我们可以这样分析,在k节点与其左右子节点的存储的元素值进行比对的时间复杂度是O(1)。我们假定k节点处于root的位置,那么对于有n个节点的堆最多需要比较的次数就是logn。因此对于堆的性质维护的时间复杂度可以认为是logn。(这样的证明并不严谨,严谨的证明请参考算法导论)
1.4 建立二叉堆
对于二叉堆的建立(这里以最大堆为例),我们通常都是在现有的数组的基础上直接建立二叉堆,因为我们利用完全二叉树的性质可以直接推出某个节点的左右子节点和父节点的下标,而且用链表建立二叉堆的代码书写十分的麻烦。但是我们要注意,为了方便一般堆的root节点的下标一般被定义为1,而不是C/C++中数组的起始下标0。
对于二叉堆的建立,我们通常是从堆中下标最大的非叶子节点开始,按下标递减的次序对每个节点调用维护最大堆函数,到root节点维护完成为止,这样一个最大堆就建立好了,二叉堆建立的伪代码(过程可以在草纸上模拟一下):
For i=size_heap/2 down to 1
Max_heap(i)
我们现在就对这个建立最大堆的时间复杂度进行分析,每次调用维护最大堆的函数的时间复杂度是logn,我们需要对n/2个节点调用维护最大堆的函数,那么建堆的过程时间复杂度为nlogn。我们可以先这样认为,算法导论中给出了更加详细的推导与证明,并指出这个建堆的过程在大多数情况下可以在线性时间内将一个无序数组建立成一个堆。
1.5 堆排序
前面我们提到过,堆排序的过程是依赖对堆的维护的,这里我们将以通过维护最大堆过程的堆排序。在进行堆排序的过程中,我们要做的是不断的交换堆顶和堆中下标最大的元素,每次交换过后,我们要重新对root进行一次维护,维护的过程中要注意,每次维护时堆的大小都要减1。这样到最后数组中的元素就以升序的顺序排好了,如果使用最小堆那么就是以降序进行排序。下面我们用伪代码对这个过程进行描述:
For i=size_heap down to 2
Change heap[1] heap[size_heap]
size_heap =size_heap – 1
Max_heap(1)
在看完堆排序的伪代码后,我们对堆排序的时间复杂度进行分析,排序过程中的维护最大堆的函数的时间复杂度为logn,在堆排序的过程中需要调用n-1次维护最大堆函数,所以对于堆排序的过程的时间复杂度为nlogn。当然这个并不是一个严谨的证明(这样并不算错),如果同学们感兴趣的话可以参考算法导论上有关堆排序时间复杂度的论证。
为什么要使用堆排序呢?最主要的一点是堆排序不但有着相对较高的排序效率,而且堆排序的运行非常的稳定,可以证明堆排序的渐近时间上界、渐进时间下界与渐进的时间确定界均为nlogn。而对于快速排序而言,存在这一些输入使快速排序的时间上界能达到n^2。而且如果是排序的过程中需要我们持续插入新的元素,那么此时堆排序的时间界要优于快速排序和插入排序。不过对于随机数据而言,快速排序与堆排序的时间消耗是差不多的(因为渐进意义上时间复杂度是相同的)。