数据结构与算法(二十)堆排序

堆排序(Heap Sort)就是对简单选择排序进行的一种改进,这种改进的效果是非常明显的。

1、堆

我们首先要知道什么是堆?

堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆,或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。

这里需要注意从堆的定义可知,根结点一定是堆中所有结点的最大(小)者。

二叉树拥有这样一个性质 :如果对一棵有n个结点的完全二叉树(其深度为k)的结点按层序编号(从第1层到第k层,每层从左到右) ,对任一结点i(1≤i≤n)有:

  1. 如果 i=1 ,则该结点是二叉树的根,无双亲;如果i>1,则结点i 的双亲是结点[i/2](向下取整)。
  2. 如果 2i>n,则结点i 无左孩子(该结点为叶子结点);否则其左孩子是结点[2i]。
  3. 如果 2i+1>n ,则结点i 无右孩子;否则其右孩子是结点[2i+1]。

可以说,这个性质仿佛就是在为堆准备的。如果将上图的大顶堆和小顶堆用层序遍历存入数组,则一定满足这条性质。

而堆结构的目的就是为了堆排序用的。

2、堆排序

堆排序(Heap Sort) 就是利用堆进行排序的方法。它的基本思想:将待排序的序列构造成一个大顶堆(假设使用大顶堆)。此时,整个序列的最大值就是堆顶的根结点。将根结点与堆数组的末尾元素交换,此时末尾元素就是最大值,然后将剩余的n-1个序列重新构造成 个大顶堆,这样就会得到n个元素中的次小值如此反复执行,便能得到一个有序序列了。

/*堆排序算法:
第一步:先把原始序列构建为一个大顶堆;
第二步:交换大顶堆的根结点(堆中的最大值)到堆尾; 
第三步:重新构建大顶堆,保持大顶堆的性质; 
需要重复第二、三步n-1次。 
*/
void Heap_Sort(int *arr, int len)
{                                
    for (int i=len/2;i>0;i--)    /*0<i<=len/2的结点i都是拥有左右孩子结点的分支结点*/
        Build_Heap(arr, i, len); /*构建堆*/
		
    for (int i=len;i>1;i--)      /*遍历n-1次*/
    {
        swap_arr(arr, 1, i);     /*交换根结点到末尾*/
        Build_Heap(arr, 1, i-1); /*重建堆,只需要调整一个根结点,因为其他结点基本有序*/
    }
}

/*已知arr[root...len]中,除了arr[root]外均满足堆的定义*/
/*所以需要调整arr[root],使arr[root...len]成为大顶堆*/
void Build_Heap(int *arr,int root, int len)
{
    arr[0] = arr[root];        /*使用哨兵记录根结点*/
    for (int i=2*root;i<=len;i*=2)
    {	/*i 记录root结点的左孩子和右孩子的较大值*/
        if (i+1<=len && arr[i]<arr[i+1]) /*i+1<=len 确保右孩子存在*/
            i=i+1;                       /*如果右孩子值大,就记录右孩子,否则默认左孩子*/
        if (arr[0] > arr[i]) break;      /*不比根结点大,就不用交换了*/
        arr[root] = arr[i];    /*不然就交换*/
        arr[i] = arr[0];
        root = i;              /*交换后,继续以被交换的结点为根结点向下调整,直到合适的位置*/
    }                          /*比如,50和90交换后,换到原来90的位置的50不一定适合这个位置*/	
}                              /*所以50还要继续往下调整,直到50的左、右孩子都不大于它,或50为叶子结点*/

假设我们要排序的序列是{50, 10, 90, 30, 70, 40, 80, 60, 20},那么第一个循环的变量i 的变化是i=9/2=4->3->2->1,下标为4、3、2、1的结点都是拥有左、右孩子结点的分支结点。

我们所谓的将待排序的序列构建成为一个大顶堆,其实就是从下往上、从右到左地将每个分支结点当作根结点,将其和其的左、右子树调整成大顶堆。

开始将原始序列构建一个大顶堆,首先 root=4,i=2*root=8,然后比较结点 root 的左、右孩子谁大,使用 i 记录下较大值,这里显然是60较大,所以 i=8,然后arr[root]=30 < arr[i]=60 ,需要交换它们的数值。

