时间复杂度O(n)级排序算法

九、计数排序

前文说到,19591959 年 77 月,希尔排序通过交换非相邻元素,打破了 O(n^2)的魔咒,使得排序算法的时间复杂度降到了 O(nlog n) 级,此后的快速排序、堆排序都是基于这样的思想,所以他们的时间复杂度都是 O(nlog n)。

那么,排序算法最好的时间复杂度就是 O(nlogn) 吗?是否有比 O(nlogn) 级还要快的排序算法呢?能否在 O(n) 的时间复杂度下完成排序呢?

事实上,O(n) 级的排序算法存在已久,但他们只能用于特定的场景。

计数排序就是一种时间复杂度为 O(n) 的排序算法,该算法于 1954 年由 Harold H. Seward 提出。在对一定范围内的整数排序时,它的复杂度为 Ο(n+k)(其中 k 是整数的范围大小)。

9.1、伪计数排序

举个例子,我们需要对一列数组排序,这个数组中每个元素都是 [1, 9] 区间内的整数。那么我们可以构建一个长度为 9 的数组用于计数,计数数组的下标分别对应区间内的 9 个整数。然后遍历待排序的数组,将区间内每个整数出现的次数统计到计数数组中对应下标的位置。最后遍历计数数组,将每个元素输出,输出的次数就是对应位置记录的次数。

算法实现如下(以 [1,9] 为例 ):

  1. public static void countingSort9(int[] arr) {
  2. // 建立长度为 9 的数组,下标 0~8 对应数字 1~9
  3. int[] counting = new int[9];
  4. // 遍历 arr 中的每个元素
  5. for (int element : arr) {
  6. // 将每个整数出现的次数统计到计数数组中对应下标的位置
  7. counting[element - 1]++;
  8. }
  9. int index = 0;
  10. // 遍历计数数组,将每个元素输出
  11. for (int i = 0; i < 9; i++) {
  12. // 输出的次数就是对应位置记录的次数
  13. while (counting[i] != 0) {
  14. arr[index++] = i + 1;
  15. counting[i]--;
  16. }
  17. }
  18. }

算法非常简单,但这里的排序算法 并不是 真正的计数排序。因为现在的实现有一个非常大的弊端:排序完成后,arr 中记录的元素已经不再是最开始的那个元素了,他们只是值相等,但却不是同一个对象。

在纯数字排序中,这个弊端或许看起来无伤大雅,但在实际工作中,这样的排序算法几乎无法使用。因为被排序的对象往往都会携带其他的属性,但这份算法将被排序对象的其他属性都丢失了。

就好比业务部门要求我们将 1 号商品,2 号商品,3 号商品,4 号商品按照价格排序,它们的价格分别为 8 元、6 元,6 元,9 元。 我们告诉业务部门:排序完成后价格为 6 元、 6 元、8 元,9 元,但不知道这些价格对应哪个商品。这显然是不可接受的。

9.2、伪计数排序 2.0

对于这个问题,我们很容易想到一种解决方案:在统计元素出现的次数时,同时把真实的元素保存到列表中,输出时,从列表中取真实的元素。算法实现如下:

  1. public static void countingSort9(int[] arr) {
  2. // 建立长度为 9 的数组,下标 0~8 对应数字 1~9
  3. int[] counting = new int[9];
  4. // 记录每个下标中包含的真实元素,使用队列可以保证排序的稳定性
  5. HashMap<Integer, Queue<Integer>> records = new HashMap<>();
  6. // 遍历 arr 中的每个元素
  7. for (int element : arr) {
  8. // 将每个整数出现的次数统计到计数数组中对应下标的位置
  9. counting[element - 1]++;
  10. if (!records.containsKey(element - 1)) {
  11. records.put(element - 1, new LinkedList<>());
  12. }
  13. records.get(element - 1).add(element);
  14. }
  15. int index = 0;
  16. // 遍历计数数组,将每个元素输出
  17. for (int i = 0; i < 9; i++) {
  18. // 输出的次数就是对应位置记录的次数
  19. while (counting[i] != 0) {
  20. // 输出记录的真实元素
  21. arr[index++] = records.get(i).remove();
  22. counting[i]--;
  23. }
  24. }
  25. }

在这份代码中,我们通过队列来保存真实的元素,计数完成后,将队列中真实的元素赋到 arr 列表中,这就解决了信息丢失的问题,并且使用队列还可以保证排序算法的稳定性。

