堆排序——详细教学

1.堆的介绍

我们前面讲到了简单选择排序,它在待排序的n个记录中选择一个最小的记录需要比较n-1次。本来也可以理解,查找第一个数据需要比较那么多次是很正常的,否则如何知道它是最小的记录?

可惜的是,这样的操作并没有把每一趟的比较结果保存下来,在后一趟的比较中,有许多比较在前一趟已经做过了,但由于前一趟排序未保存这些比较结果,所以后一趟排序时又重复执行了这些比较操作,因而记录的比较次数较多。

如果可以做到每次在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就非常高了。而堆排序(Heap Sort),就是对简单选择排序的一种改进,这种改进的效果是非常明显的。

叠罗汉我们小时候是恶作剧,但是呢在西班牙的加泰罗尼亚地区,他们将叠罗汉视为正儿八经的民族体育运动,场面极其壮观。

我们这里要介绍的“堆”就类似于这种塔型结构,当然不是这么简单的。我们先看两个图:

 很明显,我们可以发现他们都是二叉树,如果观察仔细一些,还能看出他们都是完全二叉树。如果观察仔细一些,还能看出他们都是完全二叉树。左图中根节点是所有元素的最大值,右图的根节点是所有元素中最小的。再仔细看看,左图中的每个节点都比左右孩子大,右图的每个节点都比左右孩子小。这就是我们要的堆结点。

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

这里需要注意从堆的定义可知,根节点一定是堆中所有结点最大(小)者。较大(小)的结点靠近根节点(但也不绝对,右图中60,40,均小于70,但他们并没有70靠近根结点)

如果按照层序遍历的方式给结点从一开始编号,则结点之间满足以下关系。

