数据结构与算法---排序算法

排序

排序是指将一组数据按照特定的规则或顺序进行排列,比如一个数组[1, 5, 2, 4, 3]按照从小到大的顺序排列后就是[1,2,3,4,5]。

排序算法(Sorting algorithm)是必学的算法之一,在经典算法书《算法》这本书里说学习排序算法有三大实际意义外更重要的是这些算法都很经典、优雅和高效。

即使你只是使用标准库中的排序函数,学习排序算法仍然有三大实际意义:

❏对排序算法的分析将有助于你全面理解本书中比较算法性能的方法;

❏类似的技术也能有效解决其他类型的问题;

❏排序算法常常是我们解决其他问题的第一步。

更重要的是这些算法都很经典、优雅和高效。

如何评价一个排序算法呢,主要有如下维度:

  • 计算复杂度(computational complexity):排序算法的时间复杂度尽可能低,且总体操作数较小。
  • 内存使用(memory usage):原地排序(in-place)可以在原数组上直接操作实现排序,而不需要借助额外的辅助数据,因此可以节省内存。此外,一般来说,原地排序的数据搬运相对来说更少,运行速度也更快。
  • 稳定性(stability):排序完成后相等元素在数组中的相对顺序不会发生改变。
  • 自适应性(adaptive):排序算法能否利用输入数据已有的顺序信息来减少计算量从而达到更优的时间效率。自适应排序算法的最佳时间复杂度通常优于平均时间复杂度。
  • 是否基于比较(whether or not they are a comparison sort):是否依赖于比较运算符(<, =, >) 来判断元素的相对顺序从而排序整个数组,依赖于比较运算符的排序算法理论最优时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn) ,不依赖于比较运算符的算法的时间复杂度为 O ( n ) O(n) O(n),但其通用性较差。

Hello算法将几种排序的对比总结如下图:
在这里插入图片描述

选择排序

选择排序(selection sort)的思路是先找到数组中最小的那个元素,将它和数组的第一个元素交换位置。接着在剩下的元素找到最小的元素,将它与数组的第二个元素交换位置。一直按照这个步骤对所有数组元素进行操作,直到数组数组完成排序。

arr = [1, 27, 43, 3, 9, 82, 10]
def selection_sort(arr):
    n = len(arr)
    # 选择排序
    for i in range(n):
        min_i = i
        # 找最小的元素
        for j in range(i+1, n):
            if arr[j]<arr[min_i]:
                min_i = j
        # 交换
        arr[i], arr[min_i] = arr[min_i], arr[i] 
select_sort(arr)

选择排序的特点:

  • 非自适应性,运行时间与输入数据无关,即使一个已经有序的数组和随机排列的数组用时一样长。
  • 非稳定性,元素可能被交换到与其相等元素的右边使它们的相对顺序发生改变。比如数组[4, 4, 3, 2, 1, 5]排序完后两个相等的元素4的相对顺序发生了改变。
  • 交换次数最少,选择排序有N次交换,即其交换次数和数组的大小是线性关系。

冒泡排序

冒泡排序(bubble sort)的思路是通过不断地比较相邻两个元素的大小后判断是否要交换来完成排序。这个过程是不是有点像水里的一个气泡从底部升到顶部的过程,所以就被命名为冒泡排序。

冒泡排序在实现时可以添加一个标志来标识在某一轮排序中是否有过交换,如果没有交换过说明数组已经是有序的,就可以提前退出排序了。

arr = [1, 27, 43, 3, 9, 82, 10]
# 冒泡排序
def bubble_sort(arr):
    n = len(arr)
    # 右边是已经排序的写法
    for i in range(n-1, 0, -1):
        flag = False  # 是否交换过的标志
        for j in range(i):
            # 交换
            if arr[j] > arr[j+1]:
                arr[j], arr[j+1] = arr[j+1], arr[j]
                flag = True 
        # 没有交换过,说明数组已经是有序的,则提前退出排序
        if not flag:
            break
        print(arr)
    # 左边是已经排序的写法,这种写法无法使用优化在已经排好序时提前退出
    # for i in range(n):
    #     for j in range(i+1, n):
    #         # 元素大于就交换
    #         if arr[i]>arr[j]:
    #             arr[i], arr[j] = arr[j], arr[i]
