跳转至

排序

排序算法是算法与数据结构领域的基础内容,广泛应用于数据处理、查找、去重等场景。掌握常见排序算法及其原理、复杂度和适用场景,是算法学习和面试的必备技能。

排序的方法有 插入交换选择合并 等。

十大常用排序算法

名称 数据对象 稳定性 比较类 时间复杂度
(平均/最坏)
空间复杂度 原理 描述 适用场景
冒泡排序
bubble sort
数组 $O(n^2)$ $O(1)$ 每轮将相邻元素两两比较,大的往后交换,重复 n 轮 (无序区, 有序区)。
从无序区通过交换找出最大元素放到有序区前端。
数据量小、对稳定性有要求
选择排序
selection sort
数组 × $O(n^2)$ $O(1)$ 每轮选择剩余元素中的最小值,放到前面 (有序区, 无序区)。
在无序区里找一个最小的元素跟在有序区的后面。对数组:比较得多,换得少。
数据量小
链表
插入排序
insertion sort
数组、链表 $O(n^2)$ $O(1)$ 每次将一个元素插入到已排序部分的合适位置 (有序区, 无序区)。
把无序区的第一个元素插入到有序区的合适位置。对数组:比较得少,换得多。
数据量小、部分有序
堆排序
heap sort
数组 × $O(n \log n)$ $O(1)$ 构建最大/最小堆,依次取出堆顶元素 (最大堆, 有序区)。
从堆顶把根卸出来放在有序区之前,再恢复堆。
原地排序
归并排序
merge sort
数组 $O(n \log n)$ $O(n) + O(\log n)$ 递归分组,合并有序子数组 把数据分为两段,从两段中逐个选最小的元素移入新数据段的末尾。可从上到下或从下到上进行。 大数据、链表排序、稳定性要求高
链表 $O(1)$
快速排序
quick sort
数组 × $O(n \log n) / O(n^2)$ $O(\log n)$ 选定基准,分区递归排序左右两部分 (小数, 基准元素, 大数)。
在区间中随机挑选一个元素作基准,将小于基准的元素放在基准之前,大于基准的元素放在基准之后,再分别对小数区与大数区进行排序。
通用、高效排序
链表
希尔排序
shell sort
数组 × $O(n \log^2 n) / O(n^2)$ $O(1)$ 每一轮按照事先决定的间隔进行插入排序,间隔会依次缩小,最后一次一定要是 1。
计数排序
counting sort
数组、链表 × $O(n + m)$ $O(n + m)$ 利用元素值域特性进行分组计数或分桶 统计小于等于该元素的值的元素的个数 i,于是该元素就放在目标数组的索引 i 位 (i≥0)。 数据范围有限、整数排序
桶排序
bucket sort
$O(n)$ / $O(n^2)$ $O(m)$ 将值为 i 的元素放入 i 号桶,最后依次把桶里的元素倒出来。
基数排序
radix sort
$O(k \times n) / O(n^2)$ $O(n)$ 一种多关键字的排序算法,可用桶排序实现。

表格说明

  • n:数据规模
  • m:数据的最大值减最小值
  • k:数值中的"数位"个数
  • 所有排序均按从小到大排列

重要说明

  • 比较类 vs 非比较类:计数排序、桶排序、基数排序均为非比较类排序。现代编程语言的内置排序(如 C++、Java、Python)都是 比较类排序(一般 \(O(n \log n)\)),因为要能支持 通用对象排序
  • 必须掌握:排序算法是算法基础,建议至少熟练掌握冒泡、插入、选择、快排、归并五种实现。
  • 选择依据:选择合适的排序算法需结合数据规模、稳定性需求和空间限制。
  • Quick Sorting
    快速排序示意图
  • Merge Sorting
    归并排序示意图

快速记忆口诀

  • 冒泡两两换,选择找最小,插入往前挪,希尔改插入,归并分两半。
  • 快排选基准,堆排建大堆,计数靠统计,桶排序分区,基数按位排。