但是,这也不是 真正的计数排序,计数排序中使用了一种更巧妙的方法解决这个问题。

9.3、真正的计数排序

举个例子,班上有 10 名同学:他们的考试成绩分别是:7, 8, 9, 7, 6, 7, 6, 8, 6, 6他们需要按照成绩从低到高坐到 0~9 共 10 个位置上。

用计数排序完成这一过程需要以下几步:

  • 第一步仍然是计数,统计出:4 名同学考了 6 分,3 名同学考了 7 分,2 名同学考了 8 分,1 名同学考了 9 分;
  • 然后从头遍历数组:第一名同学考了 7 分,共有 4 个人比他分数低,所以第一名同学坐在 4 号位置(也就是第 5 个位置);
  • 第二名同学考了 8 分,共有 7 个人(4 + 3)比他分数低,所以第二名同学坐在 7 号位置;
  • 第三名同学考了 9 分,共有 9 个人(4 + 3 + 2)比他分数低,所以第三名同学坐在 9 号位置;
  • 第四名同学考了 7 分,共有 4 个人比他分数低,并且之前已经有一名考了 7 分的同学坐在了 4 号位置,所以第四名同学坐在 5 号位置。
  • ...依次完成整个排序

区别就在于计数排序并不是把计数数组的下标直接作为结果输出,而是通过计数的结果,计算出每个元素在排序完成后的位置,然后将元素赋值到对应位置。

代码如下:

  1. public static void countingSort9(int[] arr) {
  2. // 建立长度为 9 的数组,下标 0~8 对应数字 1~9
  3. int[] counting = new int[9];
  4. // 遍历 arr 中的每个元素
  5. for (int element : arr) {
  6. // 将每个整数出现的次数统计到计数数组中对应下标的位置
  7. counting[element - 1]++;
  8. }
  9. // 记录前面比自己小的数字的总数
  10. int preCounts = 0;
  11. for (int i = 0; i < counting.length; i++) {
  12. int temp = counting[i];
  13. // 将 counting 计算成当前数字在结果中的起始下标位置。位置 = 前面比自己小的数字的总数。
  14. counting[i] = preCounts;
  15. // 当前的数字比下一个数字小,累计到 preCounts 中
  16. preCounts += temp;
  17. }
  18. int[] result = new int[arr.length];
  19. for (int element : arr) {
  20. // counting[element - 1] 表示此元素在结果数组中的下标
  21. int index = counting[element - 1];
  22. result[index] = element;
  23. // 更新 counting[element - 1],指向此元素的下一个下标
  24. counting[element - 1]++;
  25. }
  26. // 将结果赋值回 arr
  27. for (int i = 0; i < arr.length; i++) {
  28. arr[i] = result[i];
  29. }
  30. }

首先我们将每位元素出现的次数记录到 counting 数组中。

然后将 counting[i] 更新为数字 i 在最终排序结果中的起始下标位置。这个位置等于前面比自己小的数字的总数。

例如本例中,考 7 分的同学前面有 4 个比自己分数低的同学,所以 7 对应的下标为 4。

这一步除了使用 temp 变量这种写法以外,还可以通过多做一次减法省去 temp 变量:

  1. // 记录前面比自己小的数字的总数
  2. int preCounts = 0;
  3. for (int i = 0; i < counting.length; i++) {
  4. // 当前的数字比下一个数字小,累计到 preCounts 中
  5. preCounts += counting[i];
  6. // 将 counting 计算成当前数字在结果中的起始下标位置。位置 = 前面比自己小的数字的总数。
  7. counting[i] = preCounts - counting[i];
  8. }

接下来从头访问 arr 数组,根据 counting 中计算出的下标位置,将 arr 的每个元素直接放到最终位置上,然后更新 counting 中的下标位置。这一步中的 index 变量也是可以省略的。

最后将 result 数组赋值回 arr,完成排序。

