姜威
总结:
一、几种经典排序算法及其时间复杂度级别
冒泡、插入、选择 O(n^2) 基于比较
快排、归并 O(nlogn) 基于比较
计数、基数、桶 O(n) 不基于比较
二、如何分析一个排序算法?
1.学习排序算法的思路?明确原理、掌握实现以及分析性能。
2.如何分析排序算法性能?从执行效率、内存消耗以及稳定性3个方面分析排序算法的性能。
3.执行效率:从以下3个方面来衡量
1)最好情况、最坏情况、平均情况时间复杂度
2)时间复杂度的系数、常数、低阶:排序的数据量比较小时考虑
3)比较次数和交换(或移动)次数
4.内存消耗:通过空间复杂度来衡量。针对排序算法的空间复杂度,引入原地排序的概念,原地排序算法就是指空间复杂度为O(1)的排序算法。
5.稳定性:如果待排序的序列中存在值等的元素,经过排序之后,相等元素之间原有的先后顺序不变,就说明这个排序算法时稳定的。
三、冒泡排序
1.排序原理
1)冒泡排序只会操作相邻的两个数据。
2)对相邻两个数据进行比较,看是否满足大小关系要求,若不满足让它俩互换。
3)一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。
4)优化:若某次冒泡不存在数据交换,则说明已经达到完全有序,所以终止冒泡。
2.代码实现(见下一条留言)
3.性能分析
1)执行效率:最小时间复杂度、最大时间复杂度、平均时间复杂度
最小时间复杂度:数据完全有序时,只需进行一次冒泡操作即可,时间复杂度是O(n)。
最大时间复杂度:数据倒序排序时,需要n次冒泡操作,时间复杂度是O(n^2)。
平均时间复杂度:通过有序度和逆序度来分析。
什么是有序度?
有序度是数组中具有有序关系的元素对的个数,比如[2,4,3,1,5,6]这组数据的有序度就是11,分别是[2,4][2,3][2,5][2,6][4,5][4,6][3,5][3,6][1,5][1,6][5,6]。同理,对于一个倒序数组,比如[6,5,4,3,2,1],有序度是0;对于一个完全有序的数组,比如[1,2,3,4,5,6],有序度为n*(n-1)/2,也就是15,完全有序的情况称为满有序度。
什么是逆序度?逆序度的定义正好和有序度相反。核心公式:逆序度=满有序度-有序度。
排序过程,就是有序度增加,逆序度减少的过程,最后达到满有序度,就说明排序完成了。
冒泡排序包含两个操作原子,即比较和交换,每交换一次,有序度加1。不管算法如何改进,交换的次数总是确定的,即逆序度。
对于包含n个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏的情况初始有序度为0,所以要进行n*(n-1)/2交换。最好情况下,初始状态有序度是n*(n-1)/2,就不需要进行交互。我们可以取个中间值n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。
换句话说,平均情况下,需要n*(n-1)/4次交换操作,比较操作可定比交换操作多,而复杂度的上限是O(n^2),所以平均情况时间复杂度就是O(n^2)。
以上的分析并不严格,但很实用,这就够了。
2)空间复杂度:每次交换仅需1个临时变量,故空间复杂度为O(1),是原地排序算法。
3)算法稳定性:如果两个值相等,就不会交换位置,故是稳定排序算法。
四、插入排序
1.算法原理
首先,我们将数组中的数据分为2个区间,即已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想就是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间中的元素一直有序。重复这个过程,直到未排序中元素为空,算法结束。
2.代码实现(见下一条留言)
3.性能分析
1)时间复杂度:最好、最坏、平均情况
如果要排序的数组已经是有序的,我们并不需要搬移任何数据。只需要遍历一遍数组即可,所以时间复杂度是O(n)。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,因此时间复杂度是O(n^2)。而在一个数组中插入一个元素的平均时间复杂都是O(n),插入排序需要n次插入,所以平均时间复杂度是O(n^2)。
2)空间复杂度:从上面的代码可以看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),是原地排序算法。
3)算法稳定性:在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现的元素的后面,这样就保持原有的顺序不变,所以是稳定的。
 
 
靑城
总结

