☰
归并排序工程师复盘:从分治原理到外部排序与并行优化
2026/10/16 3:34:53 网站建设 项目流程

归并排序没你想的那么简单:一个老工程师的完整复盘

很多人第一次接触归并排序是在算法课上,背一个递归模板,记一个 O(n log n),然后稀里糊涂过了考试。但等你真正在项目里用它处理几十万行数据、踩过几次数组越界和内存暴涨之后,才会发现归并排序远不只是一道面试题。这篇文章我把归并排序从原理到实战全部拆开讲一遍,包括递归怎么写、边界怎么处理、为什么会有性能陷阱、如何并行、如何用它做外部排序。适合正在学排序原理的学生,也适合想在业务代码里真正落地经典算法的一线工程师。

1. 先别急着写代码:归并排序的分治骨架和复杂度直觉

1.1 递归版本的核心逻辑

归并排序的核心思想可以压缩成八个字:先拆到最小,再两两合并。拆的过程很简单,数组一分为二,二分为四,直到每个子数组只剩一个元素或空数组。一个元素的数组天然有序,这时候再往回合并,每一步合并产生的都是有序数组,最终得到完整的有序结果。

我用 Python 写一个最接近教材的版本:

def merge_sort(nums): if len(nums) <= 1: return nums mid = len(nums) // 2 left = merge_sort(nums[:mid]) right = merge_sort(nums[mid:]) return merge(left, right) def merge(left, right): i, j = 0, 0 result = [] 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

这个版本最大的问题你一眼就能看出来:每层递归都在创建新切片,内存开销非常大。学习阶段这么写没问题,逻辑清晰,merge 函数也独立。但如果你真拿它去排序百万级数据,光是切片产生的对象就已经让内存告急了。

所以生产级实现一般会改造成“索引区间”版本。我们不在递归时创建新数组,而是维护left, right和mid三个下标,在同一份数组上完成归并。核心递归函数长这样:

def merge_sort_range(nums, tmp, left, right): if right - left <= 1: return mid = (left + right) // 2 merge_sort_range(nums, tmp, left, mid) merge_sort_range(nums, tmp, mid, right) i, j, k = left, mid, left while i < mid and j < right: if nums[i] <= nums[j]: tmp[k] = nums[i] i += 1 else: tmp[k] = nums[j] j += 1 k += 1 while i < mid: tmp[k] = nums[i] i += 1 k += 1 while j < right: tmp[k] = nums[j] j += 1 k += 1 for idx in range(left, right): nums[idx] = tmp[idx]

注意这里right - left <= 1而不是<= 0,因为区间是左闭右开。写惯闭区间的人很容易在这里差一个元素,这种细节在归并排序里很典型,后面我会专门讲。

1.2 复杂度与空间的粗略账

归并排序的时间复杂度为什么是 O(n log n)?看一眼递归树就明白了:一个规模为 n 的数组,每层被切成两半,递归深度是 log2(n) 层。每一层里,所有子数组的归并操作加起来,比较和赋值的总次数都是 O(n)。层数乘每层工作量,就是 O(n log n)。

这里有个反直觉点:递归树的每一层虽然有很多个 merge,但每个元素只会参与该层的一次归并,所以每层的代价是 n 而不是 n 的倍数。你可以把归并排序想象成一场淘汰赛,每轮所有选手都要打一次比赛,一共打 log 轮,选手总数是 n,总比赛场次就是 n log n 的量级。

空间复杂度要细算。如果每层递归都 new 一个数组,峰值内存并不是 n + n/2 + n/4 + ... 等于 2n 吗?理论峰值确实趋近 2n,很多人误以为递归版归并排序的空间复杂度是 O(n log n),其实是 O(n)。原因是最深层递归先执行,此时还在栈上尚未释放n/2、n/4等数组,才会逐渐累积;但当最深层返回后,内存逐渐释放。关键在于:同一时刻栈上所有活动数组的总大小,约等于 n + n/2 + n/4 + ... < 2n。

不过实际工程里,频繁分配和释放数组会触发大量 GC 停顿,性能很不稳定。所以我们改进版里用了一个提前分配好的tmp工作数组,从最外层一次性传入,整个排序过程所有 merge 都复用这份额外空间。这既把空间开销稳定在 O(n),又避免了反复分配对象,是我在所有项目里的默认做法。

