一、比较排序的下界

问题:归并、堆、快排都是 Θ(n log n)。是我们还没想到更好的算法,还是这就是极限?

答案取决于允许做什么操作。 前面所有算法只通过比较获取信息(a[i] < a[j]?),这类算法称为比较排序(comparison sort)。对它们,Θ(n log n) 就是极限。

决策树模型

把一个比较排序算法在规模为 n 的所有输入上的执行,画成一棵决策树

  • 内部节点 标记为 i : j,表示"比较 a[i] 与 a[j]"
  • 两条出边 对应比较的两种结果
  • 叶节点 是一个排列,表示算法输出的顺序

n = 3 的决策树:

                    1:2
                 ≤ ╱   ╲ >
                  ╱     ╲
               2:3       1:3
             ≤╱  ╲>     ≤╱  ╲>
          ⟨123⟩  1:3   ⟨213⟩  2:3
                ≤╱ ╲>        ≤╱ ╲>
            ⟨132⟩ ⟨312⟩   ⟨231⟩ ⟨321⟩

叶子数 = 6 = 3!
最长路径 = 3 次比较

两个观察

  1. 每个可能的输出排列都必须至少对应一片叶子。否则存在某个输入,算法给不出正确答案。所以 叶子数 L ≥ n!
  2. 算法在某个输入上的比较次数 = 从根到对应叶子的路径长度。因此最坏情况比较次数 = 树高 h

定理与证明

定理:任何比较排序在最坏情况下都需要 Ω(n log n) 次比较。

证明:一棵高度为 h 的二叉树最多有 2^h 片叶子。结合 L ≥ n!:

2^h ≥ L ≥ n!
h ≥ log₂(n!)

由 Stirling 近似(第 2 讲):

n! ≈ √(2πn)·(n/e)ⁿ
log₂(n!) = n log₂ n − n log₂ e + O(log n) = Θ(n log n)
⟹ h = Ω(n log n)                                        ∎

推论:归并排序和堆排序是渐近最优的比较排序。

⭐ 下界的信息论解读

换一个角度看这个证明,它其实非常直观:

需要区分的可能答案数:n! 种排列
每次比较获得的信息:  1 比特(两种结果之一)
⟹ 至少需要 log₂(n!) ≈ n log₂ n 比特的信息
⟹ 至少需要 n log₂ n 次比较

排序的本质是「确定输入是哪一个排列」,而比较是一次只能问一个是非问题的信道。 n log n 是这个信道的信息论极限,与算法的聪明程度无关。

n = 100 时下界约 525 次比较;归并排序实际用约 550 次,快排约 730 次——都已经很接近极限了。

⚠️ 下界成立的前提

这个下界只对比较排序成立。它假设算法获取信息的唯一手段是比较。

如果允许别的操作呢? 比如"直接用键值做数组下标"——这一步不是比较,而是直接寻址,它一次获取的信息远多于 1 比特。下面三种算法正是这么做的。


二、计数排序

额外假设:键是 [0, k) 范围内的整数,且 k = O(n)。

思想:不比较,而是数每个值出现了多少次,再由此算出每个元素的最终位置。

func CountingSort(a []int, k int) []int {
    count := make([]int, k)
    for _, x := range a { // ① 统计每个值的出现次数
        count[x]++
    }
    for i := 1; i < k; i++ { // ② 前缀和:count[i] = 值 ≤ i 的元素个数
        count[i] += count[i-1]
    }
    out := make([]int, len(a))
    for i := len(a) - 1; i >= 0; i-- { // ③ ⚠️ 必须倒序遍历才稳定
        count[a[i]]--
        out[count[a[i]]] = a[i]
    }
    return out
}
输入 a = [2, 5, 3, 0, 2, 3, 0, 3],k = 6

① 计数:      count = [2, 0, 2, 3, 0, 1]
                       0  1  2  3  4  5
② 前缀和:    count = [2, 2, 4, 7, 7, 8]
              含义:值 ≤ 3 的有 7 个 ⟹ 最后一个 3 应放在下标 6
③ 倒序填充:  out   = [0, 0, 2, 2, 3, 3, 3, 5]
性质
时间 Θ(n + k)
空间 Θ(n + k)
稳定 (第 ③ 步倒序遍历)

