时间复杂度是线性的排序算法被称作线性排序。以下会讲到三种时间复杂度是O(n)的排序算法:桶排序、计数排序及基数排序。之所以能做到线性的时间复杂度,主要原因是,这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。
线性排序
桶排序(Bucket sort)
桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排序完之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
但桶排序对排序数据的要求是非常苛刻的。
- 要排序的数据需要很容易就能划分成m个桶,并且,桶与桶之间的有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。
- 数据在各个桶之间的分布是比较均匀的。如果数据很不均匀的话那桶内数据排序的时间复杂度就不是常量级了。
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
计数排序(Counting sort)
计数排序可以看做是桶排序的一种特殊情况。当要排序的n个数据,所处的范围并不大的时候,比如最大值是k,就可以把数据分成k个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
那为什么这个排序算法叫计数排序呢?以考生统计考分为例,假设有8个考生,分数在0-5之间。
- 我们把这8个考生的分数放在一个数组A[8]中,它们分别是:2,5,3,0,2,3,0,3。
- 分数为0-5分,我们拿C[6]表示桶,其下标对应分数,值对应该分数考生的个数。那我们遍历一遍就可以得到C = [2,0,2,3,0,1] ,从C中我们可以得知,考分为3分的考生有3个,小于3分的有4个,所以,成绩为3分的考生在排序之后的数组R中,会保存下标4,5,6的位置。
那我们如何计算,每个分数的考生在有序数组中对应的存储位置呢?思路如下: 先对C[6]数组顺序求和,C = [2,2,4,7,7,8], C[k]里存储小于等于分数 k 的考生个数。
我们从后到前依次扫描数组 A。比如,当扫描到 3 时,我们可以从数组 C 中取出下标为 3 的值 7,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。 当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3]要减 1,变成 6。以此类推,当我们扫描到第 2 个分数为 3 的考生的时候,就会把它放入数组 R 中的第 6 个元素的位置(也就是下标为 5 的位置)。当我们扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了。
def Counting_sort(nums):
n = len(nums)
if n <= 1: return nums
# 找到数组中数据的范围(假设最小值为0)
max_num = max(nums)
Counting = [0]*(max_num+1)
# 计算每个数出现的次数,存在Counting中,其下标对应数值,值对应该数值的个数
for i in range(len(nums)):
Counting[nums[i]] += 1
# 数据求和,C[k]里存储小于等于分数 k 的考生个数
for i in range(1, len(Counting)):
Counting[i] = Counting[i] + Counting[i-1]
result = [0]*n
# 从后到前遍历数组
for i in range(len(nums)-1,0, -1):
# 当扫描x时,取Counting中下标为x的值,即目前为止,包括自己,小于等于x的个数的值
num = Counting[nums[i]]
# 把x放入下标为结果数组x-1的位置
result[num-1] = nums[i]
# Counting中下标为x的值减去1
Counting[nums[i]] -= 1
return result
nums = [2,5,3,0,2,3,0,3]
print(Counting_sort(nums))
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
基数排序
分割出独立的“位”来比较,例如手机号有11位,范围太大,不适合桶、计数排序。先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。根据位排序可以使用桶排序或计数排序。
注意,这里按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。如果排序数据不是等长的,可以补齐到相同长度。
总结一下,基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
排序优化:如何实现一个通用的、高性能的排序函数?
如何选择合适的排序算法?
线性排序算法对数据要求过高不考虑。而为了兼顾任意规模数据的排序,一般都会首选时间复杂度是 O(nlogn) 的排序算法来实现排序函数。
那归并排序和快排时间复杂度都是 O(nlogn),甚至快排在最坏情况下的时间复杂度是 O(n^2)。但归并排序不是原地排序算法,它的空间复杂的是O(n)。所以快排会有更多的应用。
如何优化快速排序?
我们都知道,如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 O(n^2)。这么看来,这种复杂度的出现主要原因还是因为我们的分区点选的不够合理。 最理想的分区点应该是:分区后两块数据数量差不多。
这里介绍两个比较常用的分区算法:
1、三数取中法:
我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好。但是,如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”。
2、随机法: 随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选得很差的情况,所以平均情况下,这样选的分区点是比较好的。时间复杂度退化为最糟糕的 O(n2) 的情况,出现的可能性不大。