堆排序是选择排序中能对元素个数较多的序列进行相对高效排序的算法。排序过程中,要用到一种叫堆的数据结构,这种堆实际上可看作是完全二叉树的数组实现,只是加多个限制:父结点存储的元素值必须同时不小于两个子结点所存储的元素值(此为大顶堆,用于产生从小到大序列的堆排序。若要求父结点存储的元素值必须同时不大于两个子结点所存储的元素值,便产生小顶堆,此可用于产生从大到小序列的堆排序)。要进行堆排序,首先要对初始序列构建大顶堆,而这步恐怕需要读者对完全二叉树的性质有个比较熟练的掌握,下文可能不会去专门介绍有关完全二叉树的知识点,读者可以先看看相关的资料了解完全二叉树的原理。
以序列:49、38、65、97、76、13、27、 49 为例。开始时直接把序列构造成如图1的完全二叉树。图中,结点内部的数值即为序列里的元素,结点外围的数字指示结点的编号,这些编号也恰好对应其元素在初始序列中的位置。按结点编号从大到小依次寻找,找得首个非叶子结点4,对应元素97,其只有左孩子结点8,对应元素 49 。97> 49 ,则无需任何处理。紧接着的非叶子结点为3,对应元素65,其有两个孩子结点6、7,对应元素分别为13、27。65>13且65>27,则同样无需任何处理。接着下一个非叶子结点2,对应元素38,其有两个孩子结点4、5,分别对应元素97、76。显然,38<97且38<76,则需要从97与76中找出较大者97,与38交换位置,从而得到图2的结果(也即,此时的2结点本身调整为满足大顶堆的约束)。然而,结点4原本所放的97是符合大顶堆定义的,现在被换成38之后有可能就被破坏了,故对结点4要重新检查。现在其对应38,并只有1个孩子结点8,对应 49 。38< 49 ,则又得交换它们两者的位置,从而得到图3的结果。尽管原先符合大顶堆定义的结点8被换成38,但由于其没有孩子结点,故不需要进一步处理。接下来,轮到最后的非叶子结点1,对应元素49,其有两个孩子结点2、3,分别对应元素97、65。49<97且49<65,则需要从97与65间找出较大者97,与49交换位置,从而得到图4的结果。由于原本符合大顶堆定义的结点2,被换成了49,可能会遭到破坏,因此得重新检查结点2。其有两个孩子结点4、5,分别对应元素 49 、76。49= 49 ,而49<76,则49与76交换位置得图5。尽管原先符合大顶堆定义的结点5被换成了49,但由于其没有孩子结点,故不需要进一步处理。至此,大顶堆构造完成。
图1 图2
图3 图4
图5
大顶堆构造好后,堆排序就完全没问题了。可知大顶堆是棵完全二叉树,其根结点1对应了最大的元素97。取出之放到结果序列最尾端,然后令结点1对应的元素值变为当前大顶堆最后那个结点对应的元素,即结点8的38,并删除结点8。由于根结点换成了新的元素,需要像上文那样进行调整,使完全二叉树重新符合大顶堆定义。然后又把根结点对应元素取出放到结果序列次尾端,接着又执行与上述相仿的步骤。这样一来,便顺次把较大的元素从结果序列的最尾端开始依次排进去,而大顶堆剩下的结点也将越来越少。当大顶堆全部结点都按上述方法处理完后,堆排序也就完成了。
从上文描述的原理可知,堆排序仍然会存在元素的跨越式位置移动,故其为不稳定排序。代码如下:
接着排序的话,无论序列开始如何,都被构建成大顶堆,并从大顶堆的根结点开始逐个逐个结点处理。每回处理,先执行1次交换操作让大顶堆的头与尾交换,然后总要重新调整堆,排序的时间复杂度实际上主要体现在调整堆时产生的比较、交换操作次数上(同理仅看比较次数即可)。此时, 没有什么最好最坏情况之分,而且这时的比较次数要严格算出来也是相当复杂的,大家只需要知道,每回处理需要的比较次数视作O(logn),共n-1回,则总比较次数视作(n-1)O(logn)。
显然,最好情况下的总比较次数为: 2 ⌊n/2 ⌋+ (n-1)O(logn);最坏情况下的总比较次数为: ⌊n/2 ⌋ O(logn)+(n-1)O(logn)。至于开头与结尾的数组复制操作都是O(n)且没多大讨论意义,这部分就没必要也算进去了。综上所述,堆排序的时间复杂度为O(nlogn)。由于堆排序需要使用大顶堆辅助,大顶堆实际上是个数组实现的完全二叉树,其元素个数为n+1,因此,堆排序的空间复杂度为O(n)。
以序列:49、38、65、97、76、13、27、 49 为例。开始时直接把序列构造成如图1的完全二叉树。图中,结点内部的数值即为序列里的元素,结点外围的数字指示结点的编号,这些编号也恰好对应其元素在初始序列中的位置。按结点编号从大到小依次寻找,找得首个非叶子结点4,对应元素97,其只有左孩子结点8,对应元素 49 。97> 49 ,则无需任何处理。紧接着的非叶子结点为3,对应元素65,其有两个孩子结点6、7,对应元素分别为13、27。65>13且65>27,则同样无需任何处理。接着下一个非叶子结点2,对应元素38,其有两个孩子结点4、5,分别对应元素97、76。显然,38<97且38<76,则需要从97与76中找出较大者97,与38交换位置,从而得到图2的结果(也即,此时的2结点本身调整为满足大顶堆的约束)。然而,结点4原本所放的97是符合大顶堆定义的,现在被换成38之后有可能就被破坏了,故对结点4要重新检查。现在其对应38,并只有1个孩子结点8,对应 49 。38< 49 ,则又得交换它们两者的位置,从而得到图3的结果。尽管原先符合大顶堆定义的结点8被换成38,但由于其没有孩子结点,故不需要进一步处理。接下来,轮到最后的非叶子结点1,对应元素49,其有两个孩子结点2、3,分别对应元素97、65。49<97且49<65,则需要从97与65间找出较大者97,与49交换位置,从而得到图4的结果。由于原本符合大顶堆定义的结点2,被换成了49,可能会遭到破坏,因此得重新检查结点2。其有两个孩子结点4、5,分别对应元素 49 、76。49= 49 ,而49<76,则49与76交换位置得图5。尽管原先符合大顶堆定义的结点5被换成了49,但由于其没有孩子结点,故不需要进一步处理。至此,大顶堆构造完成。
图1 图2
图3 图4
图5
大顶堆构造好后,堆排序就完全没问题了。可知大顶堆是棵完全二叉树,其根结点1对应了最大的元素97。取出之放到结果序列最尾端,然后令结点1对应的元素值变为当前大顶堆最后那个结点对应的元素,即结点8的38,并删除结点8。由于根结点换成了新的元素,需要像上文那样进行调整,使完全二叉树重新符合大顶堆定义。然后又把根结点对应元素取出放到结果序列次尾端,接着又执行与上述相仿的步骤。这样一来,便顺次把较大的元素从结果序列的最尾端开始依次排进去,而大顶堆剩下的结点也将越来越少。当大顶堆全部结点都按上述方法处理完后,堆排序也就完成了。
从上文描述的原理可知,堆排序仍然会存在元素的跨越式位置移动,故其为不稳定排序。代码如下:
void heapSortKernel(int startFromOne[],int begin,int end)
{
int i=begin;
int j=2*i;
while(j<=end)
{
if((j<end)&&(startFromOne[j]<startFromOne[j+1]))
++j;
if(startFromOne[i]<startFromOne[j])
{
int temp=startFromOne[i];
startFromOne[i]=startFromOne[j];
startFromOne[j]=temp;
i=j;
j=2*i;
}
else
break; //若当前结点没有发生任何动作,说明其下全部部分都满足堆定义了,及时跳出循环。
}
}
void heapSort(int list[],int length)
{
//由于堆排序涉及到完全二叉树结点标号,堆的数组实现要下标从1开始。
int * startFromOne=new int [length+1];
for(int i=0;i<length;++i)
startFromOne[i+1]=list[i];
for(int i=length/2;i>0;--i)
heapSortKernel(startFromOne,i,length);
for(int i=length;i>1;--i)
{
int temp=startFromOne[1];
startFromOne[1]=startFromOne[i];
startFromOne[i]=temp;
heapSortKernel(startFromOne,1,i-1);
}
for(int i=0;i<length;++i)
list[i]=startFromOne[i+1];
delete [] startFromOne;
}
设序列元素个数为n。堆排序的过程显然分为建堆和排序两步。建堆时,随元素个数改变而改变次数的操作主要是:处理大顶堆的每个非叶子结点所产生的比较与交换,一边比较一边交换,交换次数不多于比较次数,所以我们可以仅看比较次数。最好情况下,对于大顶堆的每个非叶子结点的处理,仅比较两次而无交换,可得此时的总比较次数为2
⌊n/2
⌋;最坏情况下,处理每个非叶子结点而进行比较后都需交换,不仅如此而已,交换后因为破坏了大顶堆定义而要进一步调整,这又将会产生新的比较与交换,且恐怕会进行到底。因此,最坏情况下的总比较次数分析起来比较复杂,大家只需知道:此时,处理每个非叶子结点的比较次数视作O(logn),则总比较次数视作
⌊n/2
⌋
O(logn)
。接着排序的话,无论序列开始如何,都被构建成大顶堆,并从大顶堆的根结点开始逐个逐个结点处理。每回处理,先执行1次交换操作让大顶堆的头与尾交换,然后总要重新调整堆,排序的时间复杂度实际上主要体现在调整堆时产生的比较、交换操作次数上(同理仅看比较次数即可)。此时, 没有什么最好最坏情况之分,而且这时的比较次数要严格算出来也是相当复杂的,大家只需要知道,每回处理需要的比较次数视作O(logn),共n-1回,则总比较次数视作(n-1)O(logn)。
显然,最好情况下的总比较次数为: 2 ⌊n/2 ⌋+ (n-1)O(logn);最坏情况下的总比较次数为: ⌊n/2 ⌋ O(logn)+(n-1)O(logn)。至于开头与结尾的数组复制操作都是O(n)且没多大讨论意义,这部分就没必要也算进去了。综上所述,堆排序的时间复杂度为O(nlogn)。由于堆排序需要使用大顶堆辅助,大顶堆实际上是个数组实现的完全二叉树,其元素个数为n+1,因此,堆排序的空间复杂度为O(n)。