这就是计数排序的思想,我们还剩下最后一步,那就是根据 arr 中的数字范围计算出计数数组的长度。使得计数排序不仅仅适用于 [1, 9],代码如下:

  1. public static void countingSort(int[] arr) {
  2. // 判空及防止数组越界
  3. if (arr == null || arr.length <= 1) return;
  4. // 找到最大值,最小值
  5. int max = arr[0];
  6. int min = arr[0];
  7. for (int i = 1; i < arr.length; i++) {
  8. if (arr[i] > max) max = arr[i];
  9. else if (arr[i] < min) min = arr[i];
  10. }
  11. // 确定计数范围
  12. int range = max - min + 1;
  13. // 建立长度为 range 的数组,下标 0~range-1 对应数字 min~max
  14. int[] counting = new int[range];
  15. // 遍历 arr 中的每个元素
  16. for (int element : arr) {
  17. // 将每个整数出现的次数统计到计数数组中对应下标的位置,这里需要将每个元素减去 min,才能映射到 0~range-1 范围内
  18. counting[element - min]++;
  19. }
  20. // 记录前面比自己小的数字的总数
  21. int preCounts = 0;
  22. for (int i = 0; i < range; i++) {
  23. // 当前的数字比下一个数字小,累计到 preCounts 中
  24. preCounts += counting[i];
  25. // 将 counting 计算成当前数字在结果中的起始下标位置。位置 = 前面比自己小的数字的总数。
  26. counting[i] = preCounts - counting[i];
  27. }
  28. int[] result = new int[arr.length];
  29. for (int element : arr) {
  30. // counting[element - min] 表示此元素在结果数组中的下标
  31. result[counting[element - min]] = element;
  32. // 更新 counting[element - min],指向此元素的下一个下标
  33. counting[element - min]++;
  34. }
  35. // 将结果赋值回 arr
  36. for (int i = 0; i < arr.length; i++) {
  37. arr[i] = result[i];
  38. }
  39. }

这就是完整的计数排序算法。

9.4、倒序遍历的计数排序

计数排序还有一种写法,在计算元素在最终结果数组中的下标位置这一步,不是计算初始下标位置,而是计算最后一个下标位置。最后倒序遍历 arr 数组,逐个将 arr 中的元素放到最终位置上。

代码如下:

  1. public static void countingSort(int[] arr) {
  2. // 防止数组越界
  3. if (arr == null || arr.length <= 1) return;
  4. // 找到最大值,最小值
  5. int max = arr[0];
  6. int min = arr[0];
  7. for (int i = 1; i < arr.length; i++) {
  8. if (arr[i] > max) max = arr[i];
  9. else if (arr[i] < min) min = arr[i];
  10. }
  11. // 确定计数范围
  12. int range = max - min + 1;
  13. // 建立长度为 range 的数组,下标 0~range-1 对应数字 min~max
  14. int[] counting = new int[range];
  15. // 遍历 arr 中的每个元素
  16. for (int element : arr) {
  17. // 将每个整数出现的次数统计到计数数组中对应下标的位置,这里需要将每个元素减去 min,才能映射到 0~range-1 范围内
  18. counting[element - min]++;
  19. }
  20. // 每个元素在结果数组中的最后一个下标位置 = 前面比自己小的数字的总数 + 自己的数量 - 1。我们将 counting[0] 先减去 1,后续 counting 直接累加即可
  21. counting[0]--;
  22. for (int i = 1; i < range; i++) {
  23. // 将 counting 计算成当前数字在结果中的最后一个下标位置。位置 = 前面比自己小的数字的总数 + 自己的数量 - 1
  24. // 由于 counting[0] 已经减了 1,所以后续的减 1 可以省略。
  25. counting[i] += counting[i - 1];
  26. }
  27. int[] result = new int[arr.length];
  28. // 从后往前遍历数组,通过 counting 中记录的下标位置,将 arr 中的元素放到 result 数组中
  29. for (int i = arr.length - 1; i >= 0; i--) {
  30. // counting[arr[i] - min] 表示此元素在结果数组中的下标
  31. result[counting[arr[i] - min]] = arr[i];
  32. // 更新 counting[arr[i] - min],指向此元素的前一个下标
  33. counting[arr[i] - min]--;
  34. }
  35. // 将结果赋值回 arr
  36. for (int i = 0; i < arr.length; i++) {
  37. arr[i] = result[i];
  38. }
  39. }

两种算法的核心思想是一致的,并且都是稳定的。第一种写法理解起来简单一些,第二种写法在性能上更好一些。

在计算下标位置时,不仅计算量更少,还省去了 preCounts 这个变量。在《算法导论》一书中,便是采用的此种写法。

实际上,这个算法最后不通过倒序遍历也能得到正确的排序结果,但这里只有通过倒序遍历的方式,才能保证计数排序的稳定性。

9.5、时间复杂度 & 空间复杂度

从计数排序的实现代码中,可以看到,每次遍历都是进行 n 次或者 k 次,所以计数排序的时间复杂度为 O(n + k),k 表示数据的范围大小。