2. 生产环境里的归并排序:工作数组、边界条件和稳定的代价

2.1 一个容易被忽略的稳定性价值

归并排序是稳定排序,这个性质在教科书里一句话带过,但业务系统里它可能是你选择归并排序的根本原因。稳定意味着:当两个元素排序键相同时,它们在排序后的相对顺序和排序前一样。

我在某个报表系统里就遇到过这个需求。用户要求先按日期排序,再按优先级排序,但最终展示时希望同等优先级下仍然保留日期顺序。如果直接用不稳定排序,二次排序会把第一次排序结果打乱;用稳定排序就可以“分层排序”:先按日期排,再按优先级排,优先级相同的人日期顺序自然保留。

快速排序通常是不稳定的(特定分区方式可以做成稳定,但代价大),归并排序天然稳定。关键在于 merge 里的比较符号:当left[i] <= right[j]时,先把左边的元素放入结果,这样相等时左边的元素始终排在右边元素前面。如果你手滑写成<,相等元素就会错位,稳定性就悄悄丢了,很难通过普通测试发现。

2.2 原地归并的诱惑与陷阱

总有人问我为什么不写原地归并排序,因为大家都觉得额外 O(n) 空间很浪费。我先说结论:无聊的原地归并可以做到,但绝大多数实现会让时间复杂度退化到 O(n²),得不偿失。

原地归并的思路是把两个有序区间在同一个数组里交错合并,比如用旋转交换的方式腾挪元素。单次 merge 的复杂度从 O(n) 变成 O(n²) 甚至更高,外层再乘 log n 层,整体就退化成了 O(n² log n),比最朴素的冒泡排序还惨。

我见过有的代码库为了省内存做了个“伪原地归并”,结果线上处理 20 万条数据耗时从 300 毫秒涨到 30 秒。后来排查发现罪魁祸首就是这个低效 merge。从那以后我的原则很简单:追求 O(1) 额外空间,直接选堆排序;追求稳定和可预测,就老老实实接受 O(n) 的辅助数组。工程里没有银弹,只有取舍。

2.3 尾递归与递归深度问题

理论上归并排序的递归深度是 log2(n),一万亿个元素的深度也才 40 层左右,似乎根本不值得担心。但前提是你正确二分了数组。如果某个实现里mid = (left + right) / 2因为整型溢出写成了(left + right) / 2,当数组长度很大时可能出问题;更常见的是笔误导致递归区间不收敛,比如:

merge_sort_range(nums, tmp, left, mid + 1)

这种写错一次,左区间被无限放大,直接栈溢出。排查这类问题最快的方法是打印每次递归的left和right,观察区间长度是否严格递减。我建议在任何递归算法的边界上写一个简单的防御断言:

assert right - left < old_len

它可以帮你把“神秘栈溢出”变成一条清晰的报错信息。这个习惯救过我很多次。

3. 实测跑挂后的一条完整排查链路:O(n log n) 怎样悄悄退化成 O(n²)

3.1 从一份“看起来没问题”的代码开始

有一年我做日志归并统计,需要把几十万条带时间戳的记录先按时间排序,再按会话 ID 做归并。我信心满满地写了一个归并排序,本地自测一切正常,一上生产就卡死。我先怀疑数据量大,但同样的数据用sorted()只要两秒,我的函数跑了五分钟还没出结果。

我当时的第一反应是复杂度出了问题。O(n log n) 和 O(n²) 在数据量小的时候看不出区别,但 n 到几十万以后,差距会被放大到几百倍。所以排查的第一站不是优化常数,而是确认复杂度。

我打印出每次 merge 时两个子区间的长度,很快就发现问题:几乎所有 merge 都是把一个超长序列和一个长度只有 1 或 2 的序列合并。递归树失衡了,算法变成了事实上的 O(n²)。

3.2 最终定位:比较器带来的灾难

深入排查后发现,真正的病根不是分区逻辑,而是我自定义的比较器。当时为了按“时间 + 会话 ID + 序号”的组合键排序,我在比较器里拼接字符串,每次比较都消耗大量 CPU 和内存。归并排序的比较次数虽然是 O(n log n),但对这种重量级比较器来说,每次都比较三次拼接和字符串哈希,开销呈爆炸式增长。

