算法基础——排序算法

本人对浙大数据结构课程的学习过程笔记,非常口语化不专业。

排序算法

为便于示例,默认容器都为数组,元素都为整型,只考虑内部排序,即数据量足够全部放在内存上处理,不考虑外部排序。排序算法一般默认为从小到大。

稳定性:任意两个相等的数据,从始至终相对位置不变,小明1永远在小明2之前。通常如果交换条件严格符合大小关系,就满足稳定性。

冒泡排序 BubbleSort

每次遍历比较当前和下一位数据大小,满足条件则交换二者,这样每轮冒泡会将当前最大值移动到末尾,下轮冒泡只需遍历上一轮的遍历次数P–次。

在这里插入图片描述

flag用于标记某躺冒泡是否全程无交换,代表已排好序,提前结束。

最好情况一开始就是顺序的T=O(N)。最坏情况完全逆序T=O(N平方)。如果没有flag标记位提前结束,那全都是最坏情况。

冒泡排序的(唯一)优点就是如果数据是储存在单向链表里无需额外处理,因为本身的遍历和交换方式符合单向链表。

插入排序 InsertionSort

也叫直接插入排序,类似抓牌的过程,新抓的牌从后往前看,比它大的都往后移一位,直到遇到不大于它的,插在后面。P从1开始,因为把原本的第0张视为已经抓好了,即开始的A[0]就是A[0],不需要排序。

在这里插入图片描述

注意for循环的条件和for循环里的条件不等价,因为for循环的条件不满足会停止循环,而里面的条件不满足for循环还会继续。

最好情况也是摸牌(插入)的顺序就是从小到大摸的,每次直接放在最后即可,只需摸完所有牌,不需要移动牌,T=O(N)。最坏情况就是完全相反,T=O(N平方)。

插入排序的时间复杂度和冒泡是一个数量级,但有些许区别,就是交换过程插入比冒泡要简单点,也就是常数项的区别吧,2N和3N这样。

时间复杂度下界(逆序对)

如果下标i<j但元素A[i]>A[j],则称(i,j)是一对逆序对。显然想要让一个序列有序,至少要把所有逆序对消去。

冒泡排序和插入排序中的每次相邻两元素交换会消去一个逆序对,不影响其他逆序对。所以这两个算法的时间复杂度本质为遍历N+消去逆序对I,T=O(N+I)。故当逆序对只和N差不多数量级时,也就是说序列基本有序,则插入排序简单且高效。

当序列完全逆序时逆序对数量最大,即最坏情况为N(N-1)/2个,所以最坏情况是T=O(N平方)。

而一般情况的平均逆序对为N(N-1)/4个,故有定理:任何仅以交换相邻两元素的排序算法,其平均时间复杂度为Ω(N平方),Ω指下界,即最好情况也就是N平方了。

这也就意味着如果想要提高算法效率,就需要每次消去不止一个逆序对,再进一步就是每次交换相隔较远的两个元素。

希尔排序 ShellSort

就是按一定的间隔D取其子序列排序,并往后共做D次(也就是每轮分成D个D间隔的子序列并全部排序),然后让D递减地如此重复,直至间隔D为1,即最后是个直接插入排序。显然小间隔不会影响大间隔的排序结果。

在这里插入图片描述

也就是把插入排序的间隔1改成间隔D,并在外围循环增量序列,做多次间隔Dk的插入排序。

如何指定这个间隔的增量序列很关键,原始希尔的D是不断取N/2,但是这样间隔不互质,且每个都是之前的因子(有奇数的话会少一些),即小间隔排序的大量操作都在对之前大间隔的重复遍历,可能在最后一步直接插入法之前没有任何实质的交换操作,最坏情况时间复杂度T=Θ(N平方)。

有人提出更多的增量排序,如Hibbard增量序列在这里插入图片描述
最坏情况T= Θ(N的3/2次方)。

还有更好的Sedgewick增量序列

在这里插入图片描述

对这种不能简单表达的增量序列可以先根据输入序列的长度把增量序列写好放在数组h中,然后遍历作为D,结合范围循环for (int D : h)。

希尔排序在一开始就需要序列有随机访问的能力,故不适合用于链表数据排序,而其他很多排序虽然明面上需要,但实际是递归到最底层,或循环分成最小份。

选择排序 SelectionSort

每次找到未排序部分的最小元,放到有序部分的最后位置(也就成了有序部分的最大元了)

在这里插入图片描述

该算法外部是一个遍历,关键在于内部的查找最小元算法,简单的选择排序用for循环直接遍历,故时间复杂度T=Θ(N平方)。

