文章目录
查找与排序
建议大家好好看看查找、排序这两个程序员的必修课,希望大家能学有所获
排序
常见的排序有:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。
选择一个排序,需要考虑它的时间复杂度(包括最好、最坏、平均情况时间复杂度)、内存消耗以及稳定性
大家不要认为排序算法简单,抱着轻视的态度学习。排序算法简单,想要用好、用精却不容易
关于稳定性
稳定性的意思是: 若待排序序列中存在值相等的元素,排序后相等元素间原有的先后顺序保持不变
稳定性是排序算法特别重要的一个性质,例如给出一个需求:总成绩按照由高到低排序;若总成绩按数学成绩由高到低排序;若总成绩&&数学成绩相同,再按英语成绩由高到低排序。
如果采用具有稳定性的排序算法,只需三次排序(先对整体按英语成绩排序,再对整体按数学成绩排序,最后对整体按总成绩排序),而无需每次对相同的小区间进行排序
关于内部和外部排序
由于待排序的记录数量不同,使得排序过程中涉及的存储器不同。可将排序方法分为两大类:内部排序与外部排序。
内部排序:将记录存放在内存上进行的排序过程,衡量内部排序的效率通常通过时间复杂度,以上常用排序都为内部排序。
外部排序:记录数量很大导致在排序过程中需要对外存(磁盘)进行访问的排序过程,衡量外部排序的效率通常通过IO次数。
外部排序第一阶段会根据内存大小将磁盘上的文件分成若干长度为l的子文件或段,依次读入内存并通过内部排序方法对其排序,并将得到的有序段重写回磁盘。
第二阶段对这些小的有序段进行归并,直到得到整个有序文件。其中第二阶段的实现方法有:(1)多路平衡归并 (2)置换-选择排序
选择排序
选择排序将数组分为 已排序区间 和 未排序区间,每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
选择排序的流程为:
复杂度
时间复杂度:
最好情况:O(n^2)
最坏情况:O(n^2)
平均情况:O(n^2)
空间复杂度:O(1)
稳定性:不稳定(选择排序每次都要找未排序元素中的最小值,并和前面元素交换位置,这样就破坏了稳定性。例如:数组5,8,5,2,9 ,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了)
冒泡排序
冒泡排序特点就是只会操作相邻的两个数据。每次冒泡操作都会对相邻两元素进行比较,若不满足条件就让它俩互换。一次冒泡(让当前未排序数组中最大/最小的值沉底/冒泡,时间复杂度为O(n))会让至少一个元素移动到它应该在的位置。重复 n 次就完成了 n 个数据的排序工作,总时间复杂度为O(n^2)
以下是一次冒泡的流程:
复杂度
时间复杂度:
最好情况:O(n),数组有序,一次遍历发现有序后break
最坏情况:O(n^2),数组逆序
平均情况:O(n^2)
空间复杂度:O(1)
稳定性:稳定(前提是代码中保证两个相邻元素相等时不做交换)
插入排序
将数组中的数据分为已排序区间和未排序区间。初始已排序区间只有一个元素(数组第一个元素),取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程直到未排序区间中元素为空。
一下是插入排序的流程:
复杂度
时间复杂度:
最好情况:O(n),数组有序
最坏情况:O(n^2),数组逆序
平均情况:O(n^2)
空间复杂度:O(1)
稳定性:稳定(需要保证代码中元素插入的位置在已排序区间相同元素的后面)
插入排序优于冒泡?
答案是yes
虽然时间、空间复杂度以及稳定性插入排序和冒泡排序类似,但是由于插入排序每次操作的指令数少于冒泡排序(冒泡排序是交换,需要三个指令,优化为两次亦或位运算也需要两个指令;插入排序是移动,只需要一个赋值指令),因此插入排序性能相比冒泡更加优异。
至于选择排序,最好最坏情况都是O(n^2)且排序不稳定,几乎用不到~
归并排序
归并排序就是把数组下角标p~r从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,递归这个过程直到p >= r
归并排序流程为:
复杂度
时间复杂度:nlog(n)
递归拆分复杂度是log(n),每次拆分后的merger合并复杂度为n,总时间复杂度为nlog(n),且不随数组的有序程度而变动(即最好、最坏、平均情况操作的次数相同)
空间复杂度:O(n),相比其他算法,merger需要有额外空间的开辟和释放
稳定性:稳定(需要保证merger()函数遇到相同时先添加左边的)
快速排序
每次从数组下标 p 到 r 间任选一个数据作为分区点(pivot,一般选择区间最后一个元素),然后遍历 p 到 r 之间的数据,将小于分界区的放左,大于分界区放右,于是数组下角标 p 到 r 之间的数据就被分成了两个部分,递归这两个部分直到p >= r。
其核心原理就是设置标记点i代表[p, i - 1]的数据都小于分区点,[i,r - 1]的数据要么大于等于分区点,要么还未进行比较。
伪代码如下:
partition(A, p, r) {
pivot := A[r]
i := p
for j := p to r-1 do {
if A[j] < pivot {
swap A[i] with A[j]
i := i+1
}
}
swap A[i] with A[r]
return i
}
流程图如下:
复杂度
时间复杂度:
前提:一下情况都是每次选取区间最后一个元素作为分区点
最好情况:O(nlog(n)),完全无序
最坏情况:O(n^2),有序(正序或逆序)
平均情况:O(nlog(n))
空间复杂度:O(1)
稳定性:不稳定( 分区过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4。在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变)
降低最坏情况发生的概率
我们知道快速排序算法在最坏情况下时间复杂度退化到 O(n^2),我们可以通过合理选择分界区来避免这种情况的发生。
我们要想减少最坏情况发生的概率,就要尽量让分区点两边的区间数据量差不多。
这时我们可以采用以下两个办法来尽量使得两边数据量平均:
(1)三数取中法:从区间的首、尾、中间分别取一个数,然后取这3个数的中间值作为分区点。当然,取得越多中值越精确,但是耗费的额外时间也越多。所以我们要依据当前数组的数据规模来决定几数取中法~
(2)随机法:每次随机一个元素作分区点。
快排比优于归并排序?
归并排序需要额外的空间(O(n)),并涉及到内存的开辟和释放。
即使快排最坏情况下会达到O(n^2),但由于其最坏情况发生概率极小,我们一般还是采用快速排序这种算法。
扩展小作业
利用快排思想,O(n)时间复杂度求无序数组中第K大元素
桶排序
将要排序的数据分到几个有序的桶里(如果桶代表的是一个区间,则每个桶内的数据再单独进行排序)。 桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
复杂度
最好情况:O(n),当每个桶内数据无需单独排序时
最坏情况:O(nlog(n)),所有数据在一个桶里且桶代表一个区间而非一个值
平均情况:O(n)
空间复杂度:O(n + m),其中m为桶的个数
稳定性:稳定(需要保证代码中入桶和从桶中取出的顺序一致)
优化空间利用
上面方法由于是链表有了指针节点的耗费。有没有办法可以优化掉?
答案是yes。
我们利用sum数组来累加每个有序桶的前缀和,来达到桶排序的目的:
适应场景
桶排序虽然时间复杂度只有O(n),但是有严格的限制条件~
适合的场景有:
(1)适用于一些数据范围小、对内存要求不大的情况:按年龄大小对100W用户进行排序。我们可以分成150个桶分别代表1150岁(应该没有150岁以上的人了吧)。每个桶是一个链表,每入桶一个用户就追加一个节点~这样复杂度只有O(n),同样空间复杂度也是O(n)
(2)适用于外部排序:对于10GB的用户信息按资产排序。由于无法一次性加载10G数据,我们可以分成10个桶,假设第一个桶为01K,第二个桶1KW…依次递推,然后分别对每个桶内部单独排序。
基数排序
我们知道,桶排序只能解决数据范围小的情况,如果数据范围大了怎么办?把年龄扩展为资产,如果还要维持一个桶一个值,难道要开几百亿个桶?
这时我们可以先对所有用户的资产的个位进行桶排,把排序后的结果再进行十位的桶排…依次直到最高位(如果有的用户达不到该位则前补0)。
上述这种基于桶排的排序,就是基数排序
复杂度
时间复杂度:O(n*k),其中k是数据范围最大值的位数。
空间复杂度:O(n+m),其中m为桶的个数
稳定性:稳定(需要保证代码中入桶和从桶中取出的顺序一致)
一般情况下,一般情况下,logn并不比k大多少,且基数排序的空间耗费,导致其应用场景远远少于快排。
高效通用排序
由于桶排、基数排序对条件要求苛刻,选择排序时间复杂度太高,因此无法采用~
如果数据量不大(几十M以内),我们可以采用归并排序(虽然有O(n)的内存消耗,但能一直保持nlog(n)复杂度且具有稳定性)
如果数据量太大(超过百M),出于内存方面的考虑,我们可以采用快速排序(通过三点取中法降低最坏情况发生的可能)。我们都知道快速排序是一个递归过程,出于以下几个考虑:(1)避免递归太深导致堆栈溢出 (2)当[l, r]范围过小时有序的概率大大增加 (3) 当[l, r]范围过小时O(nlogn)几乎等价于O(n^2),所以当递归到元素个数小于等于32时,退化为插入排序。
当然,为了避免递归太深导致堆栈溢出,我们可以实现一个堆上的栈,手动模拟递归来解决。
如果数据量无比巨大(超过内存大小),那么我们就考虑外部排序,将数据拆分成多个部分,对每个部分分别进行内部排序,最后通过多路平衡归并,使得整体有序。
思考题
我们在面试时经常会碰到类似问题: 有 10 日志文件,每个日志文件约 300MB,每个文件里的日志都是按照时间戳从小到大排序的。如果将10 个日志文件合并为 1 个,合并之后的日志仍然按照时间戳从小到大排列,而机器内存只有 1GB,有什么快速合并的方法吗?大家会有什么样的方案?
二分查找
二分查找的基石就是有序数组(第一有序,第二数组。无序、链表等均不可), 查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素或区间被缩小为 0
复杂度
时间复杂度:
最优情况时间复杂度:O(1)
最坏情况时间复杂度:O(log(n))
平均时间复杂度:O(log(n))
空间复杂度:O(1)
三要素
- 循环条件:
l <= r
- mid取值:
mid = l+((r-l)>>1)
,这种写法可以避免整数溢出 - l和r的更新:
l = mid + 1; r = mid - 1
变型
比如查找第一个值等于给定值的元素、查找最后一个值等于给定值的元素、查找第一个大于等于给定值的元素、查找第一个大于给定值的元素等等,大家可以试着想一下怎么二分