排序算法总结——堆排序

版权声明:转载请注明出处!谢谢! https://blog.csdn.net/qq_28114615/article/details/86154057

1.算法原理及步骤

2.实现代码

3.时间复杂度分析

3.1 构建初始大顶堆时间复杂度

3.2 堆调整时间复杂度

补充说明

4. 稳定性分析 


1.算法原理及步骤

       要搞清楚堆排序,首先就需要明白什么是堆,在堆排序中,堆是一种类似于完全二叉树的结构,那么完全二叉树是什么呢?简单来讲,完全二叉树就是指叶子结点只存在于最后两层的树。而堆排序则是利用这一数据结构进行排序,排序的方法主要分为两种:大顶堆排序和小顶堆排序,其中大顶堆用于排升序小顶堆用于排降序

       其中,大顶堆的性质是:每一个子堆中(包含一个结点及其左右子结点(若有)),结点值是最大值;

                  小顶堆的性质是:每一个子堆中(包含一个结点及其左右子结点(若有)),结点值是最小值;

       不管是大顶堆还是小顶堆,都不用明确左右子结点间的大小关系。假设结点索引为i(i=0,1,2...),那么其左右结点索引分别为2*i+1和2*i+2,对于大顶堆a[i]≥a[2*i+1]&&a[i]≥a[2*i+2];对于小顶堆有a[i]≥a[2*i+1]&&a[i]≤a[2*i+2]。如下图所示:

       需要注意的是,堆排序并非是真正的在树中进行排序,它只是利用了树的性质,将数组中的各元素看做是按序分布在树上的结点,实际上还是在数组内部进行排序的。

       以升序为例,先将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。

       因此,以升序为例,堆排序的步骤分为以下几步:

       ①构造初始堆,将无序数组构造成大顶堆形式;

       假定给定数组如下,其对应的堆结构如图所示:

       此时的堆结构并非是大顶堆,因此需要对其进行调整。需要注意的是,叶子结点是没有子结点的,不用对其进行调整,因此就从最后一个非叶子结点开始从下往上调整。那么最后一个非叶子结点怎么找呢?很简单,知道了数组的长度为len,那么最后一个非叶子结点的索引必定就是[len/2]-1(其中[len/2]表示对[len/2]向下取整),即是从图中的结点6(索引为1)开始,此时发现结点6比其右子结点9小,因此就将结点6与结点9交换,如图所示:

        然后就继续遍历从下往上,从右往左遍历下一个结点4,发现此时结点4比其左子结点9小,那么就需要对其进行调整,如图所示:

        这个时候堆顶元素所在堆没有问题了,说明堆顶元素已经是最大了,但是这并没有结束,因为此时交换后的结点4比其左右子结点都小,不符合大顶堆性质,因此还需要再调整结点4,而对于结点4,其左右子结点均比它大,因此将其与二者之间较大值进行交换,如图所示:

       此时就满足了大顶堆的性质,构造初始堆也就完成了,接下来进行排序。

       ②排序,将堆顶结点与末尾结点交换,并将其抛出,交换后的堆中,堆顶最在子堆可能不满足最大堆,因此对其进行调整,调整可能会影响到其他子堆,那么就依次进行调整知道最终满足大顶堆性质,此时堆顶结点又是堆中最大值,继续执行②

       将堆顶元素与末尾元素交换,并丢弃末尾元素,如图所示:

      此时发现结点4又需要调整,因此对其进行调整:

     调整后的堆满足最大堆的性质,因此将堆顶结点8与末尾结点5进行交换,如图所示:

   

     然后重复上述操作,直到堆中的元素只剩一个。

  

       由以上可知,整个堆排序的过程主要由构建初始大顶堆+堆顶元素“沉底”后堆调整两部分组成,而这两部分中都会包含多个结点的调整。其中需要注意的是:在每次调整结点后,对于被交换的子结点也应当进行调整,才能确保最终满足大顶堆要求,如果不对子结点进行调整,那么虽然能够找到最大值,但是此时其他子堆是不满足大顶堆的,这样在堆顶与末尾元素交换后,就还需要对去掉最大元素后的数组进行重新建堆,效率低下。(上述图片来自于http://www.cnblogs.com/chengxiao/p/6129630.html

       总结可得算法步骤如下:

       ①初始化建堆。建堆过程中需进行结点调整,从最后一个非叶子结点开始(len/2-1),且每次调整需要对调整后的结点再调整;

       ②将堆顶元素与末尾元素交换,堆大小减一,再调整堆顶。


2.实现代码

#include <iostream>
#include <vector>

using namespace std;

void adjustHeap(vector<int>& nums,int root,int len)
{
    int lch=2*root+1;  //左子结点
    int rch=lch+1;   //右子结点
    int index=root;  //较大结点

    if(rch<len&&nums[rch]>nums[index])index=rch;  

    if(lch<len&&nums[lch]>nums[index])index=lch;

    if(index!=root) //当前结点非最大结点
    {
        swap(nums[index],nums[root]);
        adjustHeap(nums,index,len);
    }
    return;
}
void heapSort(vector<int>& nums,int len)
{
    if(!len)return;
    for(int i=len/2-1;i>=0;i--)  //初始化堆
    {
        adjustHeap(nums,i,len);
    }

    for(int i=len-1;i>0;i--)  //堆排序
    {
        swap(nums[i],nums[0]);
        adjustHeap(nums,0,i);   //交换后调整堆顶
    }

    return;

}

int main()
{
    cin.clear();
    vector<int>nums;

    int num;
    while(cin>>num)nums.push_back(num);

    int left=0;
    int len=nums.size();

    heapSort(nums,len);

    for(int i=0;i<len;i++)cout<<nums[i]<<" ";

    return 0;
}

3.时间复杂度分析

      前面说过,堆排序的实现主要是构建初始大顶堆+堆顶沉底后堆调整两个部分组成,因此就从这两个方面进行时间复杂度的分析:

3.1 构建初始大顶堆时间复杂度

        最好情况下,堆中所有子堆均是满足大顶堆要求的(并不代表数组有序),此时构建初始大顶堆的时间实际上就是O(N)了;

        最坏情况下,堆中每个结点调整后其被交换的子结点也需要调整,然后子结点的子结点也需要调整....一直递归到叶子结点,在这种情况下,数组长度为N,索引为0~N-1,构建初始大顶堆从最后一个非叶子结点开始,那么该层结点最多需要递归1次即可到叶子结点,其上一层需要递归2次...以此类推假设根结点需要递归K次,此时以根结点所在为第一层,则最后一个非叶子结点所在为第K层,那么第i层需要递归K+1-i次,而第i层共有结点2^{i-1}个,忽略第K层中的叶子结点,则时间复杂度近似为

       忽略低阶项,T(N)\approx 2^{K+1},而K=[logN],因此T(N)\approx 2^{[logN]+1}

       其中,K为logN取整值,因此有log(N-1)\leqslant K\leqslant logN,得到2*(N-1)\leqslant T(N))\leqslant 2*N

       综上可得,最坏情况下的构建初始大顶堆时间复杂度为O(N),最好情况下也是O(N),因此构建初始大顶堆的平均时间复杂度为O(N)。

