排序算法是《数据结构和算法》中非常基础的算法,但却占据着十分重要的位置,几乎可以说是我们在日常编程代码中使用最频繁的基础算法。本文对常见的十大经典排序算法进行了详细的知识点梳理,从排序思路、动图演示、代码实现、复杂度分析、算法优化等多个方面分别对不同的排序算法进行讲解,内容详实,一篇文章几乎囊括了排序算法所有必知必会的知识点,夸张点说,算得上是 “史上最全” 排序算法讲解。
1.排序算法的分析和评价
时间复杂度最好情况、最坏情况、平均情况时间复杂度
分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。之所以这样区分分析,一是便于排序算法的对比分析,二是待排序数据有的接近有序而有的则完全无序,我们需要知道排序算法在不同数据下的性能表现,从而能够在不同的场景下选择更加适合的排序算法。
时间复杂度的系数、常数 、低阶
时间复杂度反应的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候通常会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是 10 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
比较次数和交换(或移动)次数
基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
空间复杂度
排序算法的空间复杂度引入了一个特别的概念,即原地排序 (Sorted in place),也称内部排序。原地排序算法特指空间复杂度是 $O(1)$ 的排序算法,也就是不借用外部多余的(内存)空间消耗,只占用待排序数据原有的空间。
稳定性
排序算法还有一个重要的度量指标是稳定性。它表示如果待排序的数列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。稳定性也是实际业务中必须要考虑的因素,比如交易系统,订单金额可能一样,但订单依然有时间上的前后顺序关系。从稳定性角度来讲,有稳定的排序算法也有不稳定的排序算法
2.十大排序经典算法总览
2.1.排序算法的分类
为了便于集中分析,我们可以把经典的十大排序算法进行不同方式的分类:
按时间复杂度,可以把排序算法分为平方阶、对数阶、线性阶三类;
按空间复杂度,可以分为原地(In-place)排序算法和非原地(Out-place)排序;
按稳定性,可以分为稳定排序算法和不稳定排序算法;
按是否基于比较,可以分为比较排序算法和非比较排序算法。
2.2.排序算法的性能
排序算法平均
时间复杂度最好情况最坏情况空间复杂度原地排序稳定排序比较排序冒泡排序$O(n^2)$$O(n)$$O(n^2)$$O(1)$$\color {green} \checkmark$$\color {green} \checkmark$$\color {green} \checkmark$
选择排序$O(n^2)$$O(n^2)$$O(n^2)$$O(1)$$\color {green} \checkmark$$\color{red} \times$$\color {green} \checkmark$
插入排序$O(n^2)$$O(n)$$O(n^2)$$O(1)$$\color {green} \checkmark$$\color {green} \checkmark$$\color {green} \checkmark$
希尔排序$O(n\log^2 n)$$O(n\log n)$$O(n^2 )$$O(1)$$\color {green} \checkmark$$\color{red} \times$$\color {green} \checkmark$
归并排序$O(n\log n)$$O(n\log n)$$O(n\log n)$$O(n)$$\color{red} \times $$\color {green} \checkmark$$\color {green} \checkmark$
快速排序$O(n\log n)$$O(n\log n)$$O(n^2)$$O(\log n)$$\color {green} \checkmark$$\color{red} \times$$\color {green} \checkmark$
堆排序$O(n\log n)$$O(n\log n)$$O(n\log n)$$O(1)$$\color {green} \checkmark$$\color{red} \times$$\color {green} \checkmark$
计数排序$O(n+k)$$O(n+k)$$O(n+k)$$O(n+k)$$\color{red} \times $$\color {green} \checkmark$$\color{red} \times$
桶排序$O(n+k)$$O(n)$$O(n^2)$$O(n+k)$$\color{red} \times$$\color {green} \checkmark$$\color{red} \times$
基数排序$O(n \times k)$$O(n \times k)$$O(n \times k)$$O(n+k)$$\color{red} \times $$\color {green} \checkmark$$\color{red} \times$
*符号说明:$n$ 为数据规模,$k$ 为分桶数量。
2.3.各阶复杂度性能对比
2.4.排序算法的初始状态影响算法复杂度与初始状态无关的算法:
选择排序、归并排序、堆排序、基数排序。
总排序趟数与初始状态无关:
快速排序的排序次数(递归深度)与分区点选择(初始状态)有关,还有一个优化后的冒泡排序和后序是否有序有关,其他排序算法的总排序次数均只与总长度 n 有关,与初始状态无关
元素总比较次数与初始状态无关:
基数排序、选择排序。
元素总移动次数与初始状态无关:
基数排序、归并排序。
2.5.排序算法选择经验待排序数组规模 $n$ 较小时(如 $n \le 50$),可采用直接插入或直接选择排序;
若初始状态基本有序(正序),则应选用直接插入、冒泡或快速排序为宜;
当待排数组规模 $n$ 较大时,则应采用时间复杂度为 $O(n \log n)$ 的排序方法:快速排序(效率更高)、堆排序(原地排序)或归并排序(稳定排序);
数据范围不大的整数、存在很多重复元素的排序场景,可以考虑使用计数排序;
数据范围固定、数值都比较大(位数较多)的场景,可以考虑使用基数排序;
可以利用快速排序在 $O(n)$ 的时间复杂度内查找一个无序数组中的第 K 大元素;
可以利用堆排序高效计算 TopK 或者流式数据的中位数。
3.十大经典排序算法详解
3.1.冒泡排序(Bubble Sort)
思路
冒泡排序的基本思路是重复地走访要排序的数列,每次比较两个相邻元素,顺序错误则交换位置(大的下沉放后面、小的上浮放前面),重复进行直到没有元素再需要替换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。冒泡排序名字由来是因为越小或越大的元素会经由交换慢慢 “浮” 到数列的顶端(升序或降序排列),如同一个个上升的气泡。
场景
适用于元素较少和数组基本有序的情况。
步骤比较相邻的元素,如果第一个比第二个大,就交换它们两个;
对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
针对所有的元素重复以上的步骤,除了最后一个。
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较
动图
主要动作:比较和移动
代码
1
2
3
4
5
6
7defbubbleSort(arr):
foriinrange(1,len(arr)):# 对L-1个位置进行迭代
forjinrange(0,len(arr)-i):# 最后的是最大的,对比次数每次都减小1次
ifarr[j]>arr[j+1]:
arr[j],arr[j+1]=arr[j+1],arr[j]
returnarr
性能时间复杂度(最好)当输入的数据已经是正序时,不需要进行排序,$O(n)$。
(最坏)当输入的数据是反序时,n 个元素每个元素都要交换 n 次,所以是 $O(n^2)$。
(平均)冒泡排序的时间复杂度和逆序度有关系,每交换一次,有序度就加 1。不管算法怎么优化改进,交换次数总是确定的,即为逆序度(这个也是冒泡排序的一大缺点,可优化空间太小),逆序对也就是满有序度 - 初始有序度(相当于排序后的有序度减去开始排序前的有序度)。有序度是数列中具有有序关系的元素对的个数,逆序度定义相反,完全有序的数列的有序度叫作满有序度,值为 $n \times(n-1)/2$,逆序度 = 满有序度 - 有序度。排序的过程就是一种增加有序度,减少逆序度的过程,直到最后达到满有序度。
最坏情况下,初始状态的有序度是 0,所以要进行 $n \times(n-1)/2$ 次交换。
最好情况下,初始状态的有序度是 $n \times(n-1)/2$,就不需要进行交换。
我们可以取个中间值 $n \times(n-1)/4$,来表示初始有序度既不是很高也不是很低的平均情况。换句话说,平均情况下,需要 $n \times(n-1)/4$ 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O(n),所以平均情况下的时间复杂度就是 O(n)。
空间复杂度
冒泡排序的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 $O(1)$,是一个原地排序算法。
稳定性
相邻的两个元素大小相等时不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
优化
冒泡排序的优化思路主要是识别出已经有序的部分,避免这些部分被重复遍历比较。
优化一:对于连片有序而整体无序的数据 (例如:1, 2,3 ,4 ,7,6,5),当已经完成有序时,后面的剩余走访都是多余的,因此加入一个标记(代码中的 is_sorted),如果某次遍历没有发生元素交换,说明说明这组数据已经有序,不用再继续下去,直接跳出循环。
优化二:对于前面大部分是无序而后边小半部分有序的数据 (例如:1,2,5,7,4,3,6,8,9,10),我们可以继续优化。可以记下最后一次交换的位置(代码中 last_exchange_index),后边没有交换,必然是有序的,然后下一次排序从第一个比较到上次记录的位置结束即可。
冒泡排序优化代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20defbubbleSort_OP(arr):
last_exchange_index=0# 用于记录最后一次交换的位置
sort_border=len(arr)-1# 无序数列的边界,每次比较的终止点
foriinrange(1,len(arr)):
is_sorted=True# 有序标记
forjinrange(0,sortBorder):# 只遍历到无序数列边界
ifarr[j]>arr[j+1]:
arr[j],arr[j+1]=arr[j+1],arr[j]
# 有元素交换,所以不是有序,标记变为false
is_sorted=False
# 把无序数列的边界更新为最后一次交换元素的位置
last_exchange_index=j
sort_border=last_exchange_index
ifis_sorted:
break
returnarr
特点适用场景:适用元素较少的情况下和数组基本有序的情况;
优点:实现简单,空间复杂度低,稳定;
缺点:时间复杂度高,效率慢。
3.2.选择排序(Selection Sort)
思路
将待排序数据分为两个区间,已排序区间和未排序区间。选择排序每次会从剩余未排序区间中选择一个最小(大)的元素,将其交换至已排序区间的末尾,直到所有元素排序完毕。
步骤首先在未排序序列中找到最小(大)元素,交换到排序序列的起始位置;
再从剩余未排序元素中继续寻找最小(大)元素,然后交换到已排序序列的末尾;
重复第二步,直到所有元素均排序完毕。
动图
主要动作:比较和交换
代码
1
2
3
4
5
6
7
8
9
10defselectionSort(arr):
foriinrange(0,len(arr)-1):
min_index=i
forjinrange(i+1,len(arr)):
ifarr[j]
min_index=j
ifi!=min_index:
arr[i],arr[min_index]=arr[min_index],arr[i]
returnarr
性能时间复杂度
最好、最坏、平均时间复杂度均为 $O(n^2)$,因为选择排序的时间复杂度与数据原本有序度没有关系,它需要的遍历次数是固定的,不会受到数据原本的有序度的影响。
虽然选择排序和冒泡排序的时间复杂度一样,但实际上,选择排序进行的交换操作很少,最多会发生 $N-1$ 次交换。而冒泡排序最坏的情况下要发生 $\frac{N^2}{2}$ 次交换操作。从这个意义上讲,选择排序的性能略优于冒泡排序。而且,选择排序比冒泡排序的思想更加直观。
空间复杂度
选择排序只涉及最小(大)元素和已排序的末尾元素的交换,只需要常量级的临时空间,不需要额外空间来进行排序,所以它的空间复杂度为 $O(1)$,是一个原地排序算法。
稳定性
选择排序是不稳定排序算法,因为每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,等值的元素随时可能会被置换到后面,发生相对位置改变,这样破坏了稳定性。
优化
选择排序优化思路之一是 “双路优化”,每次遍历剩余元素的时候,一次确定两个元素的位置,找出其中最小值和最大值,比如升序排序,每次将最小值放在起始位置,最大值放在末尾位置。这样遍历的次数会减少一半。时间复杂度是 $O(\frac{N}{2} \times \frac{N}{2})$,虽然还是平方级别的,但是运行时间有了相应的减少。
选择排序优化代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22defselectionSort_OP(arr):
length=len(arr)
foriinrange(length-1):
print(arr)# 打印每一次选择后的结果
min_index=i# 最小值下标
max_index=length-i-1# 最大值下标
forjinrange(i+1,length-i-1):
ifarr[min_index]>arr[j]:
min_index=j
ifarr[max_index]
max_index=j
# 当最小元素的位置+1 = 最大元素的位置时,表明数据已经全部有序,直接退出。
ifmin_index+1==max_index:
break
#前面的数据与最小值交换
ifmin_index!=i:# 加判断避免自己和自己交换
arr[i],arr[min_index]=arr[min_index],arr[i]
#后面的数据与最大值交换
ifmax_index!=length-i-1:# 避免自己和自己交换
arr[length-i-1],arr[max_index]=arr[max_index],arr[length-i-1]
returnarr
特点适用场景:适用元素较少的情况下和数组基本有序的情况;
优点:交换次数少,移动次数确定 n 次;
缺点:效率慢,不稳定。
3.3.插入排序(Insertion Sort)
思路
将待排序数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组中的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
步骤将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
动图
主要动作:比较和移动
代码
1
2
3
4
5
6
7
8
9
10definsertionSort(arr):
foriinrange(len(arr)):
pre_index=i-1
current=arr[i]
whilepre_index>=0andarr[pre_index]>current:
arr[pre_index+1]=arr[pre_index]
pre_index-=1
arr[pre_index+1]=current
returnarr
性能时间复杂度(最好)如果要排序的数据已经是有序的,并不需要搬移任何数据。只是从头到尾遍历了一遍有序数据进行比较,所以这种情况下,最好是时间复杂度为 $O(n)$。
(最坏)如果数组是完全倒序的,每次插入都相当于在数组的第一个位置插入新的数据,需要移动大 量的数据,所以最坏情况时间复杂度为 $O(n^2)$。
(平均)数组中插入一个数据的平均时间复杂度是 $O(n)$。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 $O(n^2)$。
空间复杂度
插入排序算法的运行并不需要额外空间来进行排序,所以它的空间复杂度为 $O(1)$,是一个原地排序算法。
稳定性
等值元素可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
优化
上面提到的插入排序算法其实是直接插入排序(straight insertion sort),它还有很多优化算法,如:折半插入排序(binary insertion sort)
思路:直接插入排序在插入到已排序的数据时采用的是顺序查找的方式,因为已排序区域已经是有序数据,所以可以考虑使用折半查找(二分查找)的方法来进行插入,所以称为折半插入排序。
优缺点:折半插入排序算法相比较于直接插入排序算法,只是减少了比较次数,而移动次数没有进行优化,所以该算法的时间复杂度仍是 $O(n^2)$。
二路插入排序(two-way insertion sort)
思路:
直接插入排序是一个原地排序算法,因为基础数据结构是数组,内存空间固定,将后面的元素插入到前面必然需要先将其他元素往后移动,以此来保持相对有序。当前位置与正确顺序位置的距离越远,那么需要移动次数就越多。二路插入排序算法是对折半插入排序的进一步改进,主要目的是减少其在排序过程中移动元素的次数从而提高效率。
为了减少移动次数,二路插入排序借助了一个辅助数组 A,其大小与原数组一样,这个数组需要设置成环状数组(代码中通常是在基本数组结构中对数组索引进行一个巧妙取余运算来实现的,所以仅仅是一个逻辑环状数组),这样便可以进行双端插入,这也是二路插入排序名称的由来。大致过程是将(原数组)无序表中第一个记录添加进 A[0] 的位置上,然后从无序表中第二个记录开始,同 A[0] 作比较:如果该值比 A[0] 大,则添加到其右侧;反之添加到其左侧。当所有元素分配好后,其实数组已经变成两个有序区,整体也是一个有序序列。
详细步骤说明:设定一个辅助数组 A,大小是原来数组相同的大小,将原数组第一个元素赋值给 A[0],作为标志元素;
通过设置 first 和 final 指向整个有序序列的最小值和最大值,即为序列的尾部和头部,并且将其设置位一个循环数组,这样就可以进行双端插入;
按顺序依次插入剩下的原数组的元素;
将待插入元素与 A[0] 比较,偌大于 A[0],则插入 A[0] 前面的有序序列,否则插入后面的有序序列,具体定位可用折半查找。
查找到插入位置后进行记录的移动,分别往 first 方向前移和往 final 方向移动
插入记录将排序好的 A 数组的数据从 first 到 final,按次序赋值回原数组。
二路插入排序 Python 代码(含折半插入排序代码):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40deftwo_insertionSort(arr):
n=len(arr)
A=[0]*n
A[0]=arr[0]
first,final=0,0
foriinrange(1,n):
cur=arr[i]
ifcur>=A[final]:# 待插入元素比最大的元素大,插入右边
final+=1
A[final]=cur
elifcur<=A[first]:# 待插入元素比最小的元素小,插入左边
first=(first-1+n)%n# 取余运算,模拟环状数组,已实现前端插入
A[first]=cur
else:# 插入元素比最小大,比最大小,这里使用折半插入(二分查找)法插入
ifcur
low,high=first,n-1
else:
low,high=0,final
whilelow<=high:
m=low+(high-low)//2
ifcur