为什么第 ③ 步必须倒序? 前缀和给出的是每个值的最后一个位置。倒序遍历输入时,同值元素中原本靠后的先被放置,占据靠后的位置——原有相对顺序得以保持。正序遍历会得到正确但不稳定的结果,而稳定性正是计数排序作为基数排序子过程的前提。

⚠️ k 必须与 n 同量级。 排序 100 个 32 位整数时 k = 2³²,需要 16 GB 的计数数组——完全不可行。


三、基数排序

解决计数排序的 k 太大问题:不一次排整个键,而是按位分组,逐位排

关键决策:必须从最低位(LSD)开始,且每一趟必须用稳定排序。

输入      按个位排    按十位排    按百位排
329       720        720        329
457       355        329        355
657       436        436        436
839  ──▶  457   ──▶  839   ──▶  457
436       657        355        657
720       329        457        720
355       839        657        839
// 按 32 位无符号整数排序,每轮处理 8 位,共 4 轮
func RadixSort(a []uint32) []uint32 {
    const bits, radix = 8, 1 << 8
    buf := make([]uint32, len(a))
    count := make([]int, radix)

    for shift := 0; shift < 32; shift += bits {
        for i := range count {
            count[i] = 0
        }
        for _, x := range a {
            count[(x>>shift)&(radix-1)]++
        }
        for i := 1; i < radix; i++ {
            count[i] += count[i-1]
        }
        for i := len(a) - 1; i >= 0; i-- { // 稳定:倒序
            d := (a[i] >> shift) & (radix - 1)
            count[d]--
            buf[count[d]] = a[i]
        }
        a, buf = buf, a // 交换缓冲区
    }
    return a
}

复杂度:d 轮,每轮 Θ(n + radix):

T(n) = Θ(d · (n + radix))

对 b 位的键、每轮 r 位:d = b/rradix = 2^r

T(n) = Θ((b/r)(n + 2^r))

r 的选择:r 太小则轮数多,r 太大则计数数组太大。渐近上r = Θ(log n)(使 2^r = Θ(n),两项平衡)即得

T(n) = Θ(b·n / log n)

⚠️ 但「取 r = log₂ n」是渐近指导,不是具体参数下的最优值,实践中还有两条硬约束:

约束 后果
轮数 b/r 必须是整数 32 位键只能取 r ∈ {8, 11, 16}(4、3、2 趟);r = 20 意味着 1.6 趟,无法实现
计数数组必须放得进缓存 2^11 × 4B = 8 KB 稳进 L1;2^16 × 4B = 256 KB 已溢出 L2。计数阶段是随机写,一旦超出缓存,每次自增都是一次未命中

实算(b = 32、n = 10⁶,代入 T(r) = (32/r)(n + 2^r)):

r =  8   4.00 趟   计数数组      256    T = 4.00×10⁶
r = 11   2.91 趟   计数数组    2 048    T = 2.92×10⁶    ← 实践常用(L1 友好)
r = 16   2.00 趟   计数数组   65 536    T = 2.13×10⁶    ← 公式与整数趟数的最佳折中
r = 17   1.88 趟   计数数组  131 072    T = 2.13×10⁶    ← 公式的最小值点
r = 20   1.60 趟   计数数组 1.05×10⁶    T = 3.28×10⁶    ← ⚠️ 比 r=16 差 1.5 倍

注意 r = log₂ n = 20 反而比 r = 16 差 50%2^r 是指数增长的,一旦它超过 n,计数数组的开销就迅速压倒省下的那部分轮数。渐近公式给出的是量级,不是最优参数——具体取值必须代入数字算,并结合缓存实测。 这正是第 2 讲「渐近分析骗你的时候」那一节的又一个实例。

⭐ 为什么必须从低位开始、必须稳定

从高位开始(MSD) 也可以,但需要对每个桶递归处理,实现复杂得多(不过 MSD 适合变长字符串,且能提前终止)。

LSD 的正确性完全依赖稳定性:处理第 i 位时,前 i−1 位已经排好;稳定排序保证第 i 位相同的元素保持它们按低位排好的顺序。用不稳定排序做某一轮,前面所有轮的工作都会被打乱。

这是"稳定性"在算法中真正不可替代的一次出场(第 9 讲)。

⚠️ 符号与浮点的处理:有符号整数要先做偏移(x ^ 0x80000000)才能按无符号比较;IEEE 754 浮点数需要一次位变换(正数翻符号位、负数全翻)才能保序。


