堆排序
堆排序也是选择排序的一种,起源于罗伯特·弗洛伊德。它的特点是,排序过程中使用了堆这种数据结构,利用堆的特性来选择最小(大)的元素。
题目描述
给出一组数据,根据由小到大顺序输出。
输入要求:
输入一个整数n(数据长度)
输入n个数据
输出要求:
输出由小到大排序后的数据
样例输入:
10
37 28 46 19 55 28 92 84 63 71
样例输出:
19 28 28 37 46 55 63 71 84 92
基本思想
想理解堆排序,需要先铺垫一些数据结构的知识。
- 树
- 二叉树
- 完全二叉树
- 大根堆和小根堆
- 建堆
- 堆排序
首先,什么是树?树是数据结构中一类非线性结构,它有结点有分支,并具有层次关系,非常类似自然界中的一棵树。
客观世界也有很多用树形结构存储数据的例子,其中最经典的就是家谱。同样我们也借鉴了家谱中的习惯用词构成了树形结构中的基本术语。
图中将B,C,D结点称为A结点的孩子;A结点则是B,C,D结点的双亲;B,C,D结点之间互为兄弟。一个结点孩子的个数称为该结点的度,没有孩子(度为零)的结点称为叶子结点,图中C,F,H,I,J,K结点都是叶子结点。
显然,用树来存储数据更能体现出数据间的层次关系,但树的种类样式千变万化,为了更好的处理数据,我们引入一些具有特殊结构的树来提高算法效率。
- 二叉树: 直观的说,二叉树中每个结点最多有2个孩子,并且2个孩子有左右次序之分。
- 满二叉树: 除最后一层叶子结点外,其他所有结点度数均为2 。
- 完全二叉树: 只有最下面两层结点度数可以小于2,并且最后一层结点都集中在左孩子的位置。
有了完全二叉树的理论基础,就可以介绍我们这次排序用到的堆,堆有大根堆和小根堆(也有称为大顶堆和小顶堆)。
- 大根堆: 双亲结点关键字大于孩子结点的关键字,堆顶关键字最大。
- 小根堆: 双亲结点关键字小于孩子结点的关键字,堆顶关键字最小。
一般的,由小到大排序使用大根堆,由大到小排序使用小根堆,本例即使用大根堆。为更直观体现整个过程,我们将存储结构转化为完全二叉树的逻辑结构来表示。
完全二叉树的结构有了,但还并不符合大根堆的性质,所以我们要将其调整为大根堆。调整的总体思路就是从下至上,从右至左,一束一束调整,将每一小束都变成大根堆,然后逐步扩大,这个过程也叫做建堆。
建堆的方法:
- 找到第一个非叶子节点(i=n/2-1),本例n=10,所以我们从第4个结点开始调整。
- 比较该节点的左右孩子结点,若左孩子结点的关键字大,则 j 指向左孩子,若右孩子结点的关键字大,则 j 指向右孩子。
- 比较双亲结点和孩子结点,若孩子结点的关键字大,则将其换到双亲结点位置。
照此做,继续调整 i=3,i=2…结点,显然,整体渐渐趋向大根堆的结构。
前几步不难理解,执行起来也比较顺利,但继续进行下去时会出现一个问题:随着孩子结点与双亲结点的交换,有可能导致原本调整好的结构又破坏。
比如,继续进行下一步调整时(i=1),关键字28的结点与84的结点交换之后,就导致了以关键字28的结点为双亲的子树,28,19,63失去了大根堆的性质,所以还要进一步调整,将其再次调整为大根堆。也就是说一次调整时要将整个子树均筛查一遍,而不是一次调整就结束。
最后剩下根结点,依旧先比较左右孩子的关键字,92>84,j 指向右孩子,然后比较孩子和双亲的关键字,92>37,孩子结点与双亲结点交换,交换后继续筛查子树,i=j=2,比较左右孩子的关键字,46>28,j 指向右孩子,比较孩子和双亲的关键字,46>37,孩子结点与双亲结点交换,调整完成,最终形成大根堆。
大根堆建成后我们就可以用到排序过程中,利用大根堆的性质,堆顶关键字是最大的。我们选择堆顶关键字将其与无序区中最后一个关键字交换,这样最大的关键字已放入有序区。然后将剩余关键字再次建立大根堆,选择堆顶关键字将其与无序区中最后一个关键字交换,如此往复,直至所有元素放入有序区。概括的说,堆排序的过程就是建堆→交换→建堆→交换循环进行。
最后剩19直接放入有序区,排序完成。
参考代码(C语言)
#include<stdio.h>
void heap_sort(int R[],int n); //堆排序
void sift(int R[],int i,int m); //建堆
int main()
{
int i,R[100],n;
scanf("%d",&n);
for(i=0;i<n;i++)
scanf("%d",&R[i]);
heap_sort(R,n);
return 0;
}
void sift(int R[],int i,int m) //建立大根堆
{
int j,t;
t=R[i]; //保存i结点的关键字
j=2*i+1; //j指向i的左孩子
while(j<m)
{
if((j+1<m)&&(R[j]<R[j+1])) //若右孩子的关键字大于左孩子
j++; //j指向右孩子
if(t<R[j]) //若孩子结点的关键字大于双亲
{
R[i]=R[j]; //孩子结点与双亲结点交换
i=j; //继续筛查,i指向孩子结点
j=2*i+1; //j指向i的左孩子
}
else break;
}
R[i]=t; //最后将关键字放入正确位置
}
void heap_sort(int R[],int n)
{
int i,t;
for(i=n/2-1;i>=0;i--) //从第一个非叶子结点开始,将初始结构调整为大根堆
sift(R,i,n);
for(i=n-1;i>=0;i--) //排序开始
{
t=R[0]; //堆顶与无序区最后一个关键字交换
R[0]=R[i];
R[i]=t;
sift(R,0,i); //剩余关键字再调整为大根堆
}
for(i=0;i<n;i++)
printf("%d ",R[i]);
}
分析总结
堆排序和直接选择排序都属于选择排序算法,直接选择排序就是直接利用选择算法找出无序区的最大(小)值,堆排序则利用了大根堆和小根堆的堆顶关键字最大(小)来达到选择的目的。直接选择排序在寻找最值的时候需要将无序区中所有元素比较一遍,而堆排序利用树形结构,每次只遍历当前结点的子树即可,不用每个关键字都进行比较,因此效率有所提高。
堆排序主要主要就是由建堆和交换两部分组成,从上述过程中也可以看出,算法主要的时间开销是在建堆上,交换的过程比较简单,而建堆过程里最复杂的一次就是第一次将所有关键字调整成堆的过程。待排序记录较少的文件不适宜用堆排序,因为大部分时间都用来建立初始堆,而真正排序过程很简单,也就是说,对于记录少的文件,准备时间可能远大于实际操作时间,这样有些浪费。
平均时间复杂度O(
n
l
o
g
2
n
nlog_2n
nlog2n)
空间复杂度O(1)
堆排序是不稳定的。可以看本例中2个28在排序前后的相对位置发生了变化。
写在最后
代码的表达形式多种多样,重点是理解排序的思想和过程,附上一个网上看到的动画,可以帮助理解排序过程☛建堆过程☛排序过程(个人觉得如果有一些基础的看本文上面给的图示去理解最好,更有助于建立编程的思维,视频能相对有些趣味性)
说明: 链接内视频不是本人制作,如侵权则删。当然网上的视频有很多,我只是找了一个相对简单明了的,大家也可自行搜索。
参考资料:《数据结构-用C语言描述》高等教育出版社
(只是分享个人学习时的想法和理解,如有问题还望大佬指点)