数据结构--各种排序方法介绍及总结

排序

定义:排序就是将原本无序的序列重新排列成有序的序列。

排序的稳定性:如果待排序表中有两个元素 R i R_i Ri R j R_j Rj,其对应的关键字是 k e y i = k e y j key_i=key_j keyi=keyj,且排序前 R i R_i Ri R j R_j Rj的前面,如果使用某一个排序算法之后, R i R_i Ri仍然在 R j R_j Rj的前面,那么就称这个排序算法是稳定的,否则就是不稳定的。排序的稳定性和性能是没有关系的。

直接插入排序

方法 :首先以一个元素作为有序序列,然后把后面的元素依次插入到有序的序列中的合适的位置上,直到所有的元素都插入有序序列中。实际上,直接插入排序算法就是每次一边比较大小一边移动数组元素,直到找到插入的位置。

性能分析空间复杂度 :假设采用在数组下标为0的位置处设置一个哨兵,也就是说需要常数个辅助空间的大小,因此空间复杂度就是 O ( 1 ) O(1) O(1). 时间复杂度 :最坏的情况下就是序列是逆序的。比如 54321这样一个序列,4插入到5的前面需要移动一次,3插入到4的前面需要移动2次,依次类推,其完成总的移动次数是1+2+3+…+(n-1)=n(n-1)/2. 因此最坏的情况下,其对应的时间复杂度是 O ( n 2 ) O(n^2) O(n2). 最好的情况就是一个顺序序列12345,此时不需要进行移动操作,只需要进行比较,因此其对应的事件复杂度是 O ( n ) O(n) O(n). 另外,直接插入排序这种方法是稳定的。

插入类排序

定义 :在一个 有序序列 中,插入一个新的关键字,直到所有的关键字都插入形成一个有序的序列。

折半插入类排序

方法 :折半插入类排序与直接插入排序的区别在于,折半插入排序不是一边比较一边移动元素,而是先通过折半查找找到要插入的位置,然后一次性移动所有元素,从而找到插入位置。

性能分析 :在确定要插入的位置时,由于采用的是折半查找,所以其对应的时间复杂度 应该为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n),因为每个待插入元素的查找的时间复杂度都是 O ( l o g 2 n ) O(log_2n) O(log2n)。但是在移动关键字这方面,其做法和直接插入排序是一样的,因此时间复杂度还是 O ( n 2 ) O(n^2) O(n2). 这种排序方式是稳定的。、

希尔排序(缩小增量排序)

基本思想 :希尔排序本质上还是插入排序,无非就是把待排序序列分成几个子序列 (按照一定增量,一组元素中下标的差值,比如2 4 6 8一组,3 5 7 9一组),然后分别对这几个子序列进行直接插入排序。

方法 :①先以一个增量比如5来分割序列,那么下标为0 5 10 15的关键字就是一组,下标为1 6 11 16又是一组,然后对这些组分别进行直接插入排序,这样就完成一轮希尔排序。②然后缩小增量,(一般的,第一次的增量是 d 1 = n / 2 d_1=n/2 d1=n/2, d i + 1 = n / 2 d_{i+1}=n/2 di+1=n/2(向下取整)),比如对于10个数据序列来说,第一次的数据增量就是5,第二次的数据增量就是2,并且最后一个数据增量是1.③重复进行上述过程,直到最后一轮以增量为1进行排序。

优势 :希尔排序每一轮都会让序列更加有序,到最后一轮增量为1的时候,整个序列基本上就是有序的了,因此只需要进行比较即可,此时直接插入排序会提高排序的效率。

性能分析 :希尔排序的事件复杂度最小可到约为 O ( n 1.3 ) O(n^{1.3}) O(n1.3),最坏的情况下其时间复杂度 O ( n 2 ) O(n^2) O(n2)。空间复杂度 O ( 1 ) O(1) O(1). 希尔排序是不稳定的。

交换类排序

根据序列中两个元素关键字的比较结果来交换其在序列中的位置。

冒泡排序

方法 :从后往前或从前往后两两比较相邻的两个元素值,如果逆序那么就交换这两个元素的值,直到序列比较完,叫做一趟冒泡排序。结果将最小的元素交换到待排序序列的第一个位置。下一趟冒泡排序时,之前确定好的最小元素不再参与比较,待排序序列就会减少一个元素。