用到的空间主要是长度为 k 的计数数组和长度为 n 的结果数组,所以空间复杂度也是 O(n + k)。

需要注意的是,一般我们分析时间复杂度和空间复杂度时,常数项都是忽略不计的。但计数排序的常数项可能非常大,以至于我们无法忽略。不知你是否注意到计数排序的一个非常大的隐患,比如我们想要对这个数组排序:

  1. int[] arr = new int[]{1, Integer.MAX_VALUE};

尽管它只包含两个元素,但数据范围是 [1, 2^{31}],我们知道 java 中 int 占 44 个字节,一个长度为 2^{31}次方的 int 数组大约会占 8G 的空间。如果使用计数排序,仅仅排序这两个元素,声明计数数组就会占用超大的内存,甚至导致 OutOfMemory 异常。

所以计数排序只适用于数据范围不大的场景。例如对考试成绩排序就非常适合计数排序,如果需要排序的数字中存在一位小数,可以将所有数字乘以 10,再去计算最终的下标位置。

9.6、计数排序与 O(nlogn) 级排序算法的本质区别

前文说到,希尔排序通过交换间隔较远的元素突破了排序算法时间复杂度 O(n^2)的下界。同样地,我们接下来就一起分析一下,计数排序凭什么能够突破 O(nlogn) 的下界呢?它和之前介绍的 O(nlog n) 级排序算法的本质区别是什么?

这个问题我们可以从决策树的角度和概率的角度来理解。

9.7、决策树

决策树是一棵完全二叉树,它可以反映比较排序算法中对所有元素的比较操作。

以包含三个整数的数组 [a, b, c] 为例,基于比较的排序算法的排序过程可以抽象为这样一棵 决策树

这棵决策树上的每一个叶结点都对应了一种可能的排列,从根结点到任意一个叶结点之间的最短路径(也称为「简单路径」)的长度,表示的是完成对应排列的比较次数。所以从根结点到叶结点之间的最长简单路径的长度,就表示比较排序算法中最坏情况下的比较次数。

设决策树的高度为 h,叶结点的数量为 l,排序元素总数为 n 。

因为叶结点最多有 n! 个,所以我们可以得到:n! ≤ l,又因为一棵高度为 h 的二叉树,叶结点的数量最多为 2^h,所以我们可以得到:n! ≤ l ≤ 2^h

对该式两边取对数,可得:h≥log(n!)

由斯特林(Stirling)近似公式,可知 lg(n!)=O(nlogn)

所以 h≥log(n!)=O(nlogn)

于是我们可以得出以下定理:

《算法导论》定理 8.1:在最坏情况下,任何比较排序算法都需要做 O(n \log n)O(nlogn) 次比较。

由此我们还可以得到以下推论:

《算法导论》推论 8.2:堆排序和归并排序都是渐进最优的比较排序算法。

到这里我们就可以得出结论了,如果基于比较来进行排序,无论怎么优化都无法突破O(nlogn) 的下界。计数排序和基于比较的排序算法相比,根本区别就在于:它不是基于比较的排序算法,而是利用了数字本身的属性来进行的排序。整个计数排序算法中没有出现任何一次比较。

9.8、概率

相信大家都玩过「猜数字」游戏:一方从 [1, 100]中随机选取一个数字,另一方来猜。每次猜测都会得到「高了」或者「低了」的回答。怎样才能以最少的次数猜中呢?

答案很简单:二分。

二分算法能够保证每次都排除一半的数字。每次猜测不会出现惊喜(一次排除了多于一半的数字),也不会出现悲伤(一次只排除了少于一半的数字),因为答案的每一个分支都是等概率的,所以它在最差的情况下表现是最好的,猜测的一方在 logn 次以内必然能够猜中。

基于比较的排序算法与「猜数字」是类似的,每次比较,我们只能得到 a>b 或者 a≤b 两种结果,如果我们把数组的全排列比作一块区域,那么每次比较都只能将这块区域分成两份,也就是说每次比较最多排除掉 1/2 的可能性。

再来看计数排序算法,计数排序时申请了长度为 k 的计数数组,在遍历每一个数字时,这个数字落在计数数组中的可能性共有 k 种,但通过数字本身的大小属性,我们可以「一次」把它放到正确的位置上。相当于一次排除了 (k - 1)/k 种可能性。

这就是计数排序算法比基于比较的排序算法更快的根本原因。

十、基数排序