一、排序方法与复杂度归类
(1)几种最经典、最常用的排序方法:冒泡排序、插入排序、选择排序、快速排序、归并排序、计数排序、基数排序、桶排序。
(2)复杂度归类
冒泡排序、插入排序、选择排序 O(n^2)
快速排序、归并排序 O(nlogn)
计数排序、基数排序、桶排序 O(n)

二、如何分析一个“排序算法”?
<1>算法的执行效率
1. 最好、最坏、平均情况时间复杂度。
2. 时间复杂度的系数、常数和低阶。
3. 比较次数,交换(或移动)次数。
<2>排序算法的稳定性
1. 稳定性概念:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
2. 稳定性重要性:可针对对象的多种属性进行有优先级的排序。
3. 举例:给电商交易系统中的“订单”排序,按照金额大小对订单数据排序,对于相同金额的订单以下单时间早晚排序。用稳定排序算法可简洁地解决。先按照下单时间给订单排序,排序完成后用稳定排序算法按照订单金额重新排序。
<3>排序算法的内存损耗
原地排序算法:特指空间复杂度是O(1)的排序算法。

三、冒泡排序
       冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求,如果不满足就让它俩互换。
稳定性:冒泡排序是稳定的排序算法。
空间复杂度:冒泡排序是原地排序算法。
时间复杂度:
1. 最好情况(满有序度):O(n)。
2. 最坏情况(满逆序度):O(n^2)。
3. 平均情况:
       “有序度”和“逆序度”:对于一个不完全有序的数组,如4,5,6,3,2,1,有序元素对为3个(4,5),(4,6),(5,6),有序度为3,逆序度为12;对于一个完全有序的数组,如1,2,3,4,5,6,有序度就是n*(n-1)/2,也就是15,称作满有序度;逆序度=满有序度-有序度;冒泡排序、插入排序交换(或移动)次数=逆序度。
       最好情况下初始有序度为n*(n-1)/2,最坏情况下初始有序度为0,则平均初始有序度为n*(n-1)/4,即交换次数为n*(n-1)/4,因交换次数<比较次数<最坏情况时间复杂度,所以平均时间复杂度为O(n^2)。

四、插入排序
       插入排序将数组数据分成已排序区间和未排序区间。初始已排序区间只有一个元素,即数组第一个元素。在未排序区间取出一个元素插入到已排序区间的合适位置,直到未排序区间为空。
空间复杂度:插入排序是原地排序算法。
时间复杂度:
1. 最好情况:O(n)。
2. 最坏情况:O(n^2)。
3. 平均情况:O(n^2)(往数组中插入一个数的平均时间复杂度是O(n),一共重复n次)。
稳定性:插入排序是稳定的排序算法。

五、选择排序
       选择排序将数组分成已排序区间和未排序区间。初始已排序区间为空。每次从未排序区间中选出最小的元素插入已排序区间的末尾,直到未排序区间为空。
空间复杂度:选择排序是原地排序算法。
时间复杂度:(都是O(n^2))
1. 最好情况:O(n^2)。
2. 最坏情况:O(n^2)。
3. 平均情况:O(n^2)。
稳定性:选择排序不是稳定的排序算法。

思考
       选择排序和插入排序的时间复杂度相同,都是O(n^2),在实际的软件开发中,为什么我们更倾向于使用插入排序而不是冒泡排序算法呢?
       答:从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要3个赋值操作,而插入排序只需要1个,所以在对相同数组进行排序时,冒泡排序的运行时间理论上要长于插入排序。
 
我们讲过,特定算法是依赖特定的数据结构的。我们今天讲的几种排序算法,都是基于数组实现的。如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?
 
对于老师所提课后题,觉得应该有个前提,是否允许修改链表的节点value值,还是只能改变节点的位置。一般而言,考虑只能改变节点位置,冒泡排序相比于数组实现,比较次数一致,但交换时操作更复杂;插入排序,比较次数一致,不需要再有后移操作,找到位置后可以直接插入,但排序完毕后可能需要倒置链表;选择排序比较次数一致,交换操作同样比较麻烦。综上,时间复杂度和空间复杂度并无明显变化,若追求极致性能,冒泡排序的时间复杂度系数会变大,插入排序系数会减小,选择排序无明显变化。
 
