每日学习录(数据结构—排序)下

本文详细介绍了堆排序、归并排序和快速排序的算法、复杂度分析,包括堆结构、堆排序过程、递归与非递归实现,以及快速排序中的枢轴优化、交换优化和小数组排序策略。着重讨论了排序算法的时间复杂度、空间复杂度以及性能优化方法。
摘要由CSDN通过智能技术生成

目录

9.6堆排序

9.6.1堆排序算法

9.6.2堆排序复杂度分析

9.7归并排序

9.7.1归并排序算法

9.7.2 归并排序复杂度分析

9.7.3非递归实现归并排序

9.8快速排序

9.8.1快速排序算法

9.8.2快速排序复杂度分析

9.8.3 快速排序优化

9.8.3.1 优化选取枢轴

9.8.3.2优化不必要的交换

9.8.3.3优化小数组时的排序方案

8.3.4 优化递归操作


9.6堆排序

前面我们讲了简单选择排序,它在待排序的n个记录中选择一个最小的记录需要比较n-1次,可惜这样的操作并没有把每一趟的比较结果保存下来,在后一趟的比较中,有许多比较在前一趟已经做过了,但由于前一趟排序时为保存这些比较结果,所以后一趟排序时又重复执行了这些比较操作,因而记录的比较次数较多。

如果可以做到每次在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就会非常高了。而堆排序(Heap Sort),就是对简单选择排序进行的一种改进,这种改进的效果是非常明显的。堆排序算法是Floyd和Williams在1964年共同发明的,同时,他们发明了“堆”这样的数据结构。

观察上图,我们发现它们都是完全二叉树,左图中根结点是所有元素中最大的,右图的根结点是所有元素中最小的,而且左图每个结点都比它的左右孩子要大,右图每个结点都比它的左右孩子要小。这就是我们要讲的堆结构。

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

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

如果将上图的大顶堆和小顶堆用层序遍历存入数组,则一定满足上面的关系表达,如下图。

我们现在将这个堆结构,其目的就是为了堆排序用的。

9.6.1堆排序算法

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

如下图,图①是一个大顶堆,90为最大值,将90与20(末尾元素)互换,如图②所示,此时90就成了整个堆序列的最后一个元素,将20经过调整,使得除90以外的结点继续满足大顶堆定义(所有结点都大于等于其孩子),见图③,然后再考虑将30与80互换······

大家应该有些明白堆排序的基本思想了,不过要实现它还需要解决两个问题:

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

要解释清楚它们,让我们来看代码:

从代码中可以看出,整个排序过程分为两个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呢?看下图我们发现,它们都是有孩子的结点,注意灰色结点的下标编号就是1、2、3、4。

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

现在我们来看关键的HeapAdjust(堆调整)函数是如何实现的。

9.6.2堆排序复杂度分析

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

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

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

所以总体来说,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。

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

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

9.7归并排序

先来看下图,相信从下图中你已经大概能猜到归并排序的意思了。

9.7.1归并排序算法

“归并”一词的中文含义就是合并、并入的意思,而在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。

归并排序(Merging Sort)就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2]个长度为2或1的有序子序列;再两两归并,······,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。

代码如下:

由于我们要讲解的归并排序实现需要用到递归调用,因此我们外封装了一个函数。假设现在要对数组{50,10,90,30,70,40,80,60,20}进行排序,L.length=9,下面来看MSort的实现。

整体流程图如下:

现在我们来看看Merge函数的代码实现。

9.7.2 归并排序复杂度分析

一趟归并需要将SR[1]~SR[n]中相邻的长度为h的有序序列进行两两归并,并将结果放到TR1[1]~TR1[n]中,这需要将待排序序列中的所有记录扫描一遍,因此耗费O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行[log2n]次,因此,总的时间复杂度为O(nlogn),而且这是归并排序算法中最好、最坏、平均的时间性能。

由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果及递归时深度为log2n的栈空间,因此空间复杂度为O(n+logn)。

另外,Merge函数中有if(SR[i]<SR[j])语句,这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。

总的来说,归并排序是一种比较占用内存,但效率高且稳定的算法。

9.7.3非递归实现归并排序

归并排序大量引用了递归,尽管在代码上比较清晰,容易理解,但这会造成时间和空间上的性能损耗。我们排序追求的就是效率,这里还可以对之前的算法进行改动,使性能进一步提高,代码如下。

从代码中我们可以看出,非递归的迭代做法更加直截了当,从最小的序列开始归并直至完成。下面我们来看MergePass代码。

非递归的迭代方法,避免了递归时深度为log2n的栈空间,空间只是用到申请归并临时用的TR数组,因此空间复杂度为O(n),并且也在时间性能上有一定的提升,应该说,使用归并排序时,尽量考虑非递归方法。