堆排序 HeapSort

显然我们可以优化选择排序的查找最小元算法,(最小)堆排序应运而生。

初版堆排序

在这里插入图片描述

先用数组A构建一个最小堆,需要O(N),然后遍历每个元素O(N),里面套一个取最小堆O(logN),最后再把排好序的临时数组TmpA复制回数组A要O(N),即T=O(2N+NlogN)=O(NlogN),这个过程需要开辟临时数组TmpA,故需要额外空间O(N),差不多等于只能做平常一半容量的排序,而且最后复制操作的时间也是额外多出来的。

正版堆排序:

在这里插入图片描述

反向思维,先调整成一个最大堆,其中PercDown()下滤函数的A是要排序的数组,i是要下滤的子树根结点下标,N是数组A的元素数量,也是堆的size,而且观察这个for内条件可知根结点是从0开始的。

然后开始真正的堆排序:将当前最大值A[0]和当前堆的最后一位A[i]交换,然后将最大堆的size–,也就是把刚刚换过来的最大值移出最大堆(但其实还在数组A中,只是不在抽象出来的堆中了),接着只需做一次根结点的下滤就能重新调整成小一号的最大堆,这样循环N-1次也就完成了A的堆排序。

为什么这种方法会比初版算法好?因为初版是构建一个最小堆,虽然取的时候很直观,但每次取都会完全破坏最小堆的结构,导致重新调整堆很麻烦,而逆向思维构建一个最大堆,虽然取走最大值也要重新调整,但由于是最大堆,只需一次下滤操作,将换过来的最小值再下滤到最后面即可。而且逆向思维还能和前面那几个普通排序一样,利用到A本身这个数组空间,将其分为已排好序和未排好序前后两部分,省去了最后赋值的时间和在空间上的复杂度。

堆排序过程示例图:想象一下逐渐变空的部分其实是从后往前排好序的数组A在这里插入图片描述

堆排序里根结点并没有放哨兵,元素存放从根结点就开始了,因为用户不会在容器里空个位置放哨兵,故在计算结点下标时要注意。

