自在学
分类课程智能体订阅
分类课程AI导师价格
课程进度
6 / 11
上一节快速排序下一节数据结构简述
自在学

© 2025 - 2026 自在学,保留所有权利。

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1

编程算法入门线性时间排序

线性时间排序

到目前为止,我们所学习的归并排序、堆排序和快速排序等算法,其最快的运行时间(无论是平均还是最坏情况)都达到了 Θ(nlg⁡n)\Theta(n \lg n)Θ(nlgn)。 这引出了一个深刻的问题:排序算法的速度极限就是 Θ(nlg⁡n)\Theta(n \lg n)Θ(nlgn) 吗?我们能否做得更好,例如,在线性时间 Θ(n)\Theta(n)Θ(n) 内完成排序? 这里我们将给出肯定的答案。但在此之前,我们首先需要理解为何 Θ(nlg⁡n)\Theta(n \lg n)Θ(nlgn) 如此特殊。

线性时间排序


比较排序的下界

我们目前接触的所有排序算法,都有一个共同点:它们通过比较元素之间的大小来确定最终的顺序。我们称这类算法为比较排序(Comparison Sort)。 为了理解比较排序的极限,我们可以引入一个抽象的模型——决策树(Decision Tree)。一棵决策树能够表示一个比较排序算法在处理一个特定规模(例如 n=3n=3n=3)的输入时,所做的全部比较序列。

  • 树的每个内部节点代表一次 a[i] 与 a[j] 的比较。
  • 节点的两个分支分别对应比较结果的两种可能。
  • 树的每个叶子节点代表一种可能的排序结果,即输入元素的一种排列。

对于一个包含 nnn 个元素的输入,总共有 n!n!n! 种可能的排列。一个正确的排序算法必须能够区分并产出这 n!n!n! 种排列中的任何一种。因此,它的决策树必须至少有 n!n!n! 个叶子节点。

在一棵高度为 hhh 的二叉树中,叶子节点的数量最多为 2h2^h2h。所以,我们有: n!≤2hn! \le 2^hn!≤2h

两边取对数,得到: h≥lg⁡(n!)h \ge \lg(n!)h≥lg(n!)

根据近似公式,我们知道 lg⁡(n!)=Θ(nlg⁡n)\lg(n!) = \Theta(n \lg n)lg(n!)=Θ(nlgn)。

因此,任何基于比较的排序算法,在最坏情况下,都需要进行至少 Ω(nlg⁡n)\Omega(n \lg n)Ω(nlgn) 次比较。

这个定理为比较排序算法的速度设定了一个无法逾越的理论下界。归并排序和堆排序达到了这个下界,因此它们是渐进最优的比较排序算法。 那么,如何才能打破这个“音障”,实现线性时间排序呢?答案是:不使用比较。 接下来的几种算法,通过对输入数据做出一些特殊的假设,绕过了比较的限制,从而实现了惊人的线性时间效率。


计数排序

计数排序(Counting Sort)是一种简单而高效的非比较排序算法。它的核心思想是:如果我知道有多少个元素比我小,那我不就知道我的位置了吗? 计数排序要求输入数据是在某个确定范围内的整数。例如,对一个由 0 到 k 之间整数组成的数组进行排序。

计数:创建一个计数数组 C,其大小等于输入数据的范围 k。遍历输入数组 A,C[i] 用来记录元素 i 在 A 中出现的次数。

计算位置:修改计数数组 C,使其每个元素 C[i] 表示小于或等于 i 的元素总数。这可以通过对 C 数组进行一次累加求和来完成。

放置:创建一个与输入数组 A 等大的输出数组 B。反向遍历输入数组 A。对于每个元素 A[j],将它直接放到输出数组 B 的 C[A[j]] 位置上,然后将 C[A[j]] 的值减 1。

假设输入数组 A=⟨2,5,3,0,2,3,0,3⟩A = \langle 2, 5, 3, 0, 2, 3, 0, 3 \rangleA=⟨2,5,3,0,2,3,0,3⟩,其中 k=5k=5k=5。

计数后:C = <2, 0, 2, 3, 0, 1> (2个0, 0个1, 2个2, 3个3...)

计算位置后 (累加):C = <2, 2, 4, 7, 7, 8> (≤0的有2个, ≤1的有2个, ≤2的有4个...)