三、快速排序
1.算法原理
快排的思想是这样的:如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。然后遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将povit放到中间。经过这一步之后,数组p到r之间的数据就分成了3部分,前面p到q-1之间都是小于povit的,中间是povit,后面的q+1到r之间是大于povit的。根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,就说明所有的数据都有序了。
递推公式:quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)
终止条件:p >= r
2.代码实现(参见下一条留言)
3.性能分析
1)算法稳定性:
因为分区过程中涉及交换操作,如果数组中有两个8,其中一个是pivot,经过分区处理后,后面的8就有可能放到了另一个8的前面,先后顺序就颠倒了,所以快速排序是不稳定的排序算法。比如数组[1,2,3,9,8,11,8],取后面的8作为pivot,那么分区后就会将后面的8与9进行交换。
2)时间复杂度:最好、最坏、平均情况
快排也是用递归实现的,所以时间复杂度也可以用递推公式表示。
如果每次分区操作都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并的相同。
T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。
T(n) = 2*T(n/2) + n; n>1
所以,快排的时间复杂度也是O(nlogn)。
如果数组中的元素原来已经有序了,比如1,3,5,6,8,若每次选择最后一个元素作为pivot,那每次分区得到的两个区间都是不均等的,需要进行大约n次的分区,才能完成整个快排过程,而每次分区我们平均要扫描大约n/2个元素,这种情况下,快排的时间复杂度就是O(n^2)。
前面两种情况,一个是分区及其均衡,一个是分区极不均衡,它们分别对应了快排的最好情况时间复杂度和最坏情况时间复杂度。那快排的平均时间复杂度是多少呢?T(n)大部分情况下是O(nlogn),只有在极端情况下才是退化到O(n^2),而且我们也有很多方法将这个概率降低。
3)空间复杂度:快排是一种原地排序算法,空间复杂度是O(1)
四、归并排序与快速排序的区别
归并和快排用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢?
1.归并排序,是先递归调用,再进行合并,合并的时候进行数据的交换。所以它是自下而上的排序方式。何为自下而上?就是先解决子问题,再解决父问题。
2.快速排序,是先分区,在递归调用,分区的时候进行数据的交换。所以它是自上而下的排序方式。何为自上而下?就是先解决父问题,再解决子问题。
五、思考
1.O(n)时间复杂度内求无序数组中第K大元素,比如4,2,5,12,3这样一组数据,第3大元素是4。
我们选择数组区间A[0...n-1]的最后一个元素作为pivot,对数组A[0...n-1]进行原地分区,这样数组就分成了3部分,A[0...p-1]、A[p]、A[p+1...n-1]。
如果如果p+1=K,那A[p]就是要求解的元素;如果K>p+1,说明第K大元素出现在A[p+1...n-1]区间,我们按照上面的思路递归地在A[p+1...n-1]这个区间查找。同理,如果K<p+1,那我们就在A[0...p-1]区间查找。
时间复杂度分析?
第一次分区查找,我们需要对大小为n的数组进行分区操作,需要遍历n个元素。第二次分区查找,我们需要对大小为n/2的数组执行分区操作,需要遍历n/2个元素。依次类推,分区遍历元素的个数分别为n、n/2、n/4、n/8、n/16......直到区间缩小为1。如果把每次分区遍历的元素个数累加起来,就是等比数列求和,结果为2n-1。所以,上述解决问题的思路为O(n)。
2.有10个访问日志文件,每个日志文件大小约为300MB,每个文件里的日志都是按照时间戳从小到大排序的。现在需要将这10个较小的日志文件合并为1个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述任务的机器内存只有1GB,你有什么好的解决思路能快速地将这10个日志文件合并?
我觉得最后的思考题,[曹源]同学的策略是较优的。
该策略的最大好处是充分利用了内存。
但是我还是会这么做:
1.申请10个40M的数组和一个400M的数组。
2.每个文件都读40M,取各数组中最大时间戳中的最小值。
3.然后利用二分查找,在其他数组中快速定位到小于/等于该时间戳的位置,并做标记。
4.再把各数组中标记位置之前的数据全部放在申请的400M内存中,
5.在原来的40M数组中清除已参加排序的数据。[可优化成不挪动数据,只是用两个索引标记有效数据的起始和截止位置]
6.对400M内存中的有效数据[没装满]做快排。
将排好序的直接写文件。
7.再把每个数组尽量填充满。从第2步开始继续,知道各个文件都读区完毕。
这么做的好处有:
1.每个文件的内容只读区一次,且是批量读区。比每次只取一条快得多。
2.充分利用了读区到内存中的数据。曹源 同学在文件中查找那个中间数是会比较困难的。
3.每个拷贝到400M大数组中参加快排的数据都被写到了文件中,这样每个数只参加了一次快排。
 