稳定性与不稳定性

我们以纸牌排序为例,当纸牌用稳定排序按点值排序的时候,两个 5 之间必定保持它们最初的次序。在用不稳定排序来排序的时候,两个 5 可能被按相反次序来排序。

  • 稳定排序
    稳定排序
  • 不稳定排序
    不稳定排序

稳定排序通常是通过相邻交换或辅助空间来实现的,比较温柔,不会乱跳。而不稳定排序则通常包含长距离交换实现,容易把顺序搞乱。

  • 稳定排序包括:冒泡排序、插入排序、归并排序、计数排序、桶排序、基数排序。
  • 不稳定排序包括:快速排序、选择排序、堆排序、希尔排序。

排序算法实现

通过不断两两比较交换,直到所有元素有序。

优化点:已排序区可跳过;全部有序可提前结束遍历

# Time: O(n^2)
def bubble(nums: list[int]) -> list[int]:
    n = len(nums)
    if n < 2:
        return nums

    # 外层循环控制轮数,最后一个数不需要比较
    for i in range(n - 1):
        swapped = False

        # 内层循环进行比较交换
        # 由于每次外层循环结束后最大的元素会被移动到数组的末尾,因此内层循环的范围可以逐渐缩小
        for j in range(1, n - i):
            if nums[j - 1] > nums[j]:
                nums[j - 1], nums[j] = nums[j], nums[j - 1]
                swapped = True

        # 未发生交换则已排好序,提前结束比较
        if not swapped:
            break

    return nums

在待排序区不停选择最小值,然后与待排序区首个元素交换(已排序区末尾或待排序区头部)。不断循环,即可对所有元素排序。

# Time: O(n^2)
def selection(nums: list[int]) -> list[int]:
    n = len(nums)
    if n < 2:
        return nums

    for i in range(n - 1):
        min_idx = i
        for j in range(i + 1, n):
            if nums[j] < nums[min_idx]:
                min_idx = j
        nums[i], nums[min_idx] = nums[min_idx], nums[i]

    return nums

像插扑克牌一样,从第二个元素开始,不断将之后的元素放在之前的已排序位置。

# Time: O(n^2)
def insertion(nums: list[int]) -> list[int]:
    n = len(nums)
    if n < 2:
        return nums

    # [0,i) 为已排序区,[i,n) 为待排序区
    for i in range(1, n):
        key = nums[i]  # key 为从桌上拿起的新牌
        j = i - 1
        # j 负责逆序遍历已排序区,为 key 找到插入位置
        while j >= 0 and nums[j] > key:
            # 向右移动元素,腾出插入空间
            nums[j + 1] = nums[j]
            j -= 1
        # 将 key 插入正确位置
        nums[j + 1] = key

    return nums

像军训整理队伍一样,根据基准点来排序。

# Time: 平均 O(n log n), 最坏 O(n^2)
def quick(nums: list[int]) -> list[int]:
    if len(nums) < 2:
        return nums

    def partition(left: int, right: int) -> int:
        # 三数取中,降低接近有序数据时的退化概率
        mid = left + (right - left) // 2
        if nums[mid] < nums[left]:
            nums[left], nums[mid] = nums[mid], nums[left]
        if nums[right] < nums[left]:
            nums[left], nums[right] = nums[right], nums[left]
        if nums[right] < nums[mid]:
            nums[mid], nums[right] = nums[right], nums[mid]

        nums[left], nums[mid] = nums[mid], nums[left]
        pivot_value = nums[left]
        left_ptr, right_ptr = left, right
        while left_ptr < right_ptr:
            # 从右向左找第一个小于 pivot 的数
            while left_ptr < right_ptr and nums[right_ptr] >= pivot_value:
                right_ptr -= 1
            nums[left_ptr] = nums[right_ptr]
            # 从左向右找第一个大于 pivot 的数
            while left_ptr < right_ptr and nums[left_ptr] <= pivot_value:
                left_ptr += 1
            nums[right_ptr] = nums[left_ptr]
        # 将 pivot 放回正确的位置
        nums[left_ptr] = pivot_value
        return left_ptr

    def quick_sort_range(left: int, right: int) -> None:
        if left >= right:
            return
        # 分区并拿到 pivot 的最终位置
        pivot_index = partition(left, right)
        # 递归地对左右两部分进行排序
        quick_sort_range(left, pivot_index - 1)
        quick_sort_range(pivot_index + 1, right)

    quick_sort_range(0, len(nums) - 1)
    return nums