关键点来了,交换了之后还没有结束,root 要指向 i 位置,root=i=8,然后开始下一次循环,i=2*root=16,i>len=9,跳出循环。此时说明30已经成为叶子结点,无法继续向下调整,对于30而言才算是调整完毕。

下一轮循环,root=3,i=2*root=6,然后比较结点 root 的左、右孩子谁大,使用 i 记录下较大值,这里显然是80较大,所以 i=i+1=7,然后arr[root]=90 > arr[i]=80 ,不需要交换它们的数值。

既然没有交换,就也不需要继续向下调整,因此直接退出循环,整个序列未发生改变。

下一轮循环,root=2,i=2*root=4,然后比较结点 root 的左、右孩子谁大,使用 i 记录下较大值,这里显然是70较大,所以 i=i+1=5,然后arr[root]=10 < arr[i]=70 ,需要交换它们的数值。

继续向下调整,root=i=5,i=2*root=10,i>len=9,10成为了叶子结点,调整完毕。

下一轮循环,root=1,i=2*root=2,然后比较结点 root 的左、右孩子谁大,使用 i 记录下较大值,这里显然是90较大,所以 i=i+1=3,然后arr[root]=50 < arr[i]=90 ,需要交换它们的数值。

继续向下调整,root=i=3,i=2*root=6,然后比较结点 root 的左、右孩子谁大,使用 i 记录下较大值,这里显然是80较大,所以 i=i+1=7,然后arr[root]=50 < arr[i]=80 ,需要交换它们的数值。

继续向下调整,root=i=7,i=2*root=14,i>len=9,50成为了叶子结点,调整完毕。

构建堆结束,开始交换和重接堆。i=len指向堆尾,将 arr[1]=90 和 arr[i]=20 交换,交换了之后,堆不满足其定义,需要重新构建堆,我们会发现除了 arr[1]=20 以外,其他结点都满足堆的定义,所以只需要调整 arr[1]=20 即可。

开始调整,root=1,i=2*root=2,比较结点 root 的左、右孩子谁大,使用 i 记录下较大值,这里显然是80较大, i=i+1=3,然后arr[root]=20 < arr[i]=80 ,需要交换它们的数值。

继续向下调整,root=i=3,i=2*root=6,然后比较结点 root 的左、右孩子谁大,使用 i 记录下较大值,这里显然是50较大,所以 i=i+1=7,然后arr[root]=20 < arr[i]=50 ,需要交换它们的数值。

继续向下调整,root=i=7,i=2*root=14,i>len=9,20成为了叶子结点,调整完毕。

后面的变化都一样,不再详细解释,还是无法搭建起这个概念,继续往下看图。

最终得到了一个完全有序的序列。 

3、堆排序的时间复杂度分析

堆排序的效率到底有多高呢?

首先,堆排序也是一种选择排序,是一种树形的选择排序。只不过在直接选择排序中,为了从R[1,...,n]中选择最大记录,需比较n-1次,然后从R[1,...,n-2]中选择最大记录需比较n-2次。事实上这n-2次比较中有很多已经在前面的n-1次比较中已经做过,而树形选择排序恰好利用树形的特点保存了前面的比较结果,因此大大减少比较次数。

其次,它的运行时间主要是消耗在初始构建堆和交换后的重建堆时的反复筛选上。

重建堆时,如果根结点很小那么可能要移动到叶子结点,那移动比较次数最大值就是树的深度㏒n+1次,有n-1个根结点需要移动,那么重建堆的时间复杂度大约为O(n㏒n)。

同理,第一次构建堆时,有n/2个根结点需要移动,但每个结点移动时树的深度不一,树的深度从1依次递增到㏒n+1,根据等差数列求和公式Sn = n/2 * (a1+an) = n/2/2 * (1+㏒n+1) ≈ n㏒n/4,所以构建堆的时间复杂度大约为O(n㏒n/4),所以堆排序总的时间复杂度大约为O(5/4*n㏒n),即O(n㏒n)。

堆排序与直接选择排序一样,对待排序序列的状态不敏感,不区分最好、最坏情况,所以它的时间复杂度一直都为O(n㏒n),这效率已经非常好了。

另外,由于初始构建堆所需要的比较次数比较多,因此堆排序并不适合待排序序列个数较少的情况。

对于堆排序,一般使用顺序存储结构来实现二叉堆,而不选择使用链式存储结构。

猜你喜欢

转载自blog.csdn.net/qq_36557133/article/details/90342310
今日推荐