经典排序算法总结与Python实现(下)

在之前的博客经典排序算法总结与Python实现(上)中已经讨论过插入、冒泡、选择、快排、谢尔排序。这篇博客主要完成剩下的几个排序算法。

排序算法时间复杂度(最好)时间复杂度(最差)时间复杂度(平均)空间复杂度稳定性
归并排序 O ( n log ⁡ n ) O(n\log{n}) O(nlogn) O ( n log ⁡ n ) O(n\log{n}) O(nlogn) O ( n log ⁡ n ) O(n\log{n}) O(nlogn) O ( n ) O(n) O(n)稳定
堆排序 O ( n log ⁡ n ) O(n\log{n}) O(nlogn) O ( n log ⁡ n ) O(n\log{n}) O(nlogn) O ( n log ⁡ n ) O(n\log{n}) O(nlogn) O ( 1 ) O(1) O(1)不稳定
计数排序 O ( n + k ) O(n+k) O(n+k) O ( n + k ) O(n+k) O(n+k) O ( n + k ) O(n+k) O(n+k) O ( n + k ) O(n+k) O(n+k)稳定
桶排序 O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2) O ( n + n log ⁡ n k ) O(n+n\log{n\over k}) O(n+nlogkn) O ( n + k ) O(n+k) O(n+k)稳定
基数排序 O ( d ∗ ( n + k ) ) O(d*(n+k)) O(d(n+k)) O ( d ∗ ( n + k ) ) O(d*(n+k)) O(d(n+k)) O ( d ∗ ( n + k ) ) O(d*(n+k)) O(d(n+k)) O ( n + k ) O(n+k) O(n+k)稳定

1. 归并排序

归并排序采用分治的思想,将数组等分为左右两个子数组,分别对子数组进行归并排序,然后对排序后的有序子数组进行组合。组合的方式就是每次对比两个子数组的最小值(最左边的元素),那个小就取哪个,这样某个子数组就少了一个元素,下一次也是如此对比当前子数组的最小值,最后直到某个子数组被取空了,那直接在新合并后的数组后面将剩下的那个子数组extend上去即可。

def mergeSort(a):
    # 结束条件
    if len(a) <= 1:
        return a
    # 分治
    mid = len(a) // 2
    left = mergeSort(a[:mid])
    right = mergeSort(a[mid:])
    # 合并
    merged = []
    while left and right:
        if left[0] <= right[0]:
            merged.append(left.pop(0))
        else:
            merged.append(right.pop(0))
    merged.extend(right if right else left)
    return merged
    

性能分析

  • 时间复杂度:不管是最好的情况(数组已经有序)还是其他情况,归并排序都是需要分治和合并两个步骤,分治的复杂度为 O ( log ⁡ n ) O(\log n) O(logn),合并和过程中数组每个元素都会被比较,所以是 O ( n ) O(n) O(n)复杂度,综合来看,归并排序的时间复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn)

  • 稳定性:不会改变原本大小相等的元素的相对位置,是稳定的。

  • 空间复杂度:需要一个额外的数组空间来存放合并后的新数组,因此空间复杂度为 O ( n ) O(n) O(n)

2. 堆排序

堆排序利用了堆这种数据结构,最大堆是指所有父结点的值大于等于左右子结点的一种完全二叉树(最小堆则是小于等于),因此最大堆的根结点是数组中的最大值,利用这种结构,堆排序是将无序数组构建成一个最大堆,然后将根结点与最后一个元素交换,然后再对前n-1个元素再构建最大堆,再把根结点与倒数第二个元素交换,这样直到只剩最后一个元素的堆自然满足最大堆的性质,它也是数组中的最小元素,整个数组也就有序了。

总的来说,堆排序可以分为三步(重复第2、3步直到size为1):

  1. 对数组构建最大堆
  2. 交换最大元素
  3. 对size-1的数组调整最大堆

堆排序的关键在于如何构建最大堆(若要数组降序排列则建立最小堆)。因为叶子结点没有孩子,已经算是一个合法的堆,所以只需要从最后一个非叶子结点开始调整。而因为堆是完全二叉树,最后一个非叶子结点的坐标为 n / / 2 − 1 n//2-1 n//21(详细推导在下面),将这个节点作为堆顶构造最大堆,然后再对倒数第二个非叶子结点构造最大堆,这样从最后一个非叶子结点开始,依次构造最大堆,直到最后根结点(坐标为0)为堆顶的最大堆构造完成。每次在对新的非叶子结点进行构造时,若该节点已经比其左右孩子都大,那就不需要调整,因为其左右孩子已经是最大堆了;否则,选择左右孩子中较大的值与该节点交换,再递归地对交换的那个子树进行调整。构建最大堆和调整最大堆的代码如下:

def buildHeap(a):
    for i in range(len(a)//2-1, -1, -1):
        # 从最后一个非叶子结点开始,逆序地对所有非叶子结点调整堆结构
        adjustHeap(a, i, len(a))    


# 对当前节点位置i,其左右孩子都是最大堆,以长度为n的数组调整最大堆
def adjustHeap(a, i, n):
    root = i # 当前需要调整的非叶子结点
    while True:
        # 选择较大的子节点坐标max_child
        max_child = 2 * root + 1  # 先初始化为左孩子
        if max_child >= n:
            break
        if max_child + 1 < n and a[max_child+1] > a[max_child]:
            max_child += 1  # 与右孩子比较
        # 比较,看需不需要调整
        if a[max_child] > a[root]:
            a[max_child], a[root] = a[root], a[max_child]
            root = max_child # 对交换的那个子树再进行调整
        else:
            break  
                

建立好最大堆之后对最大堆的堆顶元素和最后一个元素进行交换。再需将交换后的堆顶元素进行调整,建立size-1的最大堆再进行交换,这样依次减小需要调整的size,就可以完成堆排序了,即:

def heapSort(a):
    # 对数组构建最大堆
    buildHeap(a)
    # 缩小size
    for size in range(len(a), 1, -1):
        # 交换最大元素
        a[0], a[size-1] = a[size-1], a[0]
        # 对size-1的数组调整最大堆
        adjustHeap(a, 0, size-1)
    return a
    
  • 最后一个非叶子结点的坐标的推导(参考《数据结构、算法与应用C++》):
    对于完全二叉树的节点 i   ( 0 ≤ i ≤ n − 1 ) i\ (0\le i\le n-1) i (0in1),其左孩子的坐标为 2 i + 1 2i+1 2i+1,其右孩子的坐标为 2 i + 2 2i+2 2i+2
    最后一个非叶子结点的坐标为 n − 1 n-1 n1,当完全二叉树的节点数n为偶数时,最后一个非叶子结点只有左孩子,所以 2 i + 1 = n − 1 2i+1=n-1 2i+1=n1,其父结点坐标 i = ( n − 2 ) / 2 = n / 2 − 1 = n / / 2 − 1 i=(n-2)/2=n/2-1=n//2-1 i=(n2)/2=n/21=n//21;当n为奇数时,最后一个非叶子结点还有右孩子,所以 2 i + 2 = n − 1 2i+2=n-1 2i+2=n1,其父结点坐标 i = ( n − 1 − 2 ) / 2 = ( n − 1 ) / 2 − 1 = n / / 2 − 1 i=(n-1-2)/2=(n-1)/2-1=n//2-1 i=(n12)/2=(n1)/21=n//21。综上得证。

性能分析

  • 时间复杂度:从步骤上来看,第一步需要对整个数组进行建堆,建堆的复杂度为 O ( n ) O(n) O(n),第二步和第三步需要重复n-1次,每次第二步交换是 O ( 1 ) O(1) O(1)复杂度,第三步的调整堆是 O ( log ⁡ n ) O(\log n) O(logn)复杂度,因为n个结点的完全二叉树的深度为 ⌈ log ⁡ 2 ( n + 1 ) ⌉ \lceil \log_2 (n+1)\rceil log2(n+1),所以是 O ( n ) + n ∗ O ( log ⁡ n ) O(n)+n*O(\log n) O(n)+nO(logn)复杂度,综合来看,归并排序的时间复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn)

  • 稳定性:不稳定。

  • 空间复杂度:在比较和交换时需要一个额外的数组空间,因此空间复杂度为 O ( 1 ) O(1) O(1)

3. 计数排序

对于数据范围比较小,数据量比较多的数组进行排序,采用计数排序会大大节省时间。因为计数排序开辟了较大的空间来计数每个元素出现的次数,假设数组每个元素都在0到k之间,则对数组中的每个元素进行遍历,大小为i则对count[i]加1,然后再根据count的数量来反向填充数组。

def countSort(a):
    # 数组的最大值
    k = max(a)
    # 用于计数的数组
    count = [0] * (k+1)
    ans = []
    # 计数
    for i in a:
        count[i] += 1
    # 填充ans
    for j in range(k+1):
        while count[j] > 0:
            ans.append(j)
            count[j] -= 1 # 填充了一个就减1
    return ans
    

