文章目录
数据结构与算法|第八章:排序-上
前言
排序应该是最常见,也是最重要的算法了,本系列文章将一些比较经典的算法进行整理,分享给大家,比如冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序;按照时间复杂度进行划分,分为 上、中、下 3 章进行发布。
1.项目环境
- jdk 1.8
- github 地址:https://github.com/huajiexiewenfeng/data-structure-algorithm
- 本章模块:chapter07
2.排序算法时间复杂度对比
排序算法 | 时间复杂度 | 是否基于比较 |
---|---|---|
冒泡、插入、选择 | O ( n 2 ) O(n^2) O(n2) | 是 |
归并、快排 | O ( n l o g n ) O(nlogn) O(nlogn) | 是 |
桶、计数、基数 | O ( n ) O(n) O(n) | 否 |
3.如何分析排序算法
可以从以下几个方面进行分析
3.1 排序算法的执行效率
1.最好情况、最坏情况、平均情况时间复杂度
这三个复杂度我们已经在 第二章:复杂度分析-续 中讨论过了。
2.时间复杂度的系数、常数 、低阶
通常时间复杂度反应的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是10个、100个、1000个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
3.比较次数和交换(或移动)次数
基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
3.2 排序算法的内存消耗
算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是O(1)的排序算法。
3.3 排序算法的稳定性
如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间相对位置不变。
这种情况再实际开发中也会见到,比如班级考试排名,A同学 和 B同学 都考了 100 分,如果按照分数大小来排序,不能保证每一次 A同学 和 B同学 的前后顺序。
怎么解决这个问题呢?一般我们能想到的比较简单的方法就是先按照名称进行排序,再按照分值进行排序,这样就能保证每次排序,A同学 的排名都在 B同学 前面,当然 B同学 比较吃亏(这不是重点),这个例子只是为了说明算法的稳定性。
4.冒泡排序(Bubble Sort)
4.1 原理图解
假设原数组为 [3,1,4,6,2,5],经过一次冒泡排序操作步骤大致如下:
可以看到经过一次冒泡排序,[1,5,6] 这 3 个元素已经存储在正确的位置上了 ,按照上面的操作,我们再进行两次冒泡操作,就可以得到正确的排序效果了
4.2 代码实现
我们这里写了两个冒泡算法
- bubbleSort 是普通的,每次都是循环比较 n ∗ ( n − 1 ) n*(n-1) n∗(n−1) 次
- bubbleSortBetter 是优化之后的,如果发现没有数据交换,就表示完全有序,提前退出
- count 只是为了打印次数,和算法无关可以忽略
public class BubbleSortDemo {
public static void main(String[] args) {
int[] numbers = new int[]{3, 1, 4, 10, 2, 5};
bubbleSort(numbers);
bubbleSortBetter(numbers);
}
private static void bubbleSort(int[] numbers) {
int count = 0;
int size = numbers.length;
for (int j = 0; j < size; j++) {
for (int i = 0; i < size - 1; i++) {
if (numbers[i] > numbers[i + 1]) {
int tmp = numbers[i + 1];
numbers[i + 1] = numbers[i];
numbers[i] = tmp;
}
count++;
}
}
System.out.printf("循环[%d]次,结果为:%s\n", count, Arrays.toString(numbers));
}
private static void bubbleSortBetter(int[] numbers) {
int count = 0;
int size = numbers.length;
for (int j = 0; j < size; j++) {
boolean flag = false;
for (int i = 0; i < size - 1; i++) {
flag = true;
if (numbers[i] > numbers[i + 1]) {
flag = false;// 有数据交换
int tmp = numbers[i + 1];
numbers[i + 1] = numbers[i];
numbers[i] = tmp;
}
count++;
}
if (flag) {// 表示没有发生交换,完全有序,可以提前退出
break;
}
}
System.out.printf("循环[%d]次,结果为:%s\n", count, Arrays.toString(numbers));
}
}
执行结果:
循环[30]次,结果为:[1, 2, 3, 4, 5, 10]
循环[5]次,结果为:[1, 2, 3, 4, 5, 10]
4.3 排序分析
我们从 2. 如何分析排序算法 提到的三种方法进行分析
4.3.1 冒泡排序是原地排序算法吗?
是的,因为冒泡排序每次比较只涉及相邻数据的交换,每次都是申请一个常量级的临时存储空间,所以它的空间复杂度是 O(1)。
4.3.2 冒泡排序是稳定排序算法吗?
是的,因为只有前一个元素大于后一个元素的时候,我们才会发生位置交换,如果两个元素值相等,不会发生交换,所以是稳定排序。
4.3.3 时间复杂度
最好时间复杂度: O ( n ) O(n) O(n),因为即使给的数组是完全有序的,我们还是需要遍历比较一遍。
最坏时间复杂度: O ( n 2 ) O(n^2) O(n2),每遍历比较一遍(n 次),只有一个元素到达正确的位置上,那么我们需要做 n 次排序。
平均时间复杂度: O ( n 2 ) O(n^2) O(n2)
在计算 平均时间复杂度 之前需要先理解几个概念
1.有序度
有序元素对:a[i] <= a[j], 如果i < j。
拿我们上面的例子 [3, 1, 4, 10, 2, 5] 来说,有序元素对 有如下 10 个
-
[3,4] [3,10] [3,5]
-
[1,4] [1,10] [1,2] [1,5]
-
[4,10] [4,5]
-
[2,5]
2.满有序度
同理,对应一个倒序排列的数组,比如 [6,5,4,3,2,1] 的数组,有序度为 0 ,对于一个完全有序数组,比如 [1,2,3,4,5,6] 有序度为 5+4+3+2+1 = 15,计算公式为 ( n − 1 ) ∗ n / 2 (n-1)*n/2 (n−1)∗n/2。我们将完全有序数组的有序度称为满有序度。
3.逆序度
有序元素对:a[i] <= a[j], 如果i > j。
方式和 有序度 相反,我们就不多做说明了。
结论:
排序的过程就是一个有序度增加,而逆序度减少的过程,直到有序度等于满有序度就说明排序完成了。
逆序度 = 满序度 - 有序度
分析:
冒泡排序包含两个操作,一个是比较一个是交换,每交换一次,有序度加 1,按我们上面的例子来看,有序度为 10,满序度为15,那么需要交换 15 - 10= 5 次。
对于包含 n 个元素的数组进行冒泡排序
平均交换次数,最坏情况,有序度为 0,需要交换 ( n − 1 ) ∗ n / 2 (n-1)*n/2 (n−1)∗n/2 次,最好情况下,不需要交换,那么中间值我们可以取 ( n − 1 ) ∗ n / 4 (n-1)*n/4 (n−1)∗n/4 来表示一个平均情况
比较次数,因为每次都需要进行比较,所以比较次数的复杂度是 O ( n 2 ) O(n^2) O(n2)
冒泡排序的平均时间复杂度就是交换次数+比较次数,去掉常量,系数,最终平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)
5.插入排序(Insertion Sort)
5.1 原理图解
往一个有序数组中添加一个新元素之后,如何保持数组的顺序性?
假设数组为 [3,5,6,9,13],现在需要插入新元素 4
进行数据迁移,腾出位置 2
将元素 4 插入位置 2
理解上面的过程之后我们再来看插入排序的原理
首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
如图所示,要排序的数据是 [4,5,6,1,3,2],其中左侧为已排序区间,右侧是未排序区间。
插入排序的操作也包括两种,一种是比较,一种是移动。
以上图第三步为例,当将元素 1 插入到已排序区域,经过比较,元素1 需要放置数组 0 的位置,那么元素 [4,5,6] 都需要往后移动一位。
这个场景点像小学时期大家第一次上体育课,排队的时候,老师拉一个人出来,一个一个对比较高度,然后插入到中间,后面的同学自然向后移动一位,经过几次这种插入排序,我们站的队就是由高到低的顺序。
5.2 代码实现
public class InsertionSortDemo {
public static void main(String[] args) {
int[] numbers = new int[]{3, 1, 4, 10, 2, 5};
insertionSort(numbers);
}
private static void insertionSort(int[] numbers) {
int size = numbers.length;
for (int i = 1; i < size; i++) {
int j = i - 1;// 表示已经排序的区域
int value = numbers[i];// 插入的值
// value 和已排序区域的每一个元素进行比较,查找插入的位置
for (; j >= 0; j--) {
if (numbers[j] > value) { // 如果已排序区域位置[j]的元素大于 value 值
numbers[j + 1] = numbers[j];// 往后移动一位
} else {
break;
}
}
numbers[j + 1] = value;// 在腾出的位置插入新元素
}
System.out.printf("排序结果为:%s\n", Arrays.toString(numbers));
}
}
执行结果:
排序结果为:[1, 2, 3, 4, 5, 10]
5.3 排序分析
5.3.1 插入排序是原地排序算法吗?
是的,从实现代码中可以看到排序并没有使用额外的存储空间,所以它的空间复杂度是 O(1)。
5.3.2 插入排序是稳定排序算法吗?
是的,如果两个元素值相等,我们可以将后插入的元素,放在先插入的元素后面,这样就不会发生交换,所以是稳定排序。
5.3.3 时间复杂度
最好时间复杂度: O ( n ) O(n) O(n),因为即使给的数组是完全有序的,我们还是需要遍历比较一遍。
最坏时间复杂度: O ( n 2 ) O(n^2) O(n2),如果数组是倒序排列的,我们每次比较,都需要移动大量的数组元素
平均时间复杂度: O ( n 2 ) O(n^2) O(n2)
在数组中插入一个数据的平均时间复杂度是是 O ( n ) O(n) O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
6.选择排序(Selection Sort)
6.1 原理图解
选择排序也分为已排序区间和未排序区间,但是不同的是,每次排序会从未排序区间找到最小的元素,将其放到已排序空间的末尾。
6.2 代码实现
public class SelectionSortDemo {
public static void main(String[] args) {
int[] numbers = new int[]{3, 1, 4, 10, 2, 5};
selectionSort(numbers);
}
private static void selectionSort(int[] numbers) {
int size = numbers.length;
for (int i = 0; i < size - 1; i++) {
int minValue = numbers[i];
int k = i;// 未排序区间最小值的下标
for (int j = i + 1; j < size; j++) {// 找到未排序区间最小的元素
if (minValue > numbers[j]) {
minValue = numbers[j];
k = j;
}
}
numbers[k] = numbers[i];// 交换
numbers[i] = minValue;// 将未排序区间最小元素插入到已排序区域的末尾
}
System.out.printf("排序结果为:%s\n", Arrays.toString(numbers));
}
}
执行结果:
排序结果为:[1, 2, 3, 4, 5, 10]
6.3 排序分析
6.3.1 插入排序是原地排序算法吗?
是的,从实现代码中可以看到排序并没有使用额外的存储空间,所以它的空间复杂度是 O(1)。
6.3.2 插入排序是稳定排序算法吗?
不是,比如 [1,6,10,6,2,3],当元素 2 和元素 6(排第2) 交换的时候,6(原来排第2) 就排到 6(原来第4) 的后面,所以不是稳定排序。
6.3.3 时间复杂度
选择排序的最好情况时间复杂度、最坏情况时间复杂度和平均情况时间复杂度都为 O ( n 2 ) O(n^2) O(n2)。
7.小结
排序算法 | 是否原地排序 | 是否稳定 | 时间复杂度 |
---|---|---|---|
冒泡排序 | 是 | 是 | 最好: O ( n ) O(n) O(n) 最坏: O ( n 2 ) O(n^2) O(n2) 平均: O ( n 2 ) O(n^2) O(n2) |
插入排序 | 是 | 是 | 最好: O ( n ) O(n) O(n) 最坏: O ( n 2 ) O(n^2) O(n2) 平均: O ( n 2 ) O(n^2) O(n2) |
选择排序 | 是 | 否 | 最好: O ( n 2 ) O(n^2) O(n2) 最坏: O ( n 2 ) O(n^2) O(n2) 平均: O ( n 2 ) O(n^2) O(n2) |
冒泡排序、选择排序在实际开发中使用的不多,从性能和优化角度上,插入排序更好。
选择排序就不需要比较了,从时间复杂度上就已经被 pass 了,我们看看冒泡和插入排序的区别,从交换的代码上来看,冒泡排序交换的次数更多,而且需要开辟一个临时存储空间,当数据量在一个比较合理且正常范围时,两种排序肯定还是优先选择插入排序。
8.参考
- 极客时间 -《数据结构与算法之美》王争