bubble_sort(arr)

冒泡排序的特点:

  • 自适应性:优化后的冒泡排序每次排序耗时与输入数据有关,当数组已经是有序的,可以提前退出排序,所以如果输入数据已经排好序了,则时间复杂度为O(n)。
  • 稳定排序:遇到相等元素不交换,所以不会改变它们的相对顺序。

插入排序

如果玩过扑克牌,就很容易理解插入排序(insertion sort)的思路,玩牌时会将牌按大小来排列,每当我们摸到一张新的牌时会将其插入到合适的位置。插入排序的思路就是将未排序的元素与其左侧已经排序的元素逐个比较大小,将其插入到正确的位置。

arr = [1, 27, 43, 3, 9, 82, 10]
def insertion_sort(arr):
    n = len(arr)
    # 插入排序
    for i in range(n):
        base = arr[i]
        # 与左侧已经排序的元素进行比较
        # 用while循环的写法
        j = i - 1
        while j>=0 and arr[j]>base:
            arr[j + 1] = arr[j] ## 将元素的位置后移一位
            j -= 1
        arr[j+1] = base  # 将base插入到正确的位置上
        # 用 for循环的写法
        # for j in range(i-1, -1, -1):
        #     if arr[j] < base:
        #         arr[j+1] = base # 将base插入到正确的位置上
        #         break
        #     # 将元素的位置后移一位
        #     arr[j+1] = arr[j]
        # 这种写法的效率没有那么高,因为没有利用左侧已经排序的特性,不能提前退出
        # for j in range(i):
        #     if arr[i] < arr[j]:
        #         tmp = arr[i]
        #         # 将元素的位置后移一位
        #         arr[j+1:i+1] = arr[j:i]
        #         arr[j] = tmp
        #         break

insertion_sort(arr)
arr

插入排序的特点:

  • 自适应性:每次排序耗时与输入数据有关,在遇到有序数据时,插入操作会提前终止,所以如果输入数据已经排好序了,则时间复杂度为O(n)。
  • 稳定排序:在插入操作过程中,当遇到相等的元素时会将插入在其右侧,所以不会改变它们的相对顺序。
  • 插入排序在数据量较小的情况下或者数组中倒置的数量较少时,插入排序的效率很高。(倒置是指数组中的两个顺序颠倒的元素,比如[1, 3, 2]中有1对倒置:3-2 )
  • 插入排序的时间复杂度虽然与选择排序和冒泡排序一样都是 O ( n 2 ) O(n^2) O(n2),但是插入排序的计算开销比冒泡排序低,因为它不需要进行交换。因为是自适应算法,比选择排序效率更高。

希尔排序

希尔排序(shell sort)是基于插入排序的算法。插入排序对于小数组和部分有序数据效率很高,但是对于大规模乱序数据很慢,因为它只会交换相邻的元素,元素是一步一步地从数组的一端移动到另一端的。比如最小的元素在数组的末端,要将它插入到正确位置需要N-1次交换。希尔排序为了加快速度改进了插入排序,交换不相邻的元素来对数组的局部进行排序,并最终用插入排序将局部有序的数组排序。

如果一个数组中任意间隔为h的元素都是有序的,这个数组就被称为h有序数组。也就是一个h有序数组就是h个互相独立的有序数组组成的一个数组,如下图所示(来自书籍《算法》)。如果h很大,也就是意味着排序时,我们可以把元素移到很远的地方,为更小的h有序创造排序方便。如果有一个以1为初始值的递增序列,用这个序列就可以将数组变得有序(因为h=1时就是插入排序了,只是先用较大的h值使得h=1的排序更简单),这个思路就是希尔排序了。

