在介绍优先队列的博文中,咱们提到了数据结构二叉堆,而且说明了二叉堆的一个特殊用途——排序,同时给出了其时间复杂度O(N*logN)。这个时间界是目前咱们看到最好的(使用Sedgewick序列的希尔排序时间复杂度为O(N4/3)),下图为二者函数图像对比,可是注意,这并非希尔排序与堆排序的对比,只是两个大O阶函数的对比。这篇博文,咱们就是要细化用二叉堆进行排序的想法,实现堆排序。html
在介绍优先队列的博文中(http://www.cnblogs.com/mm93/p/7481782.html)所提到的用二叉堆排序的想法能够简单地用以下代码表示:算法
void HeapSort(int *src,int size) { //BuildHeap即根据所给数组创建一个二叉堆并返回 struct BinaryHeap *h = BuildHeap(src, size); //有了二叉堆后,只需不断DeleteMax获得根结点,而后输出到目标数组便可 //此循环结束后,src数组中就有了从小到大的顺序 for (int i = size-1;i >=0 ;--i) { src[i] = DeleteMax(h); } }
虽然介绍优先队列的博文中没有BuildHeap和DeleteRoot函数,但学会了二叉堆的话,这两个函数不难写出,BuildHeap其实就是Initialize函数与Insert函数的结合,而DeleteMax也和Dequeue思路相同,即删除并返回堆的根,前提是创建的堆知足任一结点均大于其孩子,即Max型堆,与介绍二叉堆时实现的Min型堆刚好相反。编程
至此,堆排序的实现就算是完成了,可是不难发现上述实现方法有一个缺陷,就是原数组src占用了空间N,创建的堆h又占用了空间N,也就是说该实现耗费的空间是插入排序、希尔排序的两倍。那么是否存在解决这个空间问题的办法呢?答案是有,解决的办法就是:直接将原数组src改为一个二叉堆,然后每次DeleteMax,将所得原堆根放置在原堆尾,size次DeleteMax后src就会变为从小到大的顺序(执行DeleteMax后,原堆尾对于堆来讲就是“废弃”的,能够用于存储“删掉的根”。但愿最后顺序为有小到大是咱们将DeleteMin改成DeleteMax的缘由,若是须要从大到小的顺序,则应为DeleteMin)数组
上述解决办法中最难的一环可能就是“将src改成一个二叉堆”。BuildHeap的实现简单,只须要创建一个足够大的空堆,然后不断将数据Insert便可,而Insert的思路就是“将新元素从堆尾开始进行上滤”。那么Insert的这个思路是否能够用于将数组直接改造为二叉堆呢?好比先让src[size-1]上滤,而后src[size-2]上滤……答案是不行!由于Insert的上滤前提是向“已存在的堆”插入数据,“已存在的堆”要么为空,要么到处符合堆的要求。而src数组是“一片乱”的,这个想法是不行的。缓存
阐述正确作法以前,咱们先要明确一个不容易注意到的点:在二叉堆中,能够舍弃掉数组[0]的位置,这样可使编程更加方便,即任一数组[i]的孩子就是数组[i*2]和数组[i*2+1],而数组[i]的父亲则是数组[i/2]。可是若是是将src直接改造为二叉堆,则不能舍弃src[0],由于咱们认为src是一个“装满了”的数组。所以,将src改造为二叉堆后,任一src[i]的孩子应为src[i*2+1]与src[i*2+2],而src[i]的父亲则应为src[(i-1)/2]数据结构
接下来咱们说说将src直接改造为二叉堆的方法:令i=(size-1)/2,即src[i]为数组最后元素src[size-1]的父亲,而后令src[i]下滤,src[i]下滤结束后,令i--,重复此过程直至i为-1。函数
上述方法之因此可行,是由于按i初始值为(size-1)/2,然后i--的顺序执行下滤的话,每一个以src[i]为根的堆都只有src[i]是不符合堆要求的,此时只须要让src[i]下滤便可,根本思路与普通二叉堆的DeleteRoot中的下滤是同样的。测试
这个方法实现起来很是简单:ui
//cur即当前进行下滤的元素的下标,FilterDown即下滤之意 void FilterDown(int *src, int cur, unsigned int size) { //先暂存下滤元素的值,避免实际交换 int temp = src[cur]; unsigned int child; //child初始值为src[cur]的左孩子下标 for (child = cur * 2 + 1;child < size;child = child * 2 + 1) { //若src[cur]存在右孩子,且右孩子比左孩子大,则令child为右孩子下标,即令child为src[cur]更大的孩子的下标 if (child < size - 1 && src[child] < src[child + 1]) child++; //比较下滤元素与src[child],若小于,则令src[child]上移,不然下滤结束 if (temp < src[child]) src[(child - 1) / 2] = src[child]; else break; } //下滤结束后的child对应的父亲即下滤元素应处的位置 src[(child - 1) / 2] = temp; } void TransformToHeap(int *src, unsigned int size) { for (int i = (size - 1) / 2;i >= 0;--i) FilterDown(src, i, size); }
解决了最难的改造二叉堆后,堆排序的剩余操做也就不难实现了:spa
void HeapSort(int *src, unsigned int size) { TransformToHeap(src, size); //不断地将堆的根与堆的尾(最后一个叶子)交换,交换后新的堆根为原堆尾,令新堆根下滤。 //此操做与堆的DeleteRoot本质相同,只是将所得原堆根放在了原堆尾处,从而利用了废弃空间 for (int oldTail = size - 1;oldTail > 0;--oldTail) { int temp = src[0]; src[0] = src[oldTail]; src[oldTail] = temp; FilterDown(src, 0, oldTail - 1); } }
至此,堆排序算是改善好了。接下来要讨论的问题就是,为何堆排序时间复杂度那么好,却不如快速排序?(快速排序最坏状况为O(N2),平均为θ(N*logN))
这个问题很难解答,由于随着DeleteMax操做,堆的内部结构一直是不稳定的。但咱们能够分红三个方面来试着解释一下:
第一,咱们要明白大O阶只是一个简写的时间界,即便是1000000000*N*logN+100000000000,咱们依然是写做O(N*logN),所以两个同为O(N*logN)的算法并不意味着二者时间上会很接近。套用到堆排序与快速排序中,就是堆排序虽然也是O(N*logN),可是其实际时间可能比快速排序的平均界θ(N*logN)要大得多,大多少,我不知道╮(╯_╰)╭。
第二,从计算机的底层来讲,CPU与内存之间存在缓存,缓存通常存储着最近访问的数据所在的数据块,假设来讲,由于咱们访问了内存中的src[100],因此CPU将src[80]到src[120]都放入了缓存,这以后若是咱们访问src[80]到src[120]之间的数据就会很快,由于它们在缓存之中。可是,堆排序中相邻操做所访问的数据“距离太远了”,好比咱们访问了src[100]后要访问其孩子进行比较,则咱们须要访问src[201]或src[202],而它们极可能不在缓存中,所以对它们的访问会比访问缓存中的数据更慢,而且咱们访问其孩子后,并不必定会与父结点进行交换,若是是这样,那这次访问就能够说是“花了大代价肯定了这件事不须要作”。而在快速排序中相邻的两次访问通常访问的元素在位置上也是相邻的,进行远距离访问时都是须要进行交换操做的时候,也就是说快速排序能够比堆排序更好的利用缓存
第三,在堆排序中,DeleteMax函数的无效比较与无效交换比例很高,怎么说呢?由于咱们在拿走原堆根后,是拿原堆尾到根处,而后进行下滤的,可是直观的说,原堆尾做为“堆中较小元素”,其比原堆根的孩子要大的几率是很低的,也就是说原堆尾拿到根处几乎不用比就知道要下滤,然而咱们仍是得进行比较、交换。从这个角度来讲,将原堆尾拿到根处下滤是作了不少无效工做的,但这又是不得不为之的,由于咱们必须得保持堆的彻底二叉树性质。也就是说,为了保持堆的特性,咱们作了很多额外的操做。
关于第三点,咱们能够看看介绍优先队列的博文中关于堆删除操做的例子,不难看出,将原堆尾元素31从根处进行下滤,最后其仍是下滤到了原有深度:
最后,对大小为10000,元素随机的数组进行模拟测试显示,快速排序执行的交换操做次数比堆排序要少不少不少:
不过,虽然咱们将堆排序“狠狠地”批判了一番,其时间界依然是不错的,毕竟最坏状况也就是O(N*logN),可是在实际使用中,面对大量数据时堆排序每每是远不如快速排序的。此外,据称堆排序的实际效果甚至不如使用Sedgewick序列的希尔排序。基于上述种种缘由,通常来讲,咱们仍是按照介绍希尔排序的博文中所说的:将插入排序做为“初级排序”,希尔排序做为“中级排序”,快速排序做为“高级排序”。
那么,做为“高级排序”的快速排序到底是怎样的呢?咱们下一篇博文将会介绍。