前言
此前我们已经学习了几种 O ( n log n ) \Omicron(n \log n) O(nlogn)的排序算法,这些排序算法都有一个有趣性质,在排序的最终结果中,各元素的次序依赖于它们之间的比较,我们将这类排序称为比较排序(comparison sort)。
8.1节将要证明对包含 n n n个元素的输入序列,在最坏情况下,任何比较排序都要经过 Ω ( n log n ) \Omega(n \log n) Ω(nlogn)次比较。
8.2节、8.3节、8.4节将要介绍三种线性时间复杂度适用于某些特定输入的的排序:计数排序(counting sort)、基数排序(radix sort)、和桶排序(bucket sort)。这些排序通过其它方法来确定排序顺序。
8.1 排序算法的下界
决策树模型
在决策树中,每个内部结点都以 i : j i:j i:j标记,其中 i i i和 j j j满足 1 ≤ i , j ≤ n 1 \le i,j \le n 1≤i,j≤n, n n n是输入序列中的元素个数。每个叶结点上都标注一个序列 < π ( 1 ) , π ( 2 ) , ⋯ , π ( n ) , > <\pi(1),\pi(2),\cdots,\pi(n),> <π(1),π(2),⋯,π(n),>。排序算法的执行对应于一条从数的根结点到叶结点的路径。每个内部结点表示一次比较 a i ≤ a j a_i \le a_j ai≤aj。左子树表示确定 a i ≤ a j a_i \le a_j ai≤aj之后的后续比较,右子树表示确定 a i > a j a_i \gt a_j ai>aj的后续比较。当到达叶结点后,表示排序以完成。
例如,对于3个元素的插入排序决策树,输入序列 < a 1 = 6 , a 2 = 8 , a 3 = 5 > <a_1=6,a_2=8,a_3=5> <a1=6,a2=8,a3=5>,叶结点 < 3 , 1 , 2 > <3,1,2> <3,1,2>表示排序的结果是 a 3 = 5 ≤ a 1 = 6 ≤ a 2 = 8 a_3=5 \le a_1=6 \le a_2=8 a3=5≤a1=6≤a2=8。对于输入元素来说,共有 3 ! = 6 3!=6 3!=6种可能的排列,因此决策树包含6个叶结点。
定理:在最坏情况下,任何比较排序算法都需要做 Ω ( n log n ) \Omega(n \log n) Ω(nlogn)次比较。
证明:设决策树高度为 h h h,具有 l l l个叶结点,则 n ! ≤ l ≤ 2 h n! \le l \le 2^h n!≤l≤2h,两边取对数,得 h ≥ log ( n ! ) = Ω ( n log n ) h \ge \log(n!)=\Omega(n \log n) h≥log(n!)=Ω(nlogn)。
推论:堆排序和归并排序都是渐近最优的比较排序算法。
8.2 计数排序
计数排序假设 n n n个输入元素中的每一个都是在 0 0 0到 k k k区间内的一个整数,其中 k k k为某个整数。当 k = O ( n ) k=\Omicron(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..n] A[1..n], A . l e n g t h = n A.length=n A.length=n。我们还需要另外两个数组: B [ 1.. n ] B[1..n] B[1..n]存放排序的输出, C [ 0.. k ] C[0..k] C[0..k]提供临时存储空间, C [ i ] C[i] C[i]记录小于等于 i i i的元素个数。
下图展示了计数排序算法的运行过程。第2-3行for
循环的初始化操作之后,数组
C
C
C的值全被置为0;第4-5行for
循环遍历输入元素。如果输入元素的值为
i
i
i,就将
C
[
i
]
C[i]
C[i]值加1。于是,在第5行执行完后,
C
[
i
]
C[i]
C[i]中保存的就是等于
i
i
i的元素的个数;第7-8行通过累加计算确定对于每个
i
=
0
,
1
,
2
,
⋯
,
k
i=0,1,2,\cdots,k
i=0,1,2,⋯,k,有多少输入元素是小于或等于
i
i
i的,操作过后,
C
[
i
]
C[i]
C[i]记录的即是小于等于
i
i
i的元素个数。
import java.util.Arrays;
public class CountingSort {
public static int[] countingSort(int[] A) {
if (A == null || A.length == 0) {
return A;
}
// 遍历一次输入数组,找到数组中最大和最小元素
int max = A[0], min = A[0];
for (int i = 1; i < A.length; i++) {
if (A[i] > max) {
max = A[i];
}
if (A[i] < min) {
min = A[i];
}
}
int[] C = new int[max - min + 1]; // 辅助数组C,C[i]记录小于等于i的元素个数
for (int j = 0; j < A.length; j++) {
C[A[j] - min]++; // C[i]存储的是等于A[j]-min=i的元素个数
}
for (int i = 1; i < C.length; i++) {
C[i] += C[i - 1]; // C[i]存储的是小于等于i的元素个数
}
int[] B = new int[A.length]; // 输出数组
for (int j = A.length - 1; j >= 0; j--) {
B[C[A[j] - min] - 1] = A[j];
C[A[j] - min]--;
}
return B;
}
public static void main(String[] args) {
int[] A = {2, 5, 3, 0, 2, 3, 0, 3};
int[] countingSorted = countingSort(A);
System.out.println(Arrays.toString(countingSorted));
}
}
8.3 基数排序
基数排序(radix sort)是一种用在卡片排序机🧐(并不了解这个是什么东西)上的算法。基数排序是先按照最低有效位进行排序的,为了保证基数排序的正确性,一位数排序算法必须是稳定的。
假设 n n n个 d d d位的元素存放在数组 A A A中,其中第1位是最低位,第 d d d位是最高位。
基数排序的Java代码实现如下:
import java.util.Arrays;
public class RadixSort {
public static int[] radixSort(int[] arr) {
if (arr == null || arr.length < 2) {
return arr;
}
// 找到数组中的最大值
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
// 根据最大元素的位数确定排序的轮数
int digit = 1;
while (max / 10 > 0) {
digit++;
max /= 10;
}
int exp = 1;
int[] temp = new int[arr.length];
int[] bucket = new int[10];
for (int i = 0; i < digit; i++) {
System.arraycopy(arr, 0, temp, 0, arr.length);
Arrays.fill(bucket, 0);
// 计数排序
for (int j = 0; j < temp.length; j++) {
int radix = (temp[j] / exp) % 10;
bucket[radix]++;
}
for (int j = 1; j < bucket.length; j++) {
bucket[j] += bucket[j - 1];
}
for (int j = temp.length - 1; j >= 0; j--) {
int radix = (temp[j] / exp) % 10;
arr[--bucket[radix]] = temp[j];
}
exp *= 10;
}
return arr;
}
public static void main(String[] args) {
int[] A = {329, 457, 657, 839, 436, 720, 355};
System.out.println(Arrays.toString(radixSort(A)));
}
}
引理:给定 n n n个 d d d位数,其中每一个数位有 k k k个可能的取值。如果RADIX-SORT使用的稳定排序方法耗时 Θ ( n + k ) \Theta(n+k) Θ(n+k),那么它就可以在 Θ ( d ( n + k ) ) \Theta(d(n+k)) Θ(d(n+k))时间内完成排序。
8.4 桶排序
桶排序(bucket sort)假设输入数据服从均匀分布,平均情况下它的时间代价为 O ( n ) \Omicron(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)区间上,所以一般不会出现很多数落在同一个桶中的情况。
核心思想:先对每个桶中的数进行排序,然后遍历每个桶,按照次序把各个桶中的元素列出来即可。
在桶排序的代码中,假设输入是一个包含 n n n个元素的数组 A A A,且满足 0 ≤ A [ i ] < 1 0 \le A[i] \lt 1 0≤A[i]<1。此外,算法还需要一个临时数组 B [ 0.. n − 1 ] B[0..n-1] B[0..n−1]来存放链表(即桶)。