总结:桶排序、计数排序、基数排序
一、线性排序算法介绍
1.线性排序算法包括桶排序、计数排序、基数排序。
2.线性排序算法的时间复杂度为O(n)。
3.此3种排序算法都不涉及元素之间的比较操作,是非基于比较的排序算法。
4.对排序数据的要求很苛刻,重点掌握此3种排序算法的适用场景。
二、桶排序(Bucket sort)
1.算法原理:
1)将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行快速排序。
2)桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
2.使用条件
1)要排序的数据需要很容易就能划分成m个桶,并且桶与桶之间有着天然的大小顺序。
2)数据在各个桶之间分布是均匀的。
3.适用场景
1)桶排序比较适合用在外部排序中。
2)外部排序就是数据存储在外部磁盘且数据量大,但内存有限无法将整个数据全部加载到内存中。
4.应用案例
1)需求描述:
有10GB的订单数据,需按订单金额(假设金额都是正整数)进行排序
但内存有限,仅几百MB
2)解决思路:
扫描一遍文件,看订单金额所处数据范围,比如1元-10万元,那么就分100个桶。
第一个桶存储金额1-1000元之内的订单,第二个桶存1001-2000元之内的订单,依次类推。
每个桶对应一个文件,并按照金额范围的大小顺序编号命名(00,01,02,…,99)。
将100个小文件依次放入内存并用快排排序。
所有文件排好序后,只需按照文件编号从小到大依次读取每个小文件并写到大文件中即可。
3)注意点:若单个文件无法全部载入内存,则针对该文件继续按照前面的思路进行处理即可。
三、计数排序(Counting sort)
1.算法原理
1)计数其实就是桶排序的一种特殊情况。
2)当要排序的n个数据所处范围并不大时,比如最大值为k,则分成k个桶
3)每个桶内的数据值都是相同的,就省掉了桶内排序的时间。
2.代码实现(参见下一条留言)
案例分析:
假设只有8个考生分数在0-5分之间,成绩存于数组A[8] = [2,5,3,0,2,3,0,3]。
使用大小为6的数组C[6]表示桶,下标对应分数,即0,1,2,3,4,5。
C[6]存储的是考生人数,只需遍历一边考生分数,就可以得到C[6] = [2,0,2,3,0,1]。
对C[6]数组顺序求和则C[6]=[2,2,4,7,7,8],c[k]存储的是小于等于分数k的考生个数。
数组R[8] = [0,0,2,2,3,3,3,5]存储考生名次。那么如何得到R[8]的呢?
从后到前依次扫描数组A,比如扫描到3时,可以从数组C中取出下标为3的值7,也就是说,到目前为止,包括自己在内,分数小于等于3的考生有7个,也就是说3是数组R的第7个元素(也就是数组R中下标为6的位置)。当3放入数组R后,小于等于3的元素就剩下6个了,相应的C[3]要减1变成6。
以此类推,当扫描到第二个分数为3的考生时,就会把它放入数组R中第6个元素的位置(也就是下标为5的位置)。当扫描完数组A后,数组R内的数据就是按照分数从小到大排列的了。
3.使用条件
1)只能用在数据范围不大的场景中,若数据范围k比要排序的数据n大很多,就不适合用计数排序;
2)计数排序只能给非负整数排序,其他类型需要在不改变相对大小情况下,转换为非负整数;
3)比如如果考试成绩精确到小数后一位,就需要将所有分数乘以10,转换为整数。
四、基数排序(Radix sort)
1.算法原理(以排序10万个手机号为例来说明)
1)比较两个手机号码a,b的大小,如果在前面几位中a已经比b大了,那后面几位就不用看了。
2)借助稳定排序算法的思想,可以先按照最后一位来排序手机号码,然后再按照倒数第二位来重新排序,以此类推,最后按照第一个位重新排序。
3)经过11次排序后,手机号码就变为有序的了。
4)每次排序有序数据范围较小,可以使用桶排序或计数排序来完成。
2.使用条件
1)要求数据可以分割独立的“位”来比较;
2)位之间由递进关系,如果a数据的高位比b数据大,那么剩下的地位就不用比较了;
3)每一位的数据范围不能太大,要可以用线性排序,否则基数排序的时间复杂度无法做到O(n)。
五、思考
1.如何根据年龄给100万用户数据排序?
2.对D,a,F,B,c,A,z这几个字符串进行排序,要求将其中所有小写字母都排在大写字母前面,但是小写字母内部和大写字母内部不要求有序。比如经过排序后为a,c,z,D,F,B,A,这个如何实现呢?如果字符串中处理大小写,还有数字,将数字放在最前面,又该如何解决呢?
 
