跳转至

第 40 章 排序

本章配套 7 道例题,见 §例题。

C++ 选手学排序,是因为要写 cmp 传给 std::sort,偶尔还得手写归并求逆序对。 Python 选手学排序,理由只有一条半:

  • 半条:面试和笔试选择题会问原理;
  • 一条:归并排序的分治框架是求逆序对、CDQ 分治的基础,这个必须会。

至于「排序本身」,在 Python 里答案永远是 a.sort()。本章先把六种经典排序讲清楚, 再说明为什么它们在 Python 里只有教学价值,最后落到真正有用的那部分:分治与逆序对。

语法层面的 key/reverse/cmp_to_key 已在 自定义排序 讲透,本章不重复,只讲算法。


1 六种经典排序总览

算法 平均 最坏 最好 额外空间 稳定 思想
冒泡排序 \(O(n^2)\) \(O(n^2)\) \(O(n)\) \(O(1)\) ✅ 相邻交换
选择排序 \(O(n^2)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) ❌ 每轮选最小
插入排序 \(O(n^2)\) \(O(n^2)\) \(O(n)\) \(O(1)\) ✅ 有序区扩张
归并排序 \(O(n\log n)\) \(O(n\log n)\) \(O(n\log n)\) \(O(n)\) ✅ 分治
快速排序 \(O(n\log n)\) \(O(n^2)\) \(O(n\log n)\) \(O(\log n)\) 栈 ❌ 分治 + 划分
堆排序 \(O(n\log n)\) \(O(n\log n)\) \(O(n\log n)\) \(O(1)\) ❌ 二叉堆
Timsort(Python 内建) \(O(n\log n)\) \(O(n\log n)\) \(O(n)\) \(O(n)\) ✅ 归并 + 插入

三条要背下来的结论:

  1. 只有归并和堆排的最坏情况也是 \(O(n\log n)\);快排可以被构造数据卡到 \(O(n^2)\)。
  2. 稳定的只有冒泡、插入、归并(和 Timsort);选择、快排、堆排都不稳定。
  3. \(O(n^2)\) 的三兄弟里,插入排序对「接近有序」的数据是 \(O(n)\)——这正是 Timsort 在小块上用插入排序的原因。

2 三种 \(O(n^2)\) 排序的 Python 实现

对照 C++ 的经典写法逐一翻译。这些实现只用来理解原理,实战中一行都不要写。

冒泡排序

每一轮把最大的元素「冒」到末尾。C++ 的常见写法用一个 bool 标志提前退出, 这是必须保留的优化——它让已经有序的数组只扫一遍:

def bubble_sort(a):
    n = len(a)
    # 循环不变量:第 i 轮开始时,末尾 i 个元素已经是整个数组里最大的 i 个,且已排好
    for i in range(n - 1):                 # 只需 n-1 轮,最后一个元素自动归位
        swapped = False                    # 本轮是否发生过交换,用来提前退出
        for j in range(n - 1 - i):         # 末尾 i 个已归位,最后一次比较是 a[n-2-i] 与 a[n-1-i]
            if a[j] > a[j + 1]:            # 严格大于才交换:相等时不动,稳定性由此而来
                a[j], a[j + 1] = a[j + 1], a[j]
                swapped = True
        if not swapped:                    # 一整轮都没交换,说明相邻元素两两有序
            break                          # 已有序的数组因此只扫一遍,退化成 O(n)
    return a

选择排序

每轮在未排序区里找最小(找最大的写法完全对称,效果一样):

def selection_sort(a):
    n = len(a)
    # 循环不变量:进入第 i 轮时,a[0..i-1] 已是最终结果,且都不大于 a[i..n-1]
    for i in range(n - 1):                 # i 是本轮要填好的位置
        k = i                              # k 记录未排序区 a[i..n-1] 中最小值的下标
        for j in range(i + 1, n):
            if a[j] < a[k]:                # 严格小于才更新,相等时保留更靠左的那个
                k = j
        if k != i:
            a[i], a[k] = a[k], a[i]        # 这次长距离交换正是它不稳定的原因
    return a

选择排序为什么不稳定:[2a, 2b, 1] 第一轮把 1 和 2a 交换, 得到 [1, 2b, 2a]——两个 2 的相对顺序被打乱了。 而冒泡只做相邻交换且严格用 >,相等元素永远不换,所以稳定。

插入排序

把 a[i] 插进左边已排好的区间,标准写法是「边比较边右移」:

def insertion_sort(a):
    # 循环不变量:每轮开始时 a[0..i-1] 已排好序(但未必是全局最终位置)
    for i in range(1, len(a)):             # 从 1 开始:单个元素天然有序
        cur = a[i]                          # 先把待插入的值取出来,它原来的格子马上要被覆盖
        j = i - 1                           # 从有序区末尾往左找插入点
        while j >= 0 and a[j] > cur:        # 注意是 >,写成 >= 就不稳定了
            a[j + 1] = a[j]                 # 元素右移,而不是两两交换
            j -= 1
        a[j + 1] = cur                      # 循环停在第一个不大于 cur 的位置,它右边就是空位
    return a