想一下我们是怎么对日期进行排序的。比如对这样三个日期进行排序:2014 年 1 月 7 日,2020 年 1 月 9 日,2020 年 7 月 10 日。

我们大脑中对日期排序的思维过程是:

先看年份,2014 比 2020 要小,所以 2014 年这个日期应该放在其他两个日期前面。

另外两个日期年份相等,所以我们比较一下月份,1 比 7 要小,所以 1 月这个日期应该放在 7 月这个日期前面

这种利用多关键字进行排序的思想就是基数排序,和计数排序一样,这也是一种线性时间复杂度的排序算法。其中的每个关键字都被称作一个基数。

比如我们对 999, 997, 866, 666 这四个数字进行基数排序,过程如下:

先看第一位基数:6 比 8 小,8 比 9 小,所以 666 是最小的数字,866 是第二小的数字,暂时无法确定两个以 9 开头的数字的大小关系

再比较 9 开头的两个数字,看他们第二位基数:9 和 9 相等,暂时无法确定他们的大小关系

再比较 99 开头的两个数字,看他们的第三位基数:7 比 9 小,所以 997 小于 999

基数排序有两种实现方式。本例属于「最高位优先法」,简称 MSD (Most significant digital),思路是从最高位开始,依次对基数进行排序。

与之对应的是「最低位优先法」,简称 LSD (Least significant digital)。思路是从最低位开始,依次对基数进行排序。使用 LSD 必须保证对基数进行排序的过程是稳定的。

通常来讲,LSD 比 MSD 更常用。以上述排序过程为例,因为使用的是 MSD,所以在第二步比较两个以 9 开头的数字时,其他基数开头的数字不得不放到一边。体现在计算机中,这里会产生很多临时变量。

但在采用 LSD 进行基数排序时,每一轮遍历都可以将所有数字一视同仁,统一处理。所以 LSD 的基数排序更符合计算机的操作习惯。

基数排序最早是用在卡片排序机上的,一张卡片有 80 列,类似一个 80 位的整数。机器通过在卡片不同位置上穿孔表示当前基数的大小。卡片排序机的排序过程就是采用的 LSD 的基数排序。

基数排序可以分为以下三个步骤:

  • 找出数组中最大的数字的位数 maxDigitLength
  • 获取数组中每个数字的基数
  • 遍历 maxDigitLength 轮数组,每轮按照基数对其进行排序

10.1、找出数组中最大的数字的位数

首先找到数组中的最大值:

  1. public static void radixSort(int[] arr) {
  2. if (arr == null) return;
  3. int max = 0;
  4. for (int value : arr) {
  5. if (value > max) {
  6. max = value;
  7. }
  8. }
  9. // ...
  10. }

通过遍历一次数组,找到了数组中的最大值 max,然后我们计算这个最大值的位数:

  1. int maxDigitLength = 0;
  2. while (max != 0) {
  3. maxDigitLength++;
  4. max /= 10;
  5. }

将 maxDigitLength 初始化为 0,然后不断地除以 10,每除一次,maxDigitLength 就加一,直到 max 为 0。

读者可能会有疑惑,如果 max 初始值就是 0 呢?严格来讲,0 在数学上属于 1 位数。

但实际上,基数排序时我们无需考虑 max 为 0 的场景,因为 max 为 0 只有一种可能,那就是数组中所有的数字都为 0,此时数组已经有序,我们无需再进行后续的排序过程。

10.2、获取基数

获取基数有两种做法:

第一种:

  1. int mod = 10;
  2. int dev = 1;
  3. for (int i = 0; i < maxDigitLength; i++) {
  4. for (int value : arr) {
  5. int radix = value % mod / dev;
  6. // 对基数进行排序
  7. }
  8. mod *= 10;
  9. dev *= 10;
  10. }

第二种

  1. int dev = 1;
  2. for (int i = 0; i < maxDigitLength; i++) {
  3. for (int value : arr) {
  4. int radix = value / dev % 10;
  5. // 对基数进行排序
  6. }
  7. dev *= 10;
  8. }

两者的区别是先做除法运算还是先做模运算,推荐使用第二种写法,因为它可以节省一个变量。

10.3、对基数进行排序

对基数进行排序非常适合使用我们在上一节中学习的计数排序算法,因为每一个基数都在 [0, 9][0,9] 之间,并且计数排序是一种稳定的算法。