在这里插入图片描述

我们可以将插入排序的代码中移动元素的距离由1改为h来实现希尔排序,其过程是类似插入排序但使用不同增量的过程。

arr = [1, 27, 43, 3, 9, 82, 10]
# 希尔排序
def shell_sort(arr):
    n = len(arr)
    # h将满足递增序列(这里的实现是实时来计算递增序列的,也可以先将递增序列储存起来
    h = 1  # 1, 3, 13, 40, 121, 364, 1093, ...
    while h < n/3:
        h = 3*h + 1
        print(h)
    # h=1时就是插入排序了
    while h >= 1:
        # 将数组变为h有序
        for i in range(h, n):
            base = arr[i]
            j = i
            while j >= h and arr[j-h] > base:
                arr[j] = arr[j-h]
                j -= h
            arr[j] = base
        h = int(h/3)
        print(arr)
shell_sort(arr)

希尔排序的特点:

  • 它权衡了子数组的规模和有序性,排序开始的时候,各个子数组都很短,排序进行到后面数组是部分有序的,这两种情况都很适合用插入排序来处理,所以它比较高效,可适用于大型数组。
  • 透彻理解希尔排序的性能是个挑战,所以无法准确分析它对于乱序数据的性能特征。
  • 递增序列的选择并不简单,目前对于什么递增序列是“最好的”没有定论。

归并排序

归并排序(merge sort)是基于分治策略的排序算法,包括“划分”和“合并”两个阶段。

  • 划分阶段:通过递归不断地将数组从中点处分开,将长数组的排序问题转换成短数组的排序问题。
  • 合并阶段:当子数组长度为1时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束(leetcode里的合并两个有序数组就是完成这一步的)
def merge_sort(arr, left, right):
    # 终止条件
    if left >= right:
        return
    # 划分阶段
    mid = (left + right) // 2  # 计算中点
    merge_sort(arr, left, mid)  # 递归左子数组
    merge_sort(arr, mid + 1, right) # 递归右子数组
    # 合并阶段
    merge(arr, left, mid, right)

def merge(arr, left, mid, right):
    # 左子数组区间为 [left, mid], 右子数组区间为 [mid+1, right]
    # 创建一个临时数组 tmp ,用于存放合并后的结果    
    tmp = []
    i, j = left, mid+1
    
    # 当左右子数组都还有元素时,进行比较并将较小的元素复制到临时数组中
    while i<=mid and j<=right:
        if arr[i] <= arr[j]:
            tmp.append(arr[i])
            i += 1
        else:
            tmp.append(arr[j])
            j += 1

    # 将剩余元素复制到临时数组中
    while i <= mid:
        tmp.append(arr[i])
        i += 1
    while j <= right:
        tmp.append(arr[j])
        j += 1

    for i, item in enumerate(tmp):
        arr[left+i] = item
arr = [38, 27, 43, 3, 9, 82, 10]
merge_sort(arr, 0, len(arr)-1)
arr


# ## 归并排序,这种实现上更直观,但是会占用额外空间来存储子数组
# def merge_sort(arr):
#     # 数组长度为1时终止递归
#     if len(arr) <= 1:
#         return arr
    
#     ##  划分阶段
#     # 计算中点
#     mid = len(arr) // 2
#     left_half = arr[:mid]   # 左子数组
#     right_half = arr[mid:]  # 右子数组

#     left_sorted = merge_sort(left_half) # 递归左子数组
#     right_sorted = merge_sort(right_half) # 递归右子数组
#     # print(left_half, right_half)

#     ##  合并阶段
#     return merge(left_sorted, right_sorted)

# def merge(left, right):
#     # 临时数组
#     merged = []
#     i = j = 0
    