3.2 堆调整时间复杂度

        对于最好情况,似乎每一次换上去的新堆顶只要比其左右子结点都大,那么时间复杂度就能为O(N),但是实际上这种情况并不存在,因为对于大顶堆,其每个子堆中的堆顶元素都是最大的,因此末尾元素根本不可能比堆顶的左右子结点都大,因此这种情况是不存在的。

        最坏情况下,每一次换上去的新堆顶都需要沉底,借用3.1中的定义,那么每一次换上去的都需要递归K次,不过由于每一次的堆大小都在变小,因此这里的K也会变小,最终堆调整的时间复杂度T(N)=[log1]+[log2]+[log3]+......+[log(N-1)]\approx log(N!)

而对于N!有如下证明:

由此可得最坏情况下的堆调整的时间复杂度为O(NlogN)。

        实际上,不管是最好还是最坏,假设被换上去的新结点原来的位置处于根结点的左子树上,那么换上去的新结点必定比它的左子结点小,要调整新结点,最好的办法就是它的右子结点最大,这样新结点的调整只需要一次操作即可,但是这样之后,不管下一个新结点来自于根结点左子树还是右子树,当下一个新结点被换到堆顶,此时对新的新结点进行调整,那么由于此时新的新结点的左子结点必定是最大的(此时的右子结点来自于左子树因此必定比左子结点小,无论新的新结点来自于哪一边都必定是小于其左子结点的),这个新的新结点就必须递归K次到最底下去,下一个新结点依然如此...可见,最好的情况下,也只是第一次调整结点的时候只用了一次操作,实际上优化不了多少,时间复杂度依然是O(NlogN)

综上所述,堆排序的时间复杂度在最好和最坏情况下时间复杂度均为O(NlogN)。

补充说明

       在堆排序中,堆调整(程序中的调整是结点调整)有两种,一种是初始化堆时的堆调整,此时的堆并没有大顶堆或小顶堆的性质,因此对每个结点进行调整后还要对其被交换的子结点进行调整,这种堆调整的时间复杂度就相当于初始化堆的时间复杂度,为O(NlogN);另一种堆调整是在取出堆顶元素与末尾元素交换后的堆调整,此时的堆除了堆顶以外都是符合大顶堆性质的,因此这种情况实际上就是对新堆顶元素的递归,每次递归都对其自身进行结点调整,递归到底或者符合大顶堆性质为止,这种堆调整的时间复杂度就是O(logN)了。因此,在很多资料中都会说到调整堆,一会儿是O(NlogN)一会又是O(logN),其实只是情况不同而已,需要注意区分。

       此外,堆排序的时间复杂度是由构建初始化堆和堆调整组成的,虽然算法复杂度最终是O(NlogN),但是其中构建初始化堆的时间复杂度也有O(N),并且不管数据多少,构建初始化堆只有一次,因此堆排序往往用于数据量庞大的情况。


4. 稳定性分析 

       堆排序的稳定性并不难分析,由于每个结点的左右子结点的大小关小关系并不明确,因此相等的元素排序前后相对位置并不能得到保证,因此堆排序是不稳定排序。

猜你喜欢

转载自blog.csdn.net/qq_28114615/article/details/86154057
今日推荐