LSD 方式的基数排序代码如下:

  1. public class RadixSort {
  2. public static void radixSort(int[] arr) {
  3. if (arr == null) return;
  4. // 找出最大值
  5. int max = 0;
  6. for (int value : arr) {
  7. if (value > max) {
  8. max = value;
  9. }
  10. }
  11. // 计算最大数字的长度
  12. int maxDigitLength = 0;
  13. while (max != 0) {
  14. maxDigitLength++;
  15. max /= 10;
  16. }
  17. // 使用计数排序算法对基数进行排序
  18. int[] counting = new int[10];
  19. int[] result = new int[arr.length];
  20. int dev = 1;
  21. for (int i = 0; i < maxDigitLength; i++) {
  22. for (int value : arr) {
  23. int radix = value / dev % 10;
  24. counting[radix]++;
  25. }
  26. for (int j = 1; j < counting.length; j++) {
  27. counting[j] += counting[j - 1];
  28. }
  29. // 使用倒序遍历的方式完成计数排序
  30. for (int j = arr.length - 1; j >= 0; j--) {
  31. int radix = arr[j] / dev % 10;
  32. result[--counting[radix]] = arr[j];
  33. }
  34. // 计数排序完成后,将结果拷贝回 arr 数组
  35. System.arraycopy(result, 0, arr, 0, arr.length);
  36. // 将计数数组重置为 0
  37. Arrays.fill(counting, 0);
  38. dev *= 10;
  39. }
  40. }
  41. }

计数排序的思想上一节已经介绍过,这里不再赘述。当每一轮对基数完成排序后,我们将 result 数组的值拷贝回 arr 数组,并且将 counting 数组中的元素都置为 0,以便在下一轮中复用。

10.4、对包含负数的数组进行基数排序

如果数组中包含负数,如何进行基数排序呢?

我们很容易想到一种思路:将数组中的每个元素都加上一个合适的正整数,使其全部变成非负整数,等到排序完成后,再减去之前加的这个数就可以了。

但这种方案有一个缺点:加法运算可能导致数字越界,所以必须单独处理数字越界的情况。

事实上,有一种更好的方案解决负数的基数排序。那就是在对基数进行计数排序时,申请长度为 19 的计数数组,用来存储 [−9,9] 这个区间内的所有整数。在把每一位基数计算出来后,加上 9,就能对应上 counting 数组的下标了。也就是说,counting 数组的下标 [0, 18] 对应基数 [-9, 9]。

代码如下:

  1. public class RadixSort {
  2. public static void radixSort(int[] arr) {
  3. if (arr == null) return;
  4. // 找出最长的数
  5. int max = 0;
  6. for (int value : arr) {
  7. if (Math.abs(value) > max) {
  8. max = Math.abs(value);
  9. }
  10. }
  11. // 计算最长数字的长度
  12. int maxDigitLength = 0;
  13. while (max != 0) {
  14. maxDigitLength++;
  15. max /= 10;
  16. }
  17. // 使用计数排序算法对基数进行排序,下标 [0, 18] 对应基数 [-9, 9]
  18. int[] counting = new int[19];
  19. int[] result = new int[arr.length];
  20. int dev = 1;
  21. for (int i = 0; i < maxDigitLength; i++) {
  22. for (int value : arr) {
  23. // 下标调整
  24. int radix = value / dev % 10 + 9;
  25. counting[radix]++;
  26. }
  27. for (int j = 1; j < counting.length; j++) {
  28. counting[j] += counting[j - 1];
  29. }
  30. // 使用倒序遍历的方式完成计数排序
  31. for (int j = arr.length - 1; j >= 0; j--) {
  32. // 下标调整
  33. int radix = arr[j] / dev % 10 + 9;
  34. result[--counting[radix]] = arr[j];
  35. }
  36. // 计数排序完成后,将结果拷贝回 arr 数组
  37. System.arraycopy(result, 0, arr, 0, arr.length);
  38. // 将计数数组重置为 0
  39. Arrays.fill(counting, 0);
  40. dev *= 10;
  41. }
  42. }
  43. }

代码中主要做了两处修改:

  • 当数组中存在负数时,我们就不能简单的计算数组的最大值了,而是要计算数组中绝对值最大的数,也就是数组中最长的数
  • 在获取基数的步骤,将计算出的基数加上 9,使其与 counting 数组下标一一对应

10.5、LSD VS MSD

前文介绍的基数排序都属于 LSD,接下来我们看一下基数排序的 MSD 实现。

  1. public class RadixSort {
  2. public static void radixSort(int[] arr) {
  3. if (arr == null) return;
  4. // 找到最大值
  5. int max = 0;
  6. for (int value : arr) {
  7. if (Math.abs(value) > max) {
  8. max = Math.abs(value);
  9. }
  10. }
  11. // 计算最大长度
  12. int maxDigitLength = 0;
  13. while (max != 0) {
  14. maxDigitLength++;
  15. max /= 10;
  16. }
  17. radixSort(arr, 0, arr.length - 1, maxDigitLength);
  18. }
  19. // 对 arr 数组中的 [start, end] 区间进行基数排序
  20. private static void radixSort(int[] arr, int start, int end, int position) {
  21. if (start == end || position == 0) return;
  22. // 使用计数排序对基数进行排序
  23. int[] counting = new int[19];
  24. int[] result = new int[end - start + 1];
  25. int dev = (int) Math.pow(10, position - 1);
  26. for (int i = start; i <= end; i++) {
  27. // MSD, 从最高位开始
  28. int radix = arr[i] / dev % 10 + 9;
  29. counting[radix]++;
  30. }
  31. for (int j = 1; j < counting.length; j++) {
  32. counting[j] += counting[j - 1];
  33. }
  34. // 拷贝 counting,用于待会的递归
  35. int[] countingCopy = new int[counting.length];
  36. System.arraycopy(counting, 0, countingCopy, 0, counting.length);
  37. for (int i = end; i >= start; i--) {
  38. int radix = arr[i] / dev % 10 + 9;
  39. result[--counting[radix]] = arr[i];
  40. }
  41. // 计数排序完成后,将结果拷贝回 arr 数组
  42. System.arraycopy(result, 0, arr, start, result.length);
  43. // 对 [start, end] 区间内的每一位基数进行递归排序
  44. for (int i = 0; i < counting.length; i++) {
  45. radixSort(arr, i == 0 ? start : start + countingCopy[i - 1], start + countingCopy[i] - 1, position - 1);
  46. }
  47. }
  48. }

使用 MSD 时,下一轮排序只应该发生在当前轮次基数相等的数字之间,对每一位基数进行递归排序的过程中会产生许多临时变量。

相比 LSD,MSD 的基数排序显得较为复杂。因为我们每次对基数进行排序后,无法将所有的结果一视同仁地进行下一轮排序,否则下一轮排序会破坏本次排序的结果。

10.6、时间复杂度 & 空间复杂度

无论 LSD 还是 MSD,基数排序时都需要经历 maxDigitLength 轮遍历,每轮遍历的时间复杂度为 O(n + k) ,其中 k 表示每个基数可能的取值范围大小。如果是对非负整数排序,则 k = 10,如果是对包含负数的数组排序,则 k = 19。

所以基数排序的时间复杂度为 O(d(n + k)) (d 表示最长数字的位数,k 表示每个基数可能的取值范围大小)。

使用的空间和计数排序是一样的,空间复杂度为 O(n + k)(k 表示每个基数可能的取值范围大小)。