放置 (反向遍历 A):

  • j=8, A[8]=3: B[C[3]] = B[7] = 3。C[3] 减为 6。
  • j=7, A[7]=0: B[C[0]] = B[2] = 0。C[0] 减为 1。
  • j=6, A[6]=3: B[C[3]] = B[6] = 3。C[3] 减为 5。
  • j=5, A[5]=2: B[C[2]] = B[4] = 2。C[2] 减为 3。
  • j=4, A[4]=0: B[C[0]] = B[1] = 0。C[0] 减为 0。
  • j=3, A[3]=3: B[C[3]] = B[5] = 3。C[3] 减为 4。
  • j=2, A[2]=5: B[C[5]] = B[8] = 5。C[5] 减为 7。
  • j=1, A[1]=2: B[C[2]] = B[3] = 2。C[2] 减为 2。

最终得到 B = <0, 0, 2, 2, 3, 3, 3, 5>。

时间复杂度:如果输入范围是 kkk,输入规模是 nnn,则时间复杂度为 Θ(n+k)\Theta(n+k)Θ(n+k)。当 k=O(n)k = O(n)k=O(n) 时,计数排序就是线性时间的。

计数排序是稳定的,即相同值的元素在输出数组中的相对顺序与它们在输入数组中的顺序相同。这是通过反向遍历输入数组来实现的。稳定性在作为其他排序算法的子程序时非常重要。


基数排序

如果需要排序的整数范围很大(例如 32 位整数),计数排序就不适用了,因为它需要一个巨大的计数数组。基数排序(Radix Sort)巧妙地解决了这个问题。 基数排序模拟了老式卡片分拣机的工作方式。它不是一次性比较整个数字,而是逐位地进行排序,从最低位(最不重要位)开始,一直到最高位。

确定数字的最大位数 d。

从最低位(第 1 位)到最高位(第 d 位),对数组进行 d 次排序。

每一次排序都必须使用一个稳定的排序算法(如计数排序)来对当前位进行排序。

假设我们现在对数字 ⟨329,457,657,839,436,720,355⟩\langle 329, 457, 657, 839, 436, 720, 355 \rangle⟨329,457,657,839,436,720,355⟩ 排序:

时间复杂度:如果数字有 ddd 位,每一位有 kkk 种可能(例如十进制是 10,二进制是 2),并且使用计数排序作为子程序,则总时间复杂度为 Θ(d(n+k))\Theta(d(n+k))Θ(d(n+k))。 通过巧妙地选择基数(例如,将 32 位整数看作 4 个 8 位数),基数排序通常可以在线性时间内完成。


桶排序

桶排序(Bucket Sort)是一种高效的线性时间排序算法,特别适用于特定类型的数据集。 它的高效性依赖于一个重要的假设,即输入数据是独立且均匀地分布在一个已知的范围内。通常,这个范围被设定为 [0, 1),这意味着所有待排序的元素都是在 0 和 1 之间的实数。 桶排序通过将数据分配到多个桶中,并在每个桶内进行排序来实现整体排序。由于数据的均匀分布,每个桶内的元素数量相对较少,从而使得桶内排序的过程非常快速。

创建桶:创建一个数组,包含 nnn 个“桶”(通常是链表)。

分发:遍历输入数组,将每个元素 A[i] 放入编号为 floor(n * A[i]) 的桶中。

桶内排序:对每个非空的桶,使用另一种排序算法(如插入排序)进行排序。

连接:按顺序连接所有桶中的元素,得到最终的有序数组。

由于数据是均匀分布的,我们期望每个桶里的元素数量会很少。如果每个桶里只有常数个元素,那么对每个桶的排序就会非常快。

假设输入数组 A=⟨0.78,0.17,0.39,0.26,0.72,0.94,0.21,0.12,0.23,0.68⟩A = \langle 0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68 \rangleA=⟨0.78,0.17,0.39,0.26,0.72,0.94,0.21,0.12,0.23,0.68⟩,其中 n=10n=10n=10。

创建桶:创建10个桶(编号0到9),每个桶对应一个区间:

  • 桶0: [0.0, 0.1)
  • 桶1: [0.1, 0.2)
  • 桶2: [0.2, 0.3)
  • ...
  • 桶9: [0.9, 1.0)