四、桶排序

额外假设:输入均匀分布在某个区间上(如 [0,1) 的均匀随机数)。

func BucketSort(a []float64) {
    n := len(a)
    buckets := make([][]float64, n)
    for _, x := range a { // 期望每桶 1 个元素
        i := int(float64(n) * x)
        buckets[i] = append(buckets[i], x)
    }
    idx := 0
    for i := range buckets {
        InsertionSort(buckets[i]) // 桶内元素少,插入排序最合适
        for _, x := range buckets[i] {
            a[idx] = x
            idx++
        }
    }
}

分析:设 nᵢ 为第 i 个桶的元素数,桶内用插入排序:

E[T(n)] = Θ(n) + Σᵢ E[O(nᵢ²)]

在均匀分布假设下,nᵢ 服从二项分布 B(n, 1/n),可以算出 E[nᵢ²] = 2 − 1/n

E[T(n)] = Θ(n) + n·O(2 − 1/n) = Θ(n)                    ∎

⚠️ 对分布假设极其敏感。所有元素落进同一个桶时退化为 Θ(n²)。实际使用前必须了解数据分布,否则风险很大。


五、三者对比与选型

算法 时间 空间 稳定 额外假设
计数排序 Θ(n + k) Θ(n + k) 键是 [0,k) 的整数,k = O(n)
基数排序 Θ(d(n + radix)) Θ(n + radix) 键可按位分解且定长
桶排序 期望 Θ(n),最坏 Θ(n²) Θ(n) 取决于桶内排序 输入均匀分布

它们并没有"打破"下界,而是不在下界的适用范围内。 下界针对的是"只能比较"的算法;这三种都用了键的内部结构(数值范围、位表示、分布),因此获取信息的速率高于 1 比特/操作。

天下没有免费的午餐:每一种线性排序都用一个额外假设换来了速度。假设不成立时,它们比 Θ(n log n) 的通用排序糟糕得多。

什么时候真的该用它们

场景 选择
排序年龄、评分、状态码(值域几百) 计数排序
排序大量 32/64 位整数或定长字符串 基数排序(比 quicksort 快 2–3 倍)
数据库按整型主键排序 基数排序
已知均匀分布的浮点数 桶排序
其他一切情况 Θ(n log n) 的通用排序

一个真实数据点:现代 LSD 基数排序(如 ska_sortradsort)排序 10⁷ 个 32 位整数通常比 std::sort 快 2–4 倍。但它排不了任意 Less 函数定义的顺序——这才是通用排序不可替代的原因。


六、下界的其他形式

Ω(n log n) 只是最基本的一个。同一套决策树论证还能给出:

结论 下界
比较排序(最坏) Ω(n log n) 次比较
比较排序(期望,含随机化) Ω(n log n)
在有序数组中查找(比较模型) Ω(log n)(二分查找最优)
求最大值 恰好 n − 1 次比较
同时求最大与最小值 ⌈3n/2⌉ − 2 次比较(成对处理)
求第二大 n + ⌈log₂ n⌉ − 2 次(锦标赛法)
中位数选择(比较模型) Ω(n)(第 13 讲可达到)

注意随机化不能打破排序下界:决策树论证对随机化算法同样适用(取所有随机串上的平均树高)。这与第 11 讲的结论一致——随机化能让快排避开最坏情况,但不能让它跑得比 n log n 更快。


随堂自测

  1. 决策树模型中,叶子数为什么至少是 n!?树高对应什么?
  2. 完整写出 Ω(n log n) 下界的证明,指出用到 Stirling 近似的位置。
  3. 从信息论角度重述这个下界:为什么"每次比较得到 1 比特"是关键?
  4. 这个下界对基数排序不成立,是因为基数排序更聪明,还是因为它不在模型内?说清楚区别。
  5. 计数排序第三步为什么必须倒序遍历?正序会破坏什么?举一个具体例子。
  6. 基数排序为什么必须使用稳定的子排序?用一个 2 位数的例子说明不稳定会出什么错。
  7. 用基数排序排 n = 10⁶ 个 32 位整数,每轮取 r 位。写出复杂度关于 r 的表达式并求最优 r。
  8. 桶排序的期望 Θ(n) 依赖什么假设?构造一个使它退化为 Θ(n²) 的输入。
  9. 为什么随机化不能打破 Ω(n log n)?