在 Python 里,最直接的教训是:不要写 Python 比较器,而是把组合键算好存成元组。元组比较在 C 层实现,速度是自定义 Python 比较器的几十倍甚至上百倍。于是我把每条记录变成(timestamp, session_id, seq, record),让排序函数自己去比较元组,问题立刻消失。

如果你的语言或框架必须自定义比较器,请记住一个原则:比较器本身要尽可能 O(1),提前计算好可以预处理的字段,不要在比较函数里做 IO、字符串拼接、正则等重活。这个坑比算法本身更容易导致生产事故。

3.3 边界错误的典型症状

另一个高频边界问题出在 merge 的收尾处理。两个子序列中的某一个已经全部取完,另一个还剩下若干元素,必须把剩余元素全部追加到结果尾部。漏掉这段代码,结果数组就会缺元素,但排序结果通常是“大部分有序,个别位置古怪地错位”,很难一眼看出问题。

我建议专门为边界写一组测试数据:

  • 空数组
  • 单元素数组
  • 两个元素且已经有序
  • 两个元素且逆序
  • 大量重复元素
  • 数组长度为奇数

把这六类数据跑一遍,归并排序 90% 的边界 bug 都能暴露。重复元素那一条特别关键,它能同时验证稳定性和比较符号的正误。

4. 让归并排序真正“快起来”:插入排序阈值、自底向上和 Timsort 的启示

4.1 小数组切换插入排序,把常数压下去

归并排序的理论复杂度很漂亮,但常数项不小。每次 merge 都要维护三个指针、双重循环、复制临时数组,在数组很小的时候,这些开销远高于插入排序。插入排序虽然最坏情况是 O(n²),但它在接近有序的小数组上几乎是线性的,且常数极小。

所以业界通用做法是设置一个阈值,通常是 16 或 32。当数组片段长度小于等于阈值时,不再继续递归,直接改成插入排序:

INSERTION_THRESHOLD = 16 def insertion_sort_range(nums, left, right): for i in range(left + 1, right): key = nums[i] j = i - 1 while j >= left and nums[j] > key: nums[j + 1] = nums[j] j -= 1 nums[j + 1] = key

这个优化能让归并排序在真实数据上快 10% 到 20%。阈值不是拍脑袋定的:调得太小,递归仍然过深,优化不明显;调得太大,插入排序的 O(n²) 特性会拖后腿。我一般先用 16,再用 32 做对比测试,选性能更优的。不同硬件和语言结果会不一样,不要迷信别人给的数字。

4.2 自底向上的归并排序:消除递归,思路更工程

递归版本的归并顺序是“先一路向下拆,再返回向上合”。自底向上的归并排序反过来:先把数组看成 n 个长度为 1 的有序段,第一轮相邻两个合并成长度 2 的段,第二轮合并成长度 4 的段,直到整个数组有序。

自底向上的最大好处是没有递归,也就没有函数调用栈的额外开销。实现还特别适合理解归并的本质:你只需要重复做一件事——把相邻两个有序段合并。

def merge_sort_bottom_up(nums): n = len(nums) tmp = [0] * n size = 1 while size < n: for left in range(0, n - size, size * 2): mid = left + size right = min(left + size * 2, n) if mid < right: merge_range(nums, tmp, left, mid, right) size *= 2

注意这里的range(0, n - size, size * 2)很容易写错。如果写成range(0, n, size * 2),最后一轮可能会出现mid >= n的情况,导致 merge 的空区间或越界访问。我写这段代码时也是调了半天才意识到,循环终止条件必须保证最后一个有序段至少有size个元素。

自底向上的另一个优势是更容易做局部性优化:每一轮都顺序扫描整个数组,缓存友好度比递归版的跳跃式访问好。在 Java 或 C++ 环境里,它的实际性能常常超过朴素递归版。

4.3 Timsort 给我们的启发