#     # 当左右子数组都还有元素时,进行比较并将较小的元素复制到临时数组中
#     while i < len(left) and j < len(right):
#         if left[i] <= right[j]:
#             merged.append(left[i])
#             i += 1
#         else:
#             merged.append(right[j])
#             j += 1
#     # 将剩余元素复制到临时数组中
#     while i < len(left):
#         merged.append(left[i])
#         i += 1
#     while j < len(right):
#         merged.append(right[j])
#         j += 1
    
#     return merged

# arr = [38, 27, 43, 3, 9, 82, 10]
# sorted_arr = merge_sort(arr)
# print("Sorted array:", sorted_arr)

归并排序的特点

  • 非自适应排序:划分时生成高度为 log ⁡ n \log n logn的递归树,每层合并的总操作数为n,总体时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn)
  • 稳定排序:在合并过程中,因为先加入左子树数组的元素,所以相等元素的次序保持不变。
  • 非原地排序:合并操作需要借助辅助数组实现,使用 O ( n ) O(n) O(n)大小的额外空间。递归深度为 log ⁡ n \log n logn,使用 O ( log ⁡ n ) O(\log n) O(logn)的栈空间,所以总的空间复杂度为 O ( n ) O(n) O(n)
  • 归并排序很适合用于排序链表。

快速排序

快速排序(quick sort)也是一种分治的排序算法。它也将一个数组分成两个子数组,将两部分独立地排序。

快速排序与归并排序的区别:

  • 归并排序将两个子数组排序后需要将有序的子数组合并使整个数组有序,而快速排序当两个子数组有序时整个数组也就有序了。
  • 在归并排序中,一个数组被等分为两半;在快速排序中,切分(partition)的的位置取决于数组的内容。

快排的切分使得数组满足如下条件:1. 选定切分点j,2. 切分点左侧的所有元素都不大于arr[j],3. 切分点右侧所有元素都不小于arr[j]。切分最基本的实现思路如下:

  1. 选择数组最左端的元素作为基准数,初始化两个指针i 和指针j 分别只想数组的两端。
  2. 从左到右用i找比基准数大的第一个元素,从右往左用j找比基准数小的第一个元素。将这两个元素进行位置交换。
  3. 循环执行步骤2,直到i和j相遇时停止循环。
  4. 将基准数和交换到两个子数组的分界线。

选定切分点将数组划分为左子数组和右子数组后,快速排序算法对左子数组和右子数组分别递归执行切分,直到子数组长度为1时终止,从而完成了整个数组的排序。

def partition(arr, left, right):
    # 以 arr[left] 为基准数
    i, j = left, right
    while i < j:
        # 注意:因为边界返回的是i,所以要先计算j,不然会导致切分点不满足要求
        while i < j and arr[j] >= arr[left]:
            j -= 1  # 从右向左找首个小于基准数的元素
        while i < j and arr[i] <= arr[left]:
            i += 1  # 从左向右找首个大于基准数的元素
        # 元素交换
        arr[i], arr[j] = arr[j], arr[i]
    # 将基准数交换至两子数组的分界线
    arr[left], arr[i] = arr[i], arr[left]
    # 返回基准数的索引
    return i

def quick_sort(arr, left, right):
    #终止条件
    if left >= right:
        return
    pivot = partition(arr, left, right)
    quick_sort(arr, left, pivot-1)
    quick_sort(arr, pivot+1, right)

arr = [38, 27, 43, 3, 9, 82, 10]
quick_sort(arr, 0, len(arr)-1)
arr

快速排序的特点

  • 非自适应排序:在平均情况下,切分的递归层数为 log ⁡ n \log n logn,每层中的总循环数为n,总体时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn)。在最差情况下,每轮切分操作都将长度为n的数组划分为长度为0和n-1的两个子数组,此时递归层数达到n,每层的循环数为n,总体使用 O ( n 2 ) O(n^2) O(n2)时间。
  • 非稳定排序:在切分的最后一步,基准数可能会被交换至相等元素的右侧。
  • 原地排序:不需要借助辅助数组实现排序。
  • 空间复杂度:在数组完全倒序的情况下,递归深度为n,使用 O ( n ) O(n) O(n)的栈空间,所以总的空间复杂度为 O ( n ) O(n) O(n)
  • 归并排序很适合用于排序链表。

