排序算法可以说是所有程序员的算法初体验,大部分编程语言中封装了现成的排序方法。由此可以看出,排序算法是十分基础的算法,需要我们深入理解和掌握。
排序方法很多,其中最经典、最常用的包含:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。它们对比如下图所示:
后续,我们将按照时间复杂度分三类进行讲解。
凡分析一个事物必然要有一套标准,这样才能保证做出理性的评价。对于排序算法的分析,我们从时间复杂度、空间复杂度和排序稳定性三个方面进行分析。
分析排序算法时间复杂度,要分别给出最好情况、最坏情况、平均时间复杂度。除此之外,你还要说出最好、最坏对应的排序原始数据是什么样的。
为什么要区分这三种时间复杂度呢?
在对同阶时间复杂度的排序算法性能对比时,要把系数、常数、低阶也考虑进来。
比如"插入之所以优于冒泡就是因为系数问题"。
基于比较的排序算法,会涉及两种操作:比较大小,交换或移动。所以,分析排序算法的执行效率时,应该把比较次数和交换(或移动)次数也考虑进去。
引入一个新的概念:原地排序:对于空间复杂度为 O ( 1 ) O(1) O(1)的排序算法,我们称之为原地排序算法。
本文介绍的三种算法,都属于原地排序算法。
如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变,这个算法就是稳定的。
稳定排序算法,在特殊场景下很重要,比如"电商的订单先按金额排序,金额相同按时间排序?"
如果是稳定排序算法,能够一次排序解决问题,但是非稳定排序,需要先按照金额排序,再将金额相同的订单按照时间排序,这就复杂了。
最好时间复杂度:数据完全有序时,只需进行一次冒泡操作即可,时间复杂度是 O ( n ) O(n) O(n)。
最坏时间复杂度:数据倒序排列时,需要n次冒泡操作,时间复杂度是 O ( n 2 ) O(n^2) O(n2)。
平均时间复杂度:通过有序度和逆序度来分析 O ( n 2 ) O(n^2) O(n2)。
有序度是数组中具有有序关系的元素对的个数,逆序度的定义正好和有序度相反。
核 心 公 式 : 逆 序 度 = 满 有 序 度 − 有 序 度 核心公式:逆序度=满有序度-有序度 核心公式:逆序度=满有序度−有序度
其中, 满 有 序 度 = C n 2 = n ( n − 1 ) / 2 满有序度=C_n^2=n(n-1)/2 满有序度=Cn2=n(n−1)/2
排序过程,就是有序度增加,逆序度减少的过程,最后达到满有序度,则排序完成。
对于冒泡排序,包含两个操作原子,即比较和交换,每交换一次,有序度加1。不管算法如何改进,交换的次数总是确定的,即逆序度。
对于那个元素的序列,其平均时间复杂度计算可以假设逆序度为: 满 有 序 度 / 2 = n ( n − 1 ) / 4 满有序度/2=n(n-1)/4 满有序度/2=n(n−1)/4,这样可以得到平均需要交换 n ( n − 1 ) / 4 n(n-1)/4 n(n−1)/4次,因此平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
每次交换仅需1个临时变量,故空间复杂度为O(1),是原地排序算法。
如果两个值相等,就不会交换位置,故是稳定排序算法。
提前退出:若某次冒泡不存在数据交换,则说明已经达到完全有序,所以终止冒泡。
将数组中的数据分为2个区间,即已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想就是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间中的元素一直有序。重复这个过程,直到未排序中元素为空,算法结束。
步骤描述如下:
最好时间复杂度:数据完全有序时,我们并不需要移动数据,只需便利一遍数组,时间复杂度是 O ( n ) O(n) O(n)。
最坏时间复杂度:数据倒序排列时,每次插入都要对前边所有数据进行移动,时间复杂度是 O ( n 2 ) O(n^2) O(n2)。
平均时间复杂度:数组插入操作时间复杂度为 O ( n ) O(n) O(n),插入排序每次循环相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),是原地排序算法。
在插入排序中,对于值相同的元素,可以选择将后面出现的元素,插入到前面相同元素的后面,这样就保持原有的顺序不变,所以是稳定的。
和插入排序一样,选择排序算法也分已排序区间和未排序区间。
但是选择排序每次会从未排序区间中找到最小的元素,并将其放置到已排序区间的末尾。
由于选择排序,每次循环都要从未排序区间选择最小元素,而选择最小元素的时间复杂度为 O ( n ) O(n) O(n),因此选择排序的最好,最坏,平均时间复杂度都是 O ( n 2 ) O(n^2) O(n2)。
选择排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),是原地排序算法。
选择排序算法是非稳定排序算法。
比如[5,8,5,2,9]这个数组,使用选择排序算法第一次找到的最小元素就是2,与第一个位置的元素5交换位置,那第一个5和中间的5的顺序就变量,所以就不稳定了。正因如此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。
冒泡排序和插入排序的时间复杂度都是 O(n^2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?
冒泡排序移动数据有3条赋值语句,而插入排序的交换位置的只有1条赋值语句,因此在有序度相同的情况下,冒泡排序时间复杂度是插入排序的3倍,所以插入排序性能更好。
冒泡排序中数据的交换操作:
if (a[j] > a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true;
}
插入排序中数据的移动操作:
if (a[j] > value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?
这个问题应该有个前提,是否允许修改链表的节点value值,还是只能改变节点的位置。
考虑只能改变节点位置的情况,
综上,时间复杂度和空间复杂度并无明显变化,若追求极致性能,冒泡排序的时间复杂度系数会变大,插入排序系数会减小,选择排序无明显变化。