本文主要介绍基于比较的七种常见排序算法,分别为:选择排序法,插入排序法,希尔排序法,冒泡排序法,堆排序法,归并排序法,快速排序法。
基于比较的排序算法是指对于元素的排序必须是建立在元素之间是可以比较的。体现在 j a v a java java 语言中为待排序的元素类型是实现了 C o m p a r a b l e Comparable Comparable 接口的类型。
本文所涉及的复杂度分析都是基于现有结论加上自己的简单的理解,所以可能非常不严谨,大家看看就好,不过最终的结论都是对的。
在分析复杂度的同时也对排序算法的稳定性进行了分析,下面先对排序算法的稳定性先做一些简单的解释。
排序算法的稳定性
在一组数据中,排序前相等的两个元素,在排序后相对的位置不变,这样的排序算法就被称为稳定的排序算法。
如果排序的元素只存在一个数据域(属性),那么对于排序算法的稳定性可以没有太高的要求。但是排序的元素如果超过一个数据域,对于排序算法的稳定性可能就有要求了。
例如:对于一组学生的成绩进行降序排列,学生不仅有成绩这一个属性还有学生姓名等其它属性,那么在排列的过程中对于成绩相同的学生而言,最终的排列次序就取决于排序算法的稳定性。
稳定的排序算法可以 100 % 100\% 100% 的保证每次排序的结果中相同元素的相对位置没有发生任何变化。
不稳定的排序算法不能 100 % 100\% 100% 的保证每次排序的结果中相同元素的相对位置没有发生任何变化。
注: 本文中出现的所有动态图片全部来自于:visualgo 这个网站,感兴趣的同学可以浏览一下,网站中提供了大量数据结构相关的动态图,并且还配有教程,十分不错。
选择排序(Selection sort),是最基础的排序算法之一,也是通常最容易想到的一种排序算法,基本思路为对一组有 n n n 个元素的数据,每一轮都选择一个最小(默认升序排列)的元素放置在待排序区间的第一个位置。经过 n − 1 n - 1 n−1 轮的选择后,所有元素都被放置在它应该处于的位置。
具体实现上,需要使用两层循环来遍历数组每一个元素,外层循环控制整个排序需要的轮数,定义外层循环变量 i ,初始指向数组第一个元素,也就是下标为 0 的元素,i 需要维护的循环不变量为,每次进入循环时 [0 , i - 1]区间为有序区间,[i, n - 1] 区间为无序区间(n 表示元素个数),当 i == n - 1 时,无序区间只存在一个元素,而其它元素都是已经被放置它们最终的位置所以最后一个元素在整个数组中也一定是有序的,此时可以排序完毕,退出整个循环。
内层循环负责在本轮循环中到待排序区间找到最小元素并纪录该元素的索引,定义内层循环变量 j 用于扫描待排序区间的每一个元素,初始指向 i + 1的位置,i 进入循环时指向待排序区间的第一个元素,用变量 min 纪录 i 的位置,表示默认 i 索引上的元素为区间内的最小元素,j 通过扫描其后的所有元素与 min 上的元素比较,当发现存在小于 min 位置上的元素时,就将 min 的值更为较小值的索引 j,直到 j 遍历完数组最后一个元素时,退出内层循环。当内层循环结束时,将 min 索引上的最小元素与待排序区间第一个元素 (i 索引上的元素) 进行交换。
public class SelectionSort {
/**
* 选择排序(升序), 每一次遍历都找到待排序元素中最小的一个元素,并将该元素放置在待排序元素的第一个位置。
*/
public static <E extends Comparable<E>> void sort(E[] array) {
int n = array.length;
int min; // min 记录了每次内层循环结束后待排序元素中最小元素的索引
for (int i = 0; i < n - 1; i++) {
min = i; // 将索引 i 的元素默认为本轮选择排序的最小元素。
for (int j = i + 1; j < n; j++) { // 遍历[i + 1, n) 区间元素。
if (array[j].compareTo(array[min]) < 0) { // 区间内发现比 arr[min] 更小的元素时
min = j; // 将 min 更新为 j
}
}
if (min != i) { // 内层循环结束时, min != i 则说明 [i + 1, n)区间 存在更小的元素
swap(array, i, min); // 则交换2个索引上的元素
}
}
}
private static <E> void swap(E[] arr, int i, int j) {
E temp = arr[j];
arr[j] = arr[i];
arr[i] = temp;
}
}
时间复杂度: O(n2),每一轮比较确定一个元素的最终位置,也就是去掉一个待排序元素,直到待排序区间没有元素时停止,那么第一轮需要扫描 n 个元素,第二轮扫描 n - 1,n - 2,n - 3 … 1 总共扫描 n(n - 1) / 2 次,所以时间复杂度为 O(n2)。
空间复杂度: O(1),选择排序不需要申请额外的内层空间进行辅助排序,不管数据规模多大,使用的辅助变量都说是固定的,因此空间复杂度为 O(1)。
稳定性: 选择排序算法是不稳定的排序算法。假设当前未排序的第一个元素 a1 后存在一个相同的元素a2,在 a2 之后存在一个未排序的最小元素 b1 那么当 a1 和 b1 交换位置后,a1 就位于了 a2 之后,所以选择排序的交换是跳跃式的,会改变两个相同元素间原本的位置关系。
插入排序(Insertion Sort),也是一种基础的排序算法,其基本思想是将待排序区间中的一个元素插入到有序区间的合理位置上,使得有序区间内的元素增加,待排序区间中的元素减少,直到待排序区间没有元素位置。
具体实现上,同样需要两个循环解决问题(默认升序排列),定义外层循环变量 i 指向数组中未排序区间的第一个元素,也就是本轮需要插入至有序区间中的元素,同时 i 需要维护的循环不变量为每次进入循环时 [0, i - 1] 为有序区间,[i, n - 1] 为无序区间(n 表示元素个数),初始时,i = 0 表示有序区间没有元素。每次进入外层循环时,将 i 位置元素用临时变量 temp 保存,用于与 i 之前的有序元素进行比较。
定义内层循环变量 j,j 初始指向 i ,用于纪录 temp 元素应该插入的位置,在内存循环中每一次将 temp 与 j - 1 位置上的元素进行比较,如果 temp < j - 1 位置上的元素,那么 temp 就应该插入到 j - 1 位置,而 j - 1 位置的元素也应该后移到 j 位置上,这里直接将 j - 1 位置上的元素覆盖到 j 位置上,并用 j 来纪录 temp 应该插入的位置,也就是 j = j - 1 。因为 j 的指向已经前移,那么就继续比较更新后j - 1 这个位置上的元素,如果 temp < j - 1位置上的元素,重复上述操作。直到 temp >= j - 1 位置上的元素,或者 j 将有序区间中所有元素都比较一遍( j == 0) 时退出内层循环,此时 j 存储的位置即为 temp 应该插入的位置,将 temp 插入到该位置即可。
重复上述操作,直到 i == n,也就是无序区间 [i, n - 1] 为空区间时,退出整个循环,数组排序完成。
代码实现:
public class InsertionSort {
public static <T extends Comparable<T>> void sort(T[] arr) {
int n = arr.length;
for (int i = 0; i < n; i++) {
T temp = arr[i]; // 保存待插入元素
int j;
for (j = i; j > 0 && arr[j - 1].compareTo(temp) > 0; j--) { // 当j > 0 && j arr[j-1] > temp
arr[j] = arr[j - 1]; // arr[j-1]的元素后移
}
// 当退出循环时,arr[j]就是temp应该存入的位置
arr[j] = temp; // 将temp赋值给arr[j]
}
}
}
时间复杂度: O(n2),插入排序算法在最坏情况下,也就是数组完全逆序的情况下,后面的元素肯定小于前面的所有元素,所以每一轮待插入的元素对需要与有序区间内所有的元素进行比较,并最终插入到有序区间的第一个位置。当 i = 0 时,没有有序元素进行比较,所以不会进入内层循环,当 i = 1 时,有序区间存在一个元素,需要比较 1 次,并将索引 1 位置的元素放到索引 0 的位置上,那么当 i = 2 时,就需要比较 2 次,i = 3,比较 3 次,一直到 i = n - 1 需要比较 n - 1 次,所以总共比较的次数为 1 + 2 + 3 + … + n - 1 = n(n - 1)/2。那么最坏时间复杂度为 O(n2),而一般普通算法的时间复杂度都是取最坏时间复杂度,所以插入排序的时间复杂度为 O(n2)。
不过需要注意的是,插入排序在完全有序的情况下,时间复杂度可以达到 O(n) 级别。不难理解,当待排序元素与前一个元素进行比较时,前一个元素一定是小于待排序元素的,因此每轮的内层循环只执行了一次,所以总的时间复杂度为 O(n)。而一般而言,数据规模越小时,数据有序的可能性越大,并且插入排序所要比较的次数也越少,所以插入排序经常被用于高级排序算法的优化,用于处理小规模数据时的排序工作。
空间复杂度: O(1), 原地排序算法。
稳定性:插入排序法是稳定的排序算法。假设 a1 为未排序的元素,如果之前存在相同元素 a2,那么 a1 是不会插入到 a2 之前的,这是因为 a1 会逐个向前与元素进行比较,当遇到 a2时根据插入排序的比较逻辑就会停在 a2 的后一个位置,这样就保证了2个元素的相对位置不会发生改变。
不过将如果将内层循环的比较逻辑改成包含等于的情况,那么插入排序法也会变成不稳定的排序算法。所以对于稳定的排序算法而言,它是可以被修改为不稳定的排序算法,但是对于不稳定的排序算法而言,无论怎样修改都不能成为稳定的排序算法。
希尔排序(Shell Sort)是对插入排序算法的优化,在介绍插入排序时提到过插入排序算法在数据完全有序或者基本有序的情况下时间复杂度可以达到 O(n) 级别,而一般来说数据规模越小时,数据有序的可能性越大。不过在实际的应用中一组数据的排列不可能以我们想要的方式呈现,而希尔排序算法就是为了创造这些条件而诞生的。其基本思想为:让待排序的数据变得越来越有序。
具体实现是将一组数据分成若干个子序列,对每一个子序列进行插入排序,那么经过这一轮排序过后,整组数据就比之前变得更加有序。接着再进行下一轮的排序,继续将整组数据分成若干个子序列不过这一次的分组数量要小于上一次的分组数量,当整组数组基本有序时,最后再将整组数据看成一组进行插入排序,操作结束后整组数据也就排序完毕。
需要注意的是,所谓的基本有序是指在一组数据中,较小的元素都在靠前的位置,较大的元素都在靠后的位置,而不大不小的元素都在靠中间的位置。所以,对于一组数据的分组并不是将一段连续的子序列划分为一组,因为这样划分后每个子序列在进行插入排序后,所移动的空间是有限的,无法使得原本靠前的较大元素移动到靠后的位置,同样也无法将原本靠后的较小元素移动到较大的位置。
为了达到整组数组越来越有序,正确的分组方式是将整组数据里相距为某个增量的元素划分为一组子序列,这样在对每一个子序列进行插入排序时,如果两个相差一个增量的元素间存在逆序关系,在进行插入时逆序元素的移动范围也会更大,也就更加靠近它应该处于的范围。最终数组数据也会变的越来越有序。
增量的选择决定了每一轮希尔排序时,数据被划分为了多少组,同时增量的选择也会影响希尔排序的性能。增量的选择目前还没有得到一个最优解,这属于计算机科学界未解的一个难题。常用的增量选择每次为原增量的 1/2,或者是每次取原增量的 1/3 + 1。这里以取原增量的 1/2 举例:
首次选择的增量为整组数据 n 的一半,也就是 n/2,那么整组数据也就被划分为了 n / 2组,其中每一组中有两个元素。对这 n / 2 组数据进行插入排序后。下一次的增量为 n / 4 ,n / 8,…,一直到 1 。最后一次增量值必须唯一,也就是将整组数据划分为 1 组进行插入排序。
其实大体的逻辑与插入排序基本一致,主要是每轮插入排序后需要更新增量为之前的一半,并且比较前一个元素元素不是固定为 -1 位置上的元素,而是减去增量后位置上的元素。
代码实现:
public class ShellSort {
public static <E extends Comparable<E>> void sort(E[] array) {
if (array == null)
return;
int n = array.length;
int gap = n / 2; // 初始增量为 n/2
while (gap >= 1) {
for (int i = gap; i < n; i++) { // i 等于初始增量
E t = array[i]; // 保存要插入到前面有序区间的元素
int j = i;
for (; j - gap >= 0 && array[j - gap].compareTo(t) > 0; j -= gap) { // j 每一步的偏移量为 gap
array[j] = array[j - gap];
}
array[j] = t;
}
gap = gap / 2; // 本轮插入排序完毕后, 缩小增量继续下一轮插入排序, 直到 gap < 0
}
}
}
时间复杂度: O(nlogn) ~ O(n2),希尔排序的性能取决于增量的设置单从代码层面来看它的时间复杂度应该是 O(n2),但是实际上要比 *O(n2)*快上许多,在我的电脑上测试数据规模 10 万可以和 O(nlogn) 的排序算法性能接近,对于百万级规模的数据,也能在 2s 之内完成排序,所以它的时间复杂度应该是介于 O(nlogn) ~ O(n2 ,不过对于希尔排序的具体时间复杂度分析非常复杂,也超过了博主的能力范围,因此这里只是给出了一个大概的范围。有些书上也会说,在取特定的增量时,希尔排序的时间复杂度在 O(n1.3) 左右,这里了解即可。
空间复杂度: O(1),原地排序算法。
稳定性: 希尔排序法是不稳定的排序算法。希尔排序算法会将数据进行分组,对相距某一增量的一组数据进行插入排序,那么在排序的过程中因为是跳跃式的比较,也就很可能将相同元素的相对位置进行改变。
冒泡排序*(Bubble Sort)* 是一种交换排序算法,基本思想为:对一组有 n 个元素的数据,每次比较相邻两个元素的大小,如果是逆序排列,则交换两个元素的位置,直到所有元素有序为止。
以上图为例,对 [5, 3, 7, 1, 2, 6, 4, 8]这组数据进行排序 ,首先比较 5 和 3 的大小,5 > 3 因此交换 5 和 3 的位置,得到[3, 5, 7, 1, 2, 6, 4, 8]
继续比较后面相邻的两个元素大小, 并按照相同的判断来决定两个元素是否交换,直到遍历完未排序元素中最后两个元素时,本轮比较完毕。
可以发现通过一轮比较可以将未排序元素中最大的元素放置在未排序元素的最后一个位置。并且这个元素所处的位置也是整个排序完毕之后应该处于的位置。
所以对于给出的这组数据,只需要进行 7 轮比较就能确定 7 个较大元素的最终位置,而最后一个元素也自然是处于其最终位置上 。那么如果是一组有 n 个元素的数据,只需要进行 n - 1 轮比较就可以完成排序。并且,因为每一轮都确定了一个元素的最终位置,所以每进行下一轮比较时,上一轮确定位置的元素及其后面的所有元素都无需进行比较。假设进行第 i + 1 轮比较,之前就确定了 i 个元素的位置,本轮比较的最后两个元素只需要到 n - i - 1 和 n - i 即可,最终 n - i 位置上也会在放置剩余元素中最大的一个元素。
代码实现:
public class BubbleSort {
public static <E extends Comparable<E>> void bubbleSort1(E[] array) {
int n = array.length;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (array[j].compareTo(array[j + 1]) > 0) {
swap(array, j, j + 1);
}
}
}
}
public static <E extends Comparable<E>> void swap(E[] array, int i, int j) {
E t = array[i];
array[i] = array[j];
array[j] = t;
}
}
对于一组已经基本有序的数据,在经过几轮排序后,整组数据可能已经是完全有序的了,那么也就没必要再对剩余的元素挨个进行比较。所以在每一轮比较开始时都假设数组已经有序,如果在本轮比较中相邻元素间并没有进行交换,那么就可以证明假设是正确的,直接退出循环即可。
具体操作上,在进入外层循环后定义布尔变量 isSorted 初始化为 true,当在内层循环中发生交换行为时,将其置为 false,内层循环结束时,如果 isSorted == true,说明没有发生交换行为,即数组已经有序,则循环终止。如果 isSorted == false,说明发生了交换行为,即数组依然可能无序,继续下次循环。
public class BubbleSort {
public static <E extends Comparable<E>> void bubbleSort(E[] array) {
int n = array.length;
for (int i = 0; i < n - 1; i++) {
boolean isSorted = true; // 本轮交换开始假设数组已经有序
for (int j = 0; j < n - 1 - i; j++) {
if (array[j].compareTo(array[j + 1]) > 0) {
swap(array, j, j + 1);
isSorted = false; // 执行交换操作,则数组依然可能无序
}
}
if (isSorted) // isSorted == true, 则数组已经有序
break;
}
}
public static <E extends Comparable<E>> void swap(E[] array, int i, int j) {
E t = array[i];
array[i] = array[j];
array[j] = t;
}
}
如果是一组前面无序,而后面有序的数据,对于后面有序区间内的比较是没有必要的,但是根据上面版本的 Bubble Sort 除非数组完全有序,否则依然会将后面已经有序的数据再次比较一遍,而对于有序区间内的比较不会产生交换操作,所以每一轮比较完毕后,最后一次交换操作发生的位置及其后面的所有元素都是有序的。因此,可以纪录每轮比较的最后一次交换操作发生的位置,整组数据的元素总个数 - 最后一次交换的位置 = 有序元素的个数。对于内外两层循环的判断都可以基于有序元素的个数,外层循环根据有序元素的个数决定是否进入循环,内层循环根据有序元素的个数决定本轮比较的边界位置。
public class BubbleSort {
public static <E extends Comparable<E>> void bubbleSort(E[] array) {
for (int i = 0; i < array.length - 1; ) {
int last = 0; // 纪录本轮最后一次交换位置,初始为0
for (int j = 0; j < array.length - 1 - i; j++) {
if (array[j].compareTo(array[j + 1]) > 0) {
swap(array, j, j + 1);
last = j + 1; // 纪录交换位置,[j + 1, n) 之间为有序元素
}
}
i = array.length - last; // array.length - last,计算已经有序的元素个数,并赋值给 i
}
}
public static <E extends Comparable<E>> void swap(E[] array, int i, int j) {
E t = array[i];
array[i] = array[j];
array[j] = t;
}
}
时间复杂度: O(n2),冒泡排序在完全逆序的情况下,需要两两比较无序区间中的所有元素,总的比较次数同样也是 1 + 2 + 3 + … + (n - 1) = n(n-1)/2 ,所以时间复杂度是 O(n2)。
空间复杂度: O(1) ,原地排序算法。
稳定性: 冒泡排序法是稳定的排序算法。冒泡排序法每一次都是比较相邻两个元素的大小关系,如果存在逆序关系则交换,所以不是逆序关系的两个元素是不会进行位置的交换,也就保证了相同元素的相对位置不会发生改变。
堆排序(Heap sort)是借助了堆这种数据结构来完成排序的算法。如果没有了解过堆这种数据结构的同学可以参考这篇博客:堆和优先队列,里面比较详细的介绍了堆排序中所应用的几个函数。
堆排序的基本思想是将待排序的数组构建成一个大堆或小堆(根据需求而定),然后根据堆的特性,交换堆顶元素和堆底最后一个元素(数组最后一个元素)进行交换,那么此时数组最后一个元素就是整个数组中的最大值(以升序排列为例),因为堆顶元素此时不满足堆的性质,所以要对堆顶元素执行下沉(Sift Down)操作,在 Sift Down 的过程中不应该对已经有序的元素进行操作,也就是被交换至数组末尾的元素此时已经不属于这个堆中的元素了,因此在 Sift Down 的具体过程中需要使用一个变量,来控制 Sift Down 所能操作的范围。当操作完毕后再将新的堆顶元素与数组倒数第二个元素进行交换,以此类推直到整个数组排序完毕。
public class HeapSort {
/**
* 原地堆排序
* 核心思路:将待排序的数组看成一个堆,使用heapify的方式构建成一个堆,当形成堆以后堆顶的元素为数组中最大的元素,将
* 这个元素与堆底(数组最后一个)元素进行交换,那么此时数组最大的元素就被放置在了它应该存在的位置。而因为堆顶的元素此
* 刻不在是堆中最大的元素,应该再次siftDown()将该元素下沉重新形成堆,接着再将新的堆顶元素与新的堆底(数组倒数第二)元
* 素进行交换,那么此时数组第二大的元素就被放置再了它应该存在的位置,...以此类推,直到堆中所有元素都被重新放置。
*
* 通过上面的分析,需要维护一个指针 end 初始指向数组最后一个元素的位置。整个排序过程中所维持的循环不变量为:
* [end + 1, arr.len)区间为已经排序的元素,[0,end]区间具备大根堆的特性,并且区间内都是未排序的元素
*/
public static <E extends Comparable<E>> void sort(E[] arr) {
System.out.println("****************原地堆排序****************");
int end = arr.length - 1;
heapify(arr); // 将数组转换为堆
while (end > 0) {
swap(arr, 0, end);
siftDown(arr, 0, end); // 维持[0, end]区间内大根堆的特性。
end--; // 维持[end + 1, arr.len) 区间为已经排序的元素
}
}
/**
* 将传入的数组构建为最大堆
* 核心思路:从最后一个非叶子节点开始siftDown,直到根节点siftDown完毕。
*/
private static <E extends Comparable<E>> void heapify(E[] arr) {
if (arr == null)
throw new NullPointerException();
if (arr.length <= 1)
return;
/**
* 求最后一个非叶子节点的下标:
* 1. 最后一个叶子节点下标 = arr.length - 1
* 2. 父节点的下标 = (子节点下标 - 1) / 2
* 3. 最后一个叶子节点的父节点即为最后一个非叶子节点,因此公式为:
* lastNonLeaf = (arr.length - 2) / 2
*/
int nonLeaf = (arr.length - 1 - 1) / 2;
while (nonLeaf >= 0) {
siftDown(arr, nonLeaf, arr.length);
nonLeaf--;
}
}
/**
* 将所维护的最大堆 heap 在索引 p 位置上的元素进行下沉
*
*/
private static <E extends Comparable<E>> void siftDown(E[] heap, int p, int size) {
E parent = heap[p];
int half = size >>> 1;
while (p < half) {
int c = (p << 1) + 1;
if (c + 1 < size &&
heap[c + 1].compareTo(heap[c]) > 0)
c++;
if (parent.compareTo(heap[c]) > 0)
break;
heap[p] = heap[c];
p = c;
}
heap[p] = parent;
}
private static <E extends Comparable<E>> void swap(E[] arr, int i, int j) {
E t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
}
时间复杂度: O(nlogn),堆排序主要进行两个操作,首先是 heapify 将数组整理成堆的形式,这个过程的时间复杂度为 O(n)。其次是要进行 n - 1 次的 Sift Down 操作对数组进行排序,每一次 Sift Down 最差时间复杂度为 O(logn),因此堆排序总的时间复杂度为 O(nlogn)。
空间复杂度: O(1),这一版的堆排序使用的原地排序版本。
稳定性: 堆排序法是不稳定的排序算法,堆的底层数据组织结构是一颗完全二叉树,底层容器是数组,那么在排序过程中每次将堆顶元素与未排序的最后一个元素进行交换,接着被交换至堆顶的元素会进行 siftdown 操作,交换过程是跳跃式的,而 siftdown 的操作也可能改变两个相同元素间的相对位置。
归并排序(Merge Sort)的基本思想是将一个数组一分为二划分为更小的两个子数组,如果能对这两个子数组进行排序,并在排序后将两个子数组进行合并,那么整个数组的排序问题就能得到解决。而如何解决子数组的排序问题?同理,继续将两个子数组一分为二为 4 个子数组,8 个子数组… 直到子数组长度为 1 时不可划分,那么此时每一个长度为 1 的子数组都是有序的,再将与其相邻的子数组进行合并为更大的有序数组。那么经过不断的合并,最终就能将整个数组合并成完成有序的形式。
具体实现上,从上述的描述中不难发现,使用递归的方式来实现归并排序算法要容易许多。如果学习过二叉树的话对于递归部分的代码实现应该不难。
递归函数代码:
public class MergeSort {
// 对外的公共接口
public static <E extends Comparable<E>> void sort(E[] arr) {
// 调用私有的方法, 将待排序数组,以及数组的左右边界下标传入
mergeSort(arr, 0, arr.length - 1);
}
// 递归函数的实现
// arr 待排序的数组
// l 当前数组的左边界
// r 当前数组的右边界
private static <E extends Comparable<E>> void mergeSort(E[] arr, int l, int r) {
if (l >= r) // 当数组只有一个元素时终止递归
return;
// 计算出中间值,并以中间值将数组拆分成两半
// int mid = (l + r) / 2; l + r 可能会整型溢出,因此采用下面的计算方式
int mid = l + (r - l) / 2;
mergeSort(arr, l, mid);
mergeSort(arr, mid + 1, r);
// 拆分完毕后,再将数组进行合并
merge(arr, l, mid, r);
}
}
在递归函数最后有一个 merge 函数,它表示当拆分完毕后就需要对两个子数组进行合并的操作。而这个 merge 算是整个归并排序算法的核心部分。函数的参数中传递了待排序的数组,和 l, mid, r 三个边界变量,在这一版的 merge 函数中,是对 [l…mid] 和 *[mid + 1…r]*这两个子数组进行合并,也可以理解为是对这个两个区间进行排序。在具体实现上,需要申请一块大小为 r - l +1 的空间作为新数组,并将原数组 [l…r] 区间的元素拷贝至新数组中,同时定义两个指针分别指向新数组中相应的两个子数组的起始位置,并比较两个子数组上元素的大小,将较小(或较大)的元素放入原数组的相应位置上,一直到原数组的 [l…r] 被重新有序的覆盖。
merge 的代码:
/**
* 合并两个有序区间 [l...mid] 和 [mid + 1...r]
* 将数组 arr [l...r] 区间的内容拷贝至数组 temp 中。新拷贝的 temp 数组起始位置从 0 开始。
* 1. 定义指针 i 初始指向 l, 即左子数组的左边界位置。指针 i 扫描 temp 数组中的左子数组, 因为 temp 数组起始位置从 0
* 开始, 因此在具体指向时要考虑偏移量的问题, 偏移量为 l。
* 2. 定义指针 j 初始指向 mid + 1, 即右子数组的左边界位置。指针 j 扫描 temp 数组中的右子数组, 同样需要考虑偏移量的
* 问题, 偏移量为 l
* 3. 定义指针 k 初始指向 l, 指针 k 用于从[l...r]扫描数组 arr, 每一次扫描都将放置一个较小或较大的元素在 arr[k] 位
* 置上, 一直到 k > r, 表示数组 arr[l...r] 区间以及被有序重新覆盖。
*
* 在循环中比较 temp[i-l] 和 temp[j-l] 的大小, 将较小(或较大)的元素放入到 arr[k] 位置上, 同时将指向较小(或较大)元
* 素的指针 i 或 j 进行右偏移( +1 )。因为 i 扫描为左子数组, 左子数组的左右边界为 [l,mid], 所以当 i > mid 时, 表示
* 整个左子数组都扫描完毕, 如果 k 还未到达 r 的位置, 那么就将右子数组中的剩余元素依次放入到 [k...r] 区间中。同理 j
* 扫描为右子数组, 右子数组中的左右边界为 [mid + 1, r], 所以当 j > r 时, 表示整个右子数组都扫描完毕, 则将左子数组剩
* 余元素依次放入到 [k...r] 区间中。知道 k > r 时退出循环, 归并完毕。
*/
private static <E extends Comparable<E>> void merge(E[] arr, int l, int mid, int r) {
// 归并排序不是原地排序算法,需要额外开辟 r - l + 1 大小的空间
E[] temp = Arrays.copyOfRange(arr, l, r + 1);
int i = l, j = mid + 1;
for (int k = l; k <= r; k++) {
if (i > mid) {
arr[k] = temp[j - l];
j++;
} else if (j > r) {
arr[k] = temp[i - l];
i++;
} else if (temp[i - l].compareTo(temp[j - l]) <= 0) { // <= 保证排序稳定性
arr[k] = temp[i - l];
i++;
} else {
arr[k] = temp[j - l];
j++;
}
}
}
上述两段代码就是归并排序的完整实现,排序的执行流程如下图:
上一版的归并排序算法,还有一些地方可以进行优化,优化点如下:
完整代码:
public class MergeSort {
public static <E extends Comparable<E>> void sort(E[] arr) {
E[] temp = Arrays.copyOf(arr, arr.length); // 优化位置
mergeSort(arr, 0, arr.length - 1, temp);
}
private static <E extends Comparable<E>> void mergeSort(E[] arr, int l, int r, E[] temp) {
if (l >= r)
return;
int mid = l + (r - l) / 2;
mergeSort(arr, l, mid, temp);
mergeSort(arr, mid + 1, r, temp);
if (arr[mid].compareTo(arr[mid + 1]) > 0) // 优化位置
merge(arr, l, mid, r, temp);
}
private static <E extends Comparable<E>> void merge(E[] arr, int l, int m, int r, E[] temp) {
System.arraycopy(arr, l, temp, l, r - l + 1); // 将arr[l...r]的内容 拷贝至 temp[l...r]
// 因为temp数组与arr数组大小相同,元素所处的位置也相同,因此不必在考虑偏移量的位置
int i = l, j = m + 1;
for (int k = l; k <= r; k++) {
if (i > m) {
arr[k] = temp[j];
j++;
} else if (j > r) {
arr[k] = temp[i];
i++;
} else if (temp[i].compareTo(temp[j]) <= 0) {
arr[k] = temp[i];
i++;
} else {
arr[k] = temp[j];
j++;
}
}
}
}
自底向上的归并排序是一种非递归的实现方式,其基本思想是从最底层出发不断向上解决问题,最初从最小的数组开始执行 merge 操作,将只有 1 个元素的 2 个子数组归并成 1 个包含 2 个元素的有序子数组,接着再对有 2 个元素的有序子数组归并成含有 4 个元素的有序子数组,重复操作直到将整个数组归并。
自底向上是相对于递归树而言,从递归实现的角度来看是从顶层(整个数组)出发,将整个数组不断拆分成小的数组,直到拆分为原子数组时,在对两两相邻的子数组进行合并,拆分的过程是发生在向下递归的过程中,而合并的操作是发生在向上返回的过程,其实个人觉得,自底向上的操作方式其实与递归过程中向上返回的操作很相似。
代码实现:
public class MergeSort {
public static <E extends Comparable<E>> void sortBU(E[] arr) {
int n = arr.length;
E[] temp = Arrays.copyOf(arr, arr.length);
/**
* 对两个大小为 sz 的有序数组进行归并操作
* sz 初始等于 1, 表示第一轮循环时, 将对大小为 1 的两个有序数组进行归并, 既是对[0,1]、[1,2]、[2,3]...
* [n-2,n-1] 区间进行排序, 当内层循环退出时, 有序数组的大小为之前的两倍, 所以外层循环控制循环变量 sz 为两倍增
* 长,表示再次进入循环时,对上一轮归并后长度为 sz 的两个子数组再次进行合并,直到 sz == n 时,表示整个 arr 数
* 组排序完毕。
*
* 变量解释:
* 1. sz, 归并两个子数组的大小
*
* 2. l, 靠左子数组的左边界, 初始为0。
* -- 当每次归并完两个子数组后, l 应该移动到下一对子数组的左边界位置, 即 l += sz + sz。
* -- l + sz < n; 时, 表示还存在两个子数组可以进行归并。
*
* 3. mid, 靠左子数组的右边界, 即 [l,mid] 为左子数组。
* -- mid = l + sz - 1; 左子数组大小为 sz, 所以 l + sz - 1 表示为左子数组的右边界
*
* 4. r, 靠右子数组的右边界。mid 为左子数组的右边界, 而两个子数组是紧挨着的, 所以 mid + 1 为右子数组的左边界
* 即 [mid + 1, r] 为右子数组。
* -- r = Math.min((mid + sz), (n - 1)); mid + sz, 即可以取到右子数组的右边界, 但是当数组 arr 的长度并
* 不是 2 的整数次幂时, 无法对整个数组进行平均拆分, 最后一个子数组一定是少于 sz 的, 所以不能直接让
* r = mid + sz, 这样会导致在归并最后两个数组时, 出现下标越界, 正确的做法是取 mid + sz 和 n - 1的较小值,
* n - 1 即为 arr 数组最后一元素的下标。
*/
for (int sz = 1; sz < n; sz += sz) {
for (int l = 0; l + sz < n; l += sz + sz) {
int mid = l + sz - 1;
int r = Math.min((mid + sz), (n - 1));
if (arr[mid].compareTo(arr[l + sz]) > 0) {
mergeBU(arr, l, mid, r,temp);
}
}
}
private static <E extends Comparable<E>> void mergeBU(E[] arr, int l, int m, int r, E[] temp) {
System.arraycopy(arr, l, temp, l, r - l + 1);
int i = l, j = m + 1;
for (int k = l; k <= r; k++) {
if (i > m) {
arr[k] = temp[j];
j++;
} else if (j > r) {
arr[k] = temp[i];
i++;
} else if (temp[i].compareTo(temp[j]) <= 0) {
arr[k] = temp[i];
i++;
} else {
arr[k] = temp[j];
j++;
}
}
}
}
时间复杂度:O(nlogn),归并排序算法每一层的递归调用都对两两相邻的子数组进行归并操作,归并操作会将两个子数组的区间扫描一遍,所以每一层都对整个数组进行了一遍扫描,一层的操作总数为 n,而递归树的总深度为 logn + 1 层,所以总的时间复杂度为 O(nlogn)。
最好情况下,也就是数组完全有序的情况下,基于上面的优化并不会进入 merge 函数中进行归并,那么每一个递归函数中的操作数都是 O(1) 级别,将递归函数看作递归树中的一个节点,那么递归树中有多少个节点,就进行了多少次的 O(1) 操作。归并排序的递归树是一颗满二叉树,最底层的叶子节点数量为数组元素个数 n ,上一层节点个数为 n/2,在上一层为 n/4 … 一直到根节点时节点个数为 1。总的节点个数为 n + n / 2 + n / 4 + … + 1 ≈ 2n ,所以在数组完全有序的情况下,归并排序的时间复杂度为 O(n)。
空间复杂度:O(n),因为归并排序算法不是原地排序算法,需要额外申请一块等同于待排序数组大小的空间进行辅助排序,所以空间复杂度为 O(n)。
稳定性:规并排序法是稳定的排序算法,对于归并排序算法来说,元素的移动发生在 merge 中,在归并时没有发生跳跃式的交换,并且如果两个待合并的子数组中存在相同的元素时,只需要保证前一个子数组的相同元素先放入原数组的对应位置,就可以保证整个排序算法的稳定性。
快速排序(Quick Sort)算法,被誉为20世纪十大算法之一,其基本思想是对于一组待排序的数据,每一轮排序从待排序元素中选取一个标定点(pivot),在排序的过程中使得 pivot 左侧的元素小于或等于(默认升序排列) pivot,右侧的元素大于或等于 pivot ,并以 pivot 为轴原数组分割为较小的两个子数组,并对两个子数组分别进行如上操作,直到子数组区间长度为 1 时,整个排序完毕。
快速排序的实现方式有很多种,比如:单路快速排序,双路快速排序,三路快速排序等,由于篇幅有限这里主要介绍应用最多的双路快速排序,对于单路快排而言当待排序数组中出现大量重复元素时,快速排序会退化成 O(n2) 的排序算法,了解即可。而对于三路快速排序而言,数组中完全是重复元素时,时间复杂度可以达到 O(n) 级别,不过一般而言还是双路快速排序算法的性能更优。
具体实现上,首先需要了解如何选取 pivot ,以及如何使得 pivot 左侧的元素都是小于等于 pivot,右侧的元素都是大于等于 pivot,这其实是快速排序算法中最为重要的一个操作,被定义在一个叫 partition 函数中。
partition(arr, l, r) 函数是对数组的 [l…r] 区间进行排序,在 partition 的过程中会随机选取一个 pivot (这里使用的是随机选取法), 并将 pivot 移动到 arr[l] (左边界)的位置上。
接着定义两个指针 i 初始指向 l + 1 位置,j 初始指向 r 位置,使用指针 i, j 从数组左右两端向中间遍历,遍历的过程中指针 i 需要维护 [l + 1… i - 1] 都是 <= pivot 的元素,而指针 j 需要维护 [j + 1…r] 都是 >= pivot 的元素。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GpDE8ng6-1634830346729)(D:\课件\笔记\数据结构与算法\七大排序算法\image-20211021111001863.png)]
具体来说,当 arr[i] < pivot 时,i 向右移动(i++),那么 [l + 1…i - 1] 区间的性质就被维护住。而当 arr[j] > pivot 时,j 向左移动(j–),那么 *[j + 1…r]*区间的性质也被维护住。当 arr[i] >= pivot 并且 arr[j] <= pivot 时, arr[i] 和 arr[j] 指向的元素不满足各自所需要为何区间的性质,因此交换 arr[i] 和 arr[j] ,使得两个指针所维护的区间各自满足区间的性质。重复操作直到 i >= j 时,整个数组遍历完毕。
当退出循环后,arr[l + 1…i - 1] 的元素 <= pivot,arr[j + 1…r] 的元素 >= pivot,此时 j 指向了左区间 <= pivot 的最后一个元素,那么 j 指向的位置就是 pivot 应该处于的位置,交换 arr[l] 和 arr[j]。
j 此时指向 pivot ,那么就可以将 j 作为轴将数组分割为两个子区间 [l…j - 1] 和 [j + 1…r],并分别对两个子区间再执行上述操作,直到子区间中只存在一个元素时,就没法继续在拆分了,那么此时的整个数组的排序也就完成了。通过观察其实不难发现,对数组的分割操作与归并排序对数组的一分为二操作很像,只是在快速排序中数组的分割是以 pivot 为轴进行的,所以分割操作这一步其实也可以使用递归实现,并且使用递归实现也更加简单。所以分割操作无需定义 partition 中,只需要接收 partition 函数返回的 pivot 下标值即可,也就是j 的值。
代码实现:
public class QuickSort {
/**
* 提供给外接的公共接口
*/
public static <T extends Comparable<T>> void sort(T[] arr) {
// 对[0, lengnth - 1]区间进行排序, 传入一个Random对象用于partition函数中随机生成 pivot
quickSort(arr, 0, arr.length - 1, new Random());
}
/**
* 对数组的分割操作, 基于 partition 函数返回 pivot 的位置
*/
private static <T extends Comparable<T>> void quickSort(T[] arr, int l, int r, Random rd) {
// 区间只有一个元素时终止递归
if (l >= r) return;
int p = partition(arr, l, r, rd);
quickSort(arr, l, p - 1, rd);
quickSort(arr, p + 1, r, rd);
}
/**
* 对[l...r]区间进行 parition 操作
*/
private static <T extends Comparable<T>> int partition(T[] arr, int l, int r, Random rd) {
int p = rd.nextInt(r - l + 1) + l; // 生成[l,r]间的随机索引,索引上的值作为 pivot
swap(arr, l, p); // 将 pivot 移动到arr[l]的位置上
// arr[l + 1...i - 1] <= arr[l] (pivot)
// arr[j + 1...r] >= arr[l] (pivot)
int i = l + 1, j = r;
while (true) {
while (i <= j && arr[i].compareTo(arr[l]) < 0)
i++;
while (i <= j && arr[j].compareTo(arr[l]) > 0)
j--;
if (i >= j) break;
swap(arr, i++, j--);
}
swap(arr, l, j);
return j;
}
private static <T> void swap(T[] array, int left, int right) {
T tmp = array[left];
array[left] = array[right];
array[right] = tmp;
}
}
以上代码实现实际上是经过优化后的代码,大家可能存在一些疑惑,下面解释一些自己在学习中遇到的疑问:
为什么使用 Random 对象来随机生成标定点?
其主要解决的问题是如果数组已经是有序的情况下,每次都直接选取 arr[l] 作为 pivot 那么每一轮的 partition 中,pivot 右边的元素都是大于(默认升序) pivot 的,最终只能分割出一个区间,即为 [p + 1, r]。也就是每次只处理了一个元素,下一轮还需要处理 n - 1 个元素,n - 2,n - 3 … 1,这样实际上总共 partition 了 n 轮,那么总共的执行次数就是 n + n - 1 + n - 2 + n - 3 + … + 1 = n( 1 + n )/2,时间复杂度就退化为 O(n2),这个时间复杂度对于排序算法来说就是非常慢的了,并且因为这一版的快速排序是递归实现,那么当数据规模稍大时,递归 n 层也很容易导致栈溢出的情况。
而如果使用随机生成标定点的方式,那么每次都选取到待排序区间中最小元素的概率就是 1/n, 1/n-1, 1/n-2, 1/n-3, … 1,总的概率就是每一次的概率相乘 = 1/n! ,这是非常低的概率,因为阶乘的增长速度非常之快,当数据规模为 10 的时候,每一次 partition 选取最小值作为标定点的概率就为 1/ 3628800,如果碰上这种情况还需要敲什么代码,直接买彩票就可以了。并且就算是在数据规模为 10 时遇上了这种情况,O(n2) 级别的算法也能够轻松应对。
此外,解决有序数组退化为 O(n2) 的方法还有几数取中法,常见为三数取中,也就是分别取待排序区间的最左边 l, 中间 m, 和最右边 r,并对这三个位置上的值进行比较,最终形成 m <= l <= r 的排列,那么次数最左边 l 上的值就是三个数中的中间值,这样也可能很好的避免算法效率退化的问题。
为什么 i 维护的区间为 [l + 1…i - 1] <= pivot,j 维护的区间为 arr[j + 1…r] >= pivot ,但是当 arr[l] == pivot 或 arr[j] == pivot 时却要停止 i 和 j 的移动呢?
这是因为,当 arr 数组中出现大量重复元素时,如果 i 和 j 遍历到与 pivot 相同元素时不停止,而是继续向中间移动,那么很容易使得 pivot 最终所处于的位置是非常偏向一边的,甚至可能是最终 pivot 的一端一个元素都没有(完全重复的情况),那么如果每一次 partition 都出现这种情况,快速排序算法依然会退化成一个 O(n2) 级别的算法,所以上面设置的条件为当 arr[i] < pivot 和 arr[j] > pivot 时 i j 指针才继续移动,而如果遇到等于的情况就会将相同的元素交换到另一端,并且两个指针会继续向中间位置移动,这样就能尽可能的保证在数据有大量重复元素时 pivot 最终的位置也是处于靠中间的位置。
时间复杂度: O(nlogn),通常来说普通算法的时间复杂度都是取最坏时间复杂度。快速排序的最坏时间复杂度为O(n2),不过在上面实现的快速排序算法属于随机算法,出现最坏情况的概率非常之低,也就是没有办法*100%*的找到一组数据使得随机快速排序退化为 O(n2) 级别的算法,所以对于随机算法的时间复杂度不能简单的取最坏时间复杂度,而是应该取复杂度的期望值,简单理解就是从平均来看快速排序的时间复杂度,依然是 O(nlogn) 级别的算法,(具体如何推导超出了博主的能力范畴,感兴趣的同学可以参考《算法导论》)。
空间复杂度: O(1),快速排序算法属于原地排序,不需要额外开辟更多的内存空间辅助排序。
稳定性:快速排序法是不稳定的排序算法,在 partition 中过程中元素的交换是跳跃性的,并且每次随机选择一个元素作为标定点时,如果存在相同的元素那么它们的相对位置就会发生改变。
非递归实现快速排序,使用了栈这种数据结构,将每次需要进行 partition 操作区间的左右边界依次压入栈中,再以相反的顺序出栈进行下一次 parititon 操作,因为时间原因这里只贴出代码供大家参考
public class QuickSort {
public static <T extends Comparable<T>> void sortByStack(T[] arr) {
Random rnd = new Random();
Stack<Integer> stack = new Stack<>();
stack.push(0); // 左边界先入栈
stack.push(arr.length - 1); // 右边界后入栈
while (!stack.empty()) {
int r = stack.pop(); // 右边界先出栈
int l = stack.pop(); // 左边界后出栈
int p = partition(arr, l, r, rnd);
if (p + 1 < r) { // pivot 右边还存在两个以上元素时
stack.push(p + 1);
stack.push(r);
}
if (p - 1 > l) { // pivot 左边还存在两个以上元素时
stack.push(l);
stack.push(p - 1);
}
}
}
private static <T extends Comparable<T>> int partition(T[] arr, int l, int r, Random rd) {
int p = rd.nextInt(r - l + 1) + l;
swap(arr, l, p);
// arr[l + 1, i - 1] <= arr[l]
// arr[j + 1, r] >= arr[l]
int i = l + 1, j = r;
while (true) {
while (i <= j && arr[i].compareTo(arr[l]) < 0)
i++;
while (i <= j && arr[j].compareTo(arr[l]) > 0)
j--;
if (i >= j) break;
swap(arr, i++, j--);
}
swap(arr, l, j);
return j;
}
private static <T> void swap(T[] array, int left, int right) {
T tmp = array[left];
array[left] = array[right];
array[right] = tmp;
}
}
三路快速排序,在处理数组中存在大量重复数据时,效率很高,对于完全重复的数据时间复杂度可以达到 O(n) 级别,因为时间原因贴出之前画的图和代码供大家参考。
代码实现:
// 三路快速排序
public static <T extends Comparable<T>> void sort3Ways(T[] arr) {
quickSort3Ways(arr, 0, arr.length - 1, new Random());
}
public static <T extends Comparable<T>> void quickSort3Ways(T[] arr, int left, int right, Random rd) {
if (left >= right) return;
Pair<Integer, Integer> border = partition3Ways(arr, left, right, rd);
quickSort3Ways(arr, left, border.getKey(), rd);
quickSort3Ways(arr, border.getValue(), right, rd);
}
private static <T extends Comparable<T>> Pair<Integer, Integer> partition3Ways(T[] arr, int left, int right, Random rd) {
int p = rd.nextInt(right - left + 1) + left;
swap(arr, left, p);
// [left + 1, lt] < key [lt + 1, i - 1] == key [gt, right] > key
int lt = left, i = left + 1, gt = right + 1;
while (i < gt) {
if (arr[i].compareTo(arr[left]) < 0)
swap(arr, ++lt, i++); // lt + 1的元素 与 i的元素交换,并且lt 和 i 向后移动
else if (arr[i].compareTo(arr[left]) > 0)
swap(arr, --gt, i); // gt - 1的元素 与 i的元素交换,并且 gt 向前移动,i 不变
else // arr[i] == arr[left]
i++;
}
swap(arr, left, lt);
// 交换完毕后:[left, lt - 1] < key [lt, gt - 1] == key [gt, right] > key
return new Pair<>(lt - 1, gt);
}
// 交换
private static <T> void swap(T[] array, int left, int right) {
T tmp = array[left];
array[left] = array[right];
array[right] = tmp;
}