切分数优化:切分数的选取会影响到整个算法效率,比如在数组是完全倒序的情况下,如果以最左边的元素作为基准数,它将被挪到最右侧,导致左子数组大小为n,右子数组大小为0;这样递归下去,每轮切分之后都有一个子数组长度为0,快速排序的性能就跟冒泡排序差不多了。所以可以优化切分时基准数的选取策略,可以随机选择一个元素作为基准数,但更常用的方法是选取三个候选元素,一般选择数组的起始点、中点、结束点的元素,将这三个元素的中位数作为基准数,使得快速排序的时间复杂度是最差情况的概率大大降低。

def median_three(arr: list[int], left: int, mid: int, right: int) -> int:
    """选取三个候选元素的中位数"""
    l, m, r = arr[left], arr[mid], arr[right]
    if (l <= m <= r) or (r <= m <= l):
        return mid  # m 在 l 和 r 之间
    if (m <= l <= r) or (r <= l <= m):
        return left  # l 在 m 和 r 之间
    return right

def partition(arr: list[int], left: int, right: int) -> int:
    """哨兵划分(三数取中值)"""
    # 以 arr[left] 为基准数
    med = median_three(arr, left, (left + right) // 2, right)
    # 将中位数交换至数组最左端
    arr[left], arr[med] = arr[med], arr[left]
    # 以 arr[left] 为基准数
    i, j = left, right
    while i < j:
        while i < j and arr[j] >= arr[left]:
            j -= 1  # 从右向左找首个小于基准数的元素
        while i < j and arr[i] <= arr[left]:
            i += 1  # 从左向右找首个大于基准数的元素
        # 元素交换
        arr[i], arr[j] = arr[j], arr[i]
    # 将基准数交换至两子数组的分界线
    arr[i], arr[left] = arr[left], arr[i]
    return i  # 返回基准数的索引

用快排的思路来获取第K大的元素:用快排来倒序排列元素,第K大的元素位于第K-1位置,当切分点位于第K-1位置时,说明已经找到了第K大的元素了。

def partition(arr, left, right):
    # 以 arr[left] 为基准数
    i, j = left, right
    while i < j:
        # 注意:因为边界返回的是i,所以要先计算j,不然会导致切分点不满足要求
        # 因为是降序排列,所以这里与从小到大排列的符号是相反的
        while i < j and arr[j] <= arr[left]:
            j -= 1  # 从右向左找首个小于基准数的元素
        while i < j and arr[i] >= arr[left]:
            i += 1  # 从左向右找首个大于基准数的元素
        # 元素交换
        arr[i], arr[j] = arr[j], arr[i]
    # 将基准数交换至两子数组的分界线
    arr[left], arr[i] = arr[i], arr[left]
    # 返回基准数的索引
    return i

def get_k_max_number(arr, K, left, right):
    if left >= right:
        return
    ## 这里要降序排列
    pivot = partition(arr, left, right)
    # pivot刚好是第K大的元素
    if pivot == K-1:
        return
    # 第K大的元素在数组左侧
    elif pivot > K-1:
        get_k_max_number(arr, K, 0, pivot)
    # 第K大的元素在数组右侧
    else:
        get_k_max_number(arr, K, pivot+1, right)

arr = [38, 27, 43, 3, 9, 82, 10, 49]
get_k_max_number(arr, 3, 0, len(arr)-1)
arr

    
    
## 写法2
def find_k(arr, K):
    left, right = 0, len(arr)-1
    partition = arr[left]
    while left < right:
        while left<right and arr[right]<=partition:
            right -= 1
        arr[left] = arr[right]
        while left<right and arr[left]>partition:
            left += 1
        arr[right] = arr[left]
    arr[right] = partition
    if right == K-1:
        return arr[right]
    # 第K大的元素在数组右侧
    elif right < K -1:
        return find_k(arr[right:], K-1-right)
    # 第K大的元素在数组左侧
    else:
        return find_k(arr[:right], K)
arr = [38, 27, 43, 3, 9, 82, 10]
find_k(arr, 3)    
    

桶排序

桶排序(bucket sort)通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到每个桶中,接着在每个桶内部分别执行排序,最终按照桶的顺序将所有数据合并。

def bucket_sort(arr):
    # 初始化桶的个数
    k = len(arr) // 2
    buckets = [[] for _ in range(k)] #给桶分配空间
    # 将数组元素分配到各个桶中,注意要根据数据不同来设定不同的分配策略
    for num in arr:
        # 输入范围为[0, 1), 使用num*k 映射到索引范围[0, k-1]
        i = int(num*k)
        buckets[i].append(num)

    # 对各个桶排序
    for bucket in buckets:
        bucket.sort() # 用内置排序算法

    # 合并结果
    i = 0
    for bucket in buckets:
        for num in bucket:
            arr[i] = num
            i += 1

arr = [0.49, 0.97, 0.83, 0.08, 0.56, 0.43, 0.91, 0.75, 0.15, 0.37]
bucket_sort(arr)
arr

桶排序的特点:

  • 适合处理体量特别大的数据,比如当数据量很大时,无法一次加载所有数据到内存,可以将数据先分桶后,对每个桶的数据排序后再将结果合并。
  • 非原地排序,需要借助总共n个元素的额外空间。
  • 桶排序是否稳定取决于排序桶内元素的算法是否稳定

计数排序

计数排序(counting sort)使用于数据量大但数据范围小的非负整数数组,它统计元素数量来实现排序。

计数排序的步骤如下:

  1. 先找出数组中最大的数字,记为m。创建一个长度为m+1的辅助数组counter,用来记录数字出现的次数,初始值为0。
  2. 遍历数组,每遍历到一个数字num时将counter[num]的值增加1。
  3. 因为counter的索引是有序的,遍历counter,根据各数字出现的次数将索引对应数字填入到原数组中。
def counting_sort_naive(arr: list[int]):
    """计数排序"""
    # 简单实现,无法用于排序对象,不是稳定排序,因为相等的元素在原始数组中的位置信息没有了
    # 1. 统计数组最大元素 m
    m = 0
    for num in arr:
        m = max(m, num)
    # 2. 统计各数字的出现次数
    # counter[num] 代表 num 的出现次数
    counter = [0] * (m + 1)
    for num in arr:
        counter[num] += 1
    # 3. 遍历 counter ,将各元素填入原数组 arr
    i = 0
    for num in range(m + 1):
        for _ in range(counter[num]):
            arr[i] = num #索引值就是arr中的元素
            i += 1
arr = [1, 1, 3, 2, 3, 1, 4, 2]
counting_sort_naive(arr)
arr

在上述实现里,输入的数据只能是一个整数,并且也是非稳定排序。比如如果我们想按照年龄对人物对象来排序,上述算法就不适用了。解决办法是使用counter的前缀和。因为前缀和有明确的意义,prefix[num]-1代表元素num在结果数组res中最后一次出现的索引(如下图所示,图来源于hello 算法的图11-17),它可以告诉我们元素应该出现在数组的哪个位置。在计算前缀和之后,倒序遍历待排序原始数组的每个元素,在每轮迭代中执行如下两步,就可以保证结果是稳定排序的。

  1. 将num填入结果数组res的索引 prefix[num]-1处。
  2. 令前缀和 prefix[num]减小1,从而得到下次放置num的索引。
    在这里插入图片描述
def counting_sort(arr: list[int]):
    """计数排序
    可排序对象,并且是稳定排序
    """
    # 1. 统计数组最大元素 m
    m = max(arr)

    # 2. 统计各数字的出现次数
    # counter[num] 代表 num 的出现次数
    counter = [0] * (m + 1)
    for num in arr:
        counter[num] += 1

    # 3. 求 counter 的前缀和,将“出现次数”转换为“尾索引”
    # 即 counter[num]-1 是 num 在 res 中最后一次出现的索引
    for i in range(m):
        counter[i + 1] += counter[i]

    n = len(arr)
    res = [0] * n  # 结果数组
    # 4. 倒序遍历 arr ,将各元素填入结果数组 res
    for i in range(n - 1, -1, -1):
        num = arr[i]
        res[counter[num] - 1] = num  # 将 num 放置到对应索引处
        counter[num] -= 1  # 令前缀和自减 1 ,得到下次放置 num 的索引
        
    # 使用结果数组 res 覆盖原数组 arr
    for i in range(n):
        arr[i] = res[i]

arr = [1, 1, 3, 2, 3, 1, 4, 2]
counting_sort(arr)
arr

计数排序的特点:

  • 时间复杂度为 O ( n + m ) O(n+m) O(n+m),是非自适应排序。
  • 空间复杂度为 O ( n + m ) O(n+m) O(n+m),是非原地排序。
  • 稳定排序:倒序遍历原始数组可以避免改变相等元素之间的相对位置。
  • 计数排序只适用于非负整数,适用于数据量大但数据范围较小的情况。
  • 计数排序可被看做是桶排序的一个特例。

基数排序

基数排序(radix sort)的基本思路与计数排序一样,也是通过统计个数来实现排序。但为了解决计数排序只适用于数据量n较大但数据范围m较小的情况,基数排序利用数字每一位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。

因为数字比较时是高位优先的,所以在遍历时需要从低位到高位遍历。

def digit(num: int, exp: int) -> int:
    """获取元素 num 的第 k 位,其中 exp = 10^(k-1)"""
    # 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
    return (num // exp) % 10

def counting_sort_digit(arr: list[int], exp: int):
    """计数排序(根据 arr 第 k 位排序)"""
    # 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
    counter = [0] * 10
    n = len(arr)

    # 统计 0~9 各数字的出现次数
    for i in range(n):
        d = digit(arr[i], exp)  # 获取 arr[i] 第 k 位,记为 d
        counter[d] += 1  # 统计数字 d 的出现次数

    # 求前缀和,将“出现个数”转换为“数组索引”
    for i in range(1, 10):
        counter[i] += counter[i - 1]

    # 倒序遍历,根据桶内统计结果,将各元素填入 res
    res = [0] * n
    for i in range(n - 1, -1, -1):
        d = digit(arr[i], exp)
        j = counter[d] - 1  # 获取 d 在数组中的索引 j
        res[j] = arr[i]  # 将当前元素填入索引 j
        counter[d] -= 1  # 将 d 的数量减 1

    # 使用结果覆盖原数组 arr
    for i in range(n):
        arr[i] = res[i]

def radix_sort(arr: list[int]):
    """基数排序"""
    # 获取数组的最大元素,用于判断最大位数
    m = max(arr)

    # 按照从低位到高位的顺序遍历
    exp = 1
    while exp <= m:
        # 对数组元素的第 k 位执行计数排序
        # k = 1 -> exp = 1
        # k = 2 -> exp = 10
        # 即 exp = 10^(k-1)
        counting_sort_digit(arr, exp)
        exp *= 10

arr = [2143100112, 2143100139, 2143100137, 2143100237, 2143100337, 2143100301, 2143100125]
radix_sort(arr)
arr
  • 时间复杂度为 O ( n k ) O(nk) O(nk): 设数据量为n,数据为d进制、最大位数为k,则对某一位使用计数排序使用 时间复杂度为 O ( n + d ) O(n+d) O(n+d)时间,排序所有k位使用 O ( ( n + d ) k ) O((n+d)k) O((n+d)k) 时间。一般d和k都相对较小,时间复杂度趋于O(n)。不管输入数据如何,排序过程是一样的,是非自适应排序。
  • 空间复杂度为 O ( n + d ) O(n+d) O(n+d),是非原地排序。
  • 稳定排序:取决于计数排序的实现。

堆排序

堆排序(heap sort)是基于二叉堆这种数据结构实现的排序算法。

基本的思路:我们可以利用小顶堆来进行排序,先基于数组建立小顶堆,此时最小元素位于堆顶。接下来不断地进行出堆操作,并将元素一一保存下来即得到了从小到大的排列。

上述思路是可行的,但是它需要一个辅助数组来记录从堆里弹出来的元素,所以我们希望找到一种不需要辅助数组的方法。

那如何实现呢?我们可以利用堆本身的存储空间来保存已经排序好的数组元素,具体步骤如下:

  1. 基于数组创建大顶堆,堆创建好后,数组的最大元素位于堆顶。
  2. 将堆顶元素(第一个元素)与堆底元素(最后一个元素)进行交换。交换完成后,已经排序的数组元素个数增加了一个,将堆的大小减1。
  3. 进行堆化(heapify)操作使得堆的性质得到修复,即从堆顶元素,从顶到底执行sift down堆化操作。
  4. 循环执行第2步和第3步。循环n-1轮后就完成了数组的排序。
def sift_down(arr, i, n):
    '''
    arr:数组
    i:堆顶元素
    n:堆的大小
    '''
    while True:
        left = 2*i + 1   #左子节点
        right = 2*i + 2  #右子节点
        now = i
        # 与左子节点比较
        if left<n and arr[left] > arr[now]:
            now = left
        # 与右子节点比较
        if right<n and arr[right] > arr[now]:
            now = right
        # 若节点最大或索引left,right越界,说明已经满足堆的性质了,就退出
        if now == i:
            break
        # 交换
        arr[now], arr[i] = arr[i], arr[now]
        # 继续向下堆化
        i = now

def heap_sort(arr):
    # 建堆操作:从非叶子节点开始进行sift down堆化操作
    n = len(arr)
    for i in range(n//2-1, -1, -1):
        sift_down(arr, i, n)
    # 利用堆的性质来排序, 从堆中提取最大元素,循环n-1轮, i就是当前堆的堆底索引
    for i in range(n-1, 0, -1):
        # 交换堆顶元素和堆底元素
        arr[0], arr[i] = arr[i], arr[0]
        # 堆化
        sift_down(arr, 0, i)
arr = [38, 27, 43, 3, 9, 82, 10]
heap_sort(arr)
arr

堆排序的特点:

  • 时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn),建堆的时间复杂度为 O ( n ) O(n) O(n), 从堆中提取最大元素的时间为 O ( log ⁡ n ) O(\log n) O(logn),共循环n-1轮, 不管输入数据是什么样的,流程是一样的,所以是非自适应排序。
  • 空间复杂度为 O ( 1 ) O(1) O(1),是原地排序。
  • 非稳定排序:在交换堆顶元素和堆底元素时,相等元素的位置可能发生改变。

top-k问题:用堆还以解决top-k问题,时间复杂度为 O ( n log ⁡ k ) O(n \log k) O(nlogk),其流程为:

  1. 初始化一个小顶堆,其堆顶元素最小。
  2. 先将数组的前k个元素依次入堆。
  3. 从第k+1个元素开始,若当前元素大于堆顶元素,就将堆顶元素出堆,再将当前元素入堆。
  4. 遍历完所有数据,堆中保存的就是最大的k个元素(堆顶的元素就是第K大的元素,所以用堆来获取第K大的元素的思路也是一样的)。
  • 19
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值