像锦标赛那样两两PK快速收敛。

# Time: O(nlogn)
def merge(nums: list[int]) -> list[int]:
    if len(nums) < 2:
        return nums

    def merge_halves(left: int, mid: int, right: int) -> None:
        # 创建一个临时列表来存储合并后的结果
        merged = []
        i, j = left, mid + 1
        # 比较左右两部分,将较小的元素放入 merged
        while i <= mid and j <= right:
            if nums[i] <= nums[j]:
                merged.append(nums[i])
                i += 1
            else:
                merged.append(nums[j])
                j += 1
        # 将剩余的元素拷贝到 merged
        while i <= mid:
            merged.append(nums[i])
            i += 1
        while j <= right:
            merged.append(nums[j])
            j += 1
        # 将排好序的 merged 内容拷贝回原始的 nums 列表的对应位置
        nums[left:right + 1] = merged

    def merge_sort_range(left: int, right: int) -> None:
        if left >= right:
            return
        mid = left + (right - left) // 2
        merge_sort_range(left, mid)
        merge_sort_range(mid + 1, right)
        merge_halves(left, mid, right)

    merge_sort_range(0, len(nums) - 1)
    return nums

插入排序的改进算法,又称递减增量排序算法。

def shell(nums: list[int]) -> list[int]:
    n = len(nums)
    if n < 2:
        return nums

    gap = n // 2
    while gap > 0:
        for i in range(gap, n):
            temp = nums[i]
            j = i
            while j >= gap and nums[j - gap] > temp:
                nums[j] = nums[j - gap]
                j -= gap
            nums[j] = temp
        gap //= 2

    return nums
