您的位置:首页 > 其它

【初探】 堆排序

2017-09-10 15:54 155 查看
堆排序介绍

堆排序(Heap Sort): 是指利用堆这种数据结构所设计的一种排序算法,堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为O(nlogn) (堆排序与快速排序,归并排序一样都是时间复杂度为O(N*logN)的几种常见排序方法),它也是不稳定排序。

注意: 在学习堆排序之前,有必要了解堆!若读者不熟悉堆,
建议先了解堆(建议可以通过二叉堆,左倾堆,斜堆,二项堆或斐波那契堆等文章进行了解),
然后再来学习本章。


二叉堆

二叉堆其实是一棵有着特殊性质的完全二叉树,这里的特殊性质是指:

1、二叉堆的父节点的值总是大于等于(或小于等于)其左右孩子的值;

2、每个节点的左右子树都是一棵这样的二叉堆。


如果一个二叉堆的父节点的值总是大于其左右孩子的值,那么该二叉堆为最大堆,反之为最小堆。我们在排序时,如果要排序后的顺序为从小到大,则需选择最大堆,反之,选择最小堆。

堆的概念

在程序设计领域,堆(Heap)的概念主要有以下两种:

(1)一种数据结构,逻辑上是一颗完全二叉树,存储上是一个数组对象(二叉堆)。也正是本文谈及的堆。

(2)存储区,是软件系统可以编程的内存区域(即就是动态申请的区域)。


数据结构中的堆实质上是满足一定性质的完全二叉树:二叉树中任一非叶子结点关键字的值均小于(大于)它的孩子结点的关键字。

在小根堆中,第一个元素(完全二叉树的根结点)的关键字最小;

大根堆中,第一个元素(完全二叉树的根结点)的关键字最大,

显然,堆中任一子树仍是一个堆。


● 堆是一棵顺序存储的完全二叉树,完全二叉树适合采用顺序存储的方式,因此一个数组可以看成一个完全二叉树。

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



同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子



该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:

大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]

小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]



如上图所示,序列R{3, 8, 15, 31, 25}是一个典型的小根堆。

堆中有两个父结点,元素3和元素8。

元素3在数组中以R[0]表示,它的左孩子结点是R[1],右孩子结点是R[2]。

元素8在数组中以R[1]表示,它的左孩子结点是R[3],右孩子结点是R[4],它的父结点是R[0]。


可以看出,它们满足以下规律:

设当前元素在数组中以R[i]表示,那么,

(1) 它的左孩子结点是:R[2*i+1];  //大顶堆也是如此

(2) 它的右孩子结点是:R[2*i+2];  //大顶堆也是如此

(3) 它的父结点是:R[(i-1)/2];  //大顶堆也是如此

(4) R[i] <= R[2*i+1] 且 R[i] <= R[2*i+2]。  // 小顶堆的定义


堆排序

● 由二叉堆的定义可知,堆顶元素(即二叉堆的根节点)一定为堆中的最大值或最小值,因此如果我们输出堆顶元素后,将剩余的元素再调整为二叉堆,继而再次输出堆顶元素,再将剩余的元素调整为二叉堆,反复执行该过程,这样便可输出一个有序序列, 这个过程我们就叫做堆排序。

由于我们的输入是一个无序序列,因此要实现堆排序,我们要先后解决如下两个问题:

1、如何将一个无序序列建成一个二叉堆;

2、在去掉堆顶元素后,如何将剩余的元素调整为一个二叉堆。


针对第一个问题,可能很明显会想到用堆的插入操作,一个一个地插入元素,每次插入后调整元素的位置,使新的序列依然为二叉堆。这种操作一般是自底向上的调整操作, 即先将待插入元素放在二叉堆后面,而后逐渐向上将其与父节点比较,进而调整位置。

我们完全用不着一个节点一个节点地插入,那我们要怎么做呢?我们需要先来解决第二个问题,解决了第二个问题,第一个问题问题也就迎刃而解了。

调整二叉堆

要分析第二个问题,我们先给出以下前提:

1、我们排序的目标是从小到大,因此我们用最大堆;
2、我们将二叉堆中的元素以层序遍历后的顺序保存在一维数组中,根节点在数组中的位置序号为0。


