排序
如何分析一个排序算法?
排序算法的执行效率
对于排序算法执行效率的分析,我们一般会从这几个方面来衡量:
1.最好情况、最坏情况、平均情况时间复杂度
我们在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。
为什么要区分这三种时间复杂度呢?第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
2.时间复杂度的系数、常数 、低阶
在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
3.比较次数和交换(或移动)次数
基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
排序算法的内存消耗
算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。
排序算法的稳定性
如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
冒泡排序
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复n 次,就完成了 n 个数据的排序工作。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
def bubble_sort(a: List[int]):
length = len(a)
if length <= 1:
return
for i in range(length):
# 提前退出冒泡循环的标志位
made_swap = False
for j in range(length - i - 1):
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
made_swap = True # 表示有数据交换
if not made_swap: # 没有数据交换,提前退出
break
插入排序
插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
def insertion_sort(a: List[int]):
length = len(a)
if length <= 1:
return
for i in range(1, length):
value = a[i]
j = i - 1
while j >= 0 and a[j] > value:
a[j + 1] = a[j]
j -= 1
a[j + 1] = value
选择排序
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的(或最大的)元素,将其放到已排序区间的末尾。
def selection_sort(a: List[int]):
length = len(a)
if length <= 1:
return
for i in range(length):
min_index = i
min_val = a[i]
for j in range(i, length):
if a[j] < min_val:
min_val = a[j]
min_index = j
a[i], a[min_index] = a[min_index], a[i] # 交换
归并排序
原理
归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法的一个非常典型的应用。
**核心思想:**如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
把数组分解,得到有序的子数组后,如何将有序的子数组合并,并放入原数组呢?
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
- 重复步骤3直到某一指针达到序列尾
- 将另一序列剩下的所有元素直接复制到合并序列尾
算法代码
def merge_sort(a: List[int]):
merge_sort_between(a, 0, len(a) - 1)
def merge_sort_between(a: List[int], low: int, high: int):
if low < high:
mid = low + (high - low) // 2
merge_sort_between(a, low, mid)
merge_sort_between(a, mid + 1, high)
merge(a, low, mid, high)
def merge(a: List[int], low: int, mid: int, high: int):
# a[low:mid], a[mid+1, high] are sorted.
i, j = low, mid + 1
tmp = []
while i <= mid and j <= high:
if a[i] <= a[j]:
tmp.append(a[i])
i += 1
else:
tmp.append(a[j])
j += 1
start = i if i <= mid else j
end = mid if i <= mid else high
tmp.extend(a[start:end + 1])
a[low:high + 1] = tmp
性能分析
- 归并排序是一个稳定的排序算法。
- 归并排序不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)
- 归并排序不是原地排序算法。它的空间复杂度是 O(n)。
快速排序
原理
核心思想:
- 从数列中挑出一个元素,称为 “基准”(pivot),
- 遍历数列,将小于基准的放在左边,大于基准的放在右边,将基准放在中间
- 递归地把小于基准值元素的子数列和大于基准值元素的子数列排序。
- 递归的最底部情形,是数列的大小是零或一,也就是数列都已经被排序好了。
虽然一直递归下去,但是这个算法总会退出,因为在每次的迭代中,它至少会把一个元素摆到它最后的位置去。
为了保证快排是原地排序算法,分区过程如下:
算法代码
def quick_sort(a: List[int]):
_quick_sort_between(a, 0, len(a) - 1)
def _quick_sort_between(a: List[int], low: int, high: int):
if low < high:
# 随机获取基准值
k = random.randint(low, high)
a[low], a[k] = a[k], a[low] # 将基准值放在数组首位
m = _partition(a, low, high) # a[m] 的位置已经是最终位置了
_quick_sort_between(a, low, m - 1)
_quick_sort_between(a, m + 1, high)
def _partition(a: List[int], low: int, high: int):
pivot, j = a[low], low # j是小于基准值的数组的最后一个元素的下标
for i in range(low + 1, high + 1):
if a[i] <= pivot: # 当有元素小于基准值时
j += 1 # j 往后移一位
a[j], a[i] = a[i], a[j] # 交换,此时,j指向的元素还是小于基准值的数组的最后一个元素
# 交换基准值和j指向的元素,此时,j指向基准值,且基准值在整个数组中的位置已经确定下来就是j
a[low], a[j] = a[j], a[low]
return j
性能分析
- 快速排序是一个不稳定的排序算法。
- 快速排序是原地排序算法。
- 快速排序最好情况和平均情况的时间复杂度为 O(nlogn),最糟情况下时间复杂度为 O(),不过最糟情况出现的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。
桶排序(Bucket sort)
原理
**核心思想:**将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序的时间复杂度为什么是 O(n) 呢?
如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。
使用限制
- 要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。
- 数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
计数排序(Counting sort)
原理
计数排序是一种非基于比较的排序算法,其空间复杂度和时间复杂度均为 O(n+k),其中 k 是整数的范围,是一种线性时间复杂度的排序。
我个人觉得,计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
核心思想:将待排序集合中的元素值转化为额外开辟的数组空间的下标值,数组中存储的值为元素值出现的次数。对额外空间内数据进行计算,得出每一个元素的正确位置,将待排序集合每一个元素移动到计算得出的正确位置上。
举例:高考查分,如何通过成绩快速排序得出名次呢?
假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A 中
A = [2, 5, 3, 0, 2, 3, 0, 3]
考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C 表示桶,其中下标对应分数。C 中存储的是对应下标分数的考生的个数。
C = [2, 0, 2, 3, 0, 1]
下面我们对 C 进行顺序求和,现在 C[k] 里存储的是小于等于分数 k 的考生个数。
C = [2, 2, 4, 7, 7, 8]
现在我们申请一个临时数组 R,存储排序之后的结果。然后我们开始依序遍历 A (从前到后或从后到前都可以),我们从后到前依次扫描数组 A,第一个值为3,然后我们找到C中下标为3对应的值7,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,将 3 放到数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3] 要减 1,变成 6。依次类推。
算法代码
import itertools
def counting_sort(a: List[int]):
if len(a) <= 1: return
# a中有counts[i]个数不大于i
counts = [0] * (max(a) + 1)
for num in a:
counts[num] += 1
counts = list(itertools.accumulate(counts))
# 临时数组,储存排序之后的结果
a_sorted = [0] * len(a)
for num in reversed(a):
index = counts[num] - 1
a_sorted[index] = num
counts[num] -= 1
a[:] = a_sorted
使用限制
- 计数排序要求输入的数据必须是有确定范围的非负整数,且范围不能太大。
基数排序
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
基数排序有两种,一种是高位优先,一种是低位优先。
基数排序是稳定的排序算法。
原理
把数组中数字的每一位单独分割出来,然后根据每一位来排序,比如说低位优先,先从个位开始,利用桶排序或计数排序将数组排好序,然后再根据十位来排序,以此类推,最后按照第一位重新排序。
使用限制
- 待排序的数据需要可以分割出独立的“位”来比较,而且位之间有递进的关系。
- 每一位的数据范围不能太大
排序算法比较
| 原地排序 | 稳定 | 最好 | 最坏 | 平均 | |
|---|---|---|---|---|---|
| 冒泡排序 | √ | √ | O(n) | O() | O() |
| 插入排序 | √ | √ | O(n) | O() | O() |
| 选择排序 | √ | × | O() | O() | O() |
| 归并排序 | × | √ | O(nlogn) | O(nlogn) | O(nlogn) |
| 快速排序 | √ | × | O(nlogn) | O() | O(nlogn) |
| 桶排序 | × | √ | O(n) | ||
| 计数排序 | × | √ | O(n+k) k是数据范围 | ||
| 基数排序 | × | √ | O(dn) d是位数 |
1. 为什么插入排序要比冒泡排序更受欢迎呢?
不管怎么优化,冒泡排序元素交换的次数和插入排序元素移动的次数是固定一样的,都等于原始数据的逆序度。
但是,从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。当操作次数很大的时候,这个性能差距就体现出来了。
2. 归并排序和快速排序的区别
- 归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。
- 归并排序是一种在任何情况下时间复杂度都比较稳定的排序算法,时间复杂度为 O(nlogn) ,但是它是非原地排序算法,空间复杂度比较高,是 O(n)。
- 快速排序算法虽然最坏情况下的时间复杂度是 O(),但是平均情况下时间复杂度都是 O(nlogn)。不仅如此,快速排序算法时间复杂度退化到 O() 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。
- 快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
3. 基数排序 vs 计数排序 vs 桶排序
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
- 基数排序:根据键值的每位数字来分配桶;
- 计数排序:每个桶只存储单一键值;
- 桶排序:每个桶存储一定范围的数值;
4.快速排序优化
- 尽可能地让每次分区都比较平均:
- 三数取中法:从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。
- 随机法
- 警惕堆栈溢出:
- 限制递归深度
- 在堆上模拟实现一个函数调用栈,手动模拟递归入栈、出栈操作