性能分析空间复杂度 :在交换时需要开辟额外的空间存储中间变量,因此空间复杂度就是 O ( 1 ) O(1) O(1). 时间复杂度 :冒泡排序在做的一个基本操作就是交换数据,最坏情况下,初始序列是逆序的,对于外层的每一次循环,内层循环始终是成立的,外层循环是从0到n-1,第i次内层循环执行次数是n-1-i,因此 ∑ i = 0 n − 1 n − 1 − i = n ( n − 1 ) / 2 \sum_{i=0}^{n-1}n-1-i=n(n-1)/2 i=0n1n1i=n(n1)/2,最坏情况下其对应的时间复杂度为 O ( n 2 ) O(n^2) O(n2). 最好情况下就是序列是顺序的,内层循环始终不成立,因此外层执行n-1次就结束,所以对应的时间复杂度就是 O ( n ) O(n) O(n)。冒泡排序是稳定的,因为只有当两个关键字出现逆序的情况下才会进行交换,如果是相等的话是不会交换的,因此是稳定的。

快速排序

快速排序是一种基于分治法的排序方法。

方法 :每一次快排都会选择序列中任意一个元素作为枢轴(pivot),(通常选择第一个元素),然后将序列中比枢轴小的元素都放在枢轴的前边,比它大的元素都放在其后边。

性能分析时间复杂度 :最好情况下的时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn),待排序列越是无序,算法效率越高;最坏情况下的时间复杂度是 O ( n 2 ) O(n^2) O(n2),待排序列越是有序,算法效率越低。空间复杂度 :由于快排是递归的,因此需要借助一个递归工作栈来保存每一层递归调用的必要信息,其容量与递归调用的最大深度保持一致。最好的情况下就是每次进行快排时枢轴把数据分的比较均匀,递归树的深度就是 O ( l o g n ) O(logn) O(logn),最坏的情况就是,由于要进行n-1次的递归调用,所以栈的深度 O ( n ) O(n) O(n)。快速排序是不稳定的,因为存在交换关键字的情况。

选择类排序
简单选择排序

方法 :每一趟都从待排序序列中选择一个关键字最小的元素,从而形成一个有序序列。

性能分析空间复杂度 :在交换元素时会开辟一个额外的空间,因此空间复杂度就是 O ( 1 ) O(1) O(1)时间复杂度 :简单选择排序的关键操作在于元素的交换。算法是双重循环,外层循环是0到n-2,(因为最后一个元素不需要排序),内层循环每次执行(n-1)-(i+1)+1=n-i-1. 然后对外层循环的内层循环次数进行求和。即可得到算法的时间复杂度是 O ( n 2 ) O(n^2) O(n2). 简单选择排序是不稳定的,因为存在关键字交换的操作。

堆排序

堆是一颗完全二叉树 ,而且满足任何一个非叶结点的值都不大于或不小于左右孩子结点的值。 如果每个结点的值都不小于它左右孩子结点的值,那么就叫做大顶堆;如果每个结点的值都不大于它左右孩子结点的值,那么就叫做小顶堆。

方法 :对于一个堆而言,其根结点是整个堆的最大值或者最小值,因此堆排序的思想就是每次将一个无序序列调整成一个堆,然后从堆中在选择堆顶元素的值。这个值加入到有序序列当中去,那么无序序列就会减少一个,通过反复调节无序序列,直到所有的关键字都加入到有序序列当中去。

比如对于12 52 19 45 23 45 92这一原始序列,①首先对初始序列的完全二叉树调整成一个大顶堆。调整方式:二叉树由下到上,由右到左 进行调整,检查每个结点是否满足大顶堆的要求。对叶子结点不需要进行调整。② 一个大顶堆建好之后,此时堆顶元素就是数组中关键字最大的,此时可以把堆顶节点和最后一个结点(完全二叉树的右下角)进行交换,也就是将最大值移动到数组的末端,此时一趟堆排序完成。③此时最后一个结点不参与下一次调堆的过程,针对当前数组所建立的二叉树进行调堆,然后再得到下一个最大的关键字。依次类推。

实现堆排序的过程中会用到二叉树的部分相关性质。比如:假设对完全二叉树从上到下,从左到右依次顺序编号1,2,3,,,就会存在一些性质,当i>1时,结点i的双亲结点的编号就是i/2向下取整,其双亲结点编号是i/2,它是双亲结点的左孩子;i如果是奇数的话,那么双亲结点的编号就是(i-1)/2,它是双亲结点的右孩子。当2i≤N时,结点i的左孩子的编号就是2i,否则无左孩子,当2i+1≤N时,结点i的右孩子的编号就是2i+1,否则无右孩子。