平均时间复杂度为O(2NlogN-O(NloglogN),也就是O(NlogN),但略好一点点,个人宏观上理解是建堆O(N)+N-1次下滤操作O(NlogN),可能是细节上的问题。

但花里胡哨这么多导致虽然量级还是NlogN,但堆排序的常数相对其他算法较大,实际场景经常不如Sedgewick增量序列的希尔排序。

由于堆排序需要建堆,而建堆用数组很方便,用链表则比较麻烦,所以不建议对链表序列使用堆排序。

归并排序 MergeSort

有序子列的归并

将两个有序的子列合并成一个序列,类似之前的多项式加法,从前往后比较元素的大小,然后把小的放进去。这里默认两个子序列L和R都在序列A中,且相连,L在R前面,因为这一节是为了引出后续对无序序列进行递归调用的归并排序。

在这里插入图片描述

最后将临时数组TmpA倒过来复制回数组A,也可以提前记录起始L的位置。

显然归并merge的时间复杂度为O(N),N为两个子序列L和R的元素个数之和。

递归算法实现

看完上面的归并很容易想到对无序序列使用递归调用归并操作来实现归并排序。递归就是分而治之,将一个大的序列不断地一分为二去操作,1->2->4->8。虽然大的序列是无序的,但是不断地一分为二,到最后的一个个单元素就是有序的,最后再归并回来。

在这里插入图片描述

传入一个数组A和临时数组TmpA,以及起始和终止位置,计算出中心点center后开始左递归+右递归,把两个子序列排序完了再归并Merge。显然时间复杂度的递推式为T(N)=T(N/2)+T(N/2)+O(N),最后推出归并排序的平均和最坏复杂度都是O(NlogN),但需要O(N)的额外空间。

一般像这种直接需要2倍空间的排序算法,很少用做内排序,即对内存上的数据排序,而是用于外排序。

为了和其他排序方法一样统一函数接口:

在这里插入图片描述

注意,虽然TmpA只在每次循环最后的merge函数才用到,但实际上在每次循环中其实只是操作了数组中一小块,故直接在外部定义好即可,否则要在内部多次开辟空间,要么每次算完就自我销毁,要么就内存泄漏。

非递归算法实现

和递归思路相反,一开始就把整个序列中每个元素看作N个单独的序列(可省略),把每两个元素归并成一个序列,看作N/2个子序列,如此循环,(1–>)8->4->2->1。

为了节省空间,我们只需开辟一个临时数组TmpA,然后同时利用原数组A,每次在其中一个数组里操作,完成后调到另一个数组里继续操作,来回倒腾即可。

在这里插入图片描述

length从1开始,每次做完一对子列归并i往后挪两个length,也就是一对子列长度。这是一轮归并,8->4。

注意非递归算法里可能会出现做完倒数第二对子列归并后,剩下的子列长度可能不足以分出一对子列,如当前子列长度为4,最后余5,可以分成4+1,还是能做merge的,进if条件;但如果余3,那就进else直接存进后面处理,递归算法不会出现这个问题,因为递归是每次长序列/2,一定能分成2组,出现余1的情况就代表递归到底了。这里是Merge1,区别在于不要最后的A[i]=TmpA[i],而是直接把A的结果就存在TmpA中,即把数组参数1存进数组参数2,然后在封装时连续调用2次即可在最后存回原数组A中(也就是说运气差的话会多做一次存回A的操作)。

封装成统一函数接口

在这里插入图片描述

为了保证最后能放回原数组A中,每个循环里要做两次Merge_pass,每次之后都要让length翻倍,这样也是为了省空间,因为非递归是每次都要做完一整个归并,会出现一个完整的临时数组,而不像递归只是用一部分,如果不利用原数组空间来回倒的话,每轮归并都要开个空间存。

快速排序 QuickSort

算法概述

在实际情况下理论速度最快的算法,且没有使用额外的空间,但细节设置很多,如主元、子集划分、阈值,设置不好反而会很慢。

使用的是递归思想,每次从元素中选取一个数作为主元(Pivot),然后将比主元小的数放在左边,比主元大的数放在右边,两个子集是不包含主元的,主元将放在中间位置,显然要用递归从底往上做,这样本身就都是有序的了,好处就在于主元的位置一旦确定就是最终的正确位置,之后都不会动了,而从底往上做的过程每个元素都会被当成一次主元(假设最底分成了N/2组2个元素的序列,则每次往上归的过程都有当前还没当过主元的元素选出一半被当做主元:2选1,4选1,但是4中已经有2个被选过了)。

伪中伪代码:

在这里插入图片描述

主元选取

显然主元的选取很关键,理想情况下每次都是当前的中间值,这样两个子集就是经典的完美二分,显然递推式可知T=O(NlogN)。而最坏情况就是反之每次都是当前的最值,这样每次都有一个子集为空,就像一棵斜二叉树一样,复杂度直接拉到O(N平方),显然如果我们每次都是取首元素或尾元素,那么当传入的序列本身就是有序时就会造成这样的结果。

所以总的来说主元选取越随机、越中间越好,但是rand()函数本身就很耗时,常用的方法是k选1,如取头、中、尾三个数的中位数,以下为3选1示例:

在这里插入图片描述

传入数组A和首尾元素位置,计算出中间位置,这样先尽可能的随机,然后再取三者中位数,尽可能的中间。注意每次比较时还将元素移动了,这样最后三个位置的元素大小也就按小、中、大排序了。

而且最后还进行了一个小技巧,因为我们选出主元之后还要将其他的部分划分成大小两个子集,故我们把作为主元的中位数先移动到这段数组的最后,然后接下来处理时就可以直接处理前面N-1的数组,同时由于放在最左边的首元素已经是比主元小的数了,也可以省去处理,也就是之后直接处理Left+1到Right-2这段数组即可。

在这里插入图片描述

将Left+1和Right-2定义成2个指针i和j(广义上的,数组用下标即可),先比较左边小集合的i,如果小于主元则++右移继续比较,直到遇到不小于主元的元素就停下,然后开始比较右边大集合的j,也是反过来–直到遇到不大于主元的元素就停下,这样两边都停下了就做交换swap(A[i],A[j]),之后再继续比较左边,如此往复。直到某次两边都停下后,发现i>j,也就是i越过了j则结束,此时i指向了第一个大于主元的元素,将其与最后的主元交换位置。

注意,这里是不小于或不大于主元就停下,也就是如果相等也停下做交换,虽然当输入为元素一样的序列时会白白做一轮的交换,但是由于每步都停下来交换到大小集合,会使得两个子集大小一样,虽然不是最快的遍历一遍N,但也能达到NlogN的时间复杂度,反之如果相等就忽略继续跑,会导致左边的i一直跑到尾,这样右边就成了空集,反而复杂度成了N平方。

小规模数据处理

由于快速排序是用递归的方法,且虽然循环总次数的数量级不高,但每一步的操作不少,尤其是递归要不停的出入栈,规模越小常数项占比就大了,当N不到100时还不如插入排序。所以在实际使用时,递归的结束条件并不是当子集只有一个元素才归,而是当规模充分小时,直接调用如插入排序之类的简单排序,这里要定义一个Cutoff阈值,显然这一阈值的选取也会对效率有影响。

算法实现

在这里插入图片描述

最后统一函数接口:

在这里插入图片描述

表排序 TableSort

本质是一种间接排序,其他那些排序主要用于数组排序,或者一些比较简单的结构体,如果要排序的数据是一个很大的结构体,比如排序一堆电影,那么数据本身移动就会消耗大量的时间,故使用间接排序,即不移动数据本身,而是移动其指针,也就是重新绘制一个记录数据位置的映射“表”。

在这里插入图片描述

先按照原本的序列A定义一个table{0,1,2…,7},然后根据每个数据的key值(即用于排序的索引)对table进行排序,复杂度取决于对这个table排序的算法。

表排序的table还起到映射的作用,比如我想知道A[i]原本的元素在哪,可以利用表A[table[i]]快速找到,不必每次都要遍历一遍,可以用作一些程序的小技巧。

物理排序

在做完表排序之后,如果还是要求在物理空间上也进行移动,则可以利用定理:N个元素的排列由若干个独立的环组成。如下表,从0开始找A[table[i]],然后不断套A[table[x]],直至构成闭环使得A[table[A[table[i]]]=i,如A[table[A[table[0]]]=0,即A[0][1][3][5]构成了一个闭环,内部依次移动一步就能完成排序,不同的闭环排序互不影响,该例还有闭环A[2]和A[4][6][7]。

在这里插入图片描述

这样对每个回路进行一次排序,每次将一个元素取出,放入应该属于这个位置的元素,并将table[i]=i代表已正确归位,然后再把这个元素原本的位置放入属于它的元素,直到遇到table[i]=i说明该回路结束了,则将最开始取出的元素放入。

物理排序的时间复杂度为T=O(mN),m是每个元素复制的时间,别忘了物理排序还要建立在表排序的基础上,整体循环次数很多,主要针对大元素排序。

桶排序 BucketSort

前面所有算法排序逻辑都是基于比较key值大小,有定理说这样的排序算法总有一种最坏情况使得时间复杂度下界为O(NlogN)。

当规模N很大,但分类M很少,即有大量key值相同的数据,比如一个学校所有人的英语成绩只会在0-100,一本小说的字母排序只会在1-26。

本质就是用空间换时间。

在这里插入图片描述

我们构建一个M长度的指针数组,下标就代表key值顺序,数组每个元素是一个链表(桶)的头指针,用来装该key值下的所有数据,这样我们只要遍历一遍数据O(N),并插入(头插法)到每个数据对应的链表中,之后再遍历一遍桶,每次输出桶里的链表O(M+N),所以M越小,桶排序时间复杂度就越接近O(N)。桶排序的核心就是利用桶本身(M长的数组)是有序的这一信息。

很多时候任务要求不需要构建指针数组,一个普通数组就搞定了,比如统计字母出现次数,统计工龄。

在这里插入图片描述

统计工龄:

在这里插入图片描述

基数排序 RadixSort

桶排序的升级版。解决分类M远大于数量N的情况,显然桶排序优势不再。

个人理解,所谓基数排序就是将原本M类根据某种规则拆分成B的P次方类,

然后做P次B个类的桶排序,M=B^P或P=以B为底的logM。

例如有N=10个整数,每个整数在0-999之间,M=1000,我们将其拆成3次10类桶排序,即B=10,P=3。

次位优先指从个位往前,主位优先指从最高位往后。

我们以次位优先(Least Significant Dight)的方式为例,先将所有数按最后的个位桶排序得到一组链表,然后再按十位数桶排序,最后按百位数桶排序,每次桶排序的遍历顺序都是链表数组从小到大的顺序,排序后更新链表数组,最后按下标从小到大把链表串起来或者输出即可。

在这里插入图片描述

其实很好理解,3次桶排序等于分别按个十百位都排了一遍,而且是次位优先,依次决定了所有一位数的相对位置,所有两位数的相对位置和所有三位数的相对位置,个人测试这种数字大小主位优先可能不行,因为后面的桶排序会覆盖前面的,只有保证后面的桶排序分类优先级更高才行。

这样一般P作为次幂大概率就是常数了,所以如果每次桶排序的B足够小,那基数排序时间复杂度就能接近线性O(N)。

多关键字排序

基数排序每次桶排序的B可以代表不同的意义,具体看情况,比如扑克牌可以先按数字排序再按花色排序(比先按花色再按数字要快)。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值