堆排序
我们前面讲到简单选择排序,它在待排序的n个记录中选择一个最小的记录需要比较n-1次。本来这也可以理解,查找第一个数据需要比较这么多次是正常的,否则如何知道它是最小的记录。
可惜的是,这样的操作并没有把每一趟的比较结果保存下来,在后一趟的比较中,有许多比较在前一趟已经做过了,但由于前一趟排序时未保存这些比较结果,所以后一趟排序时又重复执行了这些比较操作,因而记录的比较次数较多。
如果可以做到每次在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就会非常高了。而堆排序(HeapSort),就是对简单选择排序进行的一种改进,这种改进的效果是非常明显的。堆排序算法是Floyd和Williams在1964年共同发明的,同时,他们发明了“堆”这样的数据结构。
回忆一下我们小时候,特别是男同学,基本都玩过叠罗汉的恶作剧。通常都是先把某个要整的人按倒在地,然后大家就一拥而上扑了上去……后果?后果当然就是一笑了之,一个恶作剧而已。不过在西班牙的加泰罗尼亚地区,他们将叠罗汉视为了正儿八经的民族体育活动。
叠罗汉运动是把人堆在一起,而我们这里要介绍的“堆”结构相当于把数字符号堆成一个塔型的结构。当然,这绝不是简单的堆砌。有一定的规律:它们都是完全二叉树。左图中根结点是所有元素中最大的,右图的根结点是所有元素中最小的。再细看看,发现左图每个结点都比它的左右孩子要大,右图每个结点都比它的左右孩子要小。这就是我们要讲的堆结构。
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
这里需要注意从堆的定义可知,根结点一定是堆中所有结点最大(小)者。较大(小)的结点靠近根结点(但也不绝对,比如右图小顶堆中60、40均小于70,但它们并没有70靠近根结点)。
如果按照层序遍历的方式给结点从1开始编号,则结点之间满足如下关系:
这里为什么i要小于等于呢?相信大家可能都忘记了二叉树的性质5,其实忘记也不奇怪,这个性质在我们讲完之后,就再也没有提到过它。可以说,这个性质仿佛就是在为堆准备的。性质5的第一条就说一棵完全二叉树,如果i=1,则结点i是二叉树的根,无双亲;如果i>1,则其双亲是结点。那么对于有n个结点的二叉树而言,它的i值自然就是小于等于了。性质5的第二、三条,也是在说明下标i与2i和2i+1的双亲子女关系。
堆结构,其目的就是为了堆排序用的。
堆排序算法
堆排序(Heap Sort)就是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次大值。如此反复执行,便能得到一个有序序列了。
相信大家有些明白堆排序的基本思想了,不过要实现它还需要解决两个问题: 1.如何由一个无序序列构建成一个堆? 2.如果在输出堆顶元素后,调整剩余元素成为一个新的堆?
要解释清楚它们,让我们来看代码。
/* 对顺序表L进行堆排序 */
void HeapSort(SqList *L)
{
int i;
/* 把L中的r构建成一个大顶堆 */
for (i = L->length / 2; i > 0; i--)
HeapAdjust(L, i, L->length);
for (i = L->length; i > 1; i--)
{
/* 将堆顶记录和当前未经排序子序列的最后一个记录交换 */
swap(L, 1, i);
/* 将L->r[1..i-1]重新调整为大顶堆 */
HeapAdjust(L, 1, i - 1);
}}
从代码中也可以看出,整个排序过程分为两个for循环。第一个循环要完成的就是将现在的待排序序列构建成一个大顶堆。第二个循环要完成的就是逐步将每个最大值的根结点与末尾元素交换,并且再调整其成为大顶堆。
假设我们要排序的序列是{50,10,90,30,70,40,80,60,20},那么L.length=9,第一个for循环,代码第4行,i是从=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(堆调整)函数是如何实现的。
/* 已知L->r[s..m]中记录的关键字除L->r[s]之外均满足堆的定义 */
/* 本函数调整L->r[s]的关键字,使L->r[s..m]成为一个大顶堆 */
void HeapAdjust(SqList *L,int s,int 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])
/* rc应插入在位置s上 */
break;
L->r[s] = L->r[j];
s = j;}
/* 插入 */
L->r[s] = temp;
}
这段代码是实现堆排序中的堆调整过程。函数接收一个顺序表L,以及两个参数s和m,表示调整范围为L中从位置s到位置m的元素。
首先,函数将位置s的元素保存在临时变量temp中。然后,通过一个循环进行调整操作,循环条件是j <= m,其中j是当前操作的元素的位置。循环内部有两个if语句,分别用来确定关键字较大的孩子结点的位置j。
在第一个if语句中,如果j < m并且L->r[j] < L->r[j+1],则j增加1,表示右孩子的位置。这是因为要找到关键字较大的孩子结点。
在第二个if语句中,如果temp大于等于L->r[j],则说明temp应该插入到位置s上,此时调整结束,跳出循环。
如果以上两个if条件都不满足,说明temp比L->r[j]小,那么将L->r[j]赋值给L->r[s],同时更新s的值为j,表示继续向下调整。
最后,将保存在temp中的值插入到位置s上,完成本次调整。
整个函数的作用是将L->r[s..m]这部分元素调整为一个大顶堆,保证L->r[s]是关键字最大的元素。
到此为止,我们构建大顶堆的过程算是完成了,也就是HeapSort函数的第4~5行循环执行完毕。或许是有点复杂,如果不明白,多试着模拟计算机执行的方式走几遍,应该就可以理解其原理。
接下来HeapSort函数的第6~11行就是正式的排序过程,由于有了前面的充分准备,其实这个排序就比较轻松了。下面是这部分代码。
for (i = L->length; i > 1; i--)
{
/* 将堆顶记录和当前未经排序子序列的最后一个记录交换 */
swap(L,1,i);
/* 将L->r[1..i-1]重新调整为大顶堆 */
HeapAdjust(L,1,i-1);
}
这段代码是堆排序算法中的排序过程。它使用了一个循环,从序列的末尾开始,每次将堆顶记录(即关键字最大的元素)与当前未经排序子序列的最后一个记录交换位置,并对前面的部分重新调整为大顶堆。
循环的初始条件是i的初始值等于序列的长度L->length,循环条件是i大于1,表示还有未排序的元素。
在循环内部,首先调用了一个swap函数,将堆顶记录L->r[1]和当前未经排序子序列的最后一个记录L->r[i]进行交换位置。这样,堆顶元素就会逐步被放到最终的位置上。
然后,调用HeapAdjust函数,将序列的前部分L->r[1..i-1]重新调整为大顶堆。这样,堆调整的过程又保证了关键字最大的元素位于堆顶。
循环结束后,整个序列就被排序完成了。
综上所述,这段代码实现了堆排序算法的排序过程,通过不断地将堆顶元素与未排序子序列的最后一个元素交换位置,并对前部分重新调整为大顶堆,最终实现了对整个序列的排序。
堆排序复杂度分析
堆排序的效率到底有多高,我们来分析一下。
它的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。
在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)。
在正式排序时,第i次取堆顶记录重建堆需要用O(logi)的时间(完全二叉树的某个结点到根结点的距离为),并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)。
所以总体来说,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、直接插入的O(n2)的时间复杂度了。
空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。
另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。