本文介绍一种神奇的排序方法——堆排序。
堆排序不像插入排序和归并排序那样直观,它利用了一种称为堆的数据结构。
堆
堆本质上是一个数组,但我们将其当做一个近似的完全二叉树来看待。树上的每一个结点对应数组中的一个元素,按层排列。除了最底层外,该树是完全充满的,而且是从左向右填充。
下图(a)为我们想象中堆的结构,而(b)则是其实际存储形式。
![47e52c7de333d392e4d4216beb164973.png](https://i-blog.csdnimg.cn/blog_migrate/d27ef93320e3271cac869df92c800ad2.jpeg)
最大堆的特点是,每一个结点都比它的两个孩子结点大(如果孩子结点存在的话)。类似地,最小堆的每一个结点都比它的两个孩子结点小。但需要注意的是,大小关系只存在于根节点与其孩子结点中,兄弟结点或不同子树中的结点没有大小关系。根据这条性质,很容易发现,最大堆的根结点是所有元素的最大值,最小堆的根结点是所有元素的最小值。
无论是最大堆还是最小堆,它们的工作原理都是一样的,因此本文以最大堆为例,因为使用最大堆排序得到的恰好是递增序列。
堆具有两个重要操作——建堆和维护堆。
维护堆
先来介绍维护堆,这是堆最基本的操作。当一个根节点的左子树和右子树都是最大堆,而根结点对应的树却不是最大堆时,说明根结点小于其左孩子或右孩子。此时,我们需要令根结点“逐级下降”,与其某个孩子结点交换位置,使整个树重新满足最大堆的性质。
维护堆的过程如下图所示。
![9c1759e2c61aba00ec32fe8d471aeb22.png](https://i-blog.csdnimg.cn/blog_migrate/760d8e593e0a3caf64abd0419bb31be2.jpeg)
图中,我们要维护结点2对应的堆。由图(a)可以看到,其左子树符合最大堆的性质,其右子树也符合最大堆的性质,但其本身却小于左孩子和右孩子。此时,我们要从结点2、结点4和结点5中选择最大的结点,将其与结点2交换位置,从而得到图(b)所示的状态。接下来,结点4又不满足最大堆的性质了,再将其与结点8和结点9比较大小,从而交换结点4与结点9,得到图(c)的状态。此时,结点9已经成为叶子结点,维护堆的操作结束。
建堆
建堆将一个无序的数组建立成满足堆性质的数组,建堆的过程如下图所示。
![d6840af79c61a388a98af07f9e232337.png](https://i-blog.csdnimg.cn/blog_migrate/3d43b47293a7a76e713aa17f2a267e00.jpeg)
首先,给定无序数组A。先将其按从上到下、从左到右的顺序建立二叉树,如图(a)所示。然后从后向前找到第一个不是叶子结点的结点,对该结点执行维护堆操作,完成后该结点对应的子树就满足了堆的性质。继续向前遍历所有的结点,重复维护堆操作,直到根结点对应的堆(即完整的堆)满足堆的性质。
堆排序
现在我们可以考虑如何使用堆实现一个排序算法。由于最大堆的根结点保存了数组的最大值,因此可以每次将根结点的值从数组中取出,再令剩下的元素重新形成堆,如此往复,就可以依次从大到小取出数组中的所有元素。
下图所示为堆排序的完整流程。
![d816ca92dd95cc44969ac9897a452ea6.png](https://i-blog.csdnimg.cn/blog_migrate/cddbec37a27ce60a864a98a7797b8273.jpeg)
图(a)为建堆后的结果。从(a)到(b)的具体过程为,取出根结点16放到末尾,然后把最后一个叶子结点1放到根结点的位置(注意此时堆的元素少了一个),执行一次“维护堆”操作,结果就成了图(b)所示的样子。不断重复这个过程,直到所有结点都离开了堆,堆排序算法就结束了。结果是一个从小到大的数组。
堆排序是原址排序,不需要额外的内存空间,因为堆中元素只存在交换位置的操作,数组在原有地址里排序。
性能分析
堆排序应用了一次建堆操作和
![equation?tex=n-1](https://i-blog.csdnimg.cn/blog_migrate/737e9d31ad1acdc495b205e4eb76c632.png)
维护堆的时间复杂度很容易分析,对于元素个数为
![equation?tex=n](https://i-blog.csdnimg.cn/blog_migrate/ce59f55893efd960f1dbdfcd5c192a96.png)
![equation?tex=%5CTheta+%5Cleft%28+lgn+%5Cright%29](https://i-blog.csdnimg.cn/blog_migrate/e86e8e9842fb4fa0780c421d6fee401c.png)
![equation?tex=O+%5Cleft%28+lgn+%5Cright%29](https://i-blog.csdnimg.cn/blog_migrate/b04aa40df6655f9d8fe7c8a3e0b9c68d.png)
建堆操作内部调用了
![equation?tex=%5CTheta+%5Cleft%28+n%5Cright%29](https://i-blog.csdnimg.cn/blog_migrate/70a8080ca620aaa1048c469288fd8601.png)
![equation?tex=O+%5Cleft%28+nlgn+%5Cright%29](https://i-blog.csdnimg.cn/blog_migrate/0e934d84d49163a6567cce1173178059.png)
![equation?tex=h](https://i-blog.csdnimg.cn/blog_migrate/aaaa0c45ab419d3843f670e3128a981c.png)
![equation?tex=O+%5Cleft%28+h%5Cright%29](https://i-blog.csdnimg.cn/blog_migrate/7d2c5085a6fdc7fc0384866741f58ed8.png)
![equation?tex=h](https://i-blog.csdnimg.cn/blog_migrate/aaaa0c45ab419d3843f670e3128a981c.png)
![equation?tex=2%5E%7Blgn+-+h+%7D](https://i-blog.csdnimg.cn/blog_migrate/101ebb6dd3b6486a7a65a357b8dad70b.png)
![equation?tex=lgn+-+h](https://i-blog.csdnimg.cn/blog_migrate/14748cbf839efc212c00bd6186f311c2.png)
![equation?tex=h](https://i-blog.csdnimg.cn/blog_migrate/aaaa0c45ab419d3843f670e3128a981c.png)
![equation?tex=h](https://i-blog.csdnimg.cn/blog_migrate/aaaa0c45ab419d3843f670e3128a981c.png)
![equation?tex=0](https://i-blog.csdnimg.cn/blog_migrate/b0002d5deeecc7acffc2335d8b8c7927.png)
![equation?tex=lgn](https://i-blog.csdnimg.cn/blog_migrate/1ac715de88ab7adbe1be45041e45c8ac.png)
![equation?tex=%5Cbegin%7Baligned%7D+%5Csum_%7Bh%3D0%7D%5E%7Blgn%7D%7B2%5E%7Blgn+-+h%7D+O%5Cleft%28h%5Cright%29%7D+%26%3D+%5Csum_%7Bh%3D0%7D%5E%7Blgn%7D%7B%5Cfrac%7Bn%7D%7B2%5Eh%7D+O%5Cleft%28h%5Cright%29%7D+%5C%5C+%26%3D+O+%5Cleft%28+n+%5Csum_%7Bh%3D0%7D%5E%7Blgn%7D%7B%5Cfrac%7Bh%7D%7B2%5Eh%7D%7D+%5Cright%29+%5C%5C+%26%3D+O+%5Cleft%28+n+%5Csum_%7Bh%3D0%7D%5E%7B%5Cinfty%7D%7B%5Cfrac%7Bh%7D%7B2%5Eh%7D%7D+%5Cright%29+%5C%5C+%26%3D+O+%5Cleft%28n%5Cright%29+%5Cend%7Baligned%7D+%5C%5C](https://i-blog.csdnimg.cn/blog_migrate/28c35aef32934b48360ac0c56326f9be.png)
其中,第三行做了一次放缩,将有限级数求和扩大到无限级数求和,而后者的极限为常数2。于是,我们得到建堆操作更紧确的渐进时间复杂度
![equation?tex=O+%5Cleft%28n%5Cright%29](https://i-blog.csdnimg.cn/blog_migrate/f66548f281be51c96a19ac38d8349f8a.png)
最后,堆排序的时间复杂度为
![equation?tex=O+%5Cleft%28+n%5Cright%29+%2B+%5Cleft%28+n+-1+%5Cright%29+O+%5Cleft%28+lgn+%5Cright%29+%3D+O+%5Cleft%28+nlgn+%5Cright%29](https://i-blog.csdnimg.cn/blog_migrate/4fcaf42cc246e719a7861e70d8298241.png)
实现代码
具体实现时,需要先实现堆数据结构,然后利用该数据结构实现堆排序。实现原理本文已经解释清楚,因此不再赘述,代码位于Heap.java和HeapSort.java。
堆的应用
堆除了用于排序之外,还有其它众多用途。
最常见的用途莫过于优先队列。将普通队列建堆,即可得到一个优先队列,最大堆对应着最大优先队列,最小堆对应着最小优先队列。当用户需要从优先队列中取出一个元素时,直接返回堆顶元素,然后将堆的末尾元素补充到堆顶,并执行一次维护堆操作。当用户需要向优先队列插入数据时,直接将该元素置于堆的末尾,然后做一次“逐级上升”的维护堆操作。这样保证了在插入或取出数据时堆的性质都得以保留。
优先队列具有很多实际用途,比如从海量数据中搜索top k、作业调度器、合并有序序列、查找中位数等等,这里就不一一介绍了,感兴趣的同学可以自行查找相关资料。
参考资料
堆的应用 MakeSail