高级排序算法笔记
本章简介
本章主要介绍了两种算法复杂度为O(nlogn)的排序算法:归并排序和快速排序
其中归并排序介绍了自顶向下与自底向上两种;
快速排序介绍了普通快速排序,随机化快速排序,双路快速排序,三路快速排序;
优化方面均对不同特色的数组进行了优化的探讨
最后以两个衍生问题:逆序对问题与取数组第n大元素问题做结
归并排序 Merge Sort
我们说的归并排序经常是指自顶向下的归并排序,思路如下:
1.将数组逐层对半分,直到分到每个分区内只有一个元素
2.对每层元素进行排序,第一层完全有序直接升到第二层,第二层是两个元素排序,第三层是四个元素排序,以此类推
3.当上升到最顶层时,排序结束
实现分析:
八个元素,可以分三层,即log₂(8) = 3;
九个元素,可以分四层,即log₂(9)的上取整 = 4;
十六个元素,可以分四层,即log₂(16) = 4;
以此类推,N个元素可以分log₂(N)的上取整层
只要在归并的过程中能设计出O(n)级别的归并,即可生成O(nlogn)级别的算法
所以现在的问题变成,如何在把归并的过程设计为O(n)级别的算法
归并(Merge)过程:
需要开辟新的存储空间,牺牲空间换取时间
1.将原来要归并的数组复制一份
2.设置三个索引,A索引指向原来数组的第一个元素,B索引指向复制数组切分左侧的第一个元素,C索引指向复制数组切分右侧的第一个元素
3.比较B与C指向元素的大小,将小的放在A处,放好后对应索引向后移位
4.A索引向后移位
5.重复3-4直到归并结束
注意:B和C指向正在考虑的元素,A指向考虑后放入的位置
现在统一将l(Left)定义为最左边的元素,m(Middle)定义为中间左侧的元素,r(Right)定义为最右边的元素,即为闭区间
mergeSort函数如下:
用"__"开头的函数表示私有,不供用户调用的意思
__mergeSort函数如下:
注意其中的 ">="的使用,时刻注意是闭区间
__merge函数如下:
aux为复制的临时空间
注意判断的时候先判断索引的合法性,当索引全部合法的时候才开始进行对比
main函数如下:
对比插入排序[O(n^2)]与归并排序[O(nlogn)]的时间差异
执行对比的结果如下:
可见归并排序的速度比插入排序要快很多倍
归并排序改进
在main函数中加入对近乎有序数组的排序:
观察其结果发现在近乎有序的情况下插入排序还是有着明显的优势:
那么我们是否能对归并排序做与之相似的优化呢?
我们观察归并排序的这三句话:
在这里无论[l,mid]与[mid + 1,r]哪个大,我们都调用了一次__merge归并函数
其实,若 mid <= mid + 1时,我们是不用对其进行归并的,因为其本身已经有序了
故我们在中间加入一个判断条件,修改后的代码如下:
这下在此以上次的条件运行,对于近乎有序的数组其结果如下:
仅多添加了一行代码,其速度就从0.032s下降到了0.006s!
但是归并无法下降为O(n)级别,故在这种特殊情况下还是不及插入排序的
第二个优化是关于对递归到底的优化
思路是当递归接近底部时,用插入排序代替递归排序
其原因式在元素比较少的时候其数组近乎有序的概率就比较大,这时候用插入排序来代替递归排序会获得一些时间上的优势
在原先InsertionSort.h中添加对[l,r]进行排序的插入排序函数:
接下来将__mergeSort中递归到底的函数修改为剩16个元素时改为插入排序:
这时在运行后对其结果进行对比:
其中普通随机数组从0.032s下降到0.013s
而近乎有序数组从0.006s下降到0.003s
效果显著!
可以研究,在不确定大小的完全随机的数组中用什么样的值来分界归并与排序是最优的?
自底向上的归并排序 Merge Sort BU
1.直接将待排序的数组分成两个两个的小段
2.在小段中排序
3.归并后变成四个四个的小段排序并且归并
4.循环直到整个数组有序
初步设想代码如下:
其中BU是Button to Up的意思
__merge函数的作用是对[i,i + size - 1]与[i + size, i + size * 2 -1]进行归并
越界问题:
第一点是处理"i + sz"越界的问题:
如果数组只有一部分的时候,就说明已经完成排序了,不需要在进行归并操作,所以把 "i < n"改为"i + sz < n"保证i + sz - 1不会越界
第二点是处理 "i + sz * 2 -1"越界的问题:
有可能出现数组超过了"i + sz"但是不足"i + sz * 2 -1"的情况,这时候就要在最后比较"i + sz * 2 -1"与"n - 1"的大小,取其小者归并之
代码修改如下:
优化:
上述自顶向下归并排序的两个优化思路:
1.若"i + sz - 1 < i + sz"则不需归并
2.小于16使用插入排序
都可以在此使用:
统计意义上来说,自顶向下的归并排序速度略微大于自底向上的归并排序
但是自底向上的归并排序有它特有的优势:
排序过程中没有使用数组[通过索引直接获取元素]的特性,所以其在链表中扮演着重要角色,可以很好的对链表进行O(nlogn)的排序
概念回顾[索引]: a[0],a[1],a[2]中0,1,2就是索引
快速排序 Quick Sort
1.选择数组中的一个元素
2.进行一次Partition,将数组分为小于该元素和大于该元素的两个部分
3.分别在两个部分中选取元素递归第1-2步至有序即可
核心操作Partition:
我们通常会使用整个数组的第一个元素v作为整个数组的分界点
其中:
指向数组分界点元素v的指针记为l(小写L);
大于v和小于v的分界线上的元素给个指针记为j;
正在判断的当前元素e的指针为i;
即:
arr[l+1, j] < v
arr[j +1,i -1] > v
此时判断e与v的大小:
若e > v,则 i++即可把e放入大于v的范围中
若e < v,则交换j+1与i所指元素的位置,然后j++,即可把e放入小于v的范围中
当所有排序结束后,只需要将l与j所指元素交换位置即可让分界点位于其应该在的位置
quickSort函数如下:
__quickSort函数如下:
注意递归的是[l,p - 1]与[p + 1, r]两个部分
__partition函数如下:
如上面所说,若是e > v则利用for进行i++即可
若e < v则需要交换位置后进行j++
最后别忘了把v提到中间去
注意j初始化定义在l,即为空位,而e元素则从l+1处开始进行比对
出现的问题:
对于近乎有序的数组进行排序测试时出现Stack Overflow的错误:
将其n减小后发现对近乎有序的数组进行排序时,快速排序的时间远大于归并排序的时间:
为什么在近乎有序的情况下快速排序那么慢呢?又有什么好的方法可以对其进行优化呢?
随机化快速排序法
思考上述问题可知,归并排序是每次平均地将待排数组分为两块,而快速排序因为有数组分界点的存在,所以并不能保证每次都是平均分配,我们就不能保证生成树的层数,即不能保证每次排序都是O(nlogn)的时间复杂度
甚至可以想象当我们设定的分界点在第一个元素,而数组完全有序的时候,那么每次都能划分为分界点与右侧除了分界点之外的所有元素的部分,这样以此类推,那么这棵树的层数为n(这也是出现了Stack Overflow错误的原因),而在每层处理的时候又用了O(n)的复杂度,此时快速排序的算法就退化成了O(n^2)级别的算法了
这样分析后改进的思路就非常明显了:我们只要尽可能的选中中间的元素即可改变现状
给出的方法是随机选中一个元素作为数组的分界点即可,利用复杂的数学原理可证明出其期望值为nlogn,具体数学证明可以参考如下网站:
https://segmentfault.com/a/1190000003704161
虽然不能保证每次都是O(nlogn),但是想要退化为O(n^2)级别的算法还是非常难的
退化为O(n^2)级别的算法需要:
第一次随机选中最小的元素作为分界点,概率为1/n
第二次随机选中次小的元素作为分界点,概率为1/( n - 1 )
以此类推,概率为1/(n!)
故不是非酋的话基本上不可能出现此种情况...
优化函数代码如下:
首先在quickSort中插入随机种子:
接下来在__partition中将原来我们选中的最左边的分界点与一个在[ l , r ]范围内的随机元素交换位置即可将分界点变为随机元素:
注意如何将随机数生成在[ l , r ]之间的,首先保证长度,然后加上偏移量即可
运行程序并对比后发现,比起未优化的快速排序效果提升还是相当明显的:
双路快速排序法 Quick Sort 2 Ways
下面我们来测试另外大重复率数组的排序情况:
在[0,10]之间取30000个随机数并测试,结果如下所示:
可见快速算法的成绩又一次变得非常不理想了
问题分析:
由于待排数组中出现大量重复元素,导致一旦分界点选择过大或过小,就会造成大规模的不平衡
举例来说,若在30000个[1,10]的元素中,第一次随机的分界点为2,那么就会有非常多的[2,10]中的元素堆积在大于v的节点,从而因分配不均而导致时间复杂度退化
甚至由于有大量重复元素,就算分界点选中了正中间的5,也有大量的5聚集在大于v的节点中,仍会导致分配不均的问题出现
优化__partition:
1.将原来放在比邻e < v 的 e > v 的元素放在数组的最后
2.需要新增加一个指针j指向e > v前的一个元素,如下图所示:
3.将i向后比较,直到遇到第一个arr[i] > v 停止,同时j向前比较,遇到第一个arr[j] < v 停止
4.交换arr[i]和arr[j]的位置
5.重复3-4步直到i与j重合,即排序结束,再将v与arr[j]交换位置即可
6.分别递归排序
注:当排序结束时,i停在数组第一个大于等于v的位置,而j停在数组最后一个小于等于v的位置,而分界点v在小于等于v的这一端,所以最后将其与arr[j]交换
其实按照这个思路,小于v的部分表示的是小于等于v,而大于v的部分表示的是大于等于v:
与之前最大的区别是将等于v的元素分在了分割数组的两边,这样就能尽最大可能的保证两边平衡了,现在我们在面临大量重复元素的情况下也可以很好的将其排序了
其中我们的__partition2代码优化如下:
仍是要注意初始化的问题,i从l + 1开始向后走,j从最后的r开始向前走
注意判断i与j是否过界的问题,并且在每次交换前要检查i是否大于了j,若大于j则说明整个数组已经排好序,可以break
下面运行检查性能优化结果:
可见在保证了近乎有序数组排序效率的前提下,对于大量重复元素的数组,使用双路快速排序法使得时间复杂度有了质的变化
三路快速排序法 Quick Sort 3 Ways
和双路快速排序比起来,三路快速排序的思想多了将等于v的部分单独提出来的过程
这样可以使等于v的部分不用递归排序,减少递归次数
优点:当==v的元素很多时,优化明显
在排序过程中用图片表达如下:
其中lt指针代表less than,gt指针代表great than的意思
1.若i指向的元素e==v,那么直接将e归并进==v中,即i++即可
2.若e < v,那我们只要把这个元素e与==v中第一个元素进行交换并使lt++,最后i++即可
3.若e > v,那么我们将e与arr[gt - 1]交换位置并使gt--即可,注意此时i不用++,因为它仍然指向的是一个未被处理的元素!
(解释如下:arr[gt - 1]是没有被处理过的,被交换了位置而落在了i指向的地方,故i就不用移动了,而在e < v中出现的情况是我们将arr[lt + 1]与e交换了位置,那么i指向的是已经判断过==v的元素arr[lt + 1],所以i需要向后移位)
待i与gt重合,将l与lt指向的元素交换位置,即一次排序完成
接下来分别对大于v与小于v的部分进行递归排序即可
运行结果如下:
可以看出,在e==v的情况大量存在的第二种情况中,三路排序是明显优于其他两种快速排序的
思考
都是用了分治的思想
其中归并排序的关键在于分之后的合,而快速排序的关键在如何分上,而不太考虑合
对于经典的算法,我们不应该只是照本宣科的记忆背诵,而要去思考前人是如何设计出这个算法的
逆序对问题
其中,顺序对如下图所示:
而逆序对如下图所示:
对于从小到大顺序排列的数组,它的逆序对为0;
对于从大到小顺序排列的数组,它的逆序对为n(n-1)/2;
我们可以通过逆序对的数量来初步判定这个数组的有序程度
暴力解法:
考察每一个数对是否是逆序对,若是则计数器++,此解法的时间复杂度为O(n^2)
利用归并排序的思路解法:
使用归并排序得到两个排好序的数组:
比较arr[i]和arr[j]的大小,发现arr[i] > arr[j],说明左边所有的元素均大于arr[j],即出现了4个逆序对,计数器加4,接着将arr[j]放置在k所指的位置,j++,k++
接下来将2与4比较,2 < 4 ,因为是顺序对所以把2放在arr[k]中即可,i++,k++
比较3与4同理
比较5与4如下图:
此时5 > 4,故记两个逆序对,即<5,4>和<8,4>,接下来同理操作直到结束
注:根据维基百科的定义,相等元素不算逆序对
取出数组中第n大的元素问题
先将问题简化思考,即可退化为取数组中的最大值和最小值的问题,遍历扫描即可,算法复杂度为O(n)
接着我们思考暴力解法,即将数组排序后根据下标索引找到那个元素即可,此时算法复杂度为O(nlogn)
利用快速排序的思路解法:
利用快速排序的思想可以以O(n)的复杂度解出此问题,分析如下:
快速排序是每次将v挪到合适的位置,这个合适的位置即排序结束后v元素应该在的位置,若所给的n大于v所处的位置,只要递归求解后半部分即可,若小于即递归前半部分即可
该算法的复杂度:
由于我们先使用了n步操作进行一次partition将数组一分为二,然后在分的一部分中递归即n/2步,以此类推,直到加到1结束
n + n/2 + n/4 + ... + 1 在 n = +∞ 时趋向于 2n,即算法时间复杂度为O(2n),记为O(n)
注意:快速排序不保证严格的二等分,但是我们使用的是随机化的快速排序,可以保证期望值平分
我的个人博客:点击打开链接