三点值得记住:

  • 移动代替交换:一次交换是三次赋值,一次移动只要一次,插入排序因此比冒泡快好几倍。
  • a[j] > cur 里的严格大于保证了稳定性。
  • 数据接近有序时 while 几乎不进入,退化成 \(O(n)\)。

3 归并排序:本章唯一必须掌握的手写排序

标准写法是:递归分成两半,各自排好,再线性归并。

def merge_sort(a):
    """返回排好序的新列表。教学实现,实战请用 a.sort()。"""
    if len(a) <= 1:                        # 递归出口:长度 0 或 1 的序列天然有序
        return a
    mid = len(a) // 2                      # 下取整分割,保证两半都非空(len >= 2 时 1 <= mid < len)
    left = merge_sort(a[:mid])             # 切片 a[:mid] 是下标 [0, mid) 这一段
    right = merge_sort(a[mid:])            # 切片 a[mid:] 是下标 [mid, len) 这一段
    return merge(left, right)


def merge(left, right):
    res = []
    i = j = 0                              # i、j 分别指向 left、right 里下一个待取的元素
    while i < len(left) and j < len(right):    # 任一半取空就退出,剩下的整段搬运
        if left[i] <= right[j]:            # <= 保证稳定:相等时优先取左半
            res.append(left[i]); i += 1
        else:
            res.append(right[j]); j += 1
    res.extend(left[i:])                   # 剩下的整段搬过去(两个切片必有一个是空的)
    res.extend(right[j:])
    return res

稳定性的关键在 <=。写成 < 时,相等元素会优先从右半取, 原本靠前的元素反而排到后面,稳定性就没了。这是面试高频追问点。

归并的真正用途:求逆序对

逆序对指满足 \(i < j\) 且 \(a_i > a_j\) 的下标对。它是归并排序的天然副产品: 归并时若从右半取走一个元素 right[j],说明左半剩下的 len(left) - i 个元素 全都比它大且都在它前面,一次性贡献这么多逆序对。

def count_inversions(a):
    """归并排序求逆序对数量,O(n log n)。返回 (排好序的列表, 逆序对数)。"""
    if len(a) <= 1:
        return a, 0                        # 单个元素构不成下标对
    mid = len(a) // 2
    left, c1 = count_inversions(a[:mid])   # c1:完全落在左半内部的逆序对
    right, c2 = count_inversions(a[mid:])  # c2:完全落在右半内部的逆序对
    res = []
    i = j = 0
    cnt = c1 + c2                          # 还差「跨越两半」的那部分,正好在归并过程中数出来
    nl = len(left)
    while i < nl and j < len(right):
        if left[i] <= right[j]:
            res.append(left[i]); i += 1    # 取左半:它在前且不更大,不构成逆序对
        else:
            res.append(right[j]); j += 1
            cnt += nl - i                  # 左半剩余的元素都与它构成逆序对
    res.extend(left[i:])
    res.extend(right[j:])
    return res, cnt
求逆序对的三种做法 复杂度 Python 现实性
暴力双重循环 \(O(n^2)\) \(n \le 3000\)
归并分治 \(O(n\log n)\) 纯 Python 递归,\(n \le 10^5\) 勉强
树状数组 + 离散化 \(O(n\log n)\) 常数更小,Python 首选

树状数组写法见 树状数组, 离散化见 桶计数与离散化, 分治的三步框架见 分治的三步框架,进阶形态见 CDQ 分治。


4 快速排序与堆排序

快速排序

C++ 里通行的写法是 Hoare 双指针划分 + 随机基准,这是防卡的标准姿势:

import random


def quick_sort(a, s, t):
    """就地快排,排序区间 [s, t](闭区间)。教学实现。"""
    if s >= t:                             # 区间里最多一个元素,无需排序
        return
    m = a[random.randint(s, t)]            # 随机基准,防止被有序数据卡成 O(n²)
    i, j = s, t                            # Hoare 划分:i 从左找不小于 m 的,j 从右找不大于 m 的
    while i <= j:
        while a[i] < m:                    # m 取自区间内部,天然是哨兵,指针不会越过区间
            i += 1
        while a[j] > m:
            j -= 1
        if i <= j:                         # 两端各找到一个「站错队」的元素
            a[i], a[j] = a[j], a[i]
            i += 1                         # 换完必须各走一步,否则 a[i] == a[j] == m 时原地死循环
            j -= 1
    # 退出时 j < i:a[s..j] 全部不大于 m,a[i..t] 全部不小于 m,中间(若有)已在最终位置
    if s < j:
        quick_sort(a, s, j)
    if i < t:
        quick_sort(a, i, t)