\left\{\begin{matrix}K_{i}\geq K_{2i} & & \\ K_{i}\geq K_{2i+1} & & \end{matrix}\right.        或者  \left\{\begin{matrix} K_{i}\leq K_{2i} & & \\ K_{i}\leq K_{2i+1} & & \end{matrix}\right.  i∈[1,n/2]

这里为什么要小于等于⌊n/2⌋?二叉树有一个性质,好像专门为这个准备的。一颗完全二叉树,如果i = 1,则结点i是二叉树的根,无双亲;如果 i > 1,则双亲是 ⌊i / 2⌋.那么对于 n 个结点的二叉树而言,它的 i 值自然就是小于等于⌊n / 2⌋.

如果将上面两个图的大顶堆和小顶堆用层次遍历存入数组,则一定满足上面的关系表达式,如图:

 我们现在这个对结构就是用来排序用的。

2.堆排序算法

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

 例如,下面左图是一个大顶堆,90为最大值,将90和20(末尾元素)互换,如图右边,此时90构成了整个堆序列的最后一个元素,将20经过调整,使得除了90以外的结点继续满足大顶堆定义。

 新构造的图:

 相信大家已经基本明白思想了,不过要实现还需要解决两个问题:

  1. 如何有一个无序序列构建一个堆?
  2. 如何在输出堆顶元素后,调整剩余元素成为一个新的堆?

我们先来看代码:

	 void HeapSort(SqList *L) 
{
		int i;
		for( i=L->length/2;i>0;i--) // 把L中的r构建成一个大顶堆
			HeapAdjust(L,i,L->length);		
		for (i=L->length;i>1;i--) 
		{
			swap(L,1,i); // 将堆顶记录和当前未经排序的子序列的最后一个记录交换
			HeapAdjust(L,1,i-1); // 将L->r[1..i-1]重新调整为大顶堆
		}
	}

从代码中也可以看出,整个排序过程分为两个 for 循环。第一个 for 循环要完成的就是将现在待排序序列构建成一个大顶堆。第二个 for 循环要完成的就是逐步将每个最大值的根结点与末尾元素交换,并且再次调整为大顶堆。

假设我们要排序的序列为{50,10,90,30,70,40,80,60,20},那么 L.length = 9,第一个 for 循环,代码第 4 行,i 是从⌊9/2⌋ = 4 开始,4 -> 3 -> 2 -> 1 的量变化。为什么不是从 1 到 9 或者从 9 到 1 开始变化,而是从 4 到 1 呢?其实我们看了下面这张图就明白了,它们都有什么规律呢?它们都是有孩子的结点。注意灰色结点的下标编号就是 1、2、3、4。

我们所谓的将待排序的序列构建成一个大顶堆,其实就是从下往上,从右到左,将每一个非叶子结点当做根节点,将其和其子树调整成大顶堆。i 的值从 4 -> 3 -> 2 -> 1的变量变化,其实也就是30,90,10,50 的结点调整过程。

既然已经弄清楚了 i 的变化是在调整那些元素了,现在我们看来关键的 HeapAdjust(堆调整) 函数是如何实现的。

	void HeapAdjust(SqList *L,int s,int m) // 已知 L->r[s..m]中记录的关键字除 L->[s] 之外均满足堆的定义。
	{									   // 本函数调整L->r[s] 的关键字,使 L->r[s..m] 成为一个大顶堆
		int temp,j;
		temp = L->r[s];		
		for( j = 2*s; j <= m; j*=2)		// 沿关键字较大的孩子结点向下筛选
		{
			if( j < m && L->r[j] < L->r[j+1])
				++j;		// j 为关键字中较大记录的下标
			if (temp >= L->r[j]) 
				break;		// rc应插入在位置s上
			L->r[s] = L->[j];
			s = j;
		}
		L->r[s] = temp;		// 插入
	}

1.函数被第一次调用时, s = 4,m = 9,传入的 SqList 参数的值为 length = 9,r[10] = {0,50,10,90,30,70,40,80,60,20}。

2.第 4 行,将 L.r[s] = L.r[4] = 30 赋值给 temp,如图:

 3.第 5-13行,循环遍历其结点的孩子。这里 j 变量为什么要从 2*s 开始呢?又是为什么 j*=2 递增呢?原因还是二叉树的性质5,因为我们这棵二叉树,当前结点序号是 s ,其左孩子的序号一定是 2s ,右孩子一定是 2s + 1 ,它们的孩子当然也是以2的倍数增加,因此 j 变量才是这样循环。

4.第7-8行,此时 j = 2*4 = 8,j < m 说明他不是最后一个结点,如果 L.r[j] < L.r[j+1],则说明了左孩子小于了右孩子。我们的目的要找的较大值,当然需要让 j+1 以便变成指向右孩子的下标。当前30的孩子是60,和20,并不满足此条件,因此 j 还是 8 。

5.第9-10行,temp = 30,L.r[j] = 60,并不满足条件。

6.第 11-12行,将 60 赋值给L.r[4],并令 s=j=8。也就是说,当前计算出,以30为根结点的子二叉树,当前最大值是 60,在第 8 的位置。注意此时 L.r[4] 和 L.r[8] 的值均为60.

7.再循环因为 j=2*s=16,m=9,j>m,因此跳出循环。

8.第 14 行,将 temp = 30赋值给 L.r[s] = L.r[8],完成 30 与 60 的交换工作。如图,本次函数调用完成。

 

 9.再次调用 HeapAdjust,此时 s=3,m=9。第 4 行,temp = L.r[3] = 90,第 7-8 行,由于 40 < 80 得到 j+1 = 2*s +1 = 7。第 9-10 行,由于 90 > 80,退出循环,结束本次调用,整个序列并未发生什么变化。

10.再次调用 HeapAdjust,此时 s = 2,m = 9 。第  4 行,temp = L.r[2] = 10,第 7-8行,60 < 70,使得 j=5。最终本次调用使得 10 和 70 的位置进行了交换,如图:

 结果图:

11.再次调用 HeapAdjust,此时 s=1,m = 9.第 4 行,temp = L.r[1] = 50 ,第 7-8 行,70 < 90,使得 j=3.第 11-12 行,L.r[1]被赋值了90,并且 s = 3,在循环,由于 2j = 6并未大于m,因此再次执行循环体,使得L.r[3]被赋值了80,完成循环后,L.r[7]被赋值为50,最终调用使得 50、90、80 进行了轮换,如图所示:

 结果图:

 

 到此为止,我们构建大顶堆的过程算是完成了,也就是 HeapSort 函数的第 4-5 循环执行完毕。或许是有点复杂,如果不明白,就转专业吧,这个专业不适合你!(假的啦,书山有路勤为径,学海无涯苦作舟。把自己变成电脑,多运行几遍,原理是可以理解的)

接下来 HeapSort 函数的第 6-11 行就是正式的排序过程,由于有了前面的充分准备,其实这个排序就比较轻松了。下面是这部分代码。

	6.	for (i=L->length;i>1;i--) 
	7.	{
	8.		swap(L,1,i); // 将堆顶记录和当前未经排序的子序列的最后一个记录交换
	9.		HeapAdjust(L,1,i-1); // 将L->r[1..i-1]重新调整为大顶堆
	10.	}

1.当 i = 9 时,第 8 行,交换 20 与 90,第 9 行,将当前的根节点20进行大顶堆的调整,调整过程和刚才的流程一样,找到它左右孩子结点的较大值,互换,再找到其子结点的较大值互换。此时序列变为{80,70,50,60,10,40,20,30,90},如图:

 2.当 i = 8 时,交换 30 与 80,并将 30 与 70 交换,再与 60 交换,此时序列变为{70,60,50,30,10,40,20,80,90},如图。

3.后面的变化完全类似,不解释了啊!

3.堆排序复杂度分析

堆排序的如此复杂,它效率如何呢?

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

在构建堆的过程中,因为我们主要是完全二叉树从最下层最右边的叶子结点开始构造,将它与孩子结点进行比较和若有必要的互换,对于每个叶子结点来说,其实最多进行两次比较和互换的操作,因此整个构建的复杂度为O(n).

在正式排序时,第 i 次取堆顶记录重建堆需要用 O(logi)的时间(完全二叉树的某个结点到根结点的距离为log_{2}n )+ 1),并且需要 n-1 次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)。

所以总体来说,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为 O(nlogn)。这在性能上远好于冒泡,简单选择,直接插入的O(n^2) 的时间复杂度了。

空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。

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

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值