# Time: O(n log n), Space: O(1)
def heap_sorting(nums: list[int]) -> list[int]:
    n = len(nums)
    if n < 2:
        return nums

    def heapify_down(root: int, size: int) -> None:
        """将 root 向下调整,维护最大堆性质"""
        while True:
            largest = root
            left, right = 2 * root + 1, 2 * root + 2
            if left < size and nums[left] > nums[largest]:
                largest = left
            if right < size and nums[right] > nums[largest]:
                largest = right
            if largest == root:
                break
            nums[root], nums[largest] = nums[largest], nums[root]
            root = largest

    # 建堆:从最后一个非叶子节点开始向下调整
    for i in range(n // 2 - 1, -1, -1):
        heapify_down(i, n)

    # 逐步将堆顶(最大值)移到末尾,缩小堆范围
    for size in range(n - 1, 0, -1):
        nums[0], nums[size] = nums[size], nums[0]
        heapify_down(0, size)

    return nums
# 假设 nums 非负且最大值不大
# Time: O(n+m)
def counting(nums: list[int]) -> list[int]:
    n = len(nums)
    if n < 2:
        return nums

    max_val = max(nums)
    counts = [0] * (max_val + 1)
    for v in nums:
        counts[v] += 1

    idx = 0
    for i, c in enumerate(counts):
        while c > 0:
            nums[idx] = i
            idx += 1
            c -= 1

    return nums
# 假设 nums 非负且分布均匀
# Time: 平均 O(n), 最坏 O(n^2)
import math

def bucket(nums: list[int]) -> list[int]:
    n = len(nums)
    if n < 2:
        return nums

    def insertion_sort(arr: list[int]) -> None:
        for i in range(1, len(arr)):
            key = arr[i]
            j = i - 1
            while j >= 0 and arr[j] > key:
                arr[j + 1] = arr[j]
                j -= 1
            arr[j + 1] = key

    max_val, min_val = max(nums), min(nums)
    bucket_num = n
    buckets: list[list[int]] = [[] for _ in range(bucket_num)]
    interval = math.ceil((max_val - min_val + 1) / bucket_num)
    for v in nums:
        bucket_idx = (v - min_val) // interval
        buckets[bucket_idx].append(v)

    write_idx = 0
    for b in buckets:
        insertion_sort(b)
        for v in b:
            nums[write_idx] = v
            write_idx += 1

    return nums
# 假设 nums 非负整数
# Time: O(k*n)
def radix(nums: list[int]) -> list[int]:
    n = len(nums)
    if n < 2:
        return nums

    max_val = max(nums)
    exp = 1
    buf = [0] * n
    while max_val // exp > 0:
        count = [0] * 10
        for v in nums:
            count[(v // exp) % 10] += 1
        for i in range(1, 10):
            count[i] += count[i - 1]
        for i in range(n - 1, -1, -1):
            digit = (nums[i] // exp) % 10
            buf[count[digit] - 1] = nums[i]
            count[digit] -= 1
        nums[:] = buf
        exp *= 10

    return nums

工程常用算法

Tim 排序(归并+插入)

Timsort 是一种混合(归并+插入)稳定的排序算法。具有 \(O(n \log n)\) 的平均和最坏时间复杂度,最优可达 \(O(n)\),空间复杂度为 \(O(n)\)。该算法是目前已知最快的排序算法,在 Python、Swift、Rust 等语言的内置排序功能中被用作默认算法。

内省排序(Introsort)(快排+堆排)

内省排序首先从快速排序开始,当递归深度超过一定深度(深度为排序元素数量的对数值)后转为堆排序。采用这个方法,内省排序既能在常规数据集上实现快速排序的高性能,又能在最坏情况下仍保持 \(O(n \log n)\) 的时间复杂度。由于这两种算法都属于比较排序算法,所以内省排序也是一个比较排序算法。

不同语言内置排序算法对比

语言 / 库 算法实现 稳定性 特点
C (qsort) 快速排序为主(不同实现可能混合插入排序) × 简单高效,但可能退化到 \(O(n²)\)
C++ (std::sort) Introsort(快速排序 + 堆排序 + 插入排序) × 平均 \(O(n log n)\),最坏 \(O(n log n)\),避免快排退化
C++ (std::stable_sort) 归并排序(常带优化) 保证稳定性,但需要额外内存
Java (Arrays.sort, 基本类型) Dual-Pivot QuickSort(双轴快排) × 比普通快排常数更小,性能优越
Java (Arrays.sort, 对象类型) Timsort(归并 + 插入) 对部分有序数据非常快,最坏 \(O(n log n)\)
Python (list.sort / sorted) Timsort 专门为现实数据优化,利用已有有序片段
JavaScript (V8 引擎) 小数组:插入排序;大数组:快排 + 混合 × 对象数组时可能使用归并变体
JavaScript (SpiderMonkey, Firefox) Timsort / 归并变体 性能和 Python 类似
Go (sort.Sort) Introsort(快排 + 堆排 + 插入) × 类似 C++ std::sort
Rust (sort) Introsort(快排 + 堆排 + 插入) × 与 C++ 类似
Rust (sort_stable) 归并排序 保证稳定性
  • 几乎所有标准库排序都是 混合算法,避免单一算法的缺陷,保证最坏复杂度不超过 \(O(n log n)\)
  • 数值数组:大多用快速排序或 Introsort(追求性能)
  • 对象数组:多数用 Timsort / 归并排序(追求稳定性)
  • 小规模数据:经常用插入排序优化

评论