不加随机化的快排必须视为错误做法。取 a[s] 当基准时,一个已经排好序的数组 就能把它卡成 \(O(n^2)\) 加上 \(O(n)\) 的递归深度——在 Python 里直接 RecursionError。这是 C++ 选手也会栽的经典坑。

堆排序

堆排序的标准流程是「建大根堆 → 反复把堆顶换到末尾」。Python 里 heapq 是小根堆,所以标准的堆排序写法是:

import heapq


def heap_sort(a):
    heapq.heapify(a)                       # 就地建小根堆,O(n) 而不是 O(n log n)
    # range(len(a)) 在推导式开始前就求好了长度,循环中 a 变短不影响次数
    return [heapq.heappop(a) for _ in range(len(a))]   # 反复弹出当前最小值,共 n 次 O(log n)

堆的原理与 heapq 全 API 见 优先队列与堆。

堆排序在竞赛里几乎没有独立价值——需要全序就用 sort,需要动态取最值才用堆。 它唯一的优势是 \(O(1)\) 额外空间,而这在 Python 里毫无意义(heapify 就地做, 但 heappop 出来还是要新建列表)。


5 Timsort:Python 实际在跑的算法

list.sort() / sorted() 用的是 Timsort:

  1. 扫描一遍,把数组切成若干天然的单调段(run),降序段就地反转成升序;
  2. 太短的 run 用二分插入排序补长到 32–64;
  3. 用栈式归并把这些 run 两两合并,合并时用 galloping(跳跃)模式加速。
性质 值 竞赛含义
最坏复杂度 \(O(n\log n)\) 不存在卡快排式的构造数据
最好复杂度 \(O(n)\) 已经有序 / 分段有序时极快
稳定性 稳定 可以做多趟排序,见自定义排序 §7
实现层 C(listsort.c) 常数比纯 Python 循环小 50–100 倍

实测量级(\(n = 10^5\) 随机整数):

写法 耗时 倍数
a.sort() 约 0.02 s 1×
手写归并排序 约 1.2 s 60×
手写快排 约 1.5 s 75×
手写堆排(纯 Python) 约 3 s 150×

结论:在 Python 里手写排序只有教学意义。 唯一的例外是题目明确要求「实现某种排序算法并输出中间过程」, 那属于模拟题,见 模拟。


6 排序在竞赛中的四种用途

排序本身很少是考点,它通常是别的算法的预处理步骤。识别用途比背算法重要:

用途 典型题型 关键点
让贪心成立 区间调度、任务调度、田忌赛马 排序键由交换论证推出,见 贪心
让双指针成立 两数之和、相差不超过 \(k\) 有序后指针才单调,见 双指针与滑动窗口
让二分成立 区间元素计数、第 \(k\) 大 bisect 要求有序,见 二分
离散化 值域大但个数少 sorted(set(a)) + bisect,见 桶计数与离散化

反过来,三种「看起来要排序、其实不用」的情况(这是本章最容易失分的地方):

需求 别排序 用什么 复杂度
只要最大 / 最小 ❌ sorted(a)[0] min(a) / max(a) \(O(n)\)
只要分组计数 ❌ 排完扫连续段 Counter(a) \(O(n)\)
只要前 \(k\) 小(\(k \ll n\)) ❌ sorted(a)[:k] heapq.nsmallest(k, a) \(O(n\log k)\)

最危险的一种:题目里「下标」参与运算时排序会直接毁掉答案。 BISHI12、BISHI25 都是这个套路——排序前先问一句: 「还需不需要原来的位置?」 需要就排 (值, 下标) 元组,或者干脆别排。


7 例题

题号 题名 难度 题解
BISHI21 【模板】排序 入门 题解
BISHI22 分数线划定 简单 题解
BISHI25 最大 FST 距离 中等 题解
BISHI9 田忌赛马 中等 题解
BISHI12 元素方碑 中等 题解
BM89 合并区间 中等 题解
LC56 合并区间 中等 题解

BISHI21 【模板】排序(入门)

给长度 \(n\ (1 \le n \le 10^5)\) 的整数数组(\(-10^9 \le a_i \le 10^9\)), 按非递减顺序排序并在一行输出。 题面见 原题。

模板题,考的就是「知不知道该用内建排序」:

import sys

data = sys.stdin.buffer.read().split()    # 整块读入再切成 token,data[0] 是 n
n = int(data[0])
# 切片 [1, n+1) 恰好是 n 个数据 token;map 惰性产出,sorted 直接消费,不建中间列表
a = sorted(map(int, data[1:n + 1]))
sys.stdout.write(" ".join(map(str, a)) + "\n")   # 一次写出,10^5 个数时远快于逐个 print