性能分析空间复杂度 :堆排序过程中在交换结点时需要额外的存储空间来存储数据,所以其空间复杂的都就是O(1). 时间复杂度 :建堆部分的时间复杂度是O(n),调堆部分,调堆是从上到下,最坏情况就是走的路径是从根节点到叶子节点,完全二叉树的高度是 l o g 2 ( n + 1 ) log_2(n+1) log2(n+1)向上取整,所以调整每个堆顶的时间复杂度就是O(logn),因此所有结点得到时间复杂度就是O(nlogn). 所以堆排序的总的时间复杂度是O(nlogn)。堆排序是不稳定的。

归并排序

基本思想 :假设待排序表中含有n个记录,那么就可以先看作是n个有序的子表,每个子表的长度为1,然后再两两归并,这样就会得到n/2(向上取整)个长度为2或1的有序表,然后再两两归并,如此重复,直到合并成一个长度为n的有序表为止。这种方法叫做2路归并排序法。

性能分析时间复杂度 :总共执行了 k = l o g 2 n k=log_2n k=log2n次排序,每次排序的时间复杂度是O(n),所以整个归并排序的时间复杂度就是O( n l o g 2 n nlog_2n nlog2n)。空间复杂度 :由于需要将待排序序列转存到一个数组中,因此需要额外开辟一个大小为n的存储空间,因此空间复杂度就是O(n). 归并排序是稳定的。

基数排序(桶排序)

基本思想 :不基于比较进行排序,而是采用多关键字排序的思想(也就是基于关键字各位的大小进行排序),然后借助分配和收集的操作对单逻辑关键字进行排序。基数排序又分为最高位优先(MSD) 排序和最低位优先(LSD) 排序。桶实际上就是一个队列。

举个例子: 53,5,542,748,14,214

首先是补充位数变为053,005,542,748,014,214

我们以LSD为例,也就是先以关键字的低位进行排序。对于上述这样一个例子,首先看个位数,分别将053放到桶3(含义就是存放关键位数为3的一个队列)中,其他类似,将014,214都放在桶4中,其中014在队列的头。这是第一次分配。接着进行第一次收集,也就是将不同队列中的数据依次进行出队,得到的一个新的序列就是542,053,014,214,005,748.此时可以看到关键字已经按照个位数进行排序了。接着就是针对十位数再次进行上述操作。因为我们要排序的是三位数,所以实际上经过三次分配和收集的过程我们就得到了一个有序序列。

性能分析 :关键字的数量是n,位数是d。r是关键字基的个数。(比如关键字是由十进制数字组成的,因此其基的个数就是10) 空间复杂度 :O® 时间复杂度 :需要对关键字位数进行d次分配和收集,一次分配需要将n个关键字放到各个队列中,一次收集需要将r个桶都遍历一次,因此一次分配和收集的时间复杂度就是O(n+r),因为要进行d次,所以总的时间复杂度就是O(d(n+r)). 基数排序是使用队列来实现的,先进先出,因此其是一个稳定的排序算法。

外部排序

之前所学习过的一些排序方法,都是建立在计算机的内存上进行的。但是由于计算机内存大小的限制,之前我们所提到的种种排序方法仅仅局限于数据量不大的情况,因此叫做内部排序。

但是很多时候我们需要对大文件进行排序,文件中的信息量是很大的,因此我们无法把整个文件都拷贝到内存中进行排序。 解决办法 :就是将待排序的记录先存储到外存上,在排序时再一部分一部分的调入内存进行排序。因此在排序的过程中这就涉及到外存和内存之间的交换,对外存文件中的记录排序后的结果依然存放到外存当中去。这种排序方法就叫做外部排序

一般常用的就是多路归并算法。以三路归并算法为例。假设我们现在得到了三路已经排序好的归并段,接下来我们选择三路归并段中的每一个段的首关键字,然后将这三个关键字放入到内存中进行排序,找到最小的一个关键字,然后将其再放入到外存中,此时必然有一个归并段的关键字个数减少一个,接下来就由这个归并段的后续的一个数字进行填补。然后再将现在的三个关键字放入到内存中进行排序。如此往复的进行这个过程。