为了使调整过程更易于理解,我们采用如下二叉堆来分析(注意下面的分析,我们并没有采用额外的数组来存储每次去掉的堆顶数据):



这里数组A中元素的个数为8,很明显最大值为A0,为了实现排序后的元素按照从小到大的顺序排列,我们可以将二叉堆中的最后一个元素A7与A0互换,这样A7中保存的就是数组中的最大值,而此时该二叉树变为了如下情况:



为了将其调整为二叉堆,我们需要寻找4应该插入的位置。为此,我们让4与它的孩子节点中最大的那个,也就是其左孩子7,进行比较,由于4<7,我们便把二者互换,这样二叉树便变成了如下的形式:



接下来,继续让4与其左右孩子中的最大者,也就是6,进行比较,同样由于4<6,需要将二者互换,这样二叉树变成了如下的形式:



这样便又构成了二叉堆,这时候A0为7,是所有元素中的最大元素。同样我们此时继续将二叉堆中的最后一个元素A6和A0互换,这样A6中保存的就是第二大的数值7,而A0就变为了3,形式如下:



为了将其调整为二叉堆,一样将3与其孩子结点中的最大值比较,由于3<6,需要将二者互换,而后继续和其孩子节点比较,需要将3和4互换,最终再次调整好的二叉堆形式如下:



一样将A0与此时堆中的最后一个元素A5互换,这样A5中保存的便是第三大的数值,再次调整剩余的节点,如此反复,直到最后堆中仅剩一个元素,这时整个数组便已经按照从小到大的顺序排列好了。

堆排序基本思想及步骤

 堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。

然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了

首先,按堆的定义将数组R[0..n]调整为堆(这个过程称为创建初始堆),交换R[0]和R
;

然后,将R[0..n-1]调整为堆,交换R[0]和R[n-1];

如此反复,直到交换了R[0]和R[1]为止。


以上思想可归纳为两个操作:

(1)根据初始数组去构造初始堆,然后构建一个完全二叉树,根据升序降序需求选择大顶堆或小顶堆。

(2)每次交换第一个和最后一个元素(堆顶元素与末尾元素),将最大元素"沉"到数组末端,输出最后一个元素(最大值)

(3)  然后把剩下元素重新调整为大根堆,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。

当输出完最后一个元素后,这个数组已经是按照从小到大的顺序排列了。


下面通过几张示例图,帮助理解!

步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。

 a.假设给定无序序列结构如下



此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从左至右,从下至上进行调整。



找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。



这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。



此时,我们就将一个无需序列构造成了一个大顶堆。

步骤二 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。

将堆顶元素9和末尾元素4进行交换



.重新调整结构,使其继续满足堆定义



再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.



后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序



下面演示heap_sort (a, n)对a={20,30,90,40,70,110,60,10,100,50,80}, n=11进行堆排序过程。下面是数组a对应的初始化结构:



1 初始化堆

在堆排序算法中,首先要将待排序的数组转化成二叉堆。

下面演示将数组{20,30,90,40,70,110,60,10,100,50,80}转换为最大堆{110,100,90,40,80,20,60,10,30,50,70}的步骤。

1.1 i=11/2-1,即i=4



上面是maxheap_down(a, 4, 9)调整过程。maxheap_down(a, 4, 9)的作用是将a[4…9]进行下调;a[4]的左孩子是a[9],右孩子是a[10]。调整时,选择左右孩子中较大的一个(即a[10])和a[4]交换。

1.2 i=3



上面是maxheap_down(a, 3, 9)调整过程。maxheap_down(a, 3, 9)的作用是将a[3…9]进行下调;a[3]的左孩子是a[7],右孩子是a[8]。调整时,选择左右孩子中较大的一个(即a[8])和a[4]交换。

1.3 i=2



上面是maxheap_down(a, 2, 9)调整过程。maxheap_down(a, 2, 9)的作用是将a[2…9]进行下调;a[2]的左孩子是a[5],右孩子是a[6]。调整时,选择左右孩子中较大的一个(即a[5])和a[2]交换。

