PAYATHON 2026

为什么不同 sorting algorithms 的运行速度和适用场景存在差异?

技术老齐

没有一种排序算法适合所有场景。选择时,先确认稳定性、内存和最坏耗时等限制,再根据数据规模、已有顺序和键值类型比较速度。普通业务代码通常应优先使用语言或标准库提供的排序函数。

平均速度稍慢的算法,也可能更适合当前任务:它可能占用更少的内存,保留相同键值记录的原有顺序,或对最坏情况下的耗时有保证。

为什么运行速度会不同?

算法执行的操作数量不同

设待排序元素数量为 (n):

  • 插入排序在随机输入上的平均时间复杂度为 (O(n^2))。
  • 归并排序、堆排序的最坏时间复杂度为 (O(n \log n))。
  • 快速排序的平均时间复杂度为 (O(n \log n)),但最坏可能达到 (O(n^2))。

数据量越大,这些增长速度的差异就越明显。不过,复杂度描述的是增长趋势,不能直接换算成运行秒数。

比较和移动元素的成本不同

比较两个整数通常成本很低,比较长字符串却可能需要检查许多个字符。如果排序键的计算很复杂,提取键值就可能花掉大部分时间。

移动数据也有成本。交换大型记录、交换对象引用和把数据写回磁盘,成本各不相同。因此,比较次数少的算法,实际运行未必更快。

算法对输入顺序的利用程度不同

插入排序处理已经有序的数据时,只需线性扫描,时间复杂度为 (O(n))。数据接近有序、逆序对较少时,它也能减少元素移动。

某些自适应排序算法会识别已有的有序片段。即使元素数量相同,完全随机、接近有序或包含大量重复值的输入,排序耗时也可能相差很大。

实现和硬件也会影响结果

连续访问数组通常有利于利用 CPU 缓存,频繁跳跃访问则可能增加缓存失效。递归、内存分配、分支预测,以及代码采用解释执行还是本地编译,都会影响实际速度。

即使两个算法的时间复杂度都是 (O(n \log n)),实际耗时也可能不同。处理小数组时,初始化和函数调用等固定开销,甚至可能抵消复杂度较低带来的收益。

常见排序算法如何比较?

下表以常见实现为准。额外空间不包含输入数据本身,但包含递归栈。

算法时间复杂度额外空间稳定性适用情况
插入排序最好 (O(n)),平均及最坏 (O(n^2))(O(1))可稳定数据量小,或数据接近有序
选择排序最好、平均及最坏均为 (O(n^2))(O(1))通常不稳定需要限制交换次数,且能接受大量比较
快速排序平均 (O(n \log n)),最坏 (O(n^2))常见递归实现平均 (O(\log n)),最坏 (O(n))通常不稳定内存中的数组排序,重视平均性能
归并排序常见实现为 (O(n \log n))数组实现通常为 (O(n))可稳定需要稳定排序,或适合顺序归并的数据
堆排序平均及最坏 (O(n \log n))迭代实现为 (O(1))通常不稳定额外内存有限,并要求最坏时间保证
计数排序(O(n+k)),(k) 为键值范围大小稳定输出的常见实现为 (O(n+k))可稳定键为整数,且取值范围较小

“可稳定”表示需要在实现中正确处理相等键值。例如,归并排序合并两个有序段时,遇到相等键值应优先取左段元素,才能保留原有顺序。

稳定性是指排序键相等的记录在排序后保持原有的相对顺序。比如,订单原本按提交时间排列,再按金额做稳定排序,同金额的订单就仍按提交时间排列。如果排的是没有附加信息的整数,通常不需要考虑稳定性。

选择排序的比较次数多,但交换次数为 (O(n))。写入成本远高于比较成本时,这个特点可能有用,是否值得采用还要看具体的存储方式。

计数排序利用了整数键的范围限制。如果元素很少,键值范围却很大,为整个范围分配计数数组就很不划算。

如何根据具体问题选择?

