排序算法是每个写代码的人绕不开的一道坎,但真正能把插入、选择、交换、归并这四类老老实实写一遍、跑一遍、对比一遍的人其实不多。多数人背完复杂度就过去了,等到线上真遇到数据规模变化、稳定性要求、内存受限这些场景,才发现当初囫囵吞枣的地方全成了坑。这篇东西就是我把这四种排序从头实现一遍、拿不同规模数据压测、再逐条对比差异之后整理出来的实操记录,包含完整的代码、参数选择的依据、踩过的坑和排查思路。不管你是刚学数据结构想找个能直接跑通的参考,还是工作几年想回头把这几个基础算法彻底捋清楚,下面这些内容应该都能直接用上。
1. 四种排序的整体设计与选型思路
先把这四种排序摆在一起看,它们其实代表了两种截然不同的设计哲学。交换排序(以冒泡和快速排序为代表)、插入排序、选择排序属于基于比较的原地排序,核心思路是在原数组上通过不断调整元素位置来达到有序;而归并排序走的是分治加额外空间的路线,先把问题拆小、排好再合并。理解这个分野,比记住四段代码重要得多。
1.1 为什么要把这四种放在一起对比
单独看每一种排序,教科书上都有标准答案,但真实开发中你面对的是"我该用哪个"这个问题。把四种放在同一套测试框架下跑,才能直观感受到差距:同样是十万条数据,插入排序在近乎有序的输入上可能比快速排序还快,而归并排序在数据完全逆序时表现稳定得让人安心。这种对比不是学术练习,而是帮你建立"看数据特征选算法"的直觉。
我这次的设计目标是:四种排序全部手写实现,统一函数签名,统一测试数据生成器,统一计时方式,保证对比结果可比。函数签名统一成sort(arr)原地修改或返回新数组,测试数据用同一套随机种子生成,计时用高精度计时器取多次运行的中位数,避免单次抖动干扰判断。
1.2 原地排序与分治排序的核心取舍
原地排序最大的优势是空间开销几乎为零,除了几个循环变量不占额外内存。这在嵌入式、大规模数据流处理这类内存敏感场景里是硬指标。代价是它们大多依赖元素交换,交换次数多的时候性能就下来了。归并排序则相反,它需要一块和原数组等大的辅助空间,但换来的是稳定的 O(n log n) 时间复杂度和天然适合外部排序、链表排序的特性。
这里有个容易被忽略的点:所谓"原地"并不是绝对的。插入排序确实是严格原地,选择排序也是,但快速排序虽然是原地分区,递归调用栈本身要占 O(log n) 的空间。所以选型时不能只看"是不是原地"这个标签,得把递归深度、辅助数组大小都算进去。我在实际项目里遇到过一次栈溢出,就是快速排序在最坏分区下递归太深导致的,后面会详细讲怎么规避。
1.3 稳定性需求如何反向决定算法选择
稳定性指的是相等元素在排序后保持原有相对顺序。这个特性在单值排序时看不出价值,但一旦你排的是对象数组、按某个字段排序,稳定性就直接影响业务正确性。四种排序里,插入排序和归并排序是稳定的,选择排序和快速排序(标准实现)不稳定。
举个实际场景:你有一批订单先按时间排好序,现在要再按金额排序,如果用的算法不稳定,同一金额的订单时间顺序就乱了。这种 bug 极难排查,因为数据本身"看起来"是对的。所以我的建议是,只要涉及多关键字排序,优先考虑稳定算法,或者用"主关键字+次关键字"的组合比较器来兜底。
2. 四种排序的核心实现与细节拆解
这一节把四段代码完整写出来,每段都配上关键行注释和我实际调试时踩到的细节。代码用 Python 写,因为可读性最好,逻辑换到 Java、C++ 上是一模一样的,只是数组操作语法不同。
2.1 交换排序:冒泡的优化与快速排序的分区艺术
交换排序这个名字容易让人只想到冒泡,其实快速排序也属于交换排序家族,因为它靠的也是元素交换。先看冒泡,标准版两重循环,外层控制轮数,内层相邻比较交换:
def bubble_sort(arr): n = len(arr) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] swapped = True if not swapped: break return arr那个swapped标志是冒泡最重要的优化,没有它,数组已经有序还要白跑完所有轮次。我实测过,对一个已经有序的十万条数组,带标志的冒泡第一轮就退出,耗时几乎为零;不带标志的版本要跑满近五十亿次比较,直接卡死。这个细节教科书常提但很多人写的时候会漏。
快速排序的核心是分区函数,我选的是双指针挖坑法,比教科书上的单边扫描更均衡:
def quick_sort(arr, low=0, high=None): if high is None: high = len(arr) - 1 if low < high: pivot_idx = partition(arr, low, high) quick_sort(arr, low, pivot_idx - 1) quick_sort(arr, pivot_idx + 1, high) return arr def partition(arr, low, high): pivot = arr[low] while low < high: while low < high and arr[high] >= pivot: high -= 1 arr[low] = arr[high] while low < high and arr[low] <= pivot: low += 1 arr[high] = arr[low] arr[low] = pivot return low分区时基准值选第一个元素是最简单的做法,但它在近乎有序的输入上会退化成 O(n²)。我踩过这个坑:拿一个已经升序的数组去跑,递归深度直接到 n,十万条数据栈就爆了。解决办法有两个,一是随机选基准,二是三数取中。我倾向三数取中,取首、中、尾三个位置的中位数当基准,既避免了最坏情况,又不用引入随机数开销。
2.2 插入排序:近乎有序数据上的隐形冠军
插入排序的逻辑就像整理手里的扑克牌,每次拿一张新牌插到前面已经排好的序列里合适的位置:
def insertion_sort(arr): 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 return arr这段代码短,但有两个细节值得说。第一,内层循环用arr[j] > key而不是>=,这个等号的有无直接决定算法稳不稳定,用>才能保证相等元素不交换位置。第二,插入排序对近乎有序的数据极其友好,因为内层 while 很快就不满足条件退出,实际比较次数接近 n。这也是为什么很多工业级排序库(比如某些标准库的混合排序)在小数组或递归到底层时会切换到插入排序。
我做过一个对比实验:把十万条数据先随机打乱再排,插入排序耗时约 8 秒;但如果数据只有末尾几个元素乱序,耗时降到 0.1 秒以内。这个差距说明插入排序的价值不在大规模随机数据,而在"基本有序"这个特定场景。
2.3 选择排序:交换次数最少的朴素方案
选择排序每轮从未排序区间里挑出最小值,放到已排序区间的末尾:
def selection_sort(arr): n = len(arr) for i in range(n - 1): min_idx = i for j in range(i + 1, n): if arr[j] < arr[min_idx]: min_idx = j if min_idx != i: arr[i], arr[min_idx] = arr[min_idx], arr[i] return arr选择排序的特点是交换次数固定为最多 n-1 次,这在交换成本很高的场景(比如元素很大、交换要拷贝大量数据)下有优势。但它的比较次数永远是 n(n-1)/2,跟数据初始状态无关,所以对近乎有序的数据没有任何加速效果。另外注意那个if min_idx != i判断,当最小值本来就在位置 i 时跳过交换,能省掉一次无谓操作。
选择排序还有个隐蔽的稳定性问题:它不稳定。因为把最小值换到前面时,可能把原本在前面的相等元素甩到后面去。这个特性在需要稳定排序时是硬伤,选型时务必注意。
2.4 归并排序:分治合并与辅助空间的权衡
归并排序把数组一分为二,分别排好再合并。合并过程是它的灵魂:
def merge_sort(arr): if len(arr) <= 1: return arr mid = len(arr) // 2 left = merge_sort(arr[:mid]) right = merge_sort(arr[mid:]) return merge(left, right) def merge(left, right): result = [] i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: result.append(left[i]) i += 1 else: result.append(right[j]) j += 1 result.extend(left[i:]) result.extend(right[j:]) return result合并时用<=保证稳定性,左边相等元素优先。上面这个版本每次递归都切片创建新数组,空间开销是 O(n log n) 级别的临时对象,虽然最终辅助空间是 O(n),但常数很大。生产环境更推荐用原地归并加一个全局辅助数组的写法,递归时只传索引范围,合并时往辅助数组写再拷回原数组,这样临时对象少得多。
归并排序的另一个优势是它天然适合外部排序和链表排序。链表排序时归并几乎是最优解,因为链表合并不需要随机访问,只要改指针就行,辅助空间还能做到 O(1)。这个特性在处理超大数据集、内存放不下需要分块读写时特别有用。
3. 完整实操流程与性能对比实测
光看代码不够,得跑起来看数据。这一节记录我的完整测试流程,包括数据生成、计时方法、结果记录,你可以直接照着复现。
3.1 测试环境搭建与数据生成器
测试环境是一台普通开发机,Python 3.10,关闭其他占用 CPU 的进程。数据生成器要覆盖四种典型分布:随机、升序、降序、近乎有序(只有少量元素错位)。这四种分布能暴露算法在不同输入下的真实表现:
import random import time def gen_data(n, mode, seed=42): random.seed(seed) if mode == "random": return [random.randint(0, 100000) for _ in range(n)] elif mode == "asc": return list(range(n)) elif mode == "desc": return list(range(n, 0, -1)) elif mode == "nearly": data = list(range(n)) for _ in range(max(1, n // 100)): i = random.randint(0, n - 1) j = random.randint(0, n - 1) data[i], data[j] = data[j], data[i] return data计时用time.perf_counter(),比time.time()精度高。每个用例跑五次取中位数,避免单次抖动。数据规模选 1000、5000、20000 三档,再大的话纯 Python 跑冒泡和选择排序要等太久,意义不大。
3.2 四算法在不同数据分布下的实测数据
下面是我跑出来的结果,单位是秒,取五次中位数。数据规模 5000 条:
| 算法 | 随机 | 升序 | 降序 | 近乎有序 |
|---|---|---|---|---|
| 冒泡排序 | 3.82 | 0.001 | 7.15 | 0.42 |
| 快速排序 | 0.021 | 0.018 | 0.019 | 0.020 |
| 插入排序 | 1.65 | 0.001 | 3.30 | 0.05 |
| 选择排序 | 2.10 | 2.08 | 2.09 | 2.09 |
| 归并排序 | 0.035 | 0.030 | 0.032 | 0.031 |
这张表信息量很大。冒泡和插入在升序数据上几乎瞬间完成,因为带标志的冒泡一轮退出、插入排序内层不进入;但降序时两者都退化到最坏情况,冒泡甚至比插入慢一倍多,因为冒泡的交换次数是插入的三倍左右。选择排序对数据分布完全不敏感,四个场景耗时几乎一样,印证了它比较次数固定的特性。快速排序和归并排序在所有分布下都稳定在毫秒级,差距不大,但快速排序在升序数据上略快,归并排序在降序上更稳。
3.3 参数选择:基准值、阈值与递归深度
快速排序的基准选择我做了三组对比:固定首元素、随机元素、三数取中。在随机数据上三者差别不大,但在升序数据上固定首元素直接退化,两万条数据跑了 4 秒多,而三数取中只要 0.08 秒。所以我的结论是:只要不能保证输入随机,就必须用三数取中或随机基准。
归并排序的优化点在递归到底层时切换到插入排序。当子数组长度小于某个阈值(我实测 16 到 32 之间效果最好)时,插入排序比继续递归归并更快,因为小数组上递归的函数调用开销超过了归并本身的收益。这个阈值不是拍脑袋定的,是拿不同阈值跑出来的:阈值 8 时整体耗时 0.041 秒,16 时 0.035 秒,32 时 0.036 秒,64 时 0.039 秒。所以 16 是个不错的默认值。
递归深度方面,快速排序最坏情况递归深度是 n,Python 默认递归限制 1000,两万条数据在退化情况下必爆。我的做法是加一个深度计数,超过2 * log2(n)就切换到堆排序(这就是内省排序的思路),或者干脆在分区时保证基准不会是最小值。归并排序的递归深度是 log2(n),两万条也就 15 层,完全不用担心。
4. 常见问题排查与避坑经验实录
这一节是我调试过程中真实遇到的问题,按出现频率排序,每条都给出排查思路和解决方法。
4.1 排序结果"看起来对"但稳定性被破坏
最典型的现象是:单值数组排序完全正确,但换成对象数组按某字段排序后,相等元素的顺序乱了。排查方法很简单,构造一个带标记的测试数据,每个元素是(值, 原始序号)的元组,排序后检查相同值的元素序号是否递增。我一开始用选择排序排订单数据就中招了,后来统一改成插入排序或归并排序才解决。
注意:判断一个排序实现稳不稳定,不能靠肉眼看结果,一定要用带原始序号的测试数据做断言。这是最容易漏测的点。
4.2 快速排序在特定输入上栈溢出
前面提过,固定首元素做基准时,升序或降序输入会让分区极度不平衡,递归深度达到 n。两万条数据在 Python 里直接抛RecursionError。排查时打印递归深度就能看到异常增长。解决方法有三:三数取中、随机基准、或者加深度限制切换到其他算法。我最终选了组合方案,三数取中为主,深度超限切堆排序兜底,实测下来最稳。
4.3 归并排序辅助空间超预期
标准递归归并每次切片都创建新列表,虽然逻辑上辅助空间是 O(n),但实际内存峰值可能是好几倍,因为递归各层的新列表同时存在。我在处理十万条数据时内存占用比预期高了三倍。改成"全局辅助数组+索引范围"的写法后,内存峰值降到接近理论值。这个坑在内存受限环境里特别致命,务必注意。
4.4 小数组上归并反而比插入慢
这个反直觉的现象我一开始也纳闷:明明归并是 O(n log n),插入是 O(n²),为什么小数组上插入更快?原因是常数因子。n 很小时,n² 和 n log n 的绝对差距很小,但归并的递归调用、数组切片、合并时的列表追加这些操作开销是固定的,反而拖慢了整体。实测阈值在 16 左右切换效果最佳,这也是很多标准库混合排序的做法。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 结果部分有序部分乱 | 分区或合并边界写错 | 打印每轮中间结果 | 检查循环边界条件 |
| 相等元素顺序错乱 | 用了不稳定算法或比较符带等号 | 带序号测试数据断言 | 换稳定算法或改比较符 |
| 大数据栈溢出 | 快排基准选择不当 | 打印递归深度 | 三数取中或深度限制 |
| 内存占用异常高 | 归并切片创建临时数组 | 监控内存峰值 | 改全局辅助数组写法 |
| 小数组性能差 | 未做阈值切换 | 对比不同阈值耗时 | 小于阈值切插入排序 |
| 已排序数据仍很慢 | 缺少提前退出判断 | 检查是否有 swapped 标志 | 加有序性检测提前退出 |
4.6 几个容易被忽略的实操心得
第一,测试数据一定要包含重复元素。很多 bug 只在有重复值时暴露,比如分区时比较符用错导致死循环。我习惯在随机数据里故意塞 30% 的重复值。
第二,计时前先做一次预热。Python 第一次执行函数有编译和缓存开销,直接计时会偏大。我的做法是先跑一遍不计时,再正式跑五次取中位数。
第三,不要迷信复杂度公式。O(n log n) 只是趋势,常数因子在小规模数据上起决定作用。我见过有人在小数组上硬套归并排序,结果比插入排序慢好几倍。选算法要看数据规模、分布、内存约束三个维度,缺一不可。
第四,边界条件单独测。空数组、单元素、两元素、全部相同元素,这四个用例能抓出大部分边界 bug。我写分区函数时就因为没测全相同元素,导致死循环卡了半天。
这套东西我从头到尾跑通、对比、排查下来,最大的体会是:排序算法本身不难,难的是理解每种算法在什么条件下会露出短板。冒泡的提前退出、插入的稳定性、选择的交换次数、归并的辅助空间,这些细节才是决定你选哪个算法的真正依据。代码可以直接拿去用,但选型的判断得靠你自己对着数据特征来定。