那么现在又有一个问题。这些文件中的记录本来就是无序的,我们如何得到一个有序的归并段?可以分批的将数据调入内存中进行排序,排序之后再写入外存中,这就形成了一个归并段。(但是这种操作并不好,因为读写内外存涉及到两次IO操作,进行IO操作的时间是比较长的)所以,如果初始归并段太短的话,那么就会增加内外存读写操作的次数,从而增大了时间开销;因此我们希望归并段越长越好,这样就可以减少次数,但是由于内存大小是有限的,可能我们所需要的归并段的长度比内存空间还要大。

上面的方法是存在一定的问题,我们如何在有限内存空间的限制下,得到一个较长的归并段呢?这就引出一个算法 置换-选择排序 算法。

举个例子:初始序列是17,21,05,44,10,12,56,32,29,假设现在内存大小为3. 我们先把初始序列的前三个数字放入到内存中,也就是17,21,05.那么现在剩余序列就是44,10,12,56,32,29。此时先对内存中的三个数据进行排序,5最小,把5放入到有序归并段中,此时内存少了一个数据,那么就从剩下的序列中再找一个数字44放入内存进行排序,此时内存中数据最小的是17,然后把17放入到有序归并段中,此时再把10放入到内存中,因为此时归并段中的最大数字是17,因此此时应该从内存中选择一个大于17的最小的关键字,也就是21.按照这个思路继续进行关键字的选择,直到内存中找不出一个大于有序归并段的最大关键字的数字。此时就形成了一个归并段,然后按照这个方法继续形成第二个,第三个。。。归并段。通过这种置换-选择排序算法,在利用较少的内存的前提下可以得到一个较大的归并段。

由于我们得到的归并段的长短不一,因此这就又涉及到一个问题,就是如何对长短不一的归并段进行归并,从而尽可能的减少IO操作次数呢? 不同的归并策略会导致归并次数不同,归并次数不同就会导致不同次数I/O操作。因此我们需要找到一种归并次数最少的归并策略

最佳归并树 :其思想就是借助哈夫曼树的思想。

现在假设由置换-选择算法得到了9个初始归并段,这9个初始归并段的长度不一,现在我们的目的是设计一个策略,使得后续进行归并的时候尽可能的减少IO操作。因此我们可以把每个归并段的长度作为某个结点的权值,那么最佳归并树就是一棵N路的哈夫曼树。

回顾之前所讲解的构造哈夫曼树的过程 ,因此如果构造n路的哈夫曼树,借鉴构建二路哈夫曼树的过程,以3路平衡归并为例,此时我们每次从序列中选择三个结点权重最小的关键字组成一个子树。最终我们要计算I/O的操作次数就是在计算这个N路哈夫曼树的WPL,只不过需要再乘以2。因为每个关键字进行一轮I/O操作实际上是进行了两次I/O操作,也就是外存数据读入内存中进行排序,内存中排序好的数据再写入外存中。

败者树

最佳归并树可以得到一个最佳归并策略,但是对于多路归并来说,每次归并如何得到最值的关键字。 当归并路数比较大的时候,如果每次都是通过比较(这个比较就是每次要从序列中找出n个最小的关键字)进行排序那么就会带来较大的开销。

败者树可以看作是一棵完全二叉树,这棵树的叶子结点存储的各个归并段当前参加比较的记录,内部结点存储的则是左右子树中的失败者,胜利者会一直继续向上比较直到根结点 。 胜利者和失败者的定义是什么呢?每次归并时我们自然是希望能够找到各个归并段中最小的关键字,所以胜利者应该是左右子树中关键字较小的。

败者树的顶端就是最后的胜者,也就是当前归并记录中关键字最小的值。

利用败者树,m路归并每次查找最小关键字,最多需要比较 l o g 2 m log_2m log2m(向上取整)次。

总结归纳:外部排序是为了解决内存容量无法容纳大量数据排序的情况。因此可以采用多路归并排序的方法。那么如何获得初始的归并段呢?使用置换-选择排序方法,解决了排序段放入内存的问题。那么如何减少多个归并段的归并次数呢?采用最佳归并树的方法来减少总的归并次数,这样就可以减少io操作次数。如何在每次进行m路归并时,并快速得到最小的关键字。采用败者树的方式来减少每次归并过程中的比较次数。

  • 5
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值