1. 先确定需要什么结果

很多任务不需要完整排序,减少不必要的工作往往比更换排序算法更有效:

  • 只要最小值或最大值,一次扫描即可,时间为 (O(n))。
  • 只要前 (k) 小的元素,且 (k \ll n),可以考虑维护大小为 (k) 的堆,通常需要 (O(n \log k)) 时间。
  • 只要中位数或第 (k) 小的值,可以考虑选择算法。Quickselect 的期望时间为 (O(n)),基本实现最坏为 (O(n^2))。

2. 写清楚必须满足的限制

先确认以下条件:

  • 相同键值的记录是否必须保持原有顺序?
  • 能否分配与输入规模成正比的额外内存?
  • 是否要求最坏情况下仍有明确的时间上界?
  • 数据能否全部放进内存?
  • 键值是否有可利用的限制,例如较小的整数范围?

如果数据放不进内存,磁盘读写会成为主要成本。常见做法是分块读取,在内存中分别排序,再归并这些有序块,这就是外部归并排序。

3. 根据数据特征缩小候选范围

数据量小或接近有序时,可以考虑插入排序。需要稳定性时,可以选择稳定归并或标准库中的稳定排序。如果额外内存有限,又要求最坏时间上界,可以考虑堆排序。

一般应用优先使用标准库更省事。成熟的排序实现可能组合多种策略,例如对小分区使用插入排序,或在出现退化迹象时切换算法。具体行为应以所用库的文档为准。

4. 用实际数据测试

性能测试至少应覆盖随机、已排序、逆序和大量重复键的数据,并使用业务中真实的数据规模与记录类型。

每次测试都要使用内容相同、彼此独立的输入。如果排序会修改原数组,反复排序同一个数组,就会让后续测试变成对已有序数据的排序。

还要明确耗时是否包含复制数据、计算排序键、内存分配和磁盘读写。测试排序本身与测试完整业务流程,回答的是不同问题。

代码示例:稳定排序与只取前几项

Python 可以分别处理完整排序和只取前几项的需求,无须依赖具体排序算法的实现细节。

from heapq import nsmallest

orders = [
    {"id": "A", "amount": 80},
    {"id": "B", "amount": 30},
    {"id": "C", "amount": 80},
    {"id": "D", "amount": 10},
]

# 完整排序:按金额升序,返回新列表。
# sorted() 保证稳定性,因此金额相同的 A 仍排在 C 前面。
ordered = sorted(orders, key=lambda order: order["amount"])
print([order["id"] for order in ordered])
# ['D', 'B', 'A', 'C']

# 只需要金额最小的两条记录。
cheapest = nsmallest(2, orders, key=lambda order: order["amount"])
print([order["id"] for order in cheapest])
# ['D', 'B']

sorted() 返回新列表,list.sort() 修改原列表。这里使用 sorted() 是为了保留原始输入。如果需要先按金额、再按编号排序,可以使用组合键:

ordered = sorted(
    orders,
    key=lambda order: (order["amount"], order["id"]),
)

组合键规定了金额相同时按什么次序排列;稳定性保留的是排序键相同的记录在输入中的顺序,两者用途不同。

比较时容易忽略什么?

时间复杂度的结论有适用前提。基于比较、处理任意输入的排序,在最坏情况下需要 (\Omega(n \log n)) 次比较。计数排序和基数排序利用键值结构,因此不受这个比较模型下界的限制。

平均时间不能代替最坏情况的保证。随机选取枢轴可以降低快速排序持续产生极端不均衡分区的概率,但普通随机化快速排序仍存在 (O(n^2)) 的最坏情况。

“原地排序”仍可能使用额外内存。快速排序即使在原数组中交换元素,递归栈也会占用空间。

比较函数必须提供一致的排序关系。如果比较结果前后矛盾,算法就无法可靠地满足排序要求。遇到缺失值、NaN 或混合类型时,应先明确它们的排序规则,再讨论性能。

备注:内容仅供参考。