性能分析

  • 时间复杂度:计数需要遍历一次数组,为 O ( n ) O( n) O(n)复杂度,填充ans需要遍历计数数组,为 O ( k ) O( k) O(k)复杂度,所以总共的复杂度为 O ( n + k ) O(n+k) O(n+k)

  • 稳定性:认为先计数的先append,所以是稳定的。

  • 空间复杂度:计数需要 O ( k ) O( k) O(k)复杂度的空间,每个count内需要 O ( n ) O( n) O(n)复杂度的空间,所以总共的空间复杂度为 O ( n + k ) O(n+k) O(n+k)

4. 桶排序

桶排序类似于计数排序,区别在于计数排序是每个数值计数一次,而桶排序的每个桶表示一个区间,落在这个区间中的数就直接映射到这个桶中,然后再分别对每个桶进行排序(可使用其他比较排序的方法,例如插入排序),最后将所有不为空的桶拼接起来就得到了有序的数组。

假设设定k个桶,按照这个思路算法实现如下:

# 自己默认设置5个桶
def bucketSort(a, k=5):
    min_value = min(a)
    max_value = max(a)
    ans = []
    # 桶间距
    distance = (max_value - min_value) / (k - 1)
    # 初始化桶
    bucket = [[] for _ in range(k)]
    # 遍历数据放入桶内
    for i in a:
        bucket[int((i - min_value) / distance)].append(i)
    # 每个桶排序并拼接
    for j in range(k):
        bucket[j].sort() # 这里直接调用sort函数
        ans.extend(bucket[j])
    return ans

性能分析

  • 最好情况:数组已经是排好序的情况下,遍历一次数据放入桶内和拼接都为 O ( n ) O(n) O(n)复杂度,只需要看每个桶内的排序复杂度,最好情况下为 O ( n ) O(n) O(n),因此时间复杂度为 O ( n ) O(n) O(n)

  • 最坏情况:使用插入或者快排等每个桶内最坏情况下的排序复杂度为 O ( n 2 ) O(n^2) O(n2),而其余步骤只要 O ( n ) O(n) O(n),因此时间复杂度为 O ( n 2 ) O(n^2) O(n2)

  • 平均情况:每个桶内平均的排序复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn),一共有k个桶,每个桶平均有 n / k n/k n/k个元素,所以桶的排序需要 O ( k × n k log ⁡ n k ) = O ( n log ⁡ n k ) O(k\times {n\over k}\log {n\over k})=O(n\log {n\over k}) O(k×knlogkn)=O(nlogkn),因此总的为 O ( n + n log ⁡ n k ) O(n+n\log{n\over k}) O(n+nlogkn)

  • 稳定性:稳定性取决于桶内的排序算法,可以做到稳定

  • 空间复杂度:需要 O ( k ) O( k) O(k)复杂度的桶空间,桶内最多需要 O ( n ) O( n) O(n)复杂度的空间,所以总共的空间复杂度为 O ( n + k ) O(n+k) O(n+k)

5. 基数排序

基数排序可以分为最高位优先(Most Significant Digit first)法和最低位优先(Least Significant Digit first)法,LSD是从最低位也就是个位开始排序(类似计数排序的方式),然后再按照十位排序,直到数组中最高位排序后整个数组就有序了。

import math

# 默认基数k为10
def radixSort(a, k=10):
    # 最高位数
    d = math.ceil(math.log(max(a), k))
    # 从最低位开始排序
    for i in range(d):
        bucket = [[] for _ in range(k)]
        # 比较第i位放入相应的桶内
        for num in a:
            bucket[num % (k**(i+1)) // (k**i)].append(num)
        a.clear()
        # 拼接桶重新赋值a,为下一次排序做准备
        for b in bucket:
            a.extend(b)
    return a
    

性能分析

  • 时间复杂度:一次排序分入桶内需要 O ( n ) O(n) O(n),拼接需要 O ( k ) O(k) O(k),一共进行 O ( d ) O(d) O(d)次排序,所以总的时间复杂度为 O ( d ∗ ( n + k ) ) O(d*(n+k)) O(d(n+k)) k k k为基数,也就是桶的数量, d d d为最大数的位数

  • 稳定性:每次排序都是稳定,所以总的来说也是稳定的

  • 空间复杂度:需要 O ( k ) O(k) O(k)复杂度的桶空间,桶内最多需要 O ( n ) O( n) O(n)复杂度的空间,所以总共的空间复杂度为 O ( n + k ) O(n+k) O(n+k)

参考资料

1、数据结构、算法与应用 C++语言描述 原书第2版
2、MOOC:数据结构与算法Python版(北大 陈斌老师)
3、参考博客1:十大经典排序算法(动图演示)
4、参考博客2:排序算法总结(Python版)
5、百度百科

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值