排序
- 排序
- 排序是什么
- 排序相关概念
- 稳定性
- 比较排序
- 非比较排序
- 内部排序
- 外部排序
- 常见比较排序
- 冒泡排序
- 基本思想
- 代码实现
- 选择排序
- 基本思想
- 代码实现
- 插入排序
- 基本思想
- 代码实现
- 希尔排序
- 基本思想
- 代码实现
- 堆排序
- 基本思想
- 代码实现
- 快速排序
- 基本思想
- 代码实现
- 优化
- 其他实现
- 寻找基准
- 非递归实现
- 归并排序
- 基本思想
- 代码实现
- 其他实现
- 应用
- 常见比较排序总结
- 常见非比较排序
- 计数排序
- 基本思想
- 代码实现
- 基数排序
- 基本思想
- 代码实现
- 桶排序
- 基本思想
- 代码实现
排序
排序是什么
排序指的是将一串序列按照其大小来排列为递增或递减序列的操作, 例如我现在有一串数字5 4 6 7 8 1 2 9 3
, 那么如果我将其按照升序排序, 那么此时他就会变为1 2 3 4 5 6 7 8 9
, 同理, 对其进行降序排序, 那么它就会变为9 8 7 6 5 4 3 2 1
.
那此时有人就要问了, 那假如比较的不是数字, 而是 Java 中的一些对象, 假如有一个学生类, 里面有年龄和姓名, 那又要怎么排序呢?
此时就需要根据我们的特定的需求来进行对应的排序, 例如我既可以选择使用年龄来作为比较的基准, 从而对其进行排序, 也可以选择名字的拼音首字母大小来进行排序等等.
接下来, 我们就会学习一些排序的算法, 每一个算法都有一些特点和其适用场景, 但是在学习那些算法前, 我们需要对排序的一些基本概念有一些了解.
排序相关概念
稳定性
首先要看的第一个概念就是排序的稳定性, 排序的稳定性指的是若在一个序列中, 有一个或多个相同的元素, 如果排序前后这些元素的先后顺序不会改变, 那么这个排序就是一个稳定的排序.
如下图, 其中可以看到有两个数字 5, 我们使用红色和蓝色对其进行标记. 如果排序后, 这两个 5 依旧是红色在前, 蓝色在后, 那么这个排序就是稳定的, 反之就是不稳定的.
当然, 也有可能不稳定的排序有时候也会看起来和稳定的一样, 稳定的排序也可以实现成不稳定的. 因此我们对于稳定性的判断是: 只要是可以实现恒定稳定, 那么这个排序算法就是稳定的. 即使修改代码后变成了不稳定的, 它也是一个稳定的排序算法. 反之, 如果无法恒定稳定, 即使看起来是稳定的效果, 那这个排序算法也不是一个稳定的排序算法.
因为我们的排序算法, 实际上核心看的是它的思想, 代码只不过是思想的一种实现. 只要通过思想是可以实现稳定的排序的, 那么这个排序算法就是一个稳定的排序算法
比较排序
比较排序, 指的是基于比较来进行的排序, 例如我们在学习循环阶段, 大多数人都接触过的一个基本的排序算法, 冒泡排序. 它就是一个比较排序, 因为我们是通过比较元素的大小, 然后判断是否要交换的.
非比较排序
非比较排序, 顾名思义, 就是和比较排序相反的, 不用通过数据之间的比较就可以实现的排序. 此时可能有人很好奇, 居然还有这么神奇的排序, 能不通过比较来实现排序. 相关的非比较排序算法, 我们会在下面进行简单介绍, 这里就不详细介绍了, 我们目前只需要知道, 非比较排序是什么意思就行.
内部排序
内部排序, 指的是能够将数据直接在内存中进行排序的排序. 我们通常实现的排序都是直接在内存中运行的, 但是如果数据非常大, 内存装不下, 此时就无法直接将所有数据放到内存中进行排序.
外部排序
外部排序, 自然就是和内部排序相对的, 也就是需要借助外部存储来进行排序. 在排序的过程中, 需要通过内存与外存数据交互来实现排序.
常见比较排序
冒泡排序
基本思想
冒泡排序的核心就是通过两两元素依次比较, 使得在一次一次的替换中让最大值/最小值排到当前数组的无序部分的末尾去, 从而让数组的末尾是全局有序的. 后续我们都默认排序是求升序序列, 如果希望得到降序则通过修改大小判断即可.
下面是一个冒泡排序的图示, 如下图所示
代码实现
由于冒泡排序大多数人应该在很早就接触过了, 我们这里不过多介绍, 直接看代码
public static void bubbleSort(int[] arr) {
// i 相当于表示有序区域
for (int i = 0; i < arr.length; i++) {
// j 用于标志无序区域
for (int j = 0; j < arr.length - i - 1; j++) {
// 如果大于, 交换
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
}
}
}
}
同时这里还有一个优化点, 我们可以发现, 冒泡排序在遍历无序区域的时候, 会把大数往后放, 那么如果出现了数组已经有序的情况, 此时就不会触发这个操作. 那么我们就可以通过检测是否有往后放的这个操作, 从而来判断数组是否已经有序了.
public static void bubbleSort(int[] arr) {
// i 相当于表示有序区域大小
for (int i = 0; i < arr.length; i++) {
// 标记
boolean flag = false;
// j 相当于表示无序区域
for (int j = 0; j < arr.length - i - 1; j++) {
// 如果大于, 交换
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
// 触发交换操作, 标记为true
flag = true;
}
}
if (!flag) {
// 如果没有触发交换操作, 说明有序, 退出循环
break;
}
}
}
时间复杂度: 由于冒泡排序是一个本质上为等差数列求和的双重 for 循环, 因此时间复杂度是O(n^2)的. 如果添加了优化, 最好情况的时间复杂度为 O(N)
空间复杂度: 冒泡排序用到的都是常量级别的额外空间, 因此空间复杂度为 O(1)
稳定性: 稳定, 当我们交换时不处理等于情况, 那么就是稳定的.
选择排序
基本思想
选择排序的总体操作和冒泡排序类似, 都是保证一块区域总体有序, 但是选择排序保证有序的方式和冒泡排序不同. 选择排序保证有序的方式是通过在无序区间内遍历, 找到最大值下标, 随后让最大值和无序区间最后一个位置交换保证结尾有序.
下面是一个图例
当然, 我们也可以通过找最小值, 来保证前面是有序的, 甚至于我们还可以遍历一次同时找最大值和最小值, 保证前后都是有序的. 上面两者虽然实现方式有一点点不一样, 但是思想一致, 我们这里就不介绍了.
代码实现
public static void selectSort(int[] arr){
// 表示有序区域
for (int i = 0; i < arr.length; i++){
// 最大值下标
int maxIndex = 0;
// 遍历无序区域
for (int j = 1; j < arr.length - i; j++){
// 如果当前值更大, 更新最大值下标
if (arr[maxIndex] < arr[j]){
maxIndex = j;
}
}
// 交换
swap(arr, maxIndex, arr.length - 1 - i);
}
}
时间复杂度: 由于冒泡排序是一个本质上为等差数列求和的双重 for 循环, 因此时间复杂度是O(n^2)的
空间复杂度: 冒泡排序用到的都是常量级别的额外空间, 因此空间复杂度为 O(1)
稳定性: 不稳定, 由于其交换会随意的变换位置, 因此无法让元素一定按照原次序排列. 下面是一个不稳定的例子.
插入排序
基本思想
插入排序是一种通过控制区域局部有序, 随后拓展区域到整个数组, 从而实现整个数组有序的一种排序. 例如我们玩扑克牌的时候, 一些人应该是按照这样的方法来排序自己手上的牌的:
假设手上的牌是3 7 9 4 5 1 2
, 那么就从第一张开始往后看, 看前三张的时候, 发现没有 问题, 就继续往后. 但是看到了第四张后, 发现前面四张的顺序就不对了, 此时就会把 4 插入到合适的位置, 实际上就是3 和 7 中间, 那么此时牌就变成了3 4 7 9 5 1 2
, 后续的操作大致同理, 就是看到一张牌, 如果它破坏了前面几张牌的顺序, 那么就把它插到前面几张牌中的合适的位置里.
上面的这个思想, 实际上就是插入排序的核心, 接下来我们依旧是来通过例子来看其具体是如何操作的
可以看到, 插入排序主要就是将元素一次一个的放入区间, 并且维护这个区间的局部有序性. 维护过程中遇到的情况就只有两种:
- 遇到大数, 将大数后移
- 遇到小数, 把当前数放进来, 循环结束
代码实现
那么上面已经讲解了思路, 接下来就是代码的实现了
public static void insertSort(int[] arr) {
// i 相当于表示有序区域
for (int i = 1; i < arr.length; i++) {
// 记录当前值
int current = arr[i];
// j 是用于遍历有序区域的指针
for (int j = i - 1; j >= 0; j--) {
if (arr[j] > current) {
// 遇到大数, 将其后移
arr[j + 1] = arr[j];
} else {
// 遇到小数, 放入数字, 退出循环
arr[j + 1] = current;
break;
}
}
}
}
虽然我们实现了代码, 但是上面的这个代码是有问题的, 它没有处理一个细节问题. 如果我们当前数是局部区域的最小值, 那么此时循环每一次都在走大数的部分, 走到头了, 循环结束了, 小数的放入环节一次没走到, 数字根本放不进去. 对于这种循环中包含着一个if-else
的代码, 我们一定要关注循环结束后是否有情况需要处理, 有没有可能处理不到.
因此, 我们这个放入小数的环节, 实际上最好就是放在退出循环后进行, 这样既不会影响到我们之前说的情况, 也可以处理我们刚刚说的这种情况. 只不过, 如果要这样处理, 我们必须在循环外面定义 j ,否则循环退出后, j 就无法使用了.
public static void insertSort(int[] arr) {
// i 相当于表示有序区域
for (int i = 1; i < arr.length; i++) {
// 记录当前值
int current = arr[i];
// j 是用于遍历有序区域的指针
int j = i - 1;
for (; j >= 0; j--) {
if (arr[j] > current) {
// 遇到大数, 将其后移
arr[j + 1] = arr[j];
} else {
// 遇到小数, 退出循环
break;
}
}
// 放入数字
arr[j + 1] = current;
}
}
时间复杂度: 对于选择排序, 我们分两种情况来讨论其时间复杂度
- 最好情况: 最好情况, 即数组有序, 那么此时 j 很明显都不会往前走, 只有 i 遍历一次. 那么此时自然时间复杂度就是 O(N)
- 最坏情况: 与最好情况相对, 即数组逆序, 那么此时很明显 j 每一次都要往前走到底, 又是一个等差数列求和, 也就是 O(N ^ 2)
从这两个情况我们也可以看出, 插入排序在处理越有序的序列时, 效率就会越高.
空间复杂度: 使用常数级额外空间, O(1)
稳定性: 稳定, 如果我们后移的时候, 不处理等于的情况, 那么此时这个排序就是稳定的.
希尔排序
基本思想
希尔排序实际上就是一种将元素分组进行插入排序的一种排序方法, 如下图中, 两个使用同颜色线相连的为一组
分组进行插入排序后, 然后就会把这个分组的间隔进行缩小, 随后再次进行排序, 以此类推, 直到间隔为 1 时, 实际上也就是直接对整个数组进行插入排序, 那么排序结束.
这个排序方式主要就是通过间隔这样的分组方式, 使得元素能够趋近于有序, 从而降低插入排序的开销.
那么下面我们依旧是通过一个例子来看希尔排序是如何进行的
代码实现
根据这个模拟画图, 我们可以看出, 希尔排序是需要进行多轮的, 并且每一次 gap 都不同, 因此我们可以封装出一个辅助方法用于辅助希尔排序的进行.
主方法则还是和之前的排序方法一样, 只提供一个数组参数, 保证接口的统一性. 另外关于这个步长 gap 的选择, 目前并没有一个明确的说法说使用什么 gap 是最好的, 因此我们就直接采用从数组长度的一半开始, 并且每一次/= 2
, 那么主方法如下所示
public static void shellSort(int[] arr) {
// 步长
for (int gap = arr.length / 2; gap > 0; gap /= 2) {
shellSortHelper(arr, gap);
}
}
接下来就是辅助方法, 本质上还是插入排序的代码, 只不过需要修改 i 的起始点和 j 的移动方式
private static void shellSortHelper(int[] arr, int gap) {
// i 从 gap开始
for(int i = gap; i < arr.length; i++){
int current = arr[i];
// j 从 i - gap 开始, 往前走, 每一次走 gap
int j = i - gap;
for (; j >= 0; j -= gap){
if(arr[j] > current){
// 这里后移也是 gap
arr[j + gap] = arr[j];
}else{
break;
}
}
// 这里放数也是 gap
arr[j + gap] = current;
}
}
时间复杂度: 希尔排序的时间复杂度计算并不是一个固定的数值, 因为其的时间复杂度与 gap 的取法有关. 一般来说, 希尔排序的复杂度我们都认为其在 O(n ^ 1.3) ~ O(n ^ 1.5) 之间. 也就是介于 O(NlogN) 到 O(N ^ 2) 之间
空间复杂度: 使用常数级额外空间, O(1)
稳定性: 不稳定, 由于其分组排序的特性, 即使要表现为稳定也很难
堆排序
基本思想
堆排序, 自然就是需要借助到我们的堆来进行的. 我们依旧是要求要升序排序, 那么此时有人就想到了: 要用堆来进行升序排序, 那我就直接把所有元素放入一个小根堆中, 然后依次出出来不就行了吗?
这个虽然可以变相的达到排序的效果, 但是并不是我们期望的排序, 假设我这里强制要求, 必须在原地进行堆的排序, 那么应该如何操作呢?
回想一下堆的性质, 提供的是一串序列中的最小/最大值. 其他的元素之间的大小关系, 无法确定. 那么如果要升序排序, 此时我们实际上就可以每一次挑出其的最大值(堆顶元素), 并且和最后一个位置进行交换, 然后进行向下调整, 并且调整的过程就不要调整到刚刚的那个刚换下去的最大值. 按照这样的操作, 反复进行, 直到调整到只剩下一个元素为止, 那么此时自然就是一个升序序列了.
下面是一个图示
代码实现
那么要实现这个堆排序, 自然我们需要的最关键的一个操作, 就是向下调整, 因为我们无论是建堆, 还是后续排序的过程中, 都是需要向下调整的. 所以我们先实现向下调整的操作
这里我们设置了三个参数, 一个是要调整的数组, 一个是要调整的父亲节点下标, 还有一个则是要调整的大小
private static void shiftDown(int[] arr, int parent, int size){
// 计算左孩子
int child = parent * 2 + 1;
// 向下调整
while(child < size){
// 找到最大孩子
if(child + 1 < size && arr[child] < arr[child + 1]){
child ++;
}
if(arr[parent] < arr[child]){
// 如果父节点小于孩子节点,交换
swap(arr, parent, child);
// 向下继续调整
parent = child;
child = parent * 2 + 1;
}else{
// 否则,无需调整, 直接结束
break;
}
}
}
同理我们再写一个创建堆的方法
private static void createHeap(int[] arr, int size){
for(int i = size / 2 - 1; i >= 0; i--){
shiftDown(arr, i, size);
}
}
最后, 我们就可以实现堆排序了.
public static void heapSort(int[] arr) {
// 创建堆
createHeap(arr, arr.length);
// 创建标记用于控制调整长度
int end = arr.length - 1;
// 标记大于0,则交换堆顶和末尾元素,调整堆
while (end > 0) {
swap(arr, 0, end);
shiftDown(arr, 0, end);
end--;
}
}
时间复杂度: 首先是向下调整建堆, 时间复杂度为 O(N). 接下来每取出一个节点都需要向下调整, 而向下调整的时间复杂度是 O(logn), n个节点调整则是 O(NlogN), 相加起来就是O(n + nlogn). 因此堆排序的时间复杂度就是 O(NlogN).
空间复杂度: 常数级额外空间, O(1).
稳定性: 不稳定
快速排序
基本思想
快速排序的核心思路, 是将数组按照一个基准来分为左右两块, 左边序列小于基准, 右边序列大于基准, 此时可以发现基准就是处于正确有序位置上的. 同时再次对左右两块递归上述规则, 最后就能够使得所有的元素都排列在对应的位置上.
下面依旧是先通过一个例子来看大概的思路
此时可能有人要问如下几个问题:
- 左指针和右指针谁先走是否会影响? 如果有, 谁先走?
- 只能用最左边作为基准吗? 最右边行不行?
- 如果遇到与基准相等的数字, 那么应该跳过还是停留?
我们先来看第一个问题, 依旧是看一个例子
如果按照我们的逻辑, 继续交换, 那么很明显这个分块是有问题的, 可以看到我们上面正确的例子中, 分块后, 左边的数字应该是小于基准, 右边的数字大于基准, 但是这个例子中, 如果交换, 那么 8 这个比基准大的数字会放到左边. 这很明显不合理
那么为什么左边先出发, 就会有这个问题呢?
实际上这和我们基准的选择有关, 可以看到, 我们交换的目的是为了能够保证基准位置的左边是小于基准的, 而右边基准的值是大于基准的. 因此在我们把基准放到基准位置之前, 基准位置的数据一定是小于基准的. 而我们的 left 是去找比基准位置大的数字, 自然就会直接越过这个基准位置.
例如上面的这个例子, 如果基准是 6, 那么他在这个例子中的基准位置就是下标为 5 的这个位置. 我们直接来看最后一次交换完的情况, 可以发现, 此时基准位的数据一定是小于基准的, 因为比基准位大的数据已经被放到了右边, 而此时等于基准的数字在最左边等着.
那么此时如果是 left 先去找比基准大的位置, 那么就一定会越过这个基准, 因为在基准及其前面已经不可能会有比基准大的位置了. 因此此时必须先让 right 去找比基准小的位置, 这样它就一定会在基准的位置停下.
当然, 有没有办法可以让 left 先走呢? 当然有, 就是让最右边的元素作为基准, 此时就是反过来的了. 当然这个例子不适合我们探究这个问题, 我们换一个例子.
那么此时我们就通过探究第一个问题, 同时解决了第一个问题和第二个问题.
然后就是解决最后一个问题: 如果遇到与基准相等的数字, 那么应该跳过还是停留?
其实这个问题很好解决, 我们就举一个极端的例子, 让两边的刚开始数字长一样, 看看要不要走不就行了吗? 其实当我提出这个例子的时候, 估计大多数人的心里都已经有答案了, 但是我们还是来看一下具体的例子.
此时很明显, 无论拿左边还是右边做基准都一样, 但是无论你拿谁做基准, 总得有一个人走出去. 不然两个人都在这等着, 这个代码不就死循环了吗. 因此, 等于的时候, 也是要走的.
所以我们将这个说法改一下, 快速排序的核心思路为, 把左边分为比基准数小于或等于的, 右边则是比基准数大于或等于的.
代码实现
很明显这个快排和二叉树的深搜还是比较像的, 都是处理完当前的情况后, 从左右递归进去, 因此我们就采用这样的思路来看如何书写代码
-
当前节点的处理: 实际上就是调整数组, 让基准位左侧小于基准, 基准位右侧大于基准, 找到基准位
-
左侧处理: 将基准左侧区间进行排序
-
右侧处理: 将基准右侧区间进行排序
-
边界条件: 如果 left 到达了 right, 或者超过了 right. 此时证明只有一个数字或者没有数字, 不用排序了, 直接返回
-
返回值: 排序方法, 没有返回值
此时我们可以先将这个大框架搭出来, 其中当前节点的处理我们也先封装为一个方法
// 保证接口统一, 使用辅助方法作为递归方法
public static void quickSort(int[] arr) {
quickSortHelper(arr, 0, arr.length - 1);
}
private static void quickSortHelper(int[] arr, int left, int right) {
if(left >= right) return;
// 找到基准
int mid = partition(arr, left, right);
// 排序左右侧
quickSortHelper(arr, left, mid - 1);
quickSortHelper(arr, mid + 1, right);
}
基准方法的实现, 经过我们上面的讨论, 实际上也比较明确了. 其中主要的注意点就是左右指针谁先走和遇到相等走不走的问题.
那么我们这里就约定左侧为基准, 以右侧为基准的感兴趣可以自行尝试
private static int partition(int[] arr, int left, int right) {
// 定左侧为基准
int pivot = arr[left];
// 记录左侧位置
int pivotIndex = left;
while (left < right){
// 左侧为基准, 右侧先走, 找比基准小的值
// 防止越过left 加上一个条件 left < right
while(left < right && arr[right] >= pivot){
right--;
}
while(left < right && arr[left] <= pivot){
left++;
}
swap(arr, left, right);
}
// 相遇, 把基准拿过来
swap(arr, pivotIndex, left);
// 返回基准位
return left;
}
实际上, 这个基准方法, 还有其他的实现, 只不过核心思想都是一样的, 因此我们这里就在后面来进行介绍, 这里就先介绍一种方法.
时间复杂度:
-
最好情况: 快速排序如果分块合理, 可以形成一种左右高度比较平衡的二叉树, 那么此时由于寻找基准的时间复杂度就是遍历一次数组 O(N). 而每一次我们假设能够将数组对半分, 此时需要寻找基准的次数就是 logN 次. 因此最好情况的复杂度就是 O(NlogN).
-
最坏情况: 但是如果你的数组逆序或者顺序, 那么此时分组每次都是分为 1 和 n - 1大小的组. 那么此时就需要分 n 组, 寻找基准 n 次. 此时时间复杂度就是 O(N ^ 2)
空间复杂度:
- 最好情况: 如果能对半分那么就是O(logN)
- 最坏情况: 如果分为了单分支树那就是 O(N).
稳定性: 不稳定
优化
可以看到, 如果是在最坏情况, 快速排序似乎也不是非常的优秀. 因此对于快速排序, 我们通常都需要添加一些优化来让其把它的优势发挥出来, 并且减少它的弱势.
经过我们对于快速排序时间复杂度的分析, 我们可以看到其对于一些极端情况的处理并不是非常理想. 如下图是极端情况和一般情况的对比
可以看到, 逆序的递归深度非常的深. 那么主要是由什么导致的呢?
从表面上来看, 确实是逆序导致的, 但是思考一下, 我们分组的凭据是什么呢? 没错, 就是基准, 即使是逆序, 只要我们能够选出一个合适的基准, 与最左位置(以按左位置为基准为例)进行交换. 那就也可以分出高度更加正常的组.
可以看到, 此时我们对第一次分组进行了简单的处理, 此时就大大降低了树成为单叉树的可能性. 那么我们在后续递归分组的时候也可以进行类似的操作, 那么就可以极大的避免递归成为一棵单叉树, 从而消除快排的缺点.
那么接下来就是一个关键的问题, 我们如何选择这个基准的数字呢? 要遍历一次求中值吗? 这是不可能也不现实的, 遍历一次求中值相当于每一次排序前要进行一次 O(n) 的遍历, 属于是丢了西瓜捡了芝麻.
实际上, 我们的换基准, 并不是说就一定要换为中间值, 而是只需要让它不是最大值/最小值就能够了, 那么此时我们就有一个使用现有信息进行简单判断的方法, 这个方法就是三数取中法.
我们目前的信息有 left 的值和 right 的值, 那么此时我们还可以通过这个 left 和 right 求出一个 mid. 最后我们就直接拿这三个值中的中间值作为基准即可.
那么此时我们就可以封装一个方法来进行这个操作
// 三数取中
private static void minOfThreeNumber(int[] arr, int left, int right) {
int mid = (left + right) / 2;
// 右值为中间值
if((arr[right] > arr[left] && arr[mid] > arr[right])
|| (arr[right] > arr[mid] && arr[left] > arr[right])){
swap(arr, left, right);
}
// 中值为中间值
if((arr[left] > arr[right] && arr[mid] > arr[left])
|| (arr[left] > arr[mid] && arr[right] > arr[left])){
swap(arr, left, mid);
}
// 左值为中间值, 不处理
}
随后我们在寻找基准位前, 进行这个操作即可.
此外, 我们还可以对快速排序的递归创建一个新的边界条件, 从而优化其的递归深度.
现在, 对于我们的快速排序, 目前是要递归到只剩一个数或者没有数的时候才会结束, 但是实际上这种只有一两个或者说几个数字的部分, 却是占据递归节点中最多的一部分. 根据我们对二叉树的学习, 它每一层的节点数都是指数增长的, 也就是说如果树到达了一定的高度, 那么此时节点数的增长是十分恐怖的. 而对于递归来说, 它的开销不仅仅来自于算法本身, 同时还会来自于栈帧的开辟和销毁, 因此对于数量很大的递归, 会有很多被栈帧的开辟和销毁的开销.
那么此时我们如果想要进一步优化这一方面, 我们就需要思考一些方法让只有一小段数字的部分不要去进入递归, 但是又能够排到序. 实际上, 此时我们就可以对这些只有几个数的节点, 使用其他的排序方法. 但是这样就引出了一个新的问题, 这个排序用什么比较好呢?
由于我们的快排会对数组进行分组, 此时大概率是能保证左侧和右侧是比较有序的, 因此此时我们就可以采用对有序友好的排序, 插入排序来做这个工作.
为了能够实现这个优化, 首先我们需要对插入排序的代码进行一些修改, 将其改为在一个区间内进行的排序.
public static void insertSortInRange(int[] arr, int left, int right) {
// i 相当于表示有序区域大小
for (int i = left + 1; i <= right; i++) {
// 记录当前值
int current = arr[i];
// j 是用于遍历有序区域的指针
int j = i - 1;
for (; j >= left; j--) {
if (arr[j] > current) {
// 遇到大数, 将其后移
arr[j + 1] = arr[j];
} else {
// 遇到小数, 放入数字, 退出循环
break;
}
}
arr[j + 1] = current;
}
}
然后就是在快排代码中, 加入判断: 当 left 和 right 之间的距离小于 x 的时候, 调用插入排序. 这里我们就设定 x 为 10, 具体取什么数字看情况而定, 这里是随便取的.
private static void quickSortHelper(int[] arr, int left, int right) {
if(left >= right) return;
// 调用插入排序
if(right - left + 1 < 10){
insertSortInRange(arr, left, right);
return;
}
// 三数取中
minOfThreeNumber(arr, left, right);
// 找到基准
int mid = partition3(arr, left, right);
// 排序左右侧
quickSortHelper(arr, left, mid - 1);
quickSortHelper(arr, mid + 1, right);
}
下面我们就来简单的书写一个测试方法, 来测试一下上面的这两个优化对于最坏情况的优化效果
public class Main {
public static void main(String[] args) {
// 创建大小为10000000的逆序数组
int[] array = new int[1000_0000];
for (int i = 0; i < array.length; i++) {
array[i] = array.length - i;
}
// 计时
long start = System.currentTimeMillis();
Sort.quickSort(array);
System.out.println("耗时: " + (System.currentTimeMillis() - start) + "ms");
}
}
首先是没有任何优化的情况, 栈直接溢出
然后是只添加了三数取中的情况
最后是两个优化都添加的情况
当然上面的这些时间都仅供参考, 只是为了效果展示. 对于一些算法的优化, 运行时间有时候并不能真正的反应出其效果.
此时可能有人尝试运行上面的代码, 发现即使加了三数取中法, 栈依旧会溢出. 如果你使用的是 IDEA, 那么有可能是因为你的 IDEA 自己设置的虚拟机栈大小比较小导致的, 此时可以自行搜索一下如何通过修改虚拟机参数来配置栈的大小, 然后尝试重新运行.
其他实现
寻找基准
接下来来看两种寻找基准的方法:
- 挖坑法
- 前后指针法
上面的那种方法也有一个名称, 叫做Hoare法. 这里值得一提的是, 对于这三种方法, 即使它们找到的基准位置是一样的, 并且也能保证左侧小右侧大, 但是左右内部的排列可能不太一样. 那么如果是遇到了选择题, 问你序列是什么, 推荐优先尝试挖坑法, 因为挖坑法使用的比较多.
当然对于代码实现, 则没有什么很大的区别, 实际上就是思路一致实现方式不同而已.
下面我们来看挖坑法的一个实现思路
注意事项则大差不差, 因此我们直接看代码实现.
private static int partition2(int[] arr, int left, int right) {
// 定左侧为基准
int pivot = arr[left];
// 这里比起 hoare法的优势是, 不用记录最开始的基准位置了, 因为刚开始就修改了值
while (left < right){
// 左侧为基准, 右侧先走, 找比基准小的值
while(left < right && arr[right] >= pivot){
right--;
}
// 填坑, 把 right 的值给 left
arr[left] = arr[right];
while(left < right && arr[left] <= pivot){
left++;
}
// 填坑, 把 left 的值给 right
arr[right] = arr[left];
}
// 相遇, 填坑, 把存储的值放进去
arr[left] = pivot;
// 返回基准位
return left;
}
下一个方法则是前后指针法, 这个方法则和前面的两个方法不太一样, 它是通过一对快慢指针来进行的, 前面的指针负责找比基准小的值, 后面的指针负责维护比基准小的区域. 下面我们依旧来看一个图示
其实看完图示, 这个代码应该还是比较好写的, 那么下面就直接展示代码
private static int partition3(int[] arr, int left, int right) {
// 定左侧为基准
int pivot = arr[left];
// 创建指针
int prev = left + 1;
int cur = left + 1;
// cur 小于等于 right 时, 循环遍历
while(cur <= right){
// 如果当前值比基准小, 交换, prev 和 cur 一起后移
if(arr[cur] < pivot){
swap(arr, prev, cur);
prev++;
}
// 否则 cur 独自往前走
cur++;
}
// 交换 prev - 1 和 left 的值
swap(arr, left, prev - 1);
// 返回基准位
return prev - 1;
}
非递归实现
这种类似于二叉树深搜的递归代码, 如果要实现为非递归版本, 自然就需要我们的栈来帮忙了. 那么如何通过一个栈来实现非递归的快排呢? 实际上主要就是对于找到基准后的处理
下面依旧是通过一个简单的例子来看思路
有了思路, 接下来就是实现代码. 这个代码还是比较简单的, 直接看代码
public static void quickSortNoRecursion(int[] arr) {
// 创建一个栈
Stack<Integer> stack = new Stack<>();
// 把左右两侧的数推进去
stack.push(0);
stack.push(arr.length - 1);
// 当栈不为空时, 循环
while(!stack.isEmpty()){
// 弹出左右边界
int right = stack.pop();
int left = stack.pop();
// 找到中间值
int mid = partition(arr, left, right);
// 如果左边有元素, 放入栈中
// 如果等于 left 只有一个元素, 如果小于 left 则没有元素, 因此只能大于 left
if (mid - 1 > left) {
stack.push(left);
stack.push(mid - 1);
}
// 如果右边有元素, 放入栈中
if (mid + 1 < right) {
stack.push(mid + 1);
stack.push(right);
}
}
}
归并排序
基本思想
归并排序的核心思想是将数组分为两块, 直到分到剩下一个数为止, 随后就可以分别看作是两个有序数组合并, 从单个数开始合并合并到整个数组, 最后就能够得到整个有序数组.
下面依旧是看一个图例
代码实现
那么这种长得就和二叉树深搜差不多的思路, 自然我们就要尝试一下使用同样的思路来写一下. 只不过很明显, 我们这里是先分开排序, 然后合并处理, 因此这里需要调整一下顺序
- 左侧处理: 排序左侧
- 右侧处理: 排序右侧
- 当前节点处理: 合并有序的左侧和右侧
- 边界条件: 当只有一个数或者没有数时, 返回
这里合并有序序列的方法, 我们也暂时封装起来, 先写好大致的框架
public static void mergeSort(int[] arr) {
mergeSortHelper(arr, 0, arr.length - 1);
}
private static void mergeSortHelper(int[] arr, int left, int right) {
// 区间内只有一个数/没有数
if(left >= right) return;
// 计算 mid
int mid = (left + right) / 2;
// 排序左侧和右侧
mergeSortHelper(arr, left, mid);
mergeSortHelper(arr, mid + 1, right);
// 合并
merge(arr, left, mid, right);
}
合并两个序列, 如果是在原地进行, 那么还是比较复杂的, 因此我们这里就采用简单的创建额外数组的方式. 那么确认了这一点, 我们就可以来看一个图例
那么看了思路, 接下来就是实现代码
private static void merge(int[] arr, int left, int mid, int right) {
// 创建临时数组
int[] tmp = new int[right - left + 1];
// 创建指针, 用于遍历
int i = left;
int j = mid + 1;
// 创建指针用于放数字
int k = 0;
while (i <= mid && j <= right) {
if (arr[i] < arr[j]) {
// 左侧小于右侧, 放入 tmp, 同时后移
tmp[k++] = arr[i++];
} else {
// 右侧小于左侧, 放入 tmp, 同时后移
tmp[k++] = arr[j++];
}
}
// 剩余部分直接放入 tmp
while (i <= mid) {
tmp[k++] = arr[i++];
}
while (j <= right) {
tmp[k++] = arr[j++];
}
// 把tmp放回原数组
for (int l = 0; l < tmp.length; l++) {
arr[left + l] = tmp[l];
}
}
当然, 这个合并也有一些不同的实现思路, 只要能够完成合并有序序列的要求, 那么就没有问题
时间复杂度: 很明显, 我们需要在每一层进行合并操作, 而合并操作主要就是遍历一次区间, 时间复杂度可以看作是 O(N), 而由于是对半分, 因此层数就是 logN, 因此最终的时间复杂度就是 O(NlogN)
空间复杂度: 主要合并的时候会使用额外空间, 最高可以到达 O(N)
稳定性: 稳定. 在合并过程中, 如果相等是放左侧元素, 那么就是稳定的. 能够实现为稳定, 因此是稳定的.
其他实现
实际上就是经典非递归的实现, 那么我们是否还是借助栈来进行操作呢? 可以但是不推荐, 为什么, 因为这里实际上就和后序遍历有一点点像, 当前节点处理是在后面, 那么就一定会涉及到一个问题, 第一次拿栈的数据不能弹出, 第二次才可以弹出. 如何判断第一次和第二次? 那么自然这样处理是非常难受的, 因此我们采用另一个思路.
此时我们就不尝试从深度解决问题了, 而是从广度解决问题, 我们从第一层看问题
可以看到, 实际上最开始就是两个数为一组, 进行一次合并. 首先是6 7
一组合并, 合并完后合并下一组8 4
, 以此类推, 当我们遍历完这一层的所有组, 此时就可以看作是 4 个新的有序数组.
随后就是两个有序数组为一组, 重复层序合并操作.
那么我们如何从代码层面实现这样的层序合并呢? 接下来, 我们依旧是通过一个图来看如何进行
由于这个实现的整体思路, 相对于递归来说有较大的变化, 因此需要重新理解很多问题. 而关键的问题在图示中都进行了提出以及解答, 还不理解的话可以自行画图再次理解. 请务必理解了思路再去实现这个相关代码, 因为根据个人经验, 如果无法理解思路的话, 代码也是基本上看的云里雾里的.
此时我们可以按照上面的思路实现代码, 但是这个代码实际上是有问题的
public static void mergeSortNoRecursion(int[] arr) {
// 定义 gap 用于分组
int gap = 1;
// 当 gap 小于数组长度时, 循环
while (gap < arr.length) {
// 使用一个 for 循环来控制 left
// left 需要小于数组长度, 同时每一次跳过两倍的gap
for (int left = 0; left < arr.length; left += 2 * gap){
// 通过 left + 2 * gap - 1 找 right, 可能越界, 因此需要求两者的最小值
int right = Math.min((left + 2 * gap) - 1 , arr.length - 1);
// 计算 mid
int mid = (left + right) / 2;
// 合并
merge(arr, left, mid, right);
}
// gap 翻倍, 进入下一层进行合并
gap *= 2;
}
}
这个代码的问题就在于, 当 right 越界后, mid 的计算, 可能会有问题. 如下所示
很明显, 当 right 越界后, mid 就无法按照我们的期望, 到达第二个有序数组的开头. 那么怎么办呢?
回忆一下我们的 gap 是代表着什么的? gap 代表的是, 组内每个有序数组的长度, 因此我们实际上可以通过 gap + left - 1, 也就是在 left 的基础上, 越过一个有序数组, 那不就到达了第二个有序数组的开头了吗? 此时再进行左移一位, 不就到达了了所需要的 mid 位置了吗?
同理, 这个计算同样可能会越界, 因此也需要进行特殊处理, 最终实现的代码如下
public static void mergeSortNoRecursion(int[] arr) {
// 定义 gap 用于分组
int gap = 1;
// 当 gap 小于数组长度时, 循环
while (gap < arr.length) {
// 使用一个 for 循环来控制 left
// left 需要小于数组长度, 同时每一次跳过两倍的gap
for (int left = 0; left < arr.length; left += 2 * gap){
// 通过 left + 2 * gap - 1 找 right, 可能越界, 因此需要求两者的最小值
int right = Math.min((left + 2 * gap) - 1 , arr.length - 1);
// 通过 left + gap - 1 找 mid, 同样需要特殊处理
int mid = Math.min(left + gap - 1, arr.length - 1);
// 合并
merge(arr, left, mid, right);
}
// gap 翻倍, 进入下一层进行合并
gap *= 2;
}
}+
应用
假如现在有一个场景: 电脑的内存只有 4G, 但是希望排序一个大小为 100G 的数据, 那么应该如何排序呢?
此时很明显我们没有办法直接把 100G 的数据放到内存中排序了, 只能通过外部排序来进行, 那么具体如何进行这个外部排序呢?
回顾我们刚刚学习的归并排序, 它有下面几个特性:
- 它通过将小的有序的内容, 进行合并, 从而得到一个大的有序内容.
- 在合并两个有序内容的过程中, 只需要比较两个指针指向的数据就可以了, 并不用关注后面的数据.
- 将比较后的数据存储到临时空间时, 并不关注前面存储过的数据, 只会将数据放到指针指向的位置.
可以看到, 在比较和存放的时候, 实际上都是只关注指针指向的部分, 并且后续也不会用到已经排序好的部分了.
那么此时在不在内存中自然也无所谓了, 我们当然也就可以通过文件来进行同样的操作. 假设有两个大文件有序, 那么此时我们就通过两个指针从头开始遍历这个文件, 创建一个指针用于存储比较后的文件. 那么此时只需要将指针指向的部分读取到内存中进行比较, 比较完后, 将小的内容存放到存储指针指向的位置.
那么此时内存就一定是够用的, 因为我们没有把所有数据放到内存中, 而是仅仅在比较的时候借助了内存.
那么此时问题就来到了, 如何得到有序小文件的一个问题. 要和归并一样从 1 开始吗? 当然不用, 并且这样也太慢了. 其实这个问题也非常简单, 既然我们内存都有 4G, 那我们当然就可以切割成内存能够处理的大小, 然后直接丢到内存里面排序即可.
比如我可以将 100G 切割为 100 个 1G 的文件, 然后依次丢到内存里进行排序, 这里的排序方法根据情景随意选择即可, 反正有 4G内存, 内存是能够处理的. 依次排完序后, 那么就得到了 100 个 1G 的有序文件, 然后使用上面说的方法进行合并, 那么就会变成 50 个 2G 的有序文件, 然后再次合并, 就可以合并为 25 个 4G的有序文件…以此类推, 最后就可以得到有序的 100G 数据了.
常见比较排序总结
这里我们简单总结一下各个排序的性质, 其中取的时间复杂度和空间复杂度都是平均情况
排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|
冒泡排序 | O(N^2) | O(1) | 稳定 |
选择排序 | O(N^2) | O(1) | 不稳定 |
插入排序 | O(N^2) | O(1) | 稳定 |
希尔排序 | O(N^1.3) ~ O(N^1.5) | O(1) | 不稳定 |
堆排序 | O(NlogN) | O(1) | 不稳定 |
快速排序 | O(NlogN) | O(logN) | 不稳定 |
归并排序 | O(NlogN) | O(N) | 稳定 |
常见非比较排序
计数排序
基本思想
计数排序的核心就和它的名字一样, 计数. 简单的说, 就是记录数字出现的次数, 然后手动放出来形成一个有序序列.
下面是一个简单的图示
代码实现
根据图示, 我们可以看到, 在创建计数数组前, 我们首先就需要遍历一下数组得到 max 和 min, 从而确认最大值和最小值的大小. 随后就是计数 + 生成的流程, 虽然文字描述起来思路看起来有点抽象, 但是代码还是比较简单的
// 计数排序
public static void countSort(int[] arr) {
// 统计最大值和最小值
int max = arr[0];
int min = arr[0];
for (int i = 1; i < arr.length; i++) {
max = Math.max(max, arr[i]);
min = Math.min(min, arr[i]);
}
// 创建计数数组, 计数
int[] count = new int[max - min + 1];
for (int i = 0; i < arr.length; i++) {
count[arr[i] - min]++;
}
// 遍历计数数组, 生成数组
int index = 0;
for (int i = 0; i < count.length; i++) {
while (count[i] > 0) {
arr[index++] = i + min;
count[i]--;
}
}
}
时间复杂度: 主要就是遍历原数组和遍历计数数组的开销, 因此时间复杂度是 O(max(N, 范围))
空间复杂度: 计数数组的开销, 空间复杂度为 O(范围)
稳定性: 稳定.
具体实现为是否稳定, 则是看对于计数数组是如何处理的. 如果你将计数数组的计数格子看作是一个队列, 先入先出, 那么此时放数字的时候从前往后放就是稳定的. 如果看作是一个栈, 那么此时是后入先出就需要从后往前放才是稳定的.
当然也有一些其他的能够保证实现稳定的方法, 例如通过计数数组, 来计算出元素期望出现的最后位置. 如下图
这个实现方法这里就不实现了, 感兴趣的可以自行结合上面思路进行实现
基数排序
基本思想
基数排序的思想主要就是根据数字每个位的权重来进行排序, 下面通过一个非常简单的例子来看
可以看到, 我们第一次排序后, 这些数字就是个位有序的, 第二次排序后就是十位有序的, 那么拿出的时候就会完全有序.
当然, 这个循环要执行多少次, 就要看最高位的数字有多少位. 同时, 这些队列要能够处理每个位能够出现的情况, 那么情况总共应该是有 19 种的, 包括[-9, 9]的情况, 总共 19 种, 也就是说我们需要 19 个队列. 当然这里的队列可以通过数组直接模拟, 也不一定就要创建 19 个队列.
代码实现
这个排序的思路也是比较简单的, 只不过实现起来可能会比较繁琐. 我们这里为了简便就直接使用 Java 中自带的队列来实现了, 并且只考虑非负数的情况, 因此只创建 10 个队列
// 基数排序
public static void radixSort(int[] arr) {
// 创建队列集合
List<Queue<Integer>> queues = new ArrayList<>();
// 创建 10 个队列
for (int i = 0; i < 10; i++){
queues.add(new LinkedList<>());
}
// 计算出最大位数:
// 1. 获取最大值
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
max = Math.max(max, arr[i]);
}
// 2. 获取其位数(这个算的方法并不严谨, 只是为了偷懒)
int radix = (int) Math.log10(max) + 1;
// 循环 radix 次, 遍历每一位
for (int i = 0; i < radix; i++) {
// 遍历当前数组
for (int k : arr) {
// 获取当前位的值
int digit = (k / (int) Math.pow(10, i)) % 10;
// 放入对应队列
queues.get(digit).add(k);
}
// 遍历队列, 放入原数组
int index = 0;
for (Queue<Integer> queue : queues) {
while (!queue.isEmpty()) {
arr[index++] = queue.poll();
}
}
}
}
时间复杂度: 主要就是遍历原数组和遍历队列的开销, 时间复杂度为 O(M + N). 其中, N 为数组大小, M 为队列大小.
空间复杂度: 主要就是队列及中间把元素装进队列后的空间开销, 因此时间复杂度为 O(N + K), 其中, N 为数组大小, K 为队列大小. 当然一般情况下, K 都是一个常数, 因此可以看作是 O(N) 的.
稳定性: 稳定
桶排序
基本思想
桶排序的基本思想类似于计数排序, 只不过计数排序的每一个格子只容纳一个数字, 而桶排序则允许每一个格子装多个元素. 此时这个格子就被称作是桶, 因此叫做桶排序.
实际上之前的计数排序和基数排序, 使用的存储空间都能够被称作是桶, 因此可以看作这三种排序都用到了桶的概念, 只不过使用的方式不同. 桶实际上就是一种抽象的概念, 用于描述了一种用于分类的空间, 简单的理解为是一种用于分类的容器即可.
下面是一个桶排序的图解
代码实现
通过图解, 我们可以看到这个桶排序依旧是比较容易理解的, 其中主要要设计的就是其各个桶的配比. 具体如何设计, 可以根据情景设计出对应的映射函数, 我们这里依旧是为了简便, 采用上面与图解相同的方法, 一个桶放 10 个数字.
public static void bucketSort(int[] arr) {
// 得到最大值和最小值
int max = arr[0];
int min = arr[0];
for (int i = 1; i < arr.length; i++) {
max = Math.max(max, arr[i]);
min = Math.min(min, arr[i]);
}
// 一个桶的大小为 10, 计算桶的数量
int bucketNum = (max - min) / 10 + 1;
// 创建桶
List<List<Integer>> buckets = new ArrayList<>();
for (int i = 0; i < bucketNum; i++) {
buckets.add(new LinkedList<>());
}
// 遍历数组, 放入桶
for (int i = 0; i < arr.length; i++) {
// 计算当前值应该放入哪个桶
int index = (arr[i] - min) / 10;
// 放入桶中
buckets.get(index).add(arr[i]);
}
// 遍历桶, 排序并且放入原数组
int index = 0;
for (List<Integer> bucket : buckets) {
Collections.sort(bucket);
for (int k : bucket) {
arr[index++] = k;
}
}
}
时间复杂度: 主要就是遍历数组和对桶操作的开销, 因此时间复杂度为O(N + M), 其中遍历原数组的开销为 O(N), 对桶内元素进行排序的开销为 O(M). O(M) 由具体的分配方式和排序算法而定.
空间复杂度: 主要就是桶本身及中间把元素装进桶后的空间开销, 因此空间复杂度为O(N + M). 其中, N 为数组大小, M 为桶大小.
稳定性: 不一定, 具体是否稳定需要根据对桶内元素排序时采用的是否时稳定的排序算法.