1.4 i=1



上面是maxheap_down(a, 1, 9)调整过程。maxheap_down(a, 1, 9)的作用是将a[1…9]进行下调;a[1]的左孩子是a[3],右孩子是a[4]。调整时,选择左右孩子中较大的一个(即a[3])和a[1]交换。交换之后,a[3]为30,它比它的右孩子a[8]要大,接着,再将它们交换。

1.5 i=0



上面是maxheap_down(a, 0, 9)调整过程。maxheap_down(a, 0, 9)的作用是将a[0…9]进行下调;a[0]的左孩子是a[1],右孩子是a[2]。调整时,选择左右孩子中较大的一个(即a[2])和a[0]交换。交换之后,a[2]为20,它比它的左右孩子要大,选择较大的孩子(即左孩子)和a[2]交换。

调整完毕,就得到了最大堆。此时,数组{20,30,90,40,70,110,60,10,100,50,80}也就变成了{110,100,90,40,80,20,60,10,30,50,70}。

第2部分 交换数据

在将数组转换成最大堆之后,接着要进行交换数据,从而使数组成为一个真正的有序数组。

交换数据部分相对比较简单,下面仅仅给出将最大值放在数组末尾的示意图。



上面是当n=10时,交换数据的示意图。

当n=10时,首先交换a[0]和a[10],使得a[10]是a[0…10]之间的最大值;然后,调整a[0…9]使它称为最大堆。交换之后:a[10]是有序的!

当n=9时, 首先交换a[0]和a[9],使得a[9]是a[0…9]之间的最大值;然后,调整a[0…8]使它称为最大堆。交换之后:a[9…10]是有序的!



依此类推,直到a[0…10]是有序的。

最后再看一个示例:

设有一个无序序列 { 1, 3, 4, 5, 2, 6, 9, 7, 8, 0 }。

初始堆:



构造了初始堆后,我们来看一下完整的堆排序处理:

还是针对前面提到的无序序列 { 1, 3, 4, 5, 2, 6, 9, 7, 8, 0 } 来加以说明。



相信,通过以上两幅图,应该能很直观的演示堆排序的操作处理。

算法分析

堆排序算法的总体情况



假设被排序的数列中有N个数。遍历一趟的时间复杂度是O(N),需要遍历多少次呢?

堆排序是采用的二叉堆进行排序的,二叉堆就是一棵二叉树,它需要遍历的次数就是二叉树的深度,而根据完全二叉树的定义,它的深度至少是lg(N+1)。最多是多少呢?由于二叉堆是完全二叉树,因此,它的深度最多也不会超过lg(2N)。因此,遍历一趟的时间复杂度是O(N),而遍历次数介于lg(N+1)和lg(2N)之间;因此得出它的时间复杂度是O(N*lgN)。

堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。其中构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)…1]逐步递减,近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)级。

我们在每次重新调整堆时,都要将父节点与孩子节点比较,这样,每次重新调整堆的时间复杂度变为O(logn),而堆排序时有n-1次重新调整堆的操作,建堆时有((len-1)/2+1)次重新调整堆的操作,因此堆排序的平均时间复杂度为O(n*logn)。由于我们这里没有借用辅助存储空间,因此空间复杂度为O(1)。

堆排序在排序元素较少时有点大才小用,待排序列元素较多时,堆排序还是很有效的。因为其运行时间主要耗费在建初始堆和调整建新堆时进行的反复“筛选”上,

另外,堆排序在最坏情况下,时间复杂度也为O(n*logn)。相对于快速排序(平均时间复杂度为O(n*logn),最坏情况下为O(n*n)),这是堆排序的最大优点。

算法稳定性

● 堆排序是不稳定的算法,它不满足稳定算法的定义。它在交换数据的时候,是比较父结点和子节点之间的数据,所以,即便是存在两个数值相等的兄弟节点,它们的相对顺序在排序也可能发生变化。

算法稳定性的定义 – 假设在数列中存在a[i]=a[j],若在排序之前,a[i]在a[j]前面;并且排序之后,a[i]仍然在a[j]前面。则这个排序算法是稳定的!
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: