超全十大经典排序算法及其分析

文章目录

  • 0.算法概述
    • 0.1 算法分类
    • 0.2 算法复杂度
    • 0.3 相关概念
  • 1. 冒泡排序(Bubble Sort)
    • 1.1 算法描述:
    • 1.2 图解演示
    • 1.3 代码实现
    • 1.4 优化过程
    • 1.5 性能分析
  • 2. 选择排序(Selection Sort)
    • 2.1 算法描述:
    • 2.2 图解演示
    • 2.3 代码实现
    • 2.4 优化过程
    • 2.5 性能分析
    • 2.6 拓展
  • 3. 插入排序(Insertion Sort)
    • 3.1 算法描述
    • 3.2 图解演示
    • 3.3 代码演示:
    • 3.4 优化过程
    • 3.5 性能分析
    • 3.6 应用分析
  • 4.快速排序(Quick Sort)
    • 4.1算法描述
    • 4.2 图解演示
    • 4.3 代码实现
      • ①快速排序递归框架
      • ②退出递归的边界条件
      • ③基数的选择
      • ④分区算法实现
      • ⑤最简单的分区算法
      • ⑥双指针分区算法
    • 4.4 性能分析
    • 4.5快速排序的优化思路
  • 5.希尔排序(Shell Sort)
    • 5.1 算法描述
    • 5.2 图解演示
    • 5.3 代码实现
    • 5.4 优化过程
    • 5.5增量序列
    • 5.6 性能分析
    • 5.7 希尔排序与 O(n^2)级排序算法的本质区别
  • 6.归并排序(Merge Sort)
    • 6.1算法描述
    • 6.2 算法图解
    • 6.3 代码实现
    • 6.4 性能分析
    • 6.5 应用分析
  • 7.堆排序(Heap Sort)
    • 7.1算法描述
    • 7.2图解演示
    • 7.3 代码实现
    • 7.4 性能分析
  • 8.计数排序(Counting Sort)
    • 8.1算法描述
    • 8.2 图解演示
    • 8.3代码实现
    • 8.4 性能分析
    • 8.5 拓展
  • 9.基数排序(Radix Sort)
    • 9.1 算法描述
    • 9.2 图解演示
    • 9.3 代码实现
    • 9.4 对包含负数的数组进行基数排序
    • 9.5 LSD VS MSD
    • 9.6 性能分析
  • 10.桶排序(Bucket Sort)
    • 10.1 算法描述
    • 10.2 图解演示
    • 10.3 代码实现
    • 10.4 性能分析
    • 10.5 拓展
  • 11.非比较类排序算法总结

0.算法概述

0.1 算法分类

1、时间复杂度O(n^2)级排序算法:

  • 选择排序
  • 插入排序
  • 冒泡排序

2、时间复杂度O(nlogn)级排序算法:

  • 快速排序
  • 希尔排序
  • 归并排序
  • 堆排序

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

  • 桶排序
  • 基数排序
  • 计数排序

4、比较类排序

  • 交换排序
    • 冒泡排序 Bubble Sort
    • 快速排序 Quick Sort
  • 插入排序
    • 简单插入排序 Insertion Sort
    • 希尔排序 Shell Sort
  • 选择排序
    • 简单选择排序 Selection Sort
    • 堆排序 Heap Sort
  • 归并排序
    • 二路归并排序 Merge Sort
    • 多路归并排序 Merge Sort
  • 非比较排序
    • 计数排序 Counting Sort
    • 基数排序 Radix Sort
    • 桶排序 Bucket Sort

0.2 算法复杂度

在这里插入图片描述

0.3 相关概念

时空复杂度:

时间复杂度:时间随着问题(数据)规模的扩大而如何变化的:一般讲时间复杂度都是讲”最坏“的;

  • 不考虑必须要做的操作:循环、赋初值、程序初始化:----->O(1)
  • 不考虑常数项:2n -----> n
  • 不考虑低次项:n^(2+n)-----> n^2

空间复杂度:算法需要用到的额外空间,不包括该问题必须分配的空间;

分类:

  • 非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序;

  • 线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序;

  • 内排序:所有排序操作都在内存中完成;

  • 外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行;

稳定性:

  • 假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i] = r[j],且 r[i] 在 r[j] 之前,而在排序后的序列中,r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。

推荐的标准来源于面试笔试的考察情况:

冒泡排序 ■■□□□
快速排序 ■■■■■
插入排序 ■■■□□
希尔排序 ■□□□□
归并排序 ■■■■■
选择排序 ■■□□□
堆排序 ■■■■□
计数排序 ■■□□□
基数排序 ■□□□□
桶排序 ■□□□□
根据上面的情况,相信你会有所侧重地学习排序算法。

1. 冒泡排序(Bubble Sort)

依次比较两个相邻的元素,如果顺序(如从大到小、首字母从Z到A)错误就把他们交换过来。走访元素的工作是重复地进行直到没有相邻元素需要交换,也就是说该元素列已经排序完成。

1.1 算法描述:

  • 比较相邻的元素。如果第一个比第二个大,就交换他们两个;
  • 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数;
  • 针对所有的元素重复以上的步骤,除了最后一个;
  • 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较;

1.2 图解演示

img

1.3 代码实现

冒泡排序第一种写法:

代码如下:

