注:本文为《算法导论》中排序相关内容的笔记。对此感兴趣的读者还望支持原作者。众所周知,任何比较排序在最坏情况下都要经过 Ω ( n lg n ) \Omega(n \lg n) Ω(nlgn)次比较。所谓比较排序,是指在排序的最终结果中,各元素的次序依赖于它们之间的的比较。之前介绍的,快速排序,堆排序,归并排序等经典排序算法都是其中的代表。那么问题来了,难道真的无法在 Ω ( n lg n ) \Omega(n \lg n) Ω(nlgn)时间内对一个无序数组完成排序吗?幸运的是,答案是可以的。我们完全可以在线性时间内完成对数组的排序,而且这类算法不止一种。接下来,我们将一一了解三种线性时间排序算法——计数排序,基数排序,桶排序。
计数排序
计数排序假设 n n n个输入元素中的每一个都是在区间 [ 0 , k ] [0, k] [0,k]内的一个整数,其中 k k k为某个整数。当 k = O ( n ) k=O(n) k=O(n)时,排序的运行时间为 Θ ( n ) \Theta(n) Θ(n)。
计数排序的基本思想是:对每一个输入元素 x x x,确定小于 x x x的元素个数。利用这一信息,就可以直接把 x x x放到它在输出数组中的位置上了。例如,如果有17个元素小于 x x x,则 x x x就应该放在第18个输出位置上。当然,当输入元素有相同时,这一方案要略作修改。因为,不能把它们放在同一输出位置上。为了帮助大家理解,我们举一个例子。
下图中,数组 A [ 1 … n ] A[1 \dots n] A[1…n]为输入数组, A . l e n g t h = n A.length=n A.length=n。我们还需要两个数组: B [ 1 … n ] B[1 \dots n] B[1…n]存放排序的输出, C [ 0 … k ] C[0 \dots k] C[0…k]提供临时存储空间。
上图中,数组
A
A
A中的每一个元素都不大于
k
k
k的非负整数。在4-5行中,数组
C
C
C首先读取数组
A
A
A,得到
A
A
A中的元素的数量,即
C
[
i
]
C[i]
C[i]中保存的就是等于
i
i
i的元素的个数。之后在7-8行中,
C
[
i
]
C[i]
C[i]不断累加,从而得知对每一个
i
=
0
,
1
…
,
k
i=0, 1 \dots ,k
i=0,1…,k,有多少输入元素时小于或等于
i
i
i的。最后,因为一共有
C
[
A
[
i
]
]
C[A[i]]
C[A[i]]个元素小于或等于
A
[
i
]
A[i]
A[i],每将一个值
A
[
i
]
A[i]
A[i]放入数组
B
B
B之后,将
C
[
A
[
i
]
]
C[A[i]]
C[A[i]]的值减一,即可完成排序。下图为算法运行示例。
不难看出,计数排序的时间复杂度为 Θ ( k + n ) \Theta(k+n) Θ(k+n)。在实际工作中,当 k = O ( n ) k = O(n) k=O(n)时,我们一般采用计数排序,此时的运行时间为 Θ ( n ) \Theta(n) Θ(n)。
此外,值得一提的是,计数排序的一个重要特性就是稳定的:具有相同值的元素在输出数组中的相对次序与它们在输入数组中的相对次序相同。也就是说,对两个相同的数来说,在输入数组中先出现的,在输出数组中也位于前面。而计数排序算法的这种稳定性在接下来介绍的基数排序中是十分重要的。
桶排序
桶排序假设输入数据服从均匀分布,平均情况下它的时间代价为 O ( n ) O(n) O(n)。与计数排序相同,因为对输入数据作了某种假设,桶排序的速度也很快。具体来说,计数排序假设输入数据都属于一个小区间内的整数,而桶排序则假设输入是由一个随机过程产生,该过程将元素均匀、独立分布在区间 [ 0 , 1 ) [0, 1) [0,1)上。
桶排序将输入 [ 0 , 1 ) [0, 1) [0,1)区间划分为 n n n个相同大小的子区间,或称为桶。然后,将 n n n个输入数分别放到各个桶中。因为输入数据是均匀、独立地分布在区间 [ 0 , 1 ) [0, 1) [0,1)上,所以一般不会出现很多数落在同一个桶中的情况。为了获得最终的排序结果,我们先对每个桶中的数进行排序,然后遍历每个桶,按照次序把每个桶中的元素列出来即可。其伪代码如下:
假如我们有如下一个数组,
我们可以为其构建10个桶,并在桶内排序,则有
其中,数组 B B B即是我们所说的桶。
现在,我们来分析一下桶排序的运行时间。我们注意到,在最坏情况下,除第8行外,所有其他行的时间代价为 O ( n ) O(n) O(n)。我们还需要分析第8行中 n n n次插入排序调用所花费的时间。因此,我们有桶排序的时间复杂度的递归式为
T ( n ) = Θ ( n ) + ∑ i = 0 n − 1 O ( n i 2 ) T(n)=\Theta(n) + \sum_{i=0}^{n-1}O(n_{i}^{2}) T(n)=Θ(n)+i=0∑n−1O(ni2)
我们现在来分析一下桶排序平均情况下的运行时间。通过对上式两边取期望,并利用期望的线性性质,我们有:
E [ T ( n ) ] = E [ Θ ( n ) + ∑ i = 0 n − 1 O ( n i 2 ) ] = Θ ( n ) + ∑ i = 0 n − 1 O ( E [ n i 2 ] ) E[T(n)]=E \left[ \Theta(n) + \sum_{i=0}^{n-1}O(n_{i}^{2}) \right]=\Theta(n) + \sum_{i=0}^{n-1}O(E[n_{i}^{2}]) E[T(n)]=E[Θ(n)+i=0∑n−1O(ni2)]=Θ(n)+i=0∑n−1O(E[ni2])
因为 E [ n i 2 ] = 2 − 1 / n E[n_{i}^{2}] = 2 - 1/n E[ni2]=2−1/n,我们有 E [ T ( n ) ] = Θ ( n ) E[T(n)] = \Theta(n) E[T(n)]=Θ(n)。
其实,即使输入数据不服从均匀分布,桶排序也仍然可以在线性时间内完成。只要输入数据满足以下条件:所有桶的大小的平方和与总的元素数呈线性关系。
基数排序
基数排序属于分配式排序,又称“桶子法”,是桶排序的扩展。它将待排序数组中的所有元素按位(从最低有效位到最高有效位,反之亦可)切割,然后按照对应的位分别对这些数进行排序,最后输出结果。说了这么一段“让人头大”的话,它到底是什么意思呢?我们不妨看看下面的示例。
上图最左边为输入数组,其他为每位的排序结果。首先,我们先对最低有效位进行排序,即各位,然后是十位,最后是百位。重复此过程,我们就完成了对原数组的排序,是不是很神奇!其实,基数排序的最鲜明的例子就是我们在日常生活中对日期进行排序——给定两个日期,先比较年(最高有效位),如果相同,再比较月,如果还是相同,就比较日。当然,在上图中,我们是从最低有效位开始比较。
为了保证基数排序的正确性,我们在每位的排序算法必须是稳定的。因此,我们需采用计数排序等稳定排序算法作为基数排序的子程序。基数排序十分简单(毕竟,真正的排序工作它并不涉及),符合人们的认知,因此它的代码也是十分直观。
最后,我们再来分析一下基数排序的时间复杂度。不难看出,基数排序的时间代价主要决定于它采用的排序算法子程序。因此,给定 n n n个 d d d位数,其中每一个数位有 k k k个取值。如果基数排序使用的稳定排序算法耗时 O ( k + n ) O(k+n) O(k+n),那么它就可以在 O ( d ( n + k ) ) O(d(n+k)) O(d(n+k))内完成排序。
算法总结
最后,我们总结一下上述三种线性时间排序——计数排序,基数排序,桶排序。为了打破比较比较排序的下界 Ω ( n lg n ) \Omega(n \lg n) Ω(nlgn),它们都对输入数据进行一定的假设(如计数排序假设数据分布在一定区间内),并采用空间换时间(如计数排序需要中间数组)的方式实现在线性时间内完成排序。因此,当我们遇见对时间要求严格,而输入数据满足要求,空间又刚好充分时,我们完全可以采用上述排序算法实现在线性时间内对输入数据的排序。