分发元素:将每个元素放入对应的桶中:

  • 0.78 → 桶7 (floor(10×0.78) = 7)
  • 0.17 → 桶1 (floor(10×0.17) = 1)
  • 0.39 → 桶3 (floor(10×0.39) = 3)
  • 0.26 → 桶2 (floor(10×0.26) = 2)
  • 0.72 → 桶7 (floor(10×0.72) = 7)
  • 0.94 → 桶9 (floor(10×0.94) = 9)
  • 0.21 → 桶2 (floor(10×0.21) = 2)
  • 0.12 → 桶1 (floor(10×0.12) = 1)
  • 0.23 → 桶2 (floor(10×0.23) = 2)
  • 0.68 → 桶6 (floor(10×0.68) = 6)

桶的分布情况:

  • 桶0: 空
  • 桶1: [0.17, 0.12]
  • 桶2: [0.26, 0.21, 0.23]
  • 桶3: [0.39]
  • 桶4: 空
  • 桶5: 空
  • 桶6: [0.68]
  • 桶7: [0.78, 0.72]
  • 桶8: 空
  • 桶9: [0.94]

桶内排序:对每个非空桶内的元素进行排序(使用插入排序):

  • 桶1: [0.12, 0.17]
  • 桶2: [0.21, 0.23, 0.26]
  • 桶3: [0.39]
  • 桶6: [0.68]
  • 桶7: [0.72, 0.78]
  • 桶9: [0.94]

连接结果:按桶的顺序连接所有元素:

shell
最终结果: [0.12, 0.17, 0.21, 0.23, 0.26, 0.39, 0.68, 0.72, 0.78, 0.94]

在输入数据满足均匀分布的假设下,桶排序的平均时间复杂度是 Θ(n)\Theta(n)Θ(n)。尽管它的最坏情况时间复杂度是 Θ(n2)\Theta(n^2)Θ(n2)(例如所有元素都掉进同一个桶里),但在实践中,对于符合其假设的数据,它的性能非常出色。

本部分我们学习了三种线性时间排序算法。它们之所以能打破 Ω(nlg⁡n)\Omega(n \lg n)Ω(nlgn) 的下界,关键在于它们都没有使用元素间的比较,而是利用了输入数据自身的特性(如范围、分布等)来确定顺序。 这告诉我们,在算法设计中,充分利用问题的内在属性,有时能带来意想不到的性能飞跃。


小练习

  1. 比较排序下界

    • 为什么任何基于比较的排序算法在最坏情况下都需要至少 Ω(nlg⁡n)\Omega(n \lg n)Ω(nlgn) 次比较?
    • 决策树的高度与排序算法性能有什么关系?
  2. 计数排序

    • 计数排序为什么要求输入数据在确定范围内?
    • 为什么计数排序是稳定的?稳定性有什么重要性?
  3. 基数排序

    • 为什么基数排序必须使用稳定的排序算法作为子程序?
    • 如果从高位到低位排序,会发生什么问题?
  4. 桶排序

    • 桶排序为什么要求数据均匀分布?
    • 如果所有元素都掉进同一个桶里,时间复杂度会变成什么?
  5. 计数排序实现

    python
    # 请实现计数排序算法
    def counting_sort(A, k):
        # 你的代码 here
        pass
  6. 基数排序实现

    python
    # 请实现基数排序算法(假设输入是正整数)
    def radix_sort(A):
        # 你的代码 here
        pass
  7. 时间复杂度分析

    • 对数组 [170, 45, 75, 90, 802, 24, 2, 66] 使用基数排序,需要多少次排序?
    • 如果使用计数排序对范围 [0, 1000] 的1000个整数排序,时间复杂度是多少?
  8. 适用场景判断 以下哪种情况最适合使用哪种排序算法?

    • 对100万个0-100之间的整数排序
    • 对1000个0-1之间的实数排序(数据分布均匀)
    • 对100个3位整数排序
  9. 算法选择

    • 为什么大多数编程语言的标准库不使用线性时间排序算法?
    • 在什么情况下,快速排序会比计数排序更快?
  10. 实际应用

    • 计数排序在哪些实际应用场景中很有用?
    • 桶排序的均匀分布假设在实际中如何验证?
  • 比较排序的下界
  • 计数排序
  • 基数排序
  • 桶排序
  • 小练习

目录

  • 比较排序的下界
  • 计数排序
  • 基数排序
  • 桶排序
  • 小练习