算法导论-第8章-线性时间排序
前言
此前我们已经学习了几种\(\Omicron(n \log n)\)的排序算法,这些排序算法都有一个有趣性质,在排序的最终结果中,各元素的次序依赖于它们之间的比较,我们将这类排序称为比较排序(comparison sort)。
8.1节将要证明对包含\(n\)个元素的输入序列,在最坏情况下,任何比较排序都要经过\(\Omega(n \log n)\)次比较。
8.2节、8.3节、8.4节将要介绍三种线性时间复杂度适用于某些特定输入的的排序:计数排序(counting sort)、基数排序(radix sort)、和桶排序(bucket sort)。这些排序通过其它方法来确定排序顺序。
8.1 排序算法的下界
决策树模型
在决策树中,每个内部结点都以\(i:j\)标记,其中\(i\)和\(j\)满足\(1 \le i,j \le n\),\(n\)是输入序列中的元素个数。每个叶结点上都标注一个序列\(<\pi(1),\pi(2),\cdots,\pi(n),>\)。排序算法的执行对应于一条从数的根结点到叶结点的路径。每个内部结点表示一次比较\(a_i \le a_j\)。左子树表示确定\(a_i \le a_j\)之后的后续比较,右子树表示确定\(a_i \gt a_j\)的后续比较。当到达叶结点后,表示排序以完成。
例如,对于3个元素的插入排序决策树,输入序列\(<a_1=6,a_2=8,a_3=5>\),叶结点\(<3,1,2>\)表示排序的结果是\(a_3=5 \le a_1=6 \le a_2=8\)。对于输入元素来说,共有\(3!=6\)种可能的排列,因此决策树包含6个叶结点。
定理:在最坏情况下,任何比较排序算法都需要做\(\Omega(n \log n)\)次比较。
证明:设决策树高度为\(h\),具有\(l\)个叶结点,则\(n! \le l \le 2^h\),两边取对数,得\(h \ge \log(n!)=\Omega(n \log n)\)。
推论:堆排序和归并排序都是渐近最优的比较排序算法。
8.2 计数排序
计数排序假设\(n\)个输入元素中的每一个都是在\(0\)到\(k\)区间内的一个整数,其中\(k\)为某个整数。当\(k=\Omicron(n)\)时,运行时间为\(\Theta(n)\)。
计数排序的基本思想是:对每一个输入元素\(x\),确定小于\(x\)的元素个数。这样就可以直接把\(x\)放到输出数组中的位置上。例如,有17个元素小于\(x\),则\(x\)就应该放在第18个输出位置上。当有几个元素相同时,需要略作修改即可。
在计数排序算法的代码中,假设输入是一个数组\(A[1..n]\),\(A.length=n\)。我们还需要另外两个数组:\(B[1..n]\)存放排序的输出,\(C[0..k]\)提供临时存储空间,\(C[i]\)记录小于等于\(i\)的元素个数。
下图展示了计数排序算法的运行过程。第2-3行for
循环的初始化操作之后,数组\(C\)的值全被置为0;第4-5行for
循环遍历输入元素。如果输入元素的值为\(i\),就将\(C[i]\)值加1。于是,在第5行执行完后,\(C[i]\)中保存的就是等于\(i\)的元素的个数;第7-8行通过累加计算确定对于每个\(i=0,1,2,\cdots,k\),有多少输入元素是小于或等于\(i\)的,操作过后,\(C[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\)个\(d\)位的元素存放在数组\(A\)中,其中第1位是最低位,第\(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\)个\(d\)位数,其中每一个数位有\(k\)个可能的取值。如果RADIX-SORT使用的稳定排序方法耗时\(\Theta(n+k)\),那么它就可以在\(\Theta(d(n+k))\)时间内完成排序。
8.4 桶排序
桶排序(bucket sort)假设输入数据服从均匀分布,平均情况下它的时间代价为\(\Omicron(n)\)。与计数排序类似,因为对输入数据作了某种假设,桶排序的速度也很快。具体来说,计数排序假设输入数据都属于一个小区间内的整数,而桶排序则假设元素均匀、独立地分布在\([0, 1)\)区间内。
桶排序将\([0, 1)\)区间均匀划分为\(n\)个相同大小的子区间,称为桶。然后,将\(n\)个输入数分别放到各个桶中。因为输入数据是均匀、独立地分布在\([0, 1)\)区间上,所以一般不会出现很多数落在同一个桶中的情况。
核心思想:先对每个桶中的数进行排序,然后遍历每个桶,按照次序把各个桶中的元素列出来即可。
在桶排序的代码中,假设输入是一个包含\(n\)个元素的数组\(A\),且满足\(0 \le A[i] \lt 1\)。此外,算法还需要一个临时数组\(B[0..n-1]\)来存放链表(即桶)。