Python 内置排序和 Java 对对象数组的排序用的都是 Timsort,它的核心思想就是归并排序的升级版。Timsort 会先扫描数组里已经天然有序的“run”片段,然后把这些 run 按照类似归并的方式合到一起。它聪明的地方在于:数据越接近有序,需要合并的 run 就越少,甚至已经有序的数组只需要扫描一遍就能完成排序,复杂度降到 O(n)。

这给我们一个很重要的工程启示:分析数据比选择算法更重要。如果你的数据经常是“基本有序但偶尔有逆序”,那直接上归并排序其实没吃到最大红利。正确做法是先识别 run,再做归并;如果业务数据本来就是随机打乱的,那识别 run 的收益不大,普通归并就够用。我一般会先写一个快速统计逆序对数量的脚本,看看逆序比例,再决定要不要上 Timsort 风格优化。

5. 多线程归并:分治天然适合并行,但瓶颈不在排序本身

5.1 先拆任务,再拆数据

归并排序的分治结构让它天生适合并行:左右两个子数组你可以放心地交给两个线程同时排序,因为它们互不依赖。等两边都排完,再由主线程做一次 merge。从理论上讲,只要核数足够,排序阶段可以被拆成多个并行子任务,整体时间大约从 n log n 降到 n log(n / p)。

但是并行归并有一个隐蔽问题:merge 阶段本身很难并行。你没法简单地让两个线程同时合并同一个数组,因为结果位置的计算涉及交叉关系。如果你只把排序阶段并行,最后归并阶段又变回单线程,那在大数据量下,归并的时间依然占总耗时的很大比例。所以并行优化不能只写递归线程,还要考虑 merge 并行策略。

一个常见的并行 merge 思路是:先找左数组和右数组的中位数,用二分找到它在另一个数组中的插入位置,这样就能把两个数组分成上下两段,每段独立合并到结果的不同区域。这个思路可以写成对数级别的准备开销,但实现复杂度明显增加。我建议先做最简单的并行递归,跑出性能数据以后再说——过早优化是大忌。

5.2 线程不是越多越好,深度阈值才是关键

写并行归并时最容易被忽视的是任务拆分的粒度。如果数组只有几百个元素,你也去创建两个线程,那么线程创建和调度开销会远超排序本身,性能反而断崖式下降。

我的经验是设置一个“并行深度阈值”:只有当数组长度大于某个值,比如 2048 或 4096 时,才继续分裂线程;小于这个值就直接走单线程的插入排序或普通归并。这样保证并行只发生在真正值得并行的大任务上。伪代码逻辑:

def parallel_merge_sort(nums, tmp, left, right, depth): if right - left <= PARALLEL_THRESHOLD: merge_sort_range(nums, tmp, left, right) return mid = (left + right) // 2 # 提交两个子任务到线程池 task_left = pool.submit(parallel_merge_sort, nums, tmp, left, mid, depth+1) task_right = pool.submit(parallel_merge_sort, nums, tmp, mid, right, depth+1) task_left.result() task_right.result() merge_range(nums, tmp, left, mid, right)

这个阈值不是越大越好。阈值太大,并行度不够;阈值太小,线程调度风暴。最好在你的目标机器上做一组小实验,画一条横轴为阈值、纵轴为耗时的曲线,选拐点位置。我在一台四核机器上测过,阈值 4096 比 128 快了近一倍,因为 128 时线程上下文切换占了太多时间。

5.3 用共享工作数组替代局部数组

并行归并里最常见的 bug 是每个线程各自分配 merge 用的临时数组,导致内存占用按线程数翻倍。正确做法是在最外层分配一个总长度 n 的共享工作数组,然后每个线程只使用它负责区间的对应部分,归并完再拷回原数组。这样不仅省内存,还能让多个线程在同一块连续内存里工作,减少缓存 miss。

但共享数组也带来了线程安全问题。如果两个线程同时操作工作数组的同一区域,数据就会被互相覆盖。解决办法是在任务划分时严格保证每个线程的工作区间不重叠,这个在归并排序里天然成立——左子任务使用[left, mid),右子任务使用[mid, right),互不相交。一旦你为了简化代码打破了这个区间约定,就会看到排序结果偶尔正确偶尔错乱的现象,这种偶发 bug 最磨人。

6. 归并思想跨过内存边界:外部排序与 k 路归并的实战思路

6.1 当数据大到内存装不下

