开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情
🔥 本文由 程序喵正在路上 原创,在稀土掘金首发!
💖 系列专栏:数据结构与算法
🌠 首发时间:2022年11月22日
🦋 欢迎关注🖱点赞👍收藏🌟留言🐾
🌟 一以贯之的努力 不得懈怠的人生
一、前言
基础的排序算法,包括冒泡排序、选择排序和插入排序,通过对它们进行时间复杂度的分析,我们不难得出其最坏时间复杂度都为 ,对于平方阶的算法我们都知道,随着输入规模的增大,时间成本将急剧上升,所以这些基本排序方法不能处理更大规模的问题,下面我们介绍一些比较高级的排序算法,争取降低算法的时间复杂度最高阶次幂
二、希尔排序
希尔排序是插入排序的一种,又称为 “缩小增量排序”,是插入排序算法的一种更高效的改进版本
在使用插入排序的时候,我们会发现一个很不友好的情况,如果已经排序的分组元素为 { 2, 4, 6, 7, 10 },未排序的分组元素为 {1, 8},那么下一个待插入元素为 1,我们需要拿着 1 从后往前依次和 10、7、6、4、2 进行交换位置,才能完成真正的插入,每次交换只能和相邻的元素交换位置,那如果我们要提高效率,直观的想法就是只进行一次交换,就能把 1 放到合适的位置,这样就可以减少交换的次数,那我们该怎么做呢?
下面我们来看看希尔排序吧
我们的需求是:
- 排序前:{ 9, 1, 2, 5, 7, 4, 8, 6, 3, 5 }
- 排序后:{ 1, 2, 3, 4, 5, 5, 6, 7, 8, 9 }
排序原理:
- 选定一个增长量 h,按照增长量 h 作为数据分组的依据,对数据进行分组
- 对分好组的每一组数据完成插入排序
- 减小增长量,最小减为 1,重复第二步操作
至于增长量是怎么确定的,有一固定的规则,我们采用以下规则:
int h = 1;
while (h < 数组的长度/2) {
h = 2 * h + 1;
}
//循环结束后我们就可以确定 h 的初始值了
h 减小的规则为:
h = h / 2;
< 希尔排序 API 设计 >
| 类名 | Shell |
|---|---|
| 构造方法 | Shell() :创建 Shell 对象 |
| 成员方法 | public static void sort(Comparable[] c) :对数组内的元素进行排序 |
| 成员方法 | private static boolean greater(Comparable c1, Comparable c2) : 判断 c1 是否大于 c2 |
| 成员方法 | private static void exch(Comparable[] c, int i, int j) :交换 c 数组中,索引 i 和索引 j 处的值 |
< 希尔排序代码实现 >
-
Shell类
public class Shell { //对数组 C 中的元素进行排序 public static void sort(Comparable[] c) { int n = c.length; //确定增长量 h 的最大值 int h = 1; while (h < n / 2) { h = 2 * h + 1; } //当增长量 h 小于 1时,排序结束 while (h >= 1) { //找到待插入的元素 for (int i = h; i < n; ++i) { //将待插入元素插入到有序数列中 for (int j = i; j >= h; j -= h) { //待插入元素是 c[j] if (greater(c[j - h], c[j])) { exch(c, j - h, j); } else { break; } } } //减小 h 的值 h = h / 2; } } //比较 c1 元素是否大于 c2 元素 private static boolean greater(Comparable c1, Comparable c2) { return c1.compareTo(c2) > 0; } //数组中索引为 i 和索引为 j 的元素互换位置 private static void exch(Comparable[] c, int i, int j) { Comparable temp = c[i]; c[i] = c[j]; c[j] = temp; } } -
test类
import java.util.Arrays; public class test { public static void main(String[] args) { Integer[] a = {9, 1, 2, 5, 7, 4, 8, 6, 3, 5}; Shell.sort(a); System.out.println(Arrays.toString(a)); } } -
运行结果
< 希尔排序时间复杂度分析 >
在希尔排序中,增长量 h 并没有固定的规则,有很多论文研究了各种不同的递增序列,但都无法证明某个序列是最好的,对于希尔排序的时间复杂度分析,已经超出了我们的能力范畴
但是我们可以使用事后分析法来比较希尔排序和插入排序的性能
首先我们准备一个保存有 10000 个数据的文件,如下图所示,有了这些数据我们就可以完成测试
测试的思想:在执行排序前记录一个时间,在排序完成后记录一个时间,两个时间的时间差就是排序的耗时
- SortCompare类
import java.io.*; import java.util.*; public class SortCompare { public static void main(String[] args) throws Exception{ List<Integer> list = new ArrayList<>(); //读取 data.txt 文件 BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("D:\\IdeaProjects\\jdbc_demo\\src\\blog\\data.txt"))); String line = null; while ((line = reader.readLine()) != null) { //将每一个数组存入到集合中 list.add(Integer.valueOf(line)); } reader.close(); //把集合转换成数组 Integer[] arr = new Integer[list.size()]; list.toArray(arr); testInsertion(arr); testShell(arr); } public static void testInsertion(Integer[] arr) { //使用插入排序完成测试 long start = System.currentTimeMillis(); Insertion.sort(arr); long end = System.currentTimeMillis(); System.out.println("使用插入排序耗时:" + (end - start) + "毫秒"); } public static void testShell(Integer[] arr) { //使用希尔排序完成测试 long start = System.currentTimeMillis(); Shell.sort(arr); long end = System.currentTimeMillis(); System.out.println("使用希尔排序耗时:" + (end - start) + "毫秒"); } } - 运行结果
通过测试发现,在处理大批量数据时,希尔排序的性能确实高于插入排序
三、归并排序
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称之为二路合并
我们的需求是:
- 排序前:{ 8, 4, 5, 7, 1, 3, 6, 2 }
- 排序后:{ 1, 2, 3, 4, 5, 6, 7, 8 }
排序原理:
- 尽可能地将一组数据拆分成两个元素个数相等的子组,并对每一个子组继续拆分,直到拆分后的每个子组的元素个数是 1
- 将相邻的两个子组进行合并成为一个有序的大组
- 不断地重复步骤 2,直到最终只有一个组为止
< 归并排序 API 设计 >
| 类名 | Merge |
|---|---|
| 构造方法 | Merge() :创建 Merge 对象 |
| 成员方法 | public static void sort(Comparable[] c) :对数组内的元素进行排序 |
| 成员方法 | private static void sort(Comparable[] c, int lo, int hi) :对数组 c 中从索引 lo 到索引 hi 之间的元素进行排序 |
| 成员方法 | private static void merge(Comparable[] c, int lo, int mid, int hi):从索引 lo 到索引 mid 为一个子组,从索引 mid + 1 到索引 hi 为另一个子组,把数组 c 中的这两个子组的数据合并成一个有序的大组(从索引 lo 到索引 hi) |
| 成员方法 | private static boolean less(Comparable c1, Comparable c2) : 判断 c1 是否小于 c2 |
| 成员方法 | private static void exch(Comparable[] c, int i, int j) :交换 c 数组中,索引 i 和索引 j 处的值 |
< 归并排序代码实现 >
-
Merge类
public class Merge { private static Comparable[] assist; //归并需要的辅助数组 //对数组 c 中元素进行排序 public static void sort(Comparable[] c) { assist = new Comparable[c.length]; int lo = 0; int hi = c.length - 1; sort(c, lo, hi); } //对数组 c 中从 lo 到 hi 的元素进行排序 private static void sort(Comparable[] c, int lo, int hi) { if (hi <= lo) { return; } int mid = lo + (hi - lo) / 2; //对 lo 到 mid 之间的元素进行排序 sort(c, lo, mid); //对 mid + 1 到 hi 之间的元素进行排序 sort(c, mid + 1, hi); //对 lo 到 mid 这组数据和 mid 到 hi 这组数据进行归并 merge(c, lo, mid, hi); } //对数组中,从 lo 到 mid 为一组,从 mid + 1 到 hi 为一组,进行归并 private static void merge(Comparable[] c, int lo, int mid, int hi) { int i = lo; //定义一个指针,指向 assist 数组中开始填充数据的索引 int p1 = lo; //定义一个指针,指向第一组数据的第一个元素 int p2 = mid + 1; //定义一个指针,指向第二组数组的第一个元素 //比较左边小组和右边小组中的元素大小,哪个小,就把哪个数据先填充到 assist 数组中 while (p1 <= mid && p2 <= hi) { if (less(c[p1], c[p2])) { assist[i++] = c[p1++]; } else { assist[i++] = c[p2++]; } } /* 上面的循环结束后,如果退出循环的条件是 p1<=mid,则证明左边小组中的数组已经 归并完毕,如果退出循环的条件是 p2<=hi,则证明右边小组中的数组已经填充完毕 所以需要把未填充完毕的数据继续填充到 assist 中,下面两个循环只会其中一个 */ while (p1 <= mid) { assist[i++] = c[p1++]; } while (p2 <= hi) { assist[i++] = c[p2++]; } /* 到这里,assist 数组中,从 lo 到 hi 的元素是有序的,再把数据拷贝到 c 数组 中对应的索引处即可 */ for (int index = lo; index <= hi; ++index) { c[index] = assist[index]; } } //比较 c1 元素是否小于 c2 元素 private static boolean less(Comparable c1, Comparable c2) { return c1.compareTo(c2) < 0; } //数组元素 i 和 j 交换位置 private static void exch(Comparable[] c, int i, int j) { Comparable temp = c[i]; c[i] = c[j]; c[j] = temp; } } -
test类
import java.util.Arrays; public class test { public static void main(String[] args) throws Exception { Integer[] arr = {8, 4, 5, 7, 1, 3, 6, 2}; Merge.sort(arr); System.out.println(Arrays.toString(arr)); } } -
运行结果
< 归并排序时间复杂度分析 >
归并排序是分治思想的最典型的例子,上面的算法中,对 c[lo...hi] 进行排序,先将它分为 c[lo..mid] 和 c[mid+1...hi] 两部分,分别通过递归调用它们单独排序,最后将有序的子数组归并为最终的排序结果。该递归的出口在于如果一个数组不能再被分为两个子数组,那么就会执行 merge 方法进行归并,在归并的时候判断元素的大小进行排序
用树状图来描述归并,如果一个数组有 8 个元素,那么它将每次除以 2 找最小的子数组,一共要拆 次,值为 3,所以树共有 3 层,那么自顶向下第 k 层有 个子数组,每个子数组的长度为 ,归并最多需要 次比较。因此每层的比较次数为 ,那么 3 层的比较次数总共为
假设元素的个数为 n,那么使用归并排序拆分的次数为 ,所以一共 层,那么使用 替换上面 中的 3 这个层数,最终得出的归并排序的时间复杂度为 ,根据大 O 推导法则,忽略底数,最终归并排序的时间复杂度为
< 归并排序的缺点 >
需要申请额外的数组空间,导致空间复杂度提升,是典型的以时间换空间的操作
< 归并排序和希尔排序的性能比较 >
我们还是借助那个保存有 10000 个数据的文件来进行测试
测试代码如下
import java.io.*;
import java.util.*;
public class SortCompare {
public static void main(String[] args) throws Exception{
List<Integer> list = new ArrayList<>();
//读取 data.txt 文件
BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("D:\\IdeaProjects\\jdbc_demo\\src\\blog\\data.txt")));
String line = null;
while ((line = reader.readLine()) != null) {
//将每一个数组存入到集合中
list.add(Integer.valueOf(line));
}
reader.close();
//把集合转换成数组
Integer[] arr = new Integer[list.size()];
list.toArray(arr);
testMerge(arr);
testShell(arr);
}
public static void testMerge(Integer[] arr) {
//使用归并排序完成测试
long start = System.currentTimeMillis();
Merge.sort(arr);
long end = System.currentTimeMillis();
System.out.println("使用归并排序耗时:" + (end - start) + "毫秒");
}
public static void testShell(Integer[] arr) {
//使用希尔排序完成测试
long start = System.currentTimeMillis();
Shell.sort(arr);
long end = System.currentTimeMillis();
System.out.println("使用希尔排序耗时:" + (end - start) + "毫秒");
}
}
测试结果如下
通过测试,我们发现希尔排序和归并排序在处理大批量数据时差别不是很大
四、快速排序
快速排序是对冒泡排序的一种改进。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一 部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序 过程可以递归进行,以此达到整个数据变成有序序列
我们的需求是:
- 排序前:{ 6, 1, 2, 7, 9, 3, 4, 5, 8 }
- 排序后:{ 1, 2, 3, 4, 5, 6, 7, 8, 9 }
排序原理:
- 首先设定一个分界值,通过该分界值将数组分成左右两部分
- 将大于或等于分界值的数据放到到数组右边,小于分界值的数据放到数组的左边。此时左边部分中各元素都小于或等于分界值,而右边部分中各元素都大于或等于分界值
- 然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理
- 重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左侧和右侧两个部分的数据排完序后,整个数组的排序也就完成了
< 快速排序 API 设计 >
| 类名 | Quick |
|---|---|
| 构造方法 | Quick() :创建 Quick 对象 |
| 成员方法 | public static void sort(Comparable[] c) :对数组内的元素进行排序 |
| 成员方法 | private static void sort(Comparable[] c, int lo, int hi) :对数组 c 中从索引 lo 到索引 hi 之间的元素进行排序 |
| 成员方法 | private static void partition(Comparable[] c, int lo, int hi):对数组 c 中,从索引 lo 到索引 hi 之间的元素进行分组,并返回分组界限对应的索引 |
| 成员方法 | private static boolean less(Comparable c1, Comparable c2) : 判断 c1 是否小于 c2 |
| 成员方法 | private static void exch(Comparable[] c, int i, int j) :交换 c 数组中,索引 i 和索引 j 处的值 |
< 切分原理 >
把一个数组切分成两个子数组的基本思想为:
- 找一个基准值,用两个指针分别指向数组的头部和尾部
- 先从尾部向头部开始搜索一个比基准值小的元素,搜索到即停止,并记录指针的位置
- 再从头部向尾部开始搜索一个比基准值大的元素,搜索到即停止,并记录指针的位置
- 交换当前左边指针位置和右边指针位置的元素
- 重复 2,3,4 步骤,直到左边指针的值大于右边指针的值为止
< 快速排序代码实现 >
-
Quick类
public class Quick { //对数组 c 中元素进行排序 public static void sort(Comparable[] c) { int lo = 0; int hi = c.length - 1; sort(c, lo, hi); } //对数组 c 中从 lo 到 hi 的元素进行排序 private static void sort(Comparable[] c, int lo, int hi) { if (hi <= lo) { return; } //对 c 数组中,从 lo 到 hi 的元素进行切分 int partition = partition(c, lo, hi); //对左边分组中的元素进行排序 sort(c, lo, partition - 1); //对右边分组中的元素进行排序 sort(c, partition + 1, hi); } private static int partition(Comparable[] c, int lo, int hi) { Comparable key = c[lo]; //把最左边的元素当作基准值 int left = lo; //定义一个左指针,初始指向最左边的元素 int right = hi + 1; //定义一个右指针,初始指向最右侧元素的下一个 //进行切分 while (true) { //先从右往左扫描,找到一个比基准值小的元素 while (less(key, c[--right])) { //循环停止,证明找到了一个比基准值小的元素 if (right == lo) { break; //已经扫描完了 } } //再从左往右扫描,找到一个比基准值大的元素 while (less(c[++left], key)) { //循环停止,证明找到了一个比基准值小的元素 if (left == hi) { break; //已经扫描完了 } } if (left >= right) { //扫描完了所有元素,结束循环 break; } else { //交换 left 和 right 索引处的元素 exch(c, left, right); } } //交换最后 right 索引处和基准值所在的索引处的值 exch(c, lo, right); return right; //right 就是切分的界限 } //比较 c1 元素是否小于 c2 元素 private static boolean less(Comparable c1, Comparable c2) { return c1.compareTo(c2) < 0; } //数组元素 i 和 j 交换位置 private static void exch(Comparable[] c, int i, int j) { Comparable temp = c[i]; c[i] = c[j]; c[j] = temp; } } -
test类
import java.util.Arrays; public class test { public static void main(String[] args) throws Exception { Integer[] arr = {6, 1, 2, 7, 9, 3, 4, 5, 8}; Quick.sort(arr); System.out.println(Arrays.toString(arr)); } } -
运行结果
< 快速排序和归并排序的区别 >
快速排序是另外一种分治的排序算法,它将一个数组分成两个子数组,将两部分独立的排序。快速排序和归并排序 是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并从而将整个数组排序,而快速排序的 方式则是当两个数组都有序时,整个数组自然就有序了。在归并排序中,一个数组被等分为两半,归并调用发生在 处理整个数组之前,在快速排序中,切分数组的位置取决于数组的内容,递归调用发生在处理整个数组之后
< 快速排序时间复杂度分析 >
快速排序的一次切分从两头开始交替搜索,直到 left 和 right 重合,因此,一次切分算法的时间复杂度为 ,但整个快速排序的时间复杂度和切分的次数相关
最优情况:每一次切分选择的基准数字刚好将当前序列等分
如果我们把数组的切分看做是一个树,那么上图就是它的最优情况的图示,共切分了 次,所以,最优情况下快速排序的时间复杂度为
最坏情况:每一次切分选择的基准数字是当前序列中最大数或者最小数,这使得每次切分都会有一个子组,那么总 共就得切分 n 次,所以,最坏情况下,快速排序的时间复杂度为
平均情况:每一次切分选择的基准数字不是最大值和最小值,也不是中值,这种情况我们也可以用数学归纳法证 明,快速排序的时间复杂度为
五、排序的稳定性
< 稳定性的定义 >
数组 arr 中有若干元素,其中 A 元素和 B 元素相等,并且 A 元素在 B 元素前面,如果使用某种排序算法排序后,能够保证 A 元素依然在 B 元素的前面,那么可以说该算法是稳定的
< 稳定性的意义 >
如果一组数据只需要一次排序,则稳定性一般是没有意义的,如果一组数据需要多次排序,稳定性是有意义的。例 如要排序的内容是一组商品对象,第一次排序按照价格由低到高排序,第二次排序按照销量由高到低排序,如果第 二次排序使用稳定性算法,就可以使得相同销量的对象依旧保持着价格高低的顺序展现,只有销量不同的对象才需 要重新排序。这样既可以保持第一次排序的原有意义,而且可以减少系统开销
< 常见排序算法的稳定性 >
冒泡排序
只有当 arr[i] > arr[i+1] 的时候,才会交换元素的位置,而相等的时候并不交换位置,所以冒泡排序是一种稳定排序
算法
选择排序
选择排序是给每个位置选择当前元素最小的,例如有数据 {5(1), 8 , 5(2), 2, 9 },第一遍选择到的最小元素为 2,所以 5(1) 会和 2 进行交换位置,此时 5(1) 到了 5(2) 后面,破坏了稳定性,所以选择排序是一种不稳定的排序算法
插入排序 比较是从有序序列的末尾开始,也就是想要插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其 后面,否则一直往前找直到找到它该插入的位置。如果碰见一个和插入元素相等的,那么把要插入的元素放在相等 元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序 是稳定的
希尔排序 希尔排序是按照不同步长对元素进行插入排序,虽然一次插入排序是稳定的,不会改变相同元素的相对顺序,但在 不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不 稳定的。
归并排序
归并排序在归并的过程中,只有 arr[i] < arr[i+1] 的时候才会交换位置,如果两个元素相等则不会交换位置,所以它
并不会破坏稳定性,归并排序是稳定的
快速排序 快速排序需要一个基准值,在基准值的右侧找一个比基准值小的元素,在基准值的左侧找一个比基准值大的元素, 然后交换这两个元素,此时会破坏稳定性,所以快速排序是一种不稳定的算法