四个要点:

  • sorted(map(int, ...)):map 是惰性的,sorted 直接消费它, 比先建 list 再排少一次中间列表。
  • 值域是 \([-10^9, 10^9]\),跨度 \(2\times10^9\),不能用计数排序(桶开不下)。 这一条决定了本题必须是比较排序,见 桶计数与离散化。
  • 别对 bytes 直接排序。data[1:n+1] 是 bytes 列表,sorted 会按字典序排, "10" < "9"、负号更是灾难。必须先 int。
  • 输出用 " ".join,\(10^5\) 个数时比 print(*a) 明显快。

题解见 solutions/nowcoder/BISHI21/sol.py。

BISHI22 / BISHI23 / BISHI24 / BISHI25:key 的四种典型形态

这四题的完整讲解在 自定义排序 §12, 这里只做算法层面的归纳,它们恰好覆盖了「排序题」的四个层次:

题 表面需求 真正考点 结论
BISHI22 分数线划定 成绩降序 + 报名号升序 元组 + 负号 key=lambda t: (-t[1], t[0])
BISHI23 小红书推荐系统 频次降序 + 字典序升序 负号技巧的适用边界 降序字段是数字才能取负
BISHI24 谐距下标对 统计满足关系的对数 根本不用排序 移项后 Counter 计数 \(O(n)\)
BISHI25 最大 FST 距离 求两两最大距离 排序解决不了 换坐标后取极差,\(O(n)\)

BISHI24 的移项是最值得记的一步: \(a_j - a_i = j - i \iff a_j - j = a_i - i\)。 把「涉及两个下标的条件」改写成「每个下标各自算一个值,再比是否相等」, 问题就从排序/枚举掉到哈希计数。 这个变形在前缀和题里还会反复出现,见 前缀和与差分。

BISHI9 田忌赛马(中等)

三局两胜,速度严格大者胜;田忌可任意调整三匹马的出场顺序,问能否赢。 题面见 原题。

这题挂着「田忌赛马」的名字,但 \(n = 3\) 固定,正解是全排列暴力而不是贪心。 完整代码与经典贪心策略的对照放在 贪心 §6,那里会讲 「\(n\) 一般时的双指针贪心」以及为什么在 \(n=3\) 时不该写贪心。

BISHI12 元素方碑(中等)

操作使 \(a_{i-1}\) 减 1、\(a_{i+1}\) 加 1,问能否让所有元素相等。 题面见 原题。

这题排序就死:能量只在同奇偶性的下标之间流动,奇数位之和与偶数位之和是不变量, 一旦排序,位置的奇偶性信息全部丢失。完整推导见 自定义排序 §12, 题解见 solutions/nowcoder/BISHI12/sol.py。

把它放在排序章的末尾,是为了强化那句话:排序是手段不是目的。


BM89 合并区间

给一组区间,合并所有重叠的区间。同一道题在另一处题库里编号 LC 56. 合并区间。

合并区间是 §6 那张表第一行「排序让贪心成立」的教科书案例: 按左端点升序排完之后,只需一次线性扫描—— 维护当前正在合并的区间,新区间的左端若不超过当前右端就把右端取 \(\max\), 否则把当前区间收下、另起一段。\(O(n\log n)\),排序占主导。

不排序则做不到线性:任意两个区间都可能重叠,直接两两比较是 \(O(n^2)\), 而且合并之后还可能产生新的重叠,得反复迭代。 排序把「任意两个都要比」降成了「只需和前一个比」,这正是排序作为预处理的价值。

一个容易忽略的边界:题面若规定端点相接(如 \([1,4]\) 与 \([4,5]\))也算重叠, 判断条件就要写成 \(\le\);若规定不算,则是 \(<\)。两种题面都存在,读清楚再动手。

8 本章速查

场景 结论
实战排序 一律 a.sort() / sorted(),手写慢 50–100 倍
最坏 \(O(n\log n)\) 的排序 归并、堆排、Timsort(快排不是)
稳定的排序 冒泡、插入、归并、Timsort
手写快排 必须随机基准,否则有序数据卡成 \(O(n^2)\) + 爆栈
归并的 <= 写成 < 就丢稳定性
求逆序对 归并分治 \(O(n\log n)\);Python 更推荐树状数组 + 离散化
只要最值 max / min,\(O(n)\)
只要分组 Counter,\(O(n)\)
只要前 \(k\) 小 heapq.nsmallest,\(k < n/10\) 时更快
值域大 不能计数排序,只能比较排序
下标参与运算 排序前先问「还要不要原下标」
bytes 排序 是字典序,数字必须先 int
排序的四种用途 章节
让贪心成立 贪心
让双指针成立 双指针与滑动窗口
让二分成立 二分
离散化 桶计数与离散化