1. 概述
像 Merge Sort 这样的通用排序算法对输入不做任何假设,因此在最坏的情况下它们无法击败 O(n log n)。相反,计数排序对输入有一个假设,使其成为线性时间排序算法。
在本教程中,我们将熟悉计数排序的机制,然后在 Java 中实现它。
2. 计数排序
与大多数经典排序算法相反,计数排序不会通过比较元素来对给定的输入进行排序。相反,**它假定输入元素是 [0, k] 范围内的 n 个整数。**当 k = O(n) 时,计数排序将在 O(n) 时间内运行。
请注意,我们不能将计数排序用作通用排序算法。但是,当输入与此假设一致时,它非常快!
2.1. 频率阵列
假设我们要对一个值在 [0, 5] 范围内的输入数组进行排序:
首先,我们应该计算输入数组中每个数字的出现次数。如果我们用数组 C 表示计数,则 C[i] 表示输入数组中数字 i 的频率:
例如,由于 5 在输入数组中出现了 3 次,因此索引 5 的值等于 3。
现在给定数组 C,我们应该确定每个输入元素小于或等于多少个元素。 例如:
- 一个元素小于或等于零,或者换句话说,只有一个零值,等于 C[0]
- 两个元素小于或等于一个,等于 C[0] + C[1]
- 四个值小于或等于 2,即等于 C[0] + C[1] + C[2]
因此,如果我们继续计算 C 中 n 个连续元素的总和,我们可以知道输入数组中有多少元素小于或等于数字 n-1。 无论如何,通过应用这个简单的公式,我们可以将 C 更新如下:
2.2. 算法
现在我们可以使用辅助数组 C 对输入数组进行排序。以下是计数排序的工作原理:
- 它反向迭代输入数组
- 对于每个元素 i,C[i] – 1 表示数字 i 在排序数组中的位置。这是因为存在小于或等于 i 的 C[i] 元素
- 然后,它在每轮结束时递减 C[i]
为了对示例输入数组进行排序,我们应该首先从数字 5 开始,因为它是最后一个元素。根据 C[5],有 11 个元素小于或等于数字 5。
所以,5 应该是 11第元素,因此索引为 10:
由于我们将 5 移动到排序数组中,我们应该递减 C[5]。 下一个相反顺序的元素是 2。由于有 4 个元素小于或等于 2,因此这个数字应该是 4第元素:
同样,我们可以为下一个元素 0 找到正确的位置:
如果我们继续反向迭代并适当地移动每个元素,我们最终会得到如下结果:
3. 计数排序 – Java 实现
3.1. 计算频率阵列
首先,给定一个元素的输入数组和 k,我们应该计算数组 C:
int[] countElements(int[] input, int k) {
int[] c = new int[k + 1];
Arrays.fill(c, 0);
for (int i : input) {
c[i] += 1;
}
for (int i = 1; i < c.length; i++) {
c[i] += c[i - 1];
}
return c;
}
让我们分解方法签名:
- input 表示我们将要排序的数字数组
- 输入数组是 [0, k] 范围内的整数数组,因此 k 表示输入中的最大数字
- 返回类型是表示 C数组的整数数组
以下是 countElements 方法的工作原理:
- 首先,我们初始化了 C 数组。由于 [0, k] 范围包含 k+1 个数字,我们正在创建一个能够包含 k+1 个数字的数组
- 然后,对于输入中的每个数字,我们正在计算该数字的频率
- 最后,我们将连续的元素相加,以了解有多少元素小于或等于特定数字
此外,我们可以验证 countElements 方法是否按预期工作:
@Test
void countElements_GivenAnArray_ShouldCalculateTheFrequencyArrayAsExpected() {
int k = 5;
int[] input = { 4, 3, 2, 5, 4, 3, 5, 1, 0, 2, 5 };
int[] c = CountingSort.countElements(input, k);
int[] expected = { 1, 2, 4, 6, 8, 11 };
assertArrayEquals(expected, c);
}
3.2. 对输入数组进行排序
现在我们可以计算频率数组了,我们应该能够对任何给定的数字集进行排序:
int[] sort(int[] input, int k) {
int[] c = countElements(input, k);
int[] sorted = new int[input.length];
for (int i = input.length - 1; i >= 0; i--) {
int current = input[i];
sorted[c[current] - 1] = current;
c[current] -= 1;
}
return sorted;
}
排序方法的工作原理如下:
- 首先,它计算 C 数组
- 然后,它反向迭代输入数组,对于输入中的每个元素,在排序数组中找到其正确的位置。这我第 元素应为C[i]第元素。由于 Java 数组是零索引的,因此 C[i]-1 条目是C[i]第 元素 – 例如,sorted[5] 是排序数组中的第六个元素
- 每次我们找到匹配项时,它都会递减相应的 C[i] 值
同样,我们可以验证排序方法是否按预期工作:
@Test
void sort_GivenAnArray_ShouldSortTheInputAsExpected() {
int k = 5;
int[] input = { 4, 3, 2, 5, 4, 3, 5, 1, 0, 2, 5 };
int[] sorted = CountingSort.sort(input, k);
// Our sorting algorithm and Java's should return the same result
Arrays.sort(input);
assertArrayEquals(input, sorted);
}
4. 重新审视计数排序算法
4.1. 复杂性分析
大多数经典的排序算法(如合并排序)仅通过比较输入元素来对任何给定的输入进行排序。 这些类型的排序算法称为比较排序。在最坏的情况下,比较排序至少需要 O(n log n) 来对 n 个元素进行排序。
另一方面,计数排序不会通过比较输入元素来对输入进行排序,因此它显然不是比较排序算法。
让我们看看对输入进行排序需要花费多少时间:
它在 O(n+k) 时间内计算 C 数组:它一次迭代一个大小为 n 的输入数组,然后迭代 O(k) 中的 C——所以总共是 O(n+k)
计算 C 后,它通过迭代输入数组并在每次迭代中执行一些原始操作来对输入进行排序。因此,实际的排序操作需要 O(n)
总的来说,计数排序需要 O(n+k) 时间才能运行:
O(n + k) + O(n) = O(2n + k) = O(n + k)
如果我们假设 k=O(n),那么计数排序算法会以线性时间为由对输入进行排序。与通用排序算法相反,计数排序对输入做出假设,并且执行时间小于 O(n log n) 下限。
4.2. 稳定性
不久前,我们制定了一些关于计数排序机制的特殊规则,但从未弄清楚它们背后的原因。更具体地说:
- 我们为什么要反向迭代输入数组?
- 为什么我们每次使用 C[i] 时都会递减它?
让我们从头开始迭代,以更好地理解第一条规则。假设我们要对一个简单的整数数组进行排序,如下所示:
在第一次迭代中,我们应该找到第一个 1 的排序位置:
因此,数字 1 的第一次出现将获得排序数组中的最后一个索引。跳过数字 0,让我们看看第二次出现数字 1 会发生什么:
具有相同值的元素在输入和排序数组中的出现顺序不同,因此当我们从头开始迭代时,算法并不稳定。
如果我们在每次使用后不递减 C[i] 值会发生什么?我看看:
数字 1 的两次出现都获得排序数组中的最后一位。因此,如果我们在每次使用后不递减 C[i] 值,我们可能会在对它们进行排序时丢失一些数字!
5. 结论
在本教程中,首先,我们学习了计数排序在内部的工作原理。然后,我们在 Java 中实现了这种排序算法,并编写了一些测试来验证其行为。最后,我们证明了该算法是一种具有线性时间复杂度的稳定排序算法。