备战秋招之十大排序——O(n)级排序算法的更多相关文章

  1. 备战秋招之十大排序——O(nlogn)级排序算法

    时间复杂度O(nlogn)级排序算法 五.希尔排序 首批将时间复杂度降到 O(n^2) 以下的算法之一.虽然原始的希尔排序最坏时间复杂度仍然是O(n^2),但经过优化的希尔排序可以达到 O(n^{1. ...

  2. 备战秋招之十大排序——O(n^2)级排序算法

    一.冒泡排序 冒泡排序是入门级的算法,但也有一些有趣的玩法.通常来说,冒泡排序有三种写法: 一边比较一边向后两两交换,将最大值 / 最小值冒泡到最后一位: 经过优化的写法:使用一个变量记录当前轮次的比 ...

  3. 我的Java秋招面经大合集

    阿里面经   阿里中间件研发面经 蚂蚁金服研发面经 岗位是研发工程师,直接找蚂蚁金服的大佬进行内推. 我参与了阿里巴巴中间件部门的提前批面试,一共经历了四次面试,拿到了口头offer. 然后我也参加了 ...

  4. Java秋招面经大合集

    微信公众号[程序员江湖] 作者黄小斜,斜杠青年,某985硕士,阿里 Java 研发工程师,于 2018 年秋招拿到 BAT 头条.网易.滴滴等 8 个大厂 offer,目前致力于分享这几年的学习经验. ...

  5. ​​ ​​我的Java秋招面经大合集(包含BAT头条网易等公司)

    ​ 微信公众号[程序员江湖] 作者黄小斜,斜杠青年,某985硕士,阿里 Java 研发工程师,于 2018 年秋招拿到 BAT 头条.网易.滴滴等 8 个大厂 offer,目前致力于分享这几年的学习经 ...

  6. 机器学习——十大数据挖掘之一的决策树CART算法

    本文始发于个人公众号:TechFlow,原创不易,求个关注 今天是机器学习专题的第23篇文章,我们今天分享的内容是十大数据挖掘算法之一的CART算法. CART算法全称是Classification ...

  7. Java程序员秋招面经大合集(BAT美团网易小米华为中兴等)

    Cvte提前批 阿里内推 便利蜂内推 小米内推 金山wps内推 多益网络 拼多多学霸批 搜狗校招 涂鸦移动 中国电信it研发中心 中兴 华为 苏宁内推 美团内推 百度 腾讯 招商银行信用卡 招银网络科 ...

  8. 备战秋招——C++知识点

    1.字符串的末尾'\0'也算一个字符,一个字节. 2.使用库函数strcpy(a,b)进行拷贝b->a操作,strcpy会从源地址一直往后拷贝,直到遇到'\0'为止.所以拷贝的长度是不定的.如果 ...

  9. 一篇夯实一个知识点系列--python实现十大排序算法

    写在前面 排序是查找是算法中最重要的两个概念,我们大多数情况下都在进行查找和排序.科学家们穷尽努力,想使得排序和查找能够更加快速.本篇文章用Python实现十大排序算法. 干货儿 排序算法从不同维度可 ...

随机推荐

  1. WPF教程十五:数据模板的使用(重发)

    数据模板 数据模板是一段如何显示绑定在VM对象的XAML代码.数据模板可以包含任意元素的组合,基于Binding来显示不同的信息. 在实际的开发中数据模板的应用场景很多,同样一个控件可以根据不同的绑定 ...

  2. Luogu P2051「AHOI2009」中国象棋

    看见第一眼觉得是状压 \(\text{DP}\)?观察数据范围发现不可做 那按照最常规思路设状态试试? 设状态为\(dp[i][j]\)表示\(i*j\)的棋盘的方案数 好像转移不了欸 要不再来一维? ...

  3. Linux系统引导过程及排除启动故障

    一.Linux操作系统引导过程二.系统初始化进程1.init进程2.Systemd3.Systemd单元类型三.排除启动类故障[1].修复MBR扇区故障(含实验过程)[2].修复GRUB引导故障●方法 ...

  4. c语言:sprintf() 数字转字符赋值给数组

    //sprintf() //sprintf 最常见的应用之一是把整数打印到字符串中,所以,spritnf 在大多数场合可以替代itoa /* 缓冲区溢出 第一个参数的长度太短了,没的说,给个大点的地方 ...

  5. kubernetes/k8s CSI分析-容器存储接口分析

    更多 k8s CSI 的分析,可以查看这篇博客kubernetes ceph-csi分析,以 ceph-csi 为例,做了详细的源码分析. 概述 kubernetes的设计初衷是支持可插拔架构,从而利 ...

  6. springMVC-10-文件上传

    导入依赖(注意会和servlet-api依赖冲突) <!--文件上传jar包, 前面已导过servlet-api需排除--> <dependency> <groupId& ...

  7. mybatis-7-缓存

    1. 一级缓存: SqlSession 级别, 默认开启, 并且不能关闭 操作数据库是需要创建 SqlSession 对象, 在对象中有一个 HashMap 用于存储缓存数据, 不同的 SqlSess ...

  8. Qt 入门 ---- 如何在程序窗口显示图片?

    步骤: 1. 选择资源(准备图片) 2. 加载资源(导入图片) 3. 使用资源(显示图片) 具体操作流程: ① 从网上寻找合适的图片素材,下载到本地,在项目根目录下创建一个images文件夹存储程序中 ...

  9. Java 中 this 和 super 的用法详解

    前言 这次我们来回顾一下this和super这两个关键字的用法,作为一名Java程序员,我觉得基础是最重要的,因为它决定了我们的上限,所以我的文章大部分还是以分享Java基础知识为主,学好基础,后面的 ...

  10. SpringBoot下Schdule的配置与使用

    我们在平常项目开发中,经常会用到周期性定时任务,这个时候使用定时任务就能很方便的实现.在SpringBoot中用得最多的就是Schedule. 一.SpringBoot集成Schedule 1.依赖配 ...