数据结构与算法之美 - 排序算法总结

189 阅读9分钟

排序

如何分析一个排序算法?

排序算法的执行效率

对于排序算法执行效率的分析,我们一般会从这几个方面来衡量:

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)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法的一个非常典型的应用。

**核心思想:**如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

把数组分解,得到有序的子数组后,如何将有序的子数组合并,并放入原数组呢?

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置
  3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
  4. 重复步骤3直到某一指针达到序列尾
  5. 将另一序列剩下的所有元素直接复制到合并序列尾

算法代码

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)。

快速排序

原理

核心思想:

  1. 从数列中挑出一个元素,称为 “基准”(pivot),
  2. 遍历数列,将小于基准的放在左边,大于基准的放在右边,将基准放在中间
  3. 递归地把小于基准值元素的子数列和大于基准值元素的子数列排序。
  4. 递归的最底部情形,是数列的大小是零或一,也就是数列都已经被排序好了。

虽然一直递归下去,但是这个算法总会退出,因为在每次的迭代中,它至少会把一个元素摆到它最后的位置去。

为了保证快排是原地排序算法,分区过程如下:

算法代码

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(n2n^2),不过最糟情况出现的概率非常小,我们可以通过合理地选择 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(n2n^2)O(n2n^2)
插入排序O(n)O(n2n^2)O(n2n^2)
选择排序×O(n2n^2)O(n2n^2)O(n2n^2)
归并排序×O(nlogn)O(nlogn)O(nlogn)
快速排序×O(nlogn)O(n2n^2)O(nlogn)
桶排序×O(n)
计数排序×O(n+k) k是数据范围
基数排序×O(dn) d是位数

1. 为什么插入排序要比冒泡排序更受欢迎呢?

不管怎么优化,冒泡排序元素交换的次数和插入排序元素移动的次数是固定一样的,都等于原始数据的逆序度。

但是,从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。当操作次数很大的时候,这个性能差距就体现出来了。

2. 归并排序和快速排序的区别

  • 归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。
  • 归并排序是一种在任何情况下时间复杂度都比较稳定的排序算法,时间复杂度为 O(nlogn) ,但是它是非原地排序算法,空间复杂度比较高,是 O(n)。
  • 快速排序算法虽然最坏情况下的时间复杂度是 O(n2n^2),但是平均情况下时间复杂度都是 O(nlogn)。不仅如此,快速排序算法时间复杂度退化到 O(n2n^2) 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。
  • 快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。

3. 基数排序 vs 计数排序 vs 桶排序

这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶;
  • 计数排序:每个桶只存储单一键值;
  • 桶排序:每个桶存储一定范围的数值;

4.快速排序优化

  • 尽可能地让每次分区都比较平均:
    1. 三数取中法:从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。
    2. 随机法
  • 警惕堆栈溢出:
    1. 限制递归深度
    2. 在堆上模拟实现一个函数调用栈,手动模拟递归入栈、出栈操作