排序(上):为什么插入排序比冒泡排序更受欢迎?(冒泡排序)
排序(上):为什么插入排序比冒泡排序更受欢迎?(冒泡排序)
排序算法太多了,王争老师讲了其中最经典最常用的一小撮:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。
按照时间复杂度可分为三类:
排序算法 | 时间复杂度 | 是否基于比较 |
---|---|---|
冒泡、插入、选择 | ○(n²) | √ |
快排、归并 | ○(n㏒n) | √ |
桶、计数、基数 | ○(n) | × |
带着问题去学习,是最有效的学习方法,所以按照惯例,有如下思考题:插入排序和冒泡排序的时间复杂度相同,都是 ○(n²),在实际的软件开发里,为什么我们更倾向于使用插入排序算法而不是冒泡排序算法呢?
一、如何分析一个 排序算法
?
排序算法的执行效率
排序算法的内存消耗
排序算法的稳定性
1. 排序算法的执行效率
最好情况、最坏情况、平均情况时间复杂度
我们在分析排序算法的时间复杂度时,要分别给出上述三个时间复杂度。除此之外还要说出最好、最坏时间复杂度对应的 要排序的原始数据
是什么样的。
为什么要区分这三种时间复杂度呢?第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。第二,对于要排序的数据,有的接近无序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
时间复杂度的系数、常数、低阶
时间复杂度反应的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候会忽略 系数、常数、低阶。但是在实际的软件开发中,我们排序的可能是 10个、100个、1000、个这样规模很小的数据,所以在对 同一阶 时间复杂度的排序算法性能对比时,我们就要把系数、常数、低阶也考虑进来。
比较次数和交换(或移动)次数
基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动,所以我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
2. 排序算法的内存消耗
算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 ○(1) 的排序算法。冒泡
插入
选择
这三种都是原地排序算法。
3. 排序算法的稳定性
仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,我们还有一个重要的度量指标,稳定性。 这个概念是说,如果待排序的数列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
image这两个3都是同一个3,交不交换位置有什么关系吗?
因为在真正的软件开发时,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个 key 来排序。
比如说,我们要给电商中的 订单
排序。订单有两个属性,一个是下单时间,一个是订单金额。如果我们现在有10万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序,对于这样的排序需求,我们怎么来做呢?
最先想到的方法是:先按照金额对订单进行排序,然后再遍历排序之后的订单数据,对于每个金额相同的小区间,按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂。
借助稳定排序算啊发,这个问题可以非常简洁地解决。解决思路是这样的:我们 先按照下单时间
给订单排序,注意不是金额。排序完成后,用稳定排序算法,按照订单金额重新排序。两边排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。
为什么呢?
稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变
第一次排序之后,所有的订单按照下单时间从早到晚有序了。
在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。
二、冒泡排序
此篇文章会介绍三种排序算法,我们先从冒泡排序开始。
image
对一组数据 4,5,6,3,2,1,从小到大进行排序,第一次冒泡操作的详细过程就是下图:
image
上图是一次冒泡操作后的结果,6 这个元素已经存储在正确的位置上,要想完成所有数据的排序,我们只需要进行 6 次这样的冒泡操作就行了。
image
上述的冒泡过程还可以优化,当某次冒泡操作没有进行任何数据交换,说明此事已经达到完全有序,那么就不需要再执行后续的冒泡操作,比如下图的例子,只需要四次冒泡即可:
image
冒泡排序的代码: 原理很简单,但是要想一次性手写代码不出错还是要仔细记忆的!!
// 冒泡排序,a 表示数组,n 表示数组大小
public void bubbleSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n; ++i) {
// 提前退出冒泡循环的标志位
boolean flag = false;
for (int j = 0; j < n - i - 1; ++j) {
if (a[j] > a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true; // 表示有数据交换
}
}
if (!flag) break; // 没有数据交换,提前退出
}
}
此处有三个问题:
第一,冒泡排序是原地排序算法吗?
空间复杂度为 ○(1) 的排序算法为原地排序算法。
那么我们算一下冒泡排序的空间复杂度:冒泡排序只涉及到相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 ○(1) ,是一个原地排序算法。
第二,冒泡排序是稳定的排序算法吗?
在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
第三,冒泡排序的时间复杂度是多少?
最好情况下:要排序的数据已经是有序的了,只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度为 ○(n) (冒泡排序是两层循环,即时只执行一次冒泡操作,也走了一次与 n 有关的内循环,所以这里是 ○(n) )。
而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 ○(n²)
1. 有序度和逆序度
对于包含 n 个数据的数组,这 n 个数据就有 n! 种排列方式。不同的排列方式,冒泡排序执行的时间肯定是不同的。不如前面两个图例,一个需要进行6次冒泡,另一个只需要四次就可以了。如果用概率论方法定量分析平均时间复杂度,设计的数学推理和计算就会很复杂。
这里还有一种思路,通过 有序度
和 逆序度
这两个概念来进行分析。
有序度
是数组中具有有序关系的元素对的个数。有序元素对用数学表达式表示就是这样:
有序元素对:a[i] <= a[j],如果 i < j
image
同理,对于一个倒序排列的数组,有序度为0;对于一个完全有序的数组,有序度就是 n*(n-1)/2 ,我们把这种完全有序的数组的有序度叫做 满有序度
。
逆序度
的定义正好和有序度相反(默认从小到大为有序),逆序元素的表达式:
逆序元素对:a[i] > a[j],如果 i < j
关于这三个概念,我们还可以得到一个公式:逆序度 = 满有序度 - 有序度
。所以我们排序的过程,就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。
2. 根据有序度、逆序度推导冒泡排序的平均时间复杂度
假设哟啊排序的数组的初始状态是 [4,5,6,3,2,1]
,其中有序元素对为:(4,5) (4,6) (5,6) ,所以有序度为 3。n 为 6,所以根据公式得知,满有序度为:15。
看一下每次冒泡后有序度的变化:
推导过程
这个平均时间复杂度推导过程其实并不严格,但是很多时候很实用,概率论的定量分析太复杂,
对概率论的要求太高。我们还会在学习快排的时候使用这种 '不严格' 的方法来分析平均时间
复杂度