public static void bubbleSort(int[] arr) {for (int i = 0; i < arr.length - 1; i++) {for (int j = 0; j < arr.length - 1 - i; j++) {if (arr[j] > arr[j + 1]) {// 如果左边的数大于右边的数,则交换,保证右边的数字最大swap(arr, j, j + 1);}}}
}
// 交换元素
private static void swap(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}

1.4 优化过程

  • 一边比较一边向后两两交换,将最大值 / 最小值冒泡到最后一位;

  • 经过优化的写法:使用一个变量记录当前轮次的比较是否发生过交换,如果没有发生交换表示已经有序,不再继续排序;

  • 进一步优化的写法:除了使用变量记录当前轮次是否发生交换外,再使用一个变量记录上次发生交换的位置,下一轮排序时到达上次交换的位置就停止比较;

    最外层的 for 循环每经过一轮,剩余数字中的最大值就会被移动到当前轮次的最后一位,中途也会有一些相邻的数字经过交换变得有序。总共比较次数是 (n-1)+(n-2)+(n-3)+…+1(n−1)+(n−2)+(n−3)+…+1。

冒泡排序第二种写法:

第二种写法是在第一种写法的改良基础下而来,代码如下:

public static void bubbleSort(int[] arr) {// 初始时 swapped 为 true,否则排序过程无法启动boolean swapped = true;for (int i = 0; i < arr.length - 1; i++) {// 如果没有发生过交换,说明剩余部分已经有序,排序完成if (!swapped) break;// 设置 swapped 为 false,如果发生交换,则将其置为 trueswapped = false;for (int j = 0; j < arr.length - 1 - i; j++) {if (arr[j] > arr[j + 1]) {// 如果左边的数大于右边的数,则交换,保证右边的数字最大swap(arr, j, j + 1);// 表示发生了交换swapped = true;}}}
}

最外层的 for 循环每经过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位。这种写法相对于第一种写法的优点是:如果一轮比较中没有发生过交换,则立即停止排序,因为此时剩余数字一定已经有序了。

看下动图演示:

img

图中可以看出:

第一轮排序将数字 66 移动到最右边;
第二轮排序将数字 55 移动到最右边,同时中途将 11 和 22 排了序;
第三轮排序时,没有发生交换,表明排序已经完成,不再继续比较。

冒泡排序的第三种写法:

第三种写法比较少见,它是在第二种写法的基础上进一步优化:

经过再一次的优化,代码看起来就稍微有点复杂了。最外层的 while 循环每经过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位。

在下一轮比较时,只需比较到上一轮比较中,最后一次发生交换的位置即可。因为后面的所有元素都没有发生过交换,必然已经有序了。

最好情况:在数组已经有序的情况下,只需遍历一次,由于没有发生交换,排序结束。

img

最差情况:数组顺序为逆序,每次比较都会发生交换。

img

代码如下:

public static void bubbleSort(int[] arr) {boolean swapped = true;// 最后一个没有经过排序的元素的下标int indexOfLastUnsortedElement = arr.length - 1;// 上次发生交换的位置int swappedIndex = -1;while (swapped) {swapped = false;for (int i = 0; i < indexOfLastUnsortedElement; i++) {if (arr[i] > arr[i + 1]) {// 如果左边的数大于右边的数,则交换,保证右边的数字最大swap(arr, i, i + 1);// 表示发生了交换swapped = true;// 更新交换的位置swappedIndex = i;}}// 最后一个没有经过排序的元素的下标就是最后一次发生交换的位置indexOfLastUnsortedElement = swappedIndex;}
}

1.5 性能分析

时间复杂度、空间复杂度:

  • ​ 第一种写法的比较次数为 (n-1)+(n-2)+(n-3)+…+1,总比较次数为 (n^2)/2,所以时间复杂度为 O(n^2),使用空间只有 i、j 两个变量,所以空间复杂度为 O(1);

  • ​ 第二种写法在数组已经有序的情况下比较次数为 n-1,只需比较一轮即可完成排序,此时时间复杂度为 O(n),最坏的情况和第一种写法一样,平均时间复杂度仍是 O(n^2)使用的空间最多 i、j、swapped、temp 四个变量,最好的情况下只有 i、j、swapped 三个变量,所以空间复杂度为 O(1);

  • ​ 第三种写法时间复杂度和第二种写法一样,平均时间复杂度是 O(n^2)只是实际运行效率比第二种写法好一些;使用的空间最多 swapped、indexOfLastUnsortedElement、swappedIndex、i、temp 五个变量,最好的情况下没有 temp 变量,所以空间复杂度为 O(1)。

稳定性:

​ 冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,是不会再交换的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法。

2. 选择排序(Selection Sort)

选择排序(Selectionsort)是一种简单直观的排序算法。它的工作原理是:第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。以此类推,直到全部待排序的数据元素的个数为零。

2.1 算法描述:

n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:

  • 初始状态:无序区为N[1…n],有序区为空;
  • 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为N[1…i-1]和N(i…n)。该趟排序从当前无序区中选出关键字最小的记录 N[m],将它与无序区的第1个记录N交换,使N[1…i]和N[i+1…n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
  • n-1趟结束,数组有序化了。

2.2 图解演示

2.3 代码实现

public static void selectionSort(int[] arr) {int minIndex;for (int i = 0; i < arr.length - 1; i++) {minIndex = i;for (int j = i + 1; j < arr.length; j++) {if (arr[minIndex] > arr[j]) {// 记录最小值的下标minIndex = j;}}// 将最小元素交换至首位int temp = arr[i];arr[i] = arr[minIndex];arr[minIndex] = temp;}
}

2.4 优化过程

二元选择排序:

选择排序算法也是可以优化的,既然每轮遍历时找出了最小值,何不把最大值也顺便找出来呢?这就是二元选择排序的思想。

使用二元选择排序,每轮选择时记录最小值和最大值,可以把数组需要遍历的范围缩小一倍。

public static void selectionSort2(int[] arr) {int minIndex, maxIndex;// i 只需要遍历一半for (int i = 0; i < arr.length / 2; i++) {minIndex = i;maxIndex = i;for (int j = i + 1; j < arr.length - i; j++) {if (arr[minIndex] > arr[j]) {// 记录最小值的下标minIndex = j;}if (arr[maxIndex] < arr[j]) {// 记录最大值的下标maxIndex = j;}}
// 如果 minIndex 和 maxIndex 都相等,那么他们必定都等于 i,且后面的所有数字都与 arr[i] 相等,此时已经排序完成if (minIndex == maxIndex) break;// 将最小元素交换至首位int temp = arr[i];arr[i] = arr[minIndex];arr[minIndex] = temp;
// 如果最大值的下标刚好是 i,由于 arr[i] 和 arr[minIndex] 已经交换了,所以这里要更新 maxIndex 的值。if (maxIndex == i) maxIndex = minIndex;// 将最大元素交换至末尾int lastIndex = arr.length - 1 - i;temp = arr[lastIndex];arr[lastIndex] = arr[maxIndex];arr[maxIndex] = temp;}
}

我们使用 minIndex 记录最小值的下标,maxIndex 记录最大值的下标。每次遍历后,将最小值交换到首位,最大值交换到末尾,就完成了排序。

由于每一轮遍历可以排好两个数字,所以最外层的遍历只需遍历一半即可。

二元选择排序中有一句很重要的代码,它位于交换最小值和交换最大值的代码中间:

if (maxIndex == i) maxIndex = minIndex;

这行代码的作用处理了一种特殊情况:如果最大值的下标等于 i,也就是说 arr[i] 就是最大值,由于 arr[i] 是当前遍历轮次的首位,它已经和 arr[minIndex] 交换了,所以最大值的下标需要跟踪到 arr[i] 最新的下标 minIndex。

二元选择排序的效率:
在二元选择排序算法中,数组需要遍历的范围缩小了一倍。那么这样可以使选择排序的效率提升一倍吗?

从代码可以看出,虽然二元选择排序最外层的遍历范围缩小了,但 for 循环内做的事情翻了一倍。也就是说二元选择排序无法将选择排序的效率提升一倍。但实测会发现二元选择排序的速度确实比选择排序的速度快一点点,它的速度提升主要是因为两点:

  • 在选择排序的外层 for 循环中,i 需要加到 arr.length - 1 ,二元选择排序中 i 只需要加到 arr.length / 2
  • 在选择排序的内层 for 循环中,j 需要加到 arr.length ,二元选择排序中 j 只需要加到 arr.length - i

二元选择排序虽然比选择排序要快,但治标不治本,二元选择排序中做的优化无法改变其时间复杂度,二元选择排序的时间复杂度仍然是 O(n^2);只使用有限个变量,空间复杂度 O(1)。

2.5 性能分析

时间复杂度:

​ 择排序的交换操作介于 0 和 (n - 1)次之间。选择排序的比较操作为 n (n - 1) / 2 次之间。选择排序的赋值操作介于 0 和 3 (n - 1) 次之间。比较次数O(n^2)

比较次数与关键字的初始状态无关,总的比较次数N=(n-1)+(n-2)+…+1=n*(n-1)/2。交换次数O(n),最好情况是,已经有序,交换0次;最坏情况交换n-1次,逆序交换n/2次,由于选择排序在进行排序时无论数组是非有序都需要进行相同次数的寻找和比较,所以最好和最差情况下的时间复杂度都是O(n^2)

空间复杂度:

​ 分配变量时不考虑,临时变量分配完在一次for循环后就消失,没有用到额外的空间,所以空间复杂度为O(1)。

稳定性:

​ 选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第n-1个元素,第n个元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果一个元素比当前元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。举个例子,序列{5,8,5,2,9},我们知道第一遍选择第1个元素5会和2交换,那么原序列中两个5的相对前后顺序就被破坏了,所以选择排序是一个不稳定的排序算法。

2.6 拓展

现在让我们思考一下,冒泡排序和选择排序有什么异同?

  • 相同点:
    • 都是两层循环,时间复杂度都为 O(n^2);都只使用有限个变量,空间复杂度 O(1)。
  • 不同点:
    • 冒泡排序在比较过程中就不断交换,而选择排序增加了一个变量保存最小值 / 最大值的下标,遍历完成后才交换,减少了交换次数。
  • 事实上还有一个非常重要的不同点,那就是:
    • 冒泡排序法是稳定的,选择排序法是不稳定的。

那么排序算法的稳定性有什么意义呢?

​ 其实它只在一种情况下有意义:当要排序的内容是一个对象的多个属性,且其原本的顺序存在意义时,如果我们需要在二次排序后保持原有排序的意义,就需要使用到稳定性的算法。排序算法的稳定性与效率、可靠性都无关。

举个例子,如果我们要对一组商品排序,商品存在两个属性:价格和销量。当我们按照价格从高到低排序后,要再按照销量对其排序,这时,如果要保证销量相同的商品仍保持价格从高到低的顺序,就必须使用稳定性算法。

​ 当然,算法的稳定性与具体的实现有关。在修改比较的条件后,稳定性排序算法可能会变成不稳定的。如冒泡算法中,如果将「左边的数大于右边的数,则交换」这个条件修改为「左边的数大于或等于右边的数,则交换」,冒泡算法就变得不稳定了。同样地,不稳定排序算法也可以经过修改,达到稳定的效果。

思考一下,选择排序算法如何实现稳定排序呢?

​ 实现的方式有很多种,这里给出一种最简单的思路:新开一个数组,将每轮找出的最小值依次添加到新数组中,选择排序算法就变成稳定的了。

​ 但如果将寻找最小值的比较条件由arr[minIndex] > arr[j]修改为arr[minIndex] >= arr[j],即使新开一个数组,选择排序算法依旧是不稳定的。所以分析算法的稳定性时,需要结合具体的实现逻辑才能得出结论,我们通常所说的算法稳定性是基于一般实现而言的。

3. 插入排序(Insertion Sort)

插入排序的思想非常简单,生活中有一个很常见的场景:在打扑克牌时,我们一边抓牌一边给扑克牌排序,每次摸一张牌,就将它插入手上已有的牌中合适的位置,逐渐完成整个排序。

3.1 算法描述

插入排序有两种写法:

  • 交换法:在新数字插入过程中,不断与前面的数字交换,直到找到自己合适的位置。

  • 移动法:在新数字插入过程中,与前面的数字不断比较,前面的数字不断向后挪出位置,当新数字找到自己的位置后,插入一次即可。

  • 具体算法描述如下:

  • 从第一个元素开始,该元素可以认为已经被排序;

  • 取出下一个元素,在已经排序的元素序列中从后向前扫描;

  • 如果该元素(已排序)大于新元素,将该元素移到下一位置;

  • 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;

  • 将新元素插入到该位置后;

  • 重复步骤2~5。

3.2 图解演示

img

3.3 代码演示:

public static void insertSort(int[] arr) {// 从第二个数开始,往前插入数字for (int i = 1; i < arr.length; i++) {// j 记录当前数字下标int j = i;// 当前数字比前一个数字小,则将当前数字与前一个数字交换while (j >= 1 && arr[j] < arr[j - 1]) {swap(arr, j, j - 1);// 更新当前数字下标j--;}}
}
private static void swap(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}

3.4 优化过程

移动法插入排序:

​ 我们发现,在交换法插入排序中,每次交换数字时,swap 函数都会进行三次赋值操作。但实际 上,新插入的这个数字并不一定适合与它交换的数字所在的位置。也就是说,它刚换到新的位置上不久,下一次比较后,如果又需要交换,它马上又会被换到前一个数字的位置。

​ 我们可以想到一种优化方案:让新插入的数字先进行比较,前面比它大的数字不断向后移动,直到找到适合这个新数字的位置后,新数字只做一次插入操作即可。整个过程就像是已经有一些数字坐成了一排,这时一个新的数字要加入,所以这一排数字不断地向后腾出位置,当新的数字找到自己合适的位置后,就可以直接坐下了。重复此过程,直到排序结束。

动图演示:

insert.gif

这种方案我们需要把新插入的数字暂存起来,代码如下:

public static void insertSort(int[] arr) {// 从第二个数开始,往前插入数字for (int i = 1; i < arr.length; i++) {int currentNumber = arr[i];int j = i - 1;// 寻找插入位置的过程中,不断地将比 currentNumber 大的数字向后挪while (j >= 0 && currentNumber < arr[j]) {arr[j + 1] = arr[j];j--;}// 两种情况会跳出循环:1. 遇到一个小于或等于 currentNumber 的数字,跳出循环,currentNumber 就坐到它后面。// 2. 已经走到数列头部,仍然没有遇到小于或等于 currentNumber 的数字,也会跳出循环,此时 j 等于 -1,currentNumber 就坐到数列头部。arr[j + 1] = currentNumber;}
}

3.5 性能分析

时间复杂度:

​ 在插入排序中,当待排序数组是有序时,是最优的情况,只需当前数跟前一个数比较一下就可以了,这时一共需要比较N- 1次,时间复杂度为O(n)。

​ 最坏的情况是待排序数组是逆序的,此时需要比较次数最多,总次数记为:1+2+3+…+N-1,所以,插入排序最坏情况下的时间复杂度为O(n^2)。

​ 平均来说,A[1…j-1]中的一半元素小于A[j],一半元素大于A[j],插入排序在平均情况运行时间与最坏情况运行时间一样,是输入规模的二次函数,时间复杂度为O(n^2) 。

空间复杂度:

​ 由于插入排序是就地排序的,没有用到单独的空间,插入排序的空间复杂度为常数阶:O(1)。

稳定性:

​ 如果待排序的序列中存在两个或两个以上具有相同关键词的数据,排序后这些数据的相对次序保持不变,即它们的位置保持不变,通俗地讲,就是两个相同的数的相对顺序不会发生改变,则该算法是稳定的;如果排序后,数据的相对次序发生了变化,则该算法是不稳定的。关键词相同的数据元素将保持原有位置不变,所以该算法是稳定的 。

3.6 应用分析

  • 插入排序适用于已经有部分数据已经排好,并且排好的部分越大越好。一般在输入规模大于1000的场合下不建议使用插入排序。
  • 比冒泡排序的效率高,冒泡排序需要两两比较两两交换,时间基本快一倍。
  • 比选择排序快一点,因为如果前面的数组基本有序,平均上比较一半就找到位置,选择排序总要从头到尾找一遍。

4.快速排序(Quick Sort)

它的时间复杂度也是 O(nlogn)O(nlogn),但它在时间复杂度为 O(nlogn)O(nlogn) 级的几种排序算法中,大多数情况下效率更高,所以快速排序的应用非常广泛。再加上快速排序所采用的分治思想非常实用,使得快速排序深受面试官的青睐,所以掌握快速排序的思想尤为重要。

4.1算法描述

快速排序算法的基本思想是:

  • 从数组中取出一个数,称之为基数(pivot)
  • 遍历数组,将比基数大的数字放到它的右边,比基数小的数字放到它的左边。遍历完成后,数组被分成了左右两个区域
  • 将左右两个区域视为两个数组,重复前两个步骤,直到排序完成

4.2 图解演示

img

4.3 代码实现

①快速排序递归框架

根据我们分析出的思路,先搭出快速排序的架子:

public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int start, int end) {// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 对左边区域快速排序quickSort(arr, start, middle - 1);// 对右边区域快速排序quickSort(arr, middle + 1, end);
}
public static int partition(int[] arr, int start, int end) {// TODO: 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
}

​ partition 意为“划分”,我们期望 partition 函数做的事情是:将 arr 从 start 到 end 这一区间的值分成两个区域,左边区域的每个数都比基数小,右边区域的每个数都比基数大,然后返回中间值的下标。

​ 只要有了这个函数,我们就能写出快速排序的递归函数框架。首先调用 partition 函数得到中间值的下标 middle,然后对左边区域执行快速排序,也就是递归调用 quickSort(arr, start, middle - 1),再对右边区域执行快速排序,也就是递归调用 quickSort(arr, middle + 1, end)。

现在还有一个问题,何时退出这个递归函数呢?

②退出递归的边界条件

​ 很容易想到,当某个区域只剩下一个数字的时候,自然不需要排序了,此时退出递归函数。实际上还有一种情况,就是某个区域只剩下 0 个数字时,也需要退出递归函数。当 middle 等于 start 或者 end 时,就会出现某个区域剩余数字为 0。

​ 在递归之前,先判断此区域剩余数字是否为 0 个或者 1 个,当数字至少为 2 个时,才执行这个区域的快速排序。因为我们知道 middle >= start && middle <= end 必然成立,所以判断剩余区域的数字为 0 个或者 1 个也就是指 start 或 end 与 middle 相等或相差 1。

我们来分析一下这四个判断条件:

  • 当 start == middle 时,相当于 quickSort(arr, start, middle - 1) 中的 start == end + 1

  • 当 start == middle - 1 时,相当于 quickSort(arr, start, middle - 1) 中的 start == end

  • 当 middle == end 时,相当于 quickSort(arr, middle + 1, end) 中的 start == end + 1

  • 当 middle == end -1 时,相当于 quickSort(arr, middle + 1, end) 中的 start == end

    由上文所说的 middle >= start && middle <= end 可以推出,除了start == end || start == end + 1这两个条件之外,其他的情况下 start 都小于 end。我们需要知道,这里的 start >= end 实际上只有两种情况:

  • start == end: 表明区域内只有一个数字

  • start == end + 1: 表明区域内一个数字也没有
    不会存在 start 比 end 大 2 或者大 3 之类的。

所以最终我们可以得出判断条件为:

public static void quickSort(int[] arr, int start, int end) {// 如果区域内的数字少于 2 个,退出递归if (start >= end) return;// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 对左边区域快速排序quickSort(arr, start, middle - 1);// 对右边区域快速排序quickSort(arr, middle + 1, end);
}

③基数的选择

基数的选择没有固定标准,随意选择区间内任何一个数字做基数都可以。通常来讲有三种选择方式:

  • 选择第一个元素作为基数
  • 选择最后一个元素作为基数
  • 选择区间内一个随机元素作为基数

选择的基数不同,算法的实现也不同。实际上第三种选择方式的平均时间复杂度是最优的。

​ 为什么说随机选择剩余数组中的一个元素作为基数的方案平均复杂度是最优的呢?要理清这个问题,我们先来看一下什么情况下快速排序算法的时间复杂度最高,一共有两种情况:数组为正序、数组为逆序,理想中的快速排序在第 k 轮遍历中,可以排好 2^{k-1}个基数。当数组原本为正序或逆序时,我们将第一个数作为基数的话,每轮分区后,都有一个区域是空的,也就是说数组中剩下的数字都被分到了同一个区域!这就导致了每一轮遍历只能排好一个基数。所以总的比较次数为 (n - 1) + (n - 2) + (n - 3) + … + 1 次,由等差数列求和公式可以计算出总的比较次数为 n(n - 1)/2 次,此时快速排序的时间复杂度达到了 O(n^2)级。

选择第一个元素作为基数:

// 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
public static int partition(int[] arr, int start, int end) {// 取第一个数为基数int pivot = arr[start];// 从第二个数开始分区int left = start + 1;// 右边界int right = end;
}

④分区算法实现

​ 快速排序中最重要的便是分区算法,也就是 partition 函数。partition 函数需要做的事情就是将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标。那么首先我们要做的事情就是选择一个基数,基数我们一般称之为 pivot,意为“轴”。整个数组就像围绕这个轴进行旋转,小于轴的数字旋转到左边,大于轴的数字旋转到右边。

⑤最简单的分区算法

​ 分区的方式也有很多种,最简单的思路是:从 left 开始,遇到比基数大的数,就交换到数组最后,并将 right 减一,直到 left 和 right 相遇,此时数组就被分成了左右两个区域。再将基数和中间的数交换,返回中间值的下标即可。

public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int start, int end) {// 如果区域内的数字少于 2 个,退出递归if (start >= end) return;// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 对左边区域快速排序quickSort(arr, start, middle - 1);// 对右边区域快速排序quickSort(arr, middle + 1, end);
}
// 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
public static int partition(int[] arr, int start, int end) {// 取第一个数为基数int pivot = arr[start];// 从第二个数开始分区int left = start + 1;// 右边界int right = end;// left、right 相遇时退出循环while (left < right) {// 找到第一个大于基数的位置while (left < right && arr[left] <= pivot) left++;// 交换这两个数,使得左边分区都小于或等于基数,右边分区大于或等于基数if (left != right) {exchange(arr, left, right);right--;}}// 如果 left 和 right 相等,单独比较 arr[right] 和 pivotif (left == right && arr[right] > pivot) right--;// 将基数和中间数交换if (right != start) exchange(arr, start, right);// 返回中间值的下标return right;
}
private static void exchange(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}

​ 因为我们选择了数组的第一个元素作为基数,并且分完区后,会执行将基数和中间值交换的操作,这就意味着交换后的中间值会被分到左边区域。所以我们需要保证中间值的下标是分区完成后,最后一个比基数小的值,这里我们用 right 来记录这个值。

​ 这段代码有一个细节。首先,在交换 left 和 right 之前,我们判断了 left != right,这是因为如果剩余的数组都比基数小,则 left 会加到 right 才停止,这时不应该发生交换。因为 right 已经指向了最后一个比基数小的值。

​ 但这里的拦截可能会拦截到一种错误情况,如果剩余的数组只有最后一个数比基数大,left 仍然加到 right 停止了,但我们并没有发生交换。所以我们在退出循环后,单独比较了 arr[right] 和 pivot。

实际上,这行单独比较的代码非常巧妙,一共处理了三种情况:

  • 一是刚才提到的剩余数组中只有最后一个数比基数大的情况
  • 二是 left 和 right 区间内只有一个值,则初始状态下, left == right,所以 while (left < right) 根本不会进入,所以此时我们单独比较这个值和基数的大小关系
  • 三是剩余数组中每个数都比基数大,此时 right 会持续减小,直到和 left 相等退出循环,此时 left 所在位置的值还没有和 pivot 进行比较,所以我们单独比较 left 所在位置的值和基数的大小关系

⑥双指针分区算法

​ 除了上述的分区算法外,还有一种双指针的分区算法更为常用:从 left 开始,遇到比基数大的数,记录其下标;再从 right 往前遍历,找到第一个比基数小的数,记录其下标;然后交换这两个数。继续遍历,直到 left 和 right 相遇。然后就和刚才的算法一样了,交换基数和中间值,并返回中间值的下标。

public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int start, int end) {// 如果区域内的数字少于 2 个,退出递归if (start >= end) return;// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 对左边区域快速排序quickSort(arr, start, middle - 1);// 对右边区域快速排序quickSort(arr, middle + 1, end);
}
// 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
public static int partition(int[] arr, int start, int end) {// 取第一个数为基数int pivot = arr[start];// 从第二个数开始分区int left = start + 1;// 右边界int right = end;while (left < right) {// 找到第一个大于基数的位置while (left < right && arr[left] <= pivot) left++;// 找到第一个小于基数的位置while (left < right && arr[right] >= pivot) right--;// 交换这两个数,使得左边分区都小于或等于基数,右边分区大于或等于基数if (left < right) {exchange(arr, left, right);left++;right--;}}// 如果 left 和 right 相等,单独比较 arr[right] 和 pivotif (left == right && arr[right] > pivot) right--;// 将基数和轴交换exchange(arr, start, right);return right;
}
private static void exchange(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}

同样地,我们需要在退出循环后,单独比较 left 和 right 的值。

4.4 性能分析

时间复杂度:

快速排序的每一次遍历,都将基数摆到了最终位置上。第一轮遍历排好 1 个基数,第二轮遍历排好 2 个基数(每个区域一个基数,但如果某个区域为空,则此轮只能排好一个基数),第三轮遍历排好 4 个基数(同理,最差的情况下,只能排好一个基数),以此类推。总遍历次数为 logn~n 次,每轮遍历的时间复杂度为 O(n),所以很容易分析出快速排序的时间复杂度为 O(nlogn)~ O(n^2),平均时间复杂度为 O(nlogn),最坏的时间复杂度为 O(n^2),

空间复杂度:

​ 空间复杂度与递归的层数有关,每层递归会生成一些临时变量,所以空间复杂度为 O(logn) ~ O(n),平均空间复杂度为 O(logn)。

稳定性:

​ 从代码实现中可以分析出,快速排序是一种不稳定的排序算法,在分区过程中,相同数字的相对顺序可能会被修改。

4.5快速排序的优化思路

如何解决这样的问题呢?其实思路也很简单,只要我们每轮选择的基数不是剩余数组中最大或最小的值就可以了。具体方案有很多种,其中较常用的有三种:

  • 第一种就是我们在前文中提到的,每轮选择基数时,从剩余的数组中随机选择一个数字作为基数。这样每轮都选到最大或最小值的概率就会变得很低了。所以我们才说用这种方式选择基数,其平均时间复杂度是最优的

  • 第二种解决方案是在排序之前,先用洗牌算法将数组的原有顺序打乱,以防止原数组正序或逆序。

  • 第三种就是既然数组重复排序的情况如此常见,那么我们可以在快速排序之前先对数组做个判断,如果已经有序则直接返回,如果是逆序则直接倒序即可。在 Java 内部封装的 Arrays.sort() 的源码中就采用了此解决方案。

5.希尔排序(Shell Sort)

希尔排序和冒泡、选择、插入等排序算法一样,逐渐被快速排序所淘汰,但作为承上启下的算法,不可否认的是,希尔排序身上始终闪耀着算法之美。希尔排序本质上是对插入排序的一种优化,它利用了插入排序的简单,又克服了插入排序每次只交换相邻两个元素的缺点。

5.1 算法描述

它的基本思想是:

  • 将待排序数组按照一定的间隔分为多个子数组,每组分别进行插入排序。这里按照间隔分组指的不是取连续的一段数组,而是每跳跃一定间隔取一个值组成一组
  • 逐渐缩小间隔进行下一轮排序
  • 最后一轮时,取间隔为 11,也就相当于直接使用插入排序。但这时经过前面的「宏观调控」,数组已经基本有序了,所以此时的插入排序只需进行少量交换便可完成

每一遍排序的间隔在希尔排序中被称之为增量,所有的增量组成的序列称之为增量序列,也就是本例中的。增量依次递减,最后一个增量必须为 1,所以希尔排序又被称之为「缩小增量排序」。要是以专业术语来描述希尔排序,可以分为以下两个步骤:

  • 定义增量序列 D_m > D_{m-1} > D_{m-2} > … > D_1 = 1
  • 对每个D_k进行「D_k间隔排序」

有一条非常重要的性质保证了希尔排序的效率:

「D_{k+1}间隔」有序的序列,在经过「D_k间隔排序」排序后,仍然是有序的增量序列的选择会极大地影响希尔排序的效率。

5.2 图解演示

img

5.3 代码实现

代码实现如下:

public static void shellSort(int[] arr) {// 间隔序列,在希尔排序中我们称之为增量序列for (int gap = arr.length / 2; gap > 0; gap /= 2) {// 分组for (int groupStartIndex = 0; groupStartIndex < gap; groupStartIndex++) {// 插入排序for (int currentIndex = groupStartIndex + gap; currentIndex < arr.length; currentIndex += gap) {// currentNumber 站起来,开始找位置int currentNumber = arr[currentIndex];int preIndex = currentIndex - gap;while (preIndex >= groupStartIndex && currentNumber < arr[preIndex]) {// 向后挪位置arr[preIndex + gap] = arr[preIndex];preIndex -= gap;}// currentNumber 找到了自己的位置,坐下arr[preIndex + gap] = currentNumber;}}}
}

​ 这份代码与我们上文中提到的思路是一模一样的,先分组,再对每组进行插入排序。同样地,这里的插入排序也可以采用交换元素的方式。

5.4 优化过程

​ 实际上,这段代码可以优化一下。我们现在的处理方式是:处理完一组间隔序列后,再回来处理下一组间隔序列,这非常符合人类思维。但对于计算机来说,它更喜欢从第 gap 个元素开始,按照顺序将每个元素依次向前插入自己所在的组这种方式。虽然这个过程看起来是在不同的间隔序列中不断跳跃,但站在计算机的角度,它是在访问一段连续数组。

代码实现如下:

public static void shellSort(int[] arr) {// 间隔序列,在希尔排序中我们称之为增量序列for (int gap = arr.length / 2; gap > 0; gap /= 2) {// 从 gap 开始,按照顺序将每个元素依次向前插入自己所在的组for (int i = gap; i < arr.length; i++) {// currentNumber 站起来,开始找位置int currentNumber = arr[i];// 该组前一个数字的索引int preIndex = i - gap;while (preIndex >= 0 && currentNumber < arr[preIndex]) {// 向后挪位置arr[preIndex + gap] = arr[preIndex];preIndex -= gap;}// currentNumber 找到了自己的位置,坐下arr[preIndex + gap] = currentNumber;}}
}

5.5增量序列

​ 增量序列的选择会极大地影响希尔排序的效率。增量序列如果选得不好,希尔排序的效率可能比插入排序效率还要低,举个例子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VhSU36io-1620379072803)(C:\Users\86178\AppData\Roaming\Typora\typora-user-images\image-20210504124048637.png)]

在这个例子中,我们发现,原数组 8 间隔、4 间隔、2 间隔都已经有序了,使用希尔排序时,真正起作用的只有最后一轮 1 间隔排序,也就是直接插入排序。希尔排序反而比直接使用插入排序多执行了许多无用的逻辑。于是人们发现:增量元素不互质,则小增量可能根本不起作用。

事实上,希尔排序的增量序列如何选择是一个数学界的难题,但它也是希尔排序算法的核心优化点。Knuth 增量序列:D_1 = 1; D_{k+1} = 3 * D_k + 1也就是1,4,13,40,…,数学界猜想它的平均时间复杂度为 O(n^{3/2});

以 Knuth 增量序列为例,使用 Knuth 序列进行希尔排序的代码如下:

public static void shellSortByKnuth(int[] arr) {// 找到当前数组需要用到的 Knuth 序列中的最大值int maxKnuthNumber = 1;while (maxKnuthNumber <= arr.length / 3) {maxKnuthNumber = maxKnuthNumber * 3 + 1;}// 增量按照 Knuth 序列规则依次递减for (int gap = maxKnuthNumber; gap > 0; gap = (gap - 1) / 3) {// 从 gap 开始,按照顺序将每个元素依次向前插入自己所在的组for (int i = gap; i < arr.length; i++) {// currentNumber 站起来,开始找位置int currentNumber = arr[i];// 该组前一个数字的索引int preIndex = i - gap;while (preIndex >= 0 && currentNumber < arr[preIndex]) {// 向后挪位置arr[preIndex + gap] = arr[preIndex];preIndex -= gap;}// currentNumber 找到了自己的位置,坐下arr[preIndex + gap] = currentNumber;}}
}

5.6 性能分析

时间复杂度:

​ 增量序列的选择,Shell排序的执行时间依赖于增量序列。

好的增量序列的共同特征:

  • 最后一个增量必须为1;

  • 应该尽量避免序列中的值(尤其是相邻的值)互为倍数的情况。

    有人通过大量的实验,给出了较好的结果:当n较大时,比较和移动的次数约在n1.25到(1.6n)1.25之间。

    事实上,希尔排序时间复杂度非常难以分析,它的平均复杂度界于 O(n) 到 O(n^2) 之间,普遍认为它最好的时间复杂度为 O(n^{1.3}),

空间复杂度:

​ 希尔排序的空间复杂度为 O(1),只需要常数级的临时变量。

稳定性:

​ 虽然插入排序是稳定的排序算法,但希尔排序是不稳定的。在增量较大时,排序过程可能会破坏原有数组中相同关键字的相对次序。

5.7 希尔排序与 O(n^2)级排序算法的本质区别

希尔排序凭什么可以打破时间复杂度 O(n^2)的魔咒呢?它和之前介绍的 O(n^2)级排序算法的本质区别是什么?

只要理解了这一点,我们就能知道为什么希尔排序能够承上启下,启发出之后的一系列 O(n^2)级以下的排序算法,这个问题我们可以用逆序对来理解。

当我们从小到大排序时,在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。

排序算法本质上就是一个消除逆序对的过程。

对于随机数组,逆序对的数量是 O(n^2)级的,如果采用「交换相邻元素」的办法来消除逆序对,每次最多只能消除一组逆序对,因此必须执行 O(n^2)级的交换次数,这就是为什么冒泡、插入、选择算法只能到 O(n^2)级的原因。反过来说,基于交换元素的排序算法要想突破 O(n^2)级,必须通过一些比较,交换间隔比较远的元素,使得一次交换能消除一个以上的逆序对。

希尔排序算法就是通过这种方式,打破了在空间复杂度为 O(1)的情况下,时间复杂度为 O(n^2)的魔咒,此后的快排、堆排等等算法也都是基于这样的思路实现的。

6.归并排序(Merge Sort)

​ 归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。

6.1算法描述

  • 把长度为n的输入序列分成两个长度为n/2的子序列;
  • 对这两个子序列分别采用归并排序;
  • 将两个排序好的子序列合并成一个最终的排序序列。

6.2 算法图解

img

6.3 代码实现

6.3.1如何将两个有序的列表合并成一个有序的列表?

​ 在第二个列表向第一个列表逐个插入的过程中,由于第二个列表已经有序,所以后续插入的元素一定不会在前面插入的元素之前。在逐个插入的过程中,每次插入时,只需要从上次插入的位置开始,继续向后寻找插入位置即可。这样一来,我们最多只需要将两个有序数组遍历一次就可以完成合并。在向数组中不断插入新数字时,原数组需要不断腾出位置,这是一个比较复杂的过程,而且这个过程必然导致增加一轮遍历。有一个替代方案:只要开辟一个长度等同于两个数组长度之和的新数组,并使用两个指针来遍历原有的两个数组,不断将较小的数字添加到新数组中,并移动对应的指针即可。

代码实现如下:

// 将两个有序数组合并为一个有序数组
private static int[] merge(int[] arr1, int[] arr2) {int[] result = new int[arr1.length + arr2.length];int index1 = 0, index2 = 0;while (index1 < arr1.length && index2 < arr2.length) {if (arr1[index1] <= arr2[index2]) {result[index1 + index2] = arr1[index1++];//index1++;} else {result[index1 + index2] = arr2[index2++];//index2++;}}// 将剩余数字补到结果数组之后while (index1 < arr1.length) {result[index1 + index2] = arr1[index1++];//index1++;}while (index2 < arr2.length) {result[index1 + index2] = arr2[index2++];//index2++;}return result;
}

6.3.2将数组拆分成有序数组

拆分过程使用了二分的思想,这是一个递归的过程,归并排序使用的递归框架如下:

6.3.3归并排序的优化:减少临时空间的开辟

为了减少在递归过程中不断开辟空间的问题,我们可以在归并排序之前,先开辟出一个临时空间,在递归过程中统一使用此空间进行归并即可。

public static void mergeSort(int[] arr) {if (arr.length == 0) return;int[] result = new int[arr.length];mergeSort(arr, 0, arr.length - 1, result);
}// 对 arr 的 [start, end] 区间归并排序
private static void mergeSort(int[] arr, int start, int end, int[] result) {// 只剩下一个数字,停止拆分if (start == end) return;int middle = (start + end) / 2;// 拆分左边区域,并将归并排序的结果保存到 result 的 [start, middle] 区间mergeSort(arr, start, middle, result);// 拆分右边区域,并将归并排序的结果保存到 result 的 [middle + 1, end] 区间mergeSort(arr, middle + 1, end, result);// 合并左右区域到 result 的 [start, end] 区间merge(arr, start, end, result);
}// 将 result 的 [start, middle] 和 [middle + 1, end] 区间合并
private static void merge(int[] arr, int start, int end, int[] result) {int end1 = (start + end) / 2;int start2 = end1 + 1;// 用来遍历数组的指针int index1 = start;int index2 = start2;while (index1 <= end1 && index2 <= end) {if (arr[index1] <= arr[index2]) {result[index1 + index2 - start2] = arr[index1++];} else {result[index1 + index2 - start2] = arr[index2++];}}// 将剩余数字补到结果数组之后while (index1 <= end1) {result[index1 + index2 - start2] = arr[index1++];}while (index2 <= end) {result[index1 + index2 - start2] = arr[index2++];}// 将 result 操作区间的数字拷贝到 arr 数组中,以便下次比较while (start <= end) {arr[start] = result[start++];}
}

​ 其中, mergeSort(int[] arr) 函数是对外暴露的公共方法,内部调用了私有的mergeSort(int[] arr, int start, int end) 函数,这个函数用于对 arr 的 [start, end] 区间进行归并排序。

​ 可以看到,我们在这个函数中,将原有数组不断地二分,直到只剩下最后一个数字。此时嵌套的递归开始返回,一层层地调用merge(int[] arr1, int[] arr2)函数,也就是我们刚才写的将两个有序数组合并为一个有序数组的函数。

​ 这就是最经典的归并排序,只需要一个二分拆数组的递归函数和一个合并两个有序列表的函数即可。

​ 我们统一使用 result 数组作为递归过程中的临时数组,所以merge 函数接收的参数不再是两个数组,而是 result 数组中需要合并的两个数组的首尾下标。根据首尾下标可以分别计算出两个有序数组的首尾下标 start1、end1、start2、end2,之后的过程就和之前合并两个有序数组的代码类似了。

6.3.4原地归并排序?

​ 现在的归并排序看起来仍"美中不足",那就是仍然需要开辟额外的空间,能不能实现不开辟额外空间的归并排序呢?好像是可以做到的。在一些文章中,将这样的归并排序称之为 In-Place Merge Sort,直译为原地归并排序。

public static void mergeSort(int[] arr) {if (arr.length == 0) return;mergeSort(arr, 0, arr.length - 1);
}// 对 arr 的 [start, end] 区间归并排序
private static void mergeSort(int[] arr, int start, int end) {// 只剩下一个数字,停止拆分if (start == end) return;int middle = (start + end) / 2;// 拆分左边区域mergeSort(arr, start, middle);// 拆分右边区域mergeSort(arr, middle + 1, end);// 合并左右区域merge(arr, start, end);
}// 将 arr 的 [start, middle] 和 [middle + 1, end] 区间合并
private static void merge(int[] arr, int start, int end) {int end1 = (start + end) / 2;int start2 = end1 + 1;// 用来遍历数组的指针int index1 = start;int index2 = start2;while (index1 <= end1 && index2 <= end) {if (arr[index1] <= arr[index2]) {index1++;} else {// 右边区域的这个数字比左边区域的数字小,于是它站了起来int value = arr[index2];int index = index2;// 前面的数字不断地后移while (index > index1) {arr[index] = arr[index - 1];index--;}// 这个数字坐到 index1 所在的位置上arr[index] = value;// 更新所有下标,使其前进一格index1++;index2++;end1++;}}
}
/*
这段代码在合并 arr 的 [start, middle] 区间和 [middle + 1, end] 区间时,将两个区间较小的数字移动到 index1 的位置,并且将左边区域不断后移,目的是给新插入的数字腾出位置。最后更新两个区间的下标,继续合并更新后的区间。
*/

​ 分析代码可以看出,这样实现的归并本质上是插入排序!前文已经说到,在插入排序中,腾出位置是一个比较复杂的过程,而且这个过程必然导致增加一轮遍历。在这两份代码中,每一次合并数组时,都使用了两层循环,目的就是不断腾挪位置以插入新数字,可以看出这里合并的效率是非常低的。这两种排序算法的时间复杂度都达到了 O(n^2)级,不能称之为归并排序。它们只是借用了归并排序的递归框架而已。

​ 也就是说,所谓的原地归并排序事实上并不存在,许多算法书籍中都没有收录这种算法。它打着归并排序的幌子,卖的是插入排序的思想,实际排序效率比归并排序低得多。

6.4 性能分析

时间复杂度:

归并排序的复杂度比较容易分析,拆分数组的过程中,会将数组拆分 logn 次,每层执行的比较次数都约等于 n 次,所以时间复杂度是 O(nlogn)。

空间复杂度:

空间复杂度是 O(n),主要占用空间的就是我们在排序前创建的长度为n的result数组。

稳定性:

​ 分析归并的过程可知,归并排序是一种稳定的排序算法。其中,对算法稳定性非常重要的一行代码是:

if (arr[index1] <= arr[index2]) {
result[index1 + index2 - start2] = arr[index1++];
}
在这里我们通过arr[index1] <= arr[index2]来合并两个有序数组,保证了原数组中,相同的元素相对顺序不会变化,如果这里的比较条件写成了arr[index1] < arr[index2],则归并排序将变得不稳定。

6.5 应用分析

​ 总结起来,归并排序分成两步,一是拆分数组,二是合并数组,它是分治思想的典型应用。分治的意思是“分而治之”,分的时候体现了二分的思想,治是一个滚雪球的过程,将 1 个数字组成的有序数组合并成一个包含 2 个数字的有序数组,再将 2 个数字组成的有序数组合并成包含 4 个数字的有序数组…

​ 由于性能较好,且排序稳定,归并排序应用非常广泛,Arrays.sort() 源码中的 TimSort就是归并排序的优化版。

7.堆排序(Heap Sort)

​ 数组、链表都是一维的数据结构,相对来说比较容易理解,而堆是二维的数据结构,对抽象思维的要求更高,所以许多程序员「谈堆色变」。但堆又是数据结构进阶必经的一步,我们不妨静下心来,将其梳理清楚。

堆:符合以下两个条件之一的完全二叉树:

根节点的值 ≥ 子节点的值,这样的堆被称之为最大堆,或大顶堆;

根节点的值 ≤ 子节点的值,这样的堆被称之为最小堆,或小顶堆。

7.1算法描述

堆排序过程如下:

  • 用数列构建出一个大顶堆,取出堆顶的数字;
  • 调整剩余的数字,构建出新的大顶堆,再次取出堆顶的数字;
  • 循环往复,完成整个排序。

整体的思路就是这么简单,我们需要解决的问题有两个:

  • 如何用数列构建出一个大顶堆;

  • 取出堆顶的数字后,如何将剩余的数字调整成新的大顶堆。

    构建大顶堆 & 调整堆
    构建大顶堆有两种方式:

    方案一:从 0 开始,将每个数字依次插入堆中,一边插入,一边调整堆的结构,使其满足大顶堆的要求;
    方案二:将整个数列的初始状态视作一棵完全二叉树,自底向上调整树的结构,使其满足大顶堆的要求。
    方案二更为常用,动图演示如下:

    7.2图解演示

    img

img

在介绍堆排序具体实现之前,我们先要了解完全二叉树的几个性质。将根节点的下标视为 0,则完全二叉树有如下性质:

  • 对于完全二叉树中的第 i 个数,它的左子节点下标:left = 2i + 1
  • 对于完全二叉树中的第 i 个数,它的右子节点下标:right = left + 1
  • 对于有 n 个元素的完全二叉树(n≥2),它的最后一个非叶子结点的下标:n/2 - 1

7.3 代码实现

堆排序代码如下:

public static void heapSort(int[] arr) {// 构建初始大顶堆buildMaxHeap(arr);for (int i = arr.length - 1; i > 0; i--) {// 将最大值交换到数组最后swap(arr, 0, i);// 调整剩余数组,使其满足大顶堆maxHeapify(arr, 0, i);}
}
// 构建初始大顶堆
private static void buildMaxHeap(int[] arr) {// 从最后一个非叶子结点开始调整大顶堆,最后一个非叶子结点的下标就是 arr.length / 2-1for (int i = arr.length / 2 - 1; i >= 0; i--) {maxHeapify(arr, i, arr.length);}
}
// 调整大顶堆,第三个参数表示剩余未排序的数字的数量,也就是剩余堆的大小
private static void maxHeapify(int[] arr, int i, int heapSize) {// 左子结点下标int l = 2 * i + 1;// 右子结点下标int r = l + 1;// 记录根结点、左子树结点、右子树结点三者中的最大值下标int largest = i;// 与左子树结点比较if (l < heapSize && arr[l] > arr[largest]) {largest = l;}// 与右子树结点比较if (r < heapSize && arr[r] > arr[largest]) {largest = r;}if (largest != i) {// 将最大值交换为根结点swap(arr, i, largest);// 再次调整交换数字后的大顶堆maxHeapify(arr, largest, heapSize);}
}
private static void swap(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}

​ 堆排序的第一步就是构建大顶堆,对应代码中的 buildMaxHeap 函数。我们将数组视作一颗完全二叉树,从它的最后一个非叶子结点开始,调整此结点和其左右子树,使这三个数字构成一个大顶堆。

​ 调整过程由 maxHeapify 函数处理, maxHeapify 函数记录了最大值的下标,根结点和其左右子树结点在经过比较之后,将最大值交换到根结点位置。这样,这三个数字就构成了一个大顶堆。

​ 需要注意的是,如果根结点和左右子树结点任何一个数字发生了交换,则还需要保证调整后的子树仍然是大顶堆,所以子树会执行一个递归的调整过程。

​ 这里的递归比较难理解,我们打个比方:构建大顶堆的过程就是一堆数字比赛谁更大。比赛过程分为初赛、复赛、决赛,每场比赛都是三人参加。但不是所有人都会参加初赛,只有叶子结点和第一批非叶子结点会进行三人组初赛。初赛的冠军站到三人组的根结点位置,然后继续参加后面的复赛。

​ 而有的人生来就在上层,比如李小胖,它出生在数列的第一个位置上,是二叉树的根结点,当其他结点进行初赛、复赛时,它就静静躺在根结点的位置等一场决赛。

​ 当王大强和张壮壮,经历了重重比拼来到了李小胖的左右子树结点位置。他们三个人开始决赛。王大强和张壮壮是靠实打实的实力打上来的,他们已经确认过自己是小组最强。而李小胖之前一直躺在这里等决赛。如果李小胖赢了他们两个,说明李小胖是所有小组里最强的,毋庸置疑,他可以继续坐在冠军宝座。

​ 但李小胖如果输给了其中任何一个人,比如输给了王大强。王大强会和张壮壮对决,选出本次构建大顶堆的冠军。但李小胖能够坐享其成获得第三名吗?生活中或许会有这样的黑幕,但程序不会欺骗我们。李小胖跌落神坛之后,就要从王大强的打拼路线回去,继续向下比较,找到自己真正实力所在的真实位置。这就是 maxHeapify 中会继续递归调用 maxHeapify 的原因。

​ 当构建出大顶堆之后,就要把冠军交换到数列最后,深藏功与名。来到冠军宝座的新人又要和李小胖一样,开始向下比较,找到自己的真实位置,使得剩下的 n - 1n−1 个数字构建成新的大顶堆。这就是 heapSort 方法的 for 循环中,调用 maxHeapify 的原因。

​ 变量 heapSize 用来记录还剩下多少个数字没有排序完成,每当交换了一个堆顶的数字,heapSize 就会减 11。在 maxHeapify 方法中,使用 heapSize 来限制剩下的选手,不要和已经躺在数组最后,当过冠军的人比较,免得被暴揍。

​ 这就是堆排序的思想。学习时我们采用的是最简单的代码实现,在熟练掌握了之后我们就可以加一些小技巧以获得更高的效率。比如我们知道计算机采用二进制来存储数据,数字左移一位表示乘以 22,右移一位表示除以 22。所以堆排序代码中的arr.length / 2 - 1 可以修改为 (arr.length >> 1) - 1,左子结点下标2 * i + 1可以修改为(i << 1) + 1。需要注意的是,位运算符的优先级比加减运算的优先级低,所以必须给位运算过程加上括号。

7.4 性能分析

时间复杂度:

​ 堆排序分为两个阶段:初始化建堆(buildMaxHeap)和重建堆(maxHeapify,直译为大顶堆化)。所以时间复杂度要从这两个方面分析。

​ 根据数学运算可以推导出初始化建堆的时间复杂度为 O(n),重建堆的时间复杂度为 O(n\log n)O,所以堆排序总的时间复杂度为 O(n\log n)。推导过程较为复杂,故不再给出证明过程。

空间复杂度:

堆排序的空间复杂度为 O(1),只需要常数级的临时变量。堆排序是一个优秀的排序算法,但是在实际应用中,快速排序的性能一般会优于堆排序。

稳定性:

​ 堆排序是不稳定的

​ 比如:9 18 27 18

​ 如果堆顶9先输出,则第三层的18(最后一个18)跑到堆顶,然后堆稳定,继续输出堆顶,是刚才那个18,这样说明后面的18先于第二个位置的18输出,所以不稳定。

8.计数排序(Counting Sort)

计数排序是一个非基于比较的排序算法,它的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k)(其中k是整数的范围),快于任何比较排序算法。当然这是一种牺牲空间换取时间的做法,而且当O(k)>O(nlog(n))的时候其效率反而不如基于比较的排序(基于比较的排序的时间复杂度在理论上的下限是O(n*log(n)), 如归并排序,堆排序)

8.1算法描述

算法思想:

  • 找出待排序的数组中最大和最小的元素;
  • 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
  • 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
  • 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。

8.2 图解演示

img

8.3代码实现

代码实现如下:

//针对c数组的大小,优化过的计数排序
public class CountSort{publicstaticvoidmain(String[]args){//排序的数组int a[]={100,93,97,92,96,99,92,89,93,97,90,94,92,95};int b[]=countSort(a);for(inti:b){System.out.print(i+"");}System.out.println();}public static int[] countSort(int[]a){int b[] = new int[a.length];int max = a[0],min = a[0];for(int i:a){if(i>max){max=i;}if(i<min){min=i;}}//这里k的大小是要排序的数组中,元素大小的极值差+1int k=max-min+1;int c[]=new int[k];for(int i=0;i<a.length;++i){c[a[i]-min]+=1;//优化过的地方,减小了数组c的大小}for(int i=1;i<c.length;++i){c[i]=c[i]+c[i-1];}for(int i=a.length-1;i>=0;--i){b[--c[a[i]-min]]=a[i];//按存取的方式取出c的元素}return b;}
}

8.4 性能分析

时间复杂度:

​ 原数组、count数组、累加数组、目标数组;先对原数组过滤一遍生成count数组,对count数组过滤一遍生成累加数组,最后再把原数组从后往前生成目标数组,原数组过滤两遍,count数组过滤两遍,原数组是长度是n,count数组长度是k,即2(n+k);

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

空间复杂度:

​ 用了额外的数组长度为n和k,所以空间复杂度为O(n+k)。

稳定性:

​ 经计数排序,输出序列中值相同的元素之间的相对次序与他们在输入序列中的相对次序相同,换句话说,计数排序算法是一个稳定的排序算法。

8.5 拓展

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

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

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

使用于特定问题,对源数据有要求,适合量比较大,数值范围比较小,例如企业员工年龄排序,快速查询高考名次,如果需要排序的数字中存在一位小数,可以将所有数字乘以 10,再去计算最终的下标位置。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。

9.基数排序(Radix Sort)

基数排序有两种实现方式。本例属于「最高位优先法」,简称 MSD (Most significant digital),思路是从最高位开始,依次对基数进行排序。与之对应的是「最低位优先法」,简称 LSD (Least significant digital)。思路是从最低位开始,依次对基数进行排序。使用 LSD 必须保证对基数进行排序的过程是稳定的。

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

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

9.1 算法描述

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

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

9.2 图解演示

img

9.3 代码实现

9.3.1找出数组中最大的数字的位数

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

public static void radixSort(int[] arr) {if (arr == null) return;int max = 0;for (int value : arr) {if (value > max) {max = value;}}// ...
}

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

int maxDigitLength = 0;
while (max != 0) {maxDigitLength++;max /= 10;
}

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

如果 max 初始值就是 00 呢?严格来讲,00 在数学上属于 11 位数。但实际上,基数排序时我们无需考虑 max 为 00 的场景,因为 max 为 00 只有一种可能,那就是数组中所有的数字都为 00,此时数组已经有序,我们无需再进行后续的排序过程。

9.3.2获取基数:

int dev = 1;
for (int i = 0; i < maxDigitLength; i++) {for (int value : arr) {int radix = value / dev % 10;// 对基数进行排序}dev *= 10;
}

9.3.3对基数进行排序:

因为每一个基数都在 [0, 9] 之间,并且计数排序是一种稳定的算法。

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

public class RadixSort {public static void radixSort(int[] arr) {if (arr == null) return;// 找出最大值int max = 0;for (int value : arr) {if (value > max) {max = value;}}// 计算最大数字的长度int maxDigitLength = 0;while (max != 0) {maxDigitLength++;max /= 10;}// 使用计数排序算法对基数进行排序int[] counting = new int[10];int[] result = new int[arr.length];int dev = 1;for (int i = 0; i < maxDigitLength; i++) {for (int value : arr) {int radix = value / dev % 10;counting[radix]++;}for (int j = 1; j < counting.length; j++) {counting[j] += counting[j - 1];}// 使用倒序遍历的方式完成计数排序for (int j = arr.length - 1; j >= 0; j--) {int radix = arr[j] / dev % 10;result[--counting[radix]] = arr[j];}// 计数排序完成后,将结果拷贝回 arr 数组System.arraycopy(result, 0, arr, 0, arr.length);// 将计数数组重置为 0Arrays.fill(counting, 0);dev *= 10;}}
}

当每一轮对基数完成排序后,我们将 result 数组的值拷贝回 arr 数组,并且将 counting 数组中的元素都置为 00,以便在下一轮中复用。

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

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

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

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

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

代码实现如下:

public class RadixSort {public static void radixSort(int[] arr) {if (arr == null) return;// 找出最长的数int max = 0;for (int value : arr) {if (Math.abs(value) > max) {max = Math.abs(value);}}// 计算最长数字的长度int maxDigitLength = 0;while (max != 0) {maxDigitLength++;max /= 10;}// 使用计数排序算法对基数进行排序,下标 [0, 18] 对应基数 [-9, 9]int[] counting = new int[19];int[] result = new int[arr.length];int dev = 1;for (int i = 0; i < maxDigitLength; i++) {for (int value : arr) {// 下标调整int radix = value / dev % 10 + 9;counting[radix]++;}for (int j = 1; j < counting.length; j++) {counting[j] += counting[j - 1];}// 使用倒序遍历的方式完成计数排序for (int j = arr.length - 1; j >= 0; j--) {// 下标调整int radix = arr[j] / dev % 10 + 9;result[--counting[radix]] = arr[j];}// 计数排序完成后,将结果拷贝回 arr 数组System.arraycopy(result, 0, arr, 0, arr.length);// 将计数数组重置为 0Arrays.fill(counting, 0);dev *= 10;}}
}

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

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

9.5 LSD VS MSD

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

public class RadixSort {public static void radixSort(int[] arr) {if (arr == null) return;// 找到最大值int max = 0;for (int value : arr) {if (Math.abs(value) > max) {max = Math.abs(value);}}// 计算最大长度int maxDigitLength = 0;while (max != 0) {maxDigitLength++;max /= 10;}radixSort(arr, 0, arr.length - 1, maxDigitLength);}// 对 arr 数组中的 [start, end] 区间进行基数排序private static void radixSort(int[] arr, int start, int end, int position) {if (start == end || position == 0) return;// 使用计数排序对基数进行排序int[] counting = new int[19];int[] result = new int[end - start + 1];int dev = (int) Math.pow(10, position - 1);for (int i = start; i <= end; i++) {// MSD, 从最高位开始int radix = arr[i] / dev % 10 + 9;counting[radix]++;}for (int j = 1; j < counting.length; j++) {counting[j] += counting[j - 1];}// 拷贝 counting,用于待会的递归int[] countingCopy = new int[counting.length];System.arraycopy(counting, 0, countingCopy, 0, counting.length);for (int i = end; i >= start; i--) {int radix = arr[i] / dev % 10 + 9;result[--counting[radix]] = arr[i];}// 计数排序完成后,将结果拷贝回 arr 数组System.arraycopy(result, 0, arr, start, result.length);// 对 [start, end] 区间内的每一位基数进行递归排序for (int i = 0; i < counting.length; i++) {radixSort(arr, i == 0 ? start : start + countingCopy[i - 1], start + countingCopy[i] - 1, position - 1);}}}

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

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

9.6 性能分析

时间复杂度:

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

​ 每次都复制一遍,一遍的时间复杂度为O(n),所以时间复杂度为O(n*k)。

空间复杂度:

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

稳定性:

​ 在基数排序过程中,每一次装桶都是将当前位数上相同数值的元素进行装桶,并不需要交换位置;所以基数排序是稳定的算法。

如果负数可以使用正负数桶,负数的排负数,正数的排正数,然后就可以达到要求。

10.桶排序(Bucket Sort)

桶排序利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定;桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排);桶排序的思想近乎彻底的分治思想;桶排序假设待排序的一组数均匀独立的分布在一个范围中,并将这一范围划分成几个子范围(桶)。

10.1 算法描述

  • 设置一个定量的数组当作空桶;
  • 遍历输入数据,并且把数据一个一个放到对应的桶里去;
  • 对每个不是空的桶进行排序;
  • 从不是空的桶里把排好序的数据拼接起来,得到结果。

10.2 图解演示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vBv608ur-1620379072809)(C:\Users\86178\AppData\Roaming\Typora\typora-user-images\image-20210506215159979.png)]

10.3 代码实现

代码实现:

public static double[] bucketSort(double[] array){//得到数列的最大值和最小值,并计算出差值ddouble max=array[0];double min=array[0];for (int i=1;i<array.length;i++){if (array[i]>max){max=array[i];}if (array[i]<min){min=array[i];}}double d=max-min;//初始化桶int bucketNum=array.length;ArrayList<LinkedList<Double>> bucketList=new ArrayList<LinkedList<Double>>(bucketNum);for (int i=0;i<bucketNum;i++){bucketList.add(new LinkedList<Double>());}//遍历原始数组将每个元素放入桶中for (int i=0;i<array.length;i++){int num=(int)((array[i]-min)*(bucketNum-1)/d);bucketList.get(num).add(array[i]);}//对每个桶内部进行排序for(int i=0;i<bucketList.size();i++){// 使用Collections.sort,其底层实现基于归并排序或归并排序的优化版本Collections.sort(bucketList.get(i));}//输出全部元素double[] sortedArray=new double[array.length];int index=0;for (LinkedList<Double> list:bucketList) {for (double element:list){sortedArray[index]=element;index++;}}return sortedArray;}

10.4 性能分析

时间复杂度(这部分内容不太重要,增加学习负担):

​ 桶排序利用函数的映射关系,减少了几乎所有的比较工作。实际上,桶排序的f(k)值的计算,其作用就相当于快排中划分,已经把大量数据分割成了基本有序的数据块(桶)。然后只需要对桶中的少量数据做先进的比较排序即可。

​ 对N个关键字进行桶排序的时间复杂度分为两个部分:

  • 循环计算每个关键字的桶映射函数,这个时间复杂度是O(n)。

  • 利用先进的比较排序算法对每个桶内的所有数据进行排序,其时间复杂度为 ∑ O(Ni*logNi) 。其中Ni 为第i个桶的数据量。

    很显然,第二部分是桶排序性能好坏的决定因素。尽量减少桶内数据的数量是提高效率的唯一办法(因为基于比较排序的最好平均时间复杂度只能达到O(n*logn)了)。因此,我们需要尽量做到下面两点:

  • 映射函数f(k)能够将n个数据平均的分配到m个桶中,这样每个桶就有[n/m]个数据量。

  • 在额外空间充足的情况下,尽量增大桶的数量,使用的映射函数能够将输入的 n个数据均匀的分配到 m个桶中。极限情况下每个桶只能得到一个数据,这样就完全避开了桶内数据的“比较”排序操作。 当然,做到这一点很不容易,数据量巨大的情况下,f(k)函数会使得桶集合的数量巨大,空间浪费严重。这就是一个时间代价和空间代价的权衡问题了。

​ 对于n个待排数据,m个桶,平均每个桶[n/m]个数据的桶排序平均时间复杂度为: O(n)+O(m(n/m)log(n/m))=O(n+n(logn-logm))=O(n+nlogn-nlogm)

  • 当n=m时,即极限情况下每个桶只有一个数据时。桶排序的最好效率能够达到O(n)。
  • 最糟糕的情况下,即所有的数据都放入了一个桶内,桶内自排序算法为插入排序,那么其时间复杂度就为 O(n^2) 了。

空间复杂度:

​ 当然桶排序的空间复杂度 为O(N+M),如果输入数据非常庞大,而桶的数量也非常多,则空间代价无疑是昂贵的。

稳定性:

​ 桶排序是稳定的。

10.5 拓展

​ 其次,我们可以发现,区间划分的越细,即桶的数量越多,理论上分到每个桶中的元素就越少,桶内数据的排序就越简单,其时间复杂度就越接近于线性。

​ 极端情况下,就是区间小到只有1,即桶内只存放一种元素,桶内的元素不再需要排序,因为它们都是相同的元素,这时桶排序差不多就和计数排序一样了。

​ 总结: 桶排序的平均时间复杂度为线性的O(n+C),其中C=n*(logn-logm)。如果相对于同样的n,桶数量m越大,其效率越高,最好的时间复杂度达到O(n)。 桶排序的空间复杂度 为O(n+m),如果输入数据非常庞大,而桶的数量也非常多,则空间代价无疑是昂贵的。此外,桶排序是稳定的。

11.非比较类排序算法总结

这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶;
  • 计数排序:每个桶只存储单一键值;
  • 桶排序:每个桶存储一定范围的数值;

部分动图参考:https://www.cnblogs.com/onepixel/articles/7674659.html
部分优化参考:https://leetcode-cn.com/leetbook/detail/sort-algorithms/


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/450836.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

eclipse安装JAVA反编译插件

前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到教程。 前言&#xff1a;在实际的开发中几乎都会使用到一些框架来辅助项目的开发工作&#xff0c;对于一些框架的代码我们总怀有一些好奇之心&a…

noip2014生活大爆炸版石头剪刀布

题目描述 石头剪刀布是常见的猜拳游戏:石头胜剪刀,剪刀胜布,布胜石头。如果两个人出拳一 样&#xff0c;则不分胜负。在《生活大爆炸》第二季第8集中出现了一种石头剪刀布的升级版游戏。 升级版游戏在传统的石头剪刀布游戏的基础上,增加了两个新手势: 斯波克:《星际迷航》主角之…

聊聊毕业设计系列 --- 系统实现

效果展示 github moment-server github地址 moment github地址 moment-manage github地址 articles 聊聊毕业设计系列 --- 项目介绍 聊聊毕业设计系列 --- 系统实现 前言 在上一篇文章中&#xff0c;主要是对项目做了介绍&#xff0c;并且对系统分析和系统设计做了大概的介绍。…

js 实现用window.print()打印页面中的部分内容,局部打印

前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到教程。 如下方法&#xff1a; function printProof(){var printData document.getElementById("forPrint").innerHTML; // 只打印 f…

Spring 之注解事务 @Transactional

前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到教程。 先让我们看代码吧&#xff01; 以下代码为在“Spring3事务管理——基于tx/aop命名空间的配置”基础上修改。首先修改applicationContext…

超级程序员神话

摘要&#xff1a;大部分的程序员在思想里都会某种程度的承认&#xff0c;承认自己只是一个普通的程序员&#xff0c;但这世界上确实有一些超级程序员&#xff0c;在一个为企业开发应用的程序员和一个为谷歌写搜索算法的程序员之间&#xff0c;或和一个开发用来控制读写头从磁盘…

HashMap30连问,彻底搞懂HashMap

文章目录一、背景知识1、什么是Map&#xff1f;2、什么是Hash&#xff1f;3、什么是哈希表&#xff1f;4、什么是HashMap?5、如何使用HashMap&#xff1f;6、HashMap有哪些核心参数&#xff1f;7、HashMap与HashTable的对比&#xff1f;8、HashMap和HashSet的区别&#xff1f;…

如何使用postman做接口测试

1、get请求传参 只要是get请求都可以在浏览器中直接发&#xff1a; 在访问地址后面拼 ?keyvalue&keyvalue 例如&#xff1a;在浏览器中直接输入访问地址&#xff0c;后面直接拼需要传给服务器的参数http://api.nnzhp.cn/api/user/stu_info?stu_name小黑2、post请求&…

【狂神说】分析前后端分离开源项目?

文章目录1.如何分析开源项目项目简介项目源码2.观察开源项目3.开源项目下载4.跑起来是第一步5.前后端分离项目固定套路6.如何找到一个开源项目1.如何分析开源项目 学习的方式&#xff1a; 不知道这个代码怎么来的这个代码跑不起来这个项目对我们有什么帮助&#xff0c;不会模…

设计公共API的六个注意事项

摘要&#xff1a;俗话说&#xff1a;“好东西就要贡献出来和大家一起分享”&#xff0c;尤其是在互联网业务高度发达的今天&#xff0c;如果你的创业公司提供了一项很酷的技术或者服务&#xff0c;并且其他用户也非常喜欢该产品&#xff0c;在这种情况下&#xff0c;最好的解决…

go 交叉编译

golang中windows交叉编译 env GOOSlinux GOARCHamd64 go build .打包镜像 FROM alpineMAINTAINER "congge"ADD ./casino_niuniu /usr/local/casino_niuniu/bin/casino_niuniu ADD ./templates /usr/loca/lcasino_niuniu/bin/templates ADD ./public /usr/local/casin…

P3193 [HNOI2008]GT考试

传送门 容易看出是道DP 考虑一位一位填数字 设 f [ i ] [ j ] 表示填到第 i 位&#xff0c;在不吉利串上匹配到第 j 位时不出现不吉利数字的方案数 设 g [ i ] [ j ] 表示不吉利串匹配到第 i 位&#xff0c;再添加一个数字&#xff0c;使串匹配到第 j 位的方案数 那么方程显然为…

SQLserver数据库反编译生成Hibernate实体类和映射文件

一、建立项目和sqlserver数据库 eclipse&#xff0c;我使用的版本是neon3 二、Data Source Explorer 选择OK 在data source Explorer的Database Connections 选择New 填写好General的连接信息 新建New Driver Definition 填写完选择OK 选择刚才的Drivers Test Connetion测试 N…

最受欢迎的5大Linux发行版

摘要&#xff1a;要统计有多少人在使用那款Linux发行版几乎是不可能的事情&#xff0c;但我们可以使用一些在线分析工具来大概地看看哪些Linux发行版更受欢迎。 Google Trends的数据显示&#xff0c;Ubuntu用户正在流向Mint&#xff0c;但依然在各方面都比其它Linux发行版更有优…

使用IntelliJ IDEA 配置Maven(入门)

前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到教程。 1. 下载Maven 官方地址&#xff1a;http://maven.apache.org/download.cgi 解压并新建一个本地仓库文件夹 2.配置本地仓库路径 3.配…

为什么程序员不擅长估算时间?

摘要&#xff1a;时间估算是困难的&#xff0c;每一个程序员都有一个现实的估计区间&#xff0c;低于这个区间的估计意味着&#xff08;构件&#xff0c;测试&#xff0c;检查代码的&#xff09;时间开销被低估了&#xff0c;超过这个区间的估计意味着这个任务太大而很难预估。…

red hat enterprise linux 7关闭防火墙的方法

2019独角兽企业重金招聘Python工程师标准>>> red hat enterprise linux 7发布后&#xff0c;发现防火墙也变了&#xff0c;如何关闭防火墙呢&#xff0c;下面是方法 1.查看firewall的状态 [rootsztech7 ~]# systemctl status firewalld firewalld.service - firewal…

IOS —— 网络那些事(上) - http协议

作为一名并不太合格的程序员&#xff0c;今天要分享学习的成果&#xff0c;竟然讲的是网络相关HTTP协议的事情。&#xff08;也算是复习了&#xff09; 乍看HTTP协议的内容着实是十分复杂的&#xff0c;涉及到十分多互联网"底层"框架的东西。今天就先撇开这部分详细内…

老派程序员——徒手实现伟大成就

摘要&#xff1a;本文介绍了三位非常著名的程序员&#xff1a;Ken Thompson,Joe Armstrong 和 Jamie Zawinski&#xff0c;他们是如何发明一门新语言&#xff0c;他们开发软件时会像我们一样使用当今流行的开发工具吗&#xff1f;当读Peter Seibel的精彩著作《编程人生:15位软件…

互联网大厂项目研发流程

文章目录阶段一&#xff1a;阶段二&#xff1a;阶段三&#xff1a;阶段四&#xff1a;阶段五&#xff1a;开发人员&#xff1a;测试人员&#xff1a;设计师&#xff1a;阶段六&#xff1a;阶段七&#xff1a;总结&#xff1a;本文章学习自&#xff1a;https://www.bilibili.com…