用两个指针a、b:a指针从头开始往后遍历,遇到大写字母就停下,b从后往前遍历,遇到小写字母就停下,交换a、b指针对应的元素;重复如上过程,直到a、b指针相交。
对于小写字母放前面,数字放中间,大写字母放后面,可以先将数据分为小写字母和非小写字母两大类,进行如上交换后再在非小写字母区间内分为数字和大写字母做同样处理
 
课后思考,利用桶排序思想,弄小写,大写,数字三个桶,遍历一遍,都放进去,然后再从桶中取出来就行了。相当于遍历了两遍,复杂度O(n)
 
总结:如何实现一个通用的高性能的排序函数?
一、如何选择合适的排序算法?
1.排序算法一览表
                 时间复杂度 是稳定排序? 是原地排序?
冒泡排序 O(n^2) 是 是
插入排序 O(n^2) 是 是
选择排序 O(n^2) 否 是
快速排序 O(nlogn) 否 是
归并排序 O(nlogn) 是 否
桶排序 O(n) 是 否
计数排序 O(n+k),k是数据范围 是 否
基数排序 O(dn),d是纬度 是 否
2.为什选择快速排序?
1)线性排序时间复杂度很低但使用场景特殊,如果要写一个通用排序函数,不能选择线性排序。
2)为了兼顾任意规模数据的排序,一般会首选时间复杂度为O(nlogn)的排序算法来实现排序函数。
3)同为O(nlogn)的快排和归并排序相比,归并排序不是原地排序算法,所以最优的选择是快排。
二、如何优化快速排序?
导致快排时间复杂度降为O(n)的原因是分区点选择不合理,最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。如何优化分区点的选择?有2种常用方法,如下:
1.三数取中法
①从区间的首、中、尾分别取一个数,然后比较大小,取中间值作为分区点。
②如果要排序的数组比较大,那“三数取中”可能就不够用了,可能要“5数取中”或者“10数取中”。
2.随机法:每次从要排序的区间中,随机选择一个元素作为分区点。
3.警惕快排的递归发生堆栈溢出,有2中解决方法,如下:
①限制递归深度,一旦递归超过了设置的阈值就停止递归。
②在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈过程,这样就没有系统栈大小的限制。
三、通用排序函数实现技巧
1.数据量不大时,可以采取用时间换空间的思路
2.数据量大时,优化快排分区点的选择
3.防止堆栈溢出,可以选择在堆上手动模拟调用栈解决
4.在排序区间中,当元素个数小于某个常数是,可以考虑使用O(n^2)级别的插入排序
5.用哨兵简化代码,每次排序都减少一次判断,尽可能把性能优化到极致
四、思考
1.Java中的排序函数都是用什么排序算法实现的?有有哪些技巧?
 posted on 2020-08-13 16:53  JetaimeBeaucoup  阅读(370)  评论(0编辑  收藏  举报