归并排序有一个其他 O(n log n) 算法很难替代的价值:它可以工作在内存之外。快速排序、堆排序都要求所有数据随机访问,数据放不进内存就没法直接运行。而归并排序的 merge 过程只需要顺序读取两个有序片段,所以它天然适合磁盘、SSD 这种顺序访问很快、但随机访问很慢的存储介质。

我处理过一个场景:单日日志 200 GB,内存只有 16 GB,不可能一次性把数据读进内存排序。我当时用的就是外部排序的标准做法:

  1. 把大文件切分成多个可以放进内存的小块。
  2. 对每一块用普通内存排序算法排好,写回临时文件。
  3. 把多个临时文件同时打开,用归并思路合并成一个整体有序文件。

每个临时文件内部是有序的,但放在一起完全乱序。这时候归并排序的 merge 方法就派上用场了——从每个文件头部读取最小元素,放入结果。

6.2 两路归并升级为 k 路归并

如果临时文件只有两个,直接两两归并即可。但实际生成的临时文件可能成百上千,再用两路归并要反复读磁盘很多轮,代价非常大。更高效的做法是 k 路归并:

维护 k 个文件的当前读取位置,每次从 k 个元素里选出最小值,写入输出文件,并从对应的文件里补读下一个元素。朴素实现里,选出最小值需要扫描 k 个元素,复杂度 O(k),一旦 k 大了代价就会失控。

常规解法是用最小堆。把所有文件当前最小值得塞进一个小顶堆,堆顶就是全局最小,弹出后从对应文件补充一个新元素,堆重新调整。这样选最小值的时间复杂度从 O(k) 降到 O(log k),在文件数量很大时收益非常明显。用 Python 写一个伪代码流程:

import heapq files = [open(f) for f in sorted_chunks] heap = [] for idx, f in enumerate(files): first = f.readline() if first: heapq.heappush(heap, (first, idx)) with open("merged_output", "w") as out: while heap: val, idx = heapq.heappop(heap) out.write(val) next_line = files[idx].readline() if next_line: heapq.heappush(heap, (next_line, idx))

这段代码里我故意省略了很多工程细节,比如按记录切分、处理换行、统一编码。真实场景里每个细节都可能坑你一遍:二进制格式下记录边界怎么定、某个文件提前读完怎么处理、排序 key 和 value 是否要一起写入临时文件。

6.3 外部归并的实际教训

外部排序的性能瓶颈几乎永远在 IO,不在排序本身。第一次做外部归并时,我犯过的错误是频繁读取小数据块,每次读取都触发磁盘寻址,结果合并一整天都没完成。后来我把缓冲区调到 8 MB 以上,顺序读一批数据进内存,再逐条做归并,耗时立刻降到原来的十分之一。

另一点是关于临时文件数量和磁盘分区的。临时文件最好均匀分布到不同磁盘或分区上,让读写并行;如果全部堆在同一块磁盘,归并阶段的读写会互相抢占磁头。这个问题在做超大文件排序时很致命。

还有稳定性。外部归并排序要保证稳定性,必须做到两点:临时文件之间合并时,遇到相等 key 要优先取编号更靠前的文件;同一个文件内部,也要保证之前排序的稳定性。你在内存排序阶段用了一个不稳定排序,整个外部排序的稳定性就被破坏了,后面补都补不回来。

最后提一个容易被忽略的现实问题:崩溃恢复。临时文件写完以后如果进程中途挂掉,所有临时文件都变成垃圾。我的习惯是每个临时文件生成后立即写入元信息,比如数据范围、记录数、校验值;归并完成后统一清理。万一某次跑到一半崩溃,至少能知道哪些文件是完整的、哪些要重做,而不是从头再来。这种细节通常不会写进任何算法教科书,但生产环境里它们往往比算法本身更决定成败。

我个人的最终体会是:归并排序是少有的“教学价值和工程价值同样高”的算法。你既可以用它理解分治、递归、复杂度分析,又可以直接把它改造成多线程版本、外部排序版本、或者 Timsort 这样的生产级排序。每当你觉得自己的排序需求“内存装不下”或“必须稳定”时,都可以回到归并排序这个基础框架上,再叠加你的业务约束,它基本都能接得住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询