算法-排序
概述
排序算法 用于将无序数组转换为有序数组。常见排序算法有:冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序、计数排序、桶排序、基数排序。
我们将实现上述常见排序算法,并给出相应算法的时间复杂度、空间复杂度及稳定性。
假定待排序数组中存在关键字相同的元素,我们使用排序算法排序该数组。如果排序前后,具有相同关键字的元素在数组中相对顺序保持不变,则该排序算法是稳定的。举例:以数组元素的第一个值为关键字排序数组 $a = [[1,11],[2,22],[1,9]]$,如果排序算法稳定,则排序结果中元素 $[1,11]$ 总是在元素 $[1,9]$ 之前。
假定我们需要基于多关键字 $[a,b,c]$ 排序待排序数组,通常做法就是重新编写比较函数。如果一个排序算法是稳定的,我们还可以这样做:首先基于关键字 $a$ 排序待排序数组,随后基于关键字 $b$ 排序待排序数组,最后基于关键字 $c$ 排序待排序数组。
冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序均基于比较元素实现排序,计数排序、桶排序、基数排序则不是如此。
实现
实现各排序算法之前,首先做如下声明:
- 为简化实现流程,假定数组元素中仅有关键字一项内容。
- 所有排序算法按照从小到大进行排序。
冒泡排序
冒泡排序的思想在于:遍历未排序部分数组,如果当前元素关键字大于下一个元素关键字,则交换二者,如此操作会将未排序部分数组中含最大关键字的元素逐步交换 (冒泡) 至排序部分数组首部/未排序部分数组尾部。
1 | public void bubbleSort(Integer[] objects) { |
时间复杂度
排序算法使用双重循环,而且每次循环所需 $O(N)$ 时间,故而该算法的时间复杂度为 $O(N^2)$。
空间复杂度
排序算法需要一个额外空间以实现元素交换,故而该算法的空间复杂度为 $O(1)$。
稳定性
代码实现中,只有当前元素关键字大于下一个元素关键字,元素交换才会发生。如果当前元素关键字等于下一个元素关键字,交换不会发生,从而保证具有相同关键字元素之间的相对顺序保持不变,故而该算法是稳定的。
选择排序
选择排序的思想在于:遍历未排序部分数组,将其中含最小关键字的元素置于排序部分数组尾部。
选择排序与冒泡排序的区别有两点:
- 选择排序的排序部分数组位于未排序部分数组的前方,冒泡排序则正好相反。
- 选择排序将含最小关键字的元素置于排序部分数组尾部,冒泡排序则是将含最大关键字的元素置于排序部分数组首部。
1 | public void selectSort(Integer[] objects) { |
时间复杂度
排序算法使用双重循环,而且每次循环所需 $O(N)$ 时间,故而该算法的时间复杂度为 $O(N^2)$。
空间复杂度
排序算法需要两个额外空间,一个用于记录当前位置下标,一个用于实现元素交换,故而该算法的空间复杂度为 $O(1)$。
稳定性
代码实现中,从前往后遍历未排序部分数组以获取具有最小关键字的元素下标,并且只有当前元素的关键字小于当前最小关键字时才更新具有最小关键字的元素下标,如此两项操作足以保证具有相同关键字元素之间的相对顺序保持不变,故而该算法是稳定的。
插入排序
插入排序的思想在于:将未知元素依次插入至排序部分数组之中。
1 | public void insertSort(Integer[] objects) { |
时间复杂度
排序算法使用双重循环,而且每次循环所需 $O(N)$ 时间,故而该算法的时间复杂度为 $O(N^2)$。
空间复杂度
排序算法需要两个额外空间,一个用于记录未知元素,一个用于记录插入位置,故而该算法的空间复杂度为 $O(1)$。
稳定性
寻找插入位置的代码实现中,只有当前元素的关键字大于未知元素的关键字,才将当前元素后移一位。另外,所有元素采用从前往后顺序依次插入。如此两项操作足以保证具有相同关键字元素之间的相对顺序保持不变,故而该算法是稳定的。
希尔排序
希尔排序是插入排序的一种改进版本。在插入排序中,为将未知元素插入至排序部分数组,我们需要按位后移元素。如果未知元素位于排序部分数组前部,则其所需后移元素过多。在希尔排序中,它基于步长将数组划分为若干子数组,对子数组实行插入排序,此时基于步长可将元素快速放至合适位置,最后通过逐步减少步长到1实现整个数组的排序。
1 | // 开始位置为begin,步长为k所构建的子数组进行排序(过程类似插入排序)。 |
时间复杂度
其复杂度分析比较困难,而且不同步长具有不同的时间复杂度。这里给出一个大致的时间复杂度:$O(N^{3/2})$。
空间复杂度
基本与插入排序相同,故而该算法的空间复杂度为 $O(1)$。
稳定性
子数组排序过程可保证具有相同关键字元素之间的相对顺序保持不变。如果具有相同关键字的元素位于不同子数组之中,元素之间的相对顺序则难以保证,故而该算法是不稳定的。
归并排序
归并排序的思想在于:基于分治思想将未排序数组划分为若干子数组,排序子数组后依次合并各个子数组,从而得到整个数组的排序结果。
1 | // 递归合并objects[l,r]中元素。 |
时间复杂度
记归并排序的时间复杂度为 $T(N)$,其中 $N$ 为待排序数组长度。
根据递归过程,我们可得到:$T(N) = 2 \times T(N/2) + N$。求解该式,最终将得到:$T(N) = Nlog^N$。故而该算法的时间复杂度为 $O(Nlog^N)$。
空间复杂度
排序算法需要一个额外数组空间用于合并子数组、若干额外空间用于记录下标位置。故而该算法的空间复杂度为 $O(N)$。
稳定性
代码实现中,子数组合并过程可以保证具有相同关键字元素之间的相对顺序保持不变,故而该算法是稳定的。
快速排序
快速排序的思想在于:每个过程将数组中一个元素放置到正确位置 (即其前方元素的关键字均小于该元素的关键字,其后方元素的关键字均大于该元素的关键字),随后递归处理左半部分和右半部分即可。
1 | // 递归排序objects[l,r]中元素。(这是一个快排模板) |
时间复杂度
记快速排序的时间复杂度为 $T(N)$,其中 $N$ 为待排序数组长度。
根据递归过程,我们可得到:$T(N) = 2 \times T(N/2) + N$。求解该式,最终将得到:$T(N) = Nlog^N$。故而该算法的时间复杂度为 $O(Nlog^N)$。
空间复杂度
排序算法需要若干额外空间用于记录下标位置,故而该算法的空间复杂度为 $O(1)$。
稳定性
调整元素顺序的代码实现中,交换元素将会使得位于后面的元素被放置到前方,这种操作无法保证具有相同关键字的元素之间相对顺序保持不变,故而该算法是不稳定的。
堆排序
堆排序借助于堆数据结构实现排序数组。
1 | // 调整index位置元素至堆中合适位置(堆结构从数组下标0开始,故而左儿子为2*index+1,右儿子为2*index+2)。 |
时间复杂度
堆是一棵完全二叉树,其树高为 $O(log^N)$,故而
heap()
的时间复杂度为 $O(log^N)$。建堆过程的时间复杂度为 $O(N)$,循环操作的时间复杂度为 $O(Nlog^N)$,故而该算法的时间复杂度为 $O(N + Nlog^N) = O(Nlog^N)$。
空间复杂度
排序算法需要若干额外空间用于记录下标位置,故而该算法的空间复杂度为 $O(1)$。
稳定性
与快速排序类似,由于元素交换存在跳跃性,排序算法无法保证具有相同关键字的元素之间相对顺序保持不变,故而该算法是不稳定的。
计数排序
计数排序的思想在于:使用额外数组统计待排序数组中各元素出现次数,遍历额外数组将各元素按照出现次数输出,输出结果便是排序结果。
计数排序的代码实现与思想描述有些差别,原因在于:代码实现考虑了稳定性。
1 | public void countSort(Integer[] objects) { |
时间复杂度
排序算法的时间复杂度依赖于额外数组 (即关键字数据范围),我们假定其长度为 $M$。
排序算法实现中,多次需要遍历额外数组及原始数组。通常关键字数据范围远大于数组元素个数,故而该算法的时间复杂度为 $O(M + N) \approx O(M)$ 。
空间复杂度
排序算法的空间复杂度同样依赖于额外数组,故而该算法的空间复杂度为 $O(M)$。
稳定性
该算法是稳定的,其稳定性基于此实现——根据前缀值将原始数组中元素逆序放入临时数组。
基数排序
基数排序的思想在于:它将元素关键字 (限定为数字) 拆分为若干数位,例如 789 可拆分为 7\8\9 三个数位。从低到高依次对数位执行计数排序可实现排序元素。
1 | // 假定关键字为十进制数字,则数位取值范围为[0,9]。 |
时间复杂度
排序算法实现中,需要多次循环数位数组和原始数组。通常数位数组远小于原始数组,故而该算法的时间复杂度为 $O(N)$。
空间复杂度
排序算法需要使用数位数组及临时数组。如上所述,数位数组通常远小于原始数组,故而该算法的空间复杂度为 $O(N)$。
稳定性
就本质而言,基数排序基于多次使用计数排序而实现的。由于计数排序是稳定的,该算法亦是稳定的。
桶排序
桶排序十分简单,首先将数组元素映射到若干桶 (桶表示区间范围),随后桶内排序元素,最后按序输出桶内元素即可。
如果将桶区间范围的大小限定为 1,此时桶排序就是计数排序。
桶排序比较简单,故而不再实现。