9.8快速排序

希尔排序相当于直接插入排序的升级,他们同属于插入排序类,堆排序相当于简单选择排序的升级,它们同属于选择排序类。而快速排序其实就是我们前面任务最慢的冒泡排序的升级,它们同属于交换排序类。即它也是通过不断比较和移动交换来实现排序的,只不过它的实现,增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面,从而减少了总的比较次数和移动交换次数。

9.8.1快速排序算法

快速排序(Quick Sort)的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

代码如下:

9.8.2快速排序复杂度分析

快速排序的时间性能取决于快速排序递归的深度,可以用递归树来描述递归算法的执行情况。如下图,它是{50,10,90,30,70,40,80,60,20}在快速排序过程中的递归过程。由于我们的第一个关键字是50,正好是待排序的序列中间值,因此递归树是平衡的,此时性能也比较好。

在最优情况下,Partition每次都划分得很均匀,如果排序n个关键字,其递归树的深度就为[log2n]+1,即仅需递归log2n次,需要时间为T(n)的话,第一次Partition应该是需要对整个数组扫描一遍,做n次比较。然后,获得的枢轴将数组一分为二,那么各自还需要T(n/2)的时间(注意是最好情况,所以是平方两半)。于是不断地划分下去,我们就有了下面的不等式推断。

也就是说,在最优的情况下,快速排序算法的时间复杂度为O(nlogn)。

在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。如果递归树画出来,它就是一棵斜树。此时需要执行n-1次递归调用,且第i次划分需要经过n-i次关键字的比较才能找到第i个记录,也就是枢轴的位置,因此比较次数为n-1+n-2+···+1=n(n-1)/2,最终其时间复杂度为O(m2)。

平均的情况,设枢轴的关键字应该在第k的位置(1≤k≤n),那么

由数学归纳法可证明,其数量级为O(nlogn)。

就空间复杂度来说,主要是递归造成的栈空间的使用,最好情况,递归树的深度为log2n,其空间复杂度也就为O(logn),最坏情况,需要进行n-1递归调用其空间复杂度为O(n),平均情况,空间复杂度也为O(logn)。

另外,由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。

9.8.3 快速排序优化

9.8.3.1 优化选取枢轴

之前我们用pivotkey=L->r[low]来取第一个枢轴,如果这个关键字太大或太小都会影响性能,所以总是固定选取第一个关键字作为首个枢轴就变成了极为不合理的做法。

我们可以通过三数取中法,即取三个关键字先进行排序,将中间树作为枢轴,一般是取左端、右端和中间三个数,也可以随机选取。这样至少这个中间数一定不会是最小或者最大的数,从概率来说,去三个数均为最小会最大树的可能性是微乎其微的,因此中间数位于较为中间的值的可能性就大大提高了。

在之前第4行和第5代码行之间增加这样一段代码:

三数取中对小数组来说有很大的概率选择到一个比较好的pivotkey,但是对于非常大的待排序的序列来说还是不足以保证能够选择出一个好的pivotkey,因此还有个办法是所谓的九树取中,它先从数组中分三次取样,每次取三个数,三个样品各取出中数,然后从这三个中数中再取出一个中数作为枢轴。

9.8.3.2优化不必要的交换

我们之前举的例子,仔细研究会发现,50这个关键字,其位置变化是1->9->3->6->5,可其实它的最终目标就是5,当中的交换其实是不需要的。因此我们对Partition函数的代码再进行优化。

9.8.3.3优化小数组时的排序方案

刚刚一直在讨论对于非常大的数组的解决办法,那么对于数组非常小的情况,其实快速排序反而不如直接插入排序来得更好。其原因在于快速排序用到了递归操作,在大量数据排序时,这点性能影响相对于它的整体算法优势而言是可以忽略的,但如果数组只有几个记录需要排序时,这就成了大炮打蚊子的问题了。因此我们需要改进一下QSort函数。

8.3.4 优化递归操作

大家知道,递归对性能是有一定影响的,QSort函数在其尾部有两次递归操作。如果待排序的序列划分极端不平衡,递归深度将趋近于n,而不是平衡时的log2n,这就不仅仅是速度快慢的问题了。栈的大小是有限的,每次递归调用都会消耗一定的栈空间,函数的参数越多,每次递归耗费的空间也越多。因此如果能够减少递归,将会大大提高性能。

于是我们对QSort实施尾递归优化,代码如下:

当我们将if改成while后,因为第一次递归以后,遍历low就没有用处了,所以可以将pivot+1赋值给low,再循环后,来一次Partition(L,low,high),其效果等同于“QSort(L,pivot+1,high)”。结果相同,但因采用迭代而不是递归的方法可以缩减堆栈深度,从而提高了整体性能。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值