第一次认真琢磨归并排序,是在一次面试被问到“说说你熟悉的排序算法”的时候。当时我张口就是快排,面试官点点头,又补了一句:“那归并排序呢?说说它的稳定性和空间复杂度。”那一瞬间我意识到,很多人(包括当时的我)都把归并排序当成“听说过但没细想过”的算法——知道它是分治,知道它稳定,但真要手写、要讲清楚每一步为什么这样做,还是会卡壳。
归并排序(Merge Sort)是计算机科学里最经典的排序算法之一,也是“分治思想”最直观的落地案例。它不追求像快速排序那样“分完就基本有序”的巧妙,而是用一套笨拙却极其可靠的办法:先把数组不断对半拆,拆到不能再拆,再在合并过程中把每一对子数组排好序。这种“先拆后合”的策略,天然保证了两个关键特性——稳定性和确定性。无论输入数据是什么分布,它的时间复杂度都是 O(n log n),不会像快排那样在最坏情况下退化到 O(n²)。
这篇内容适合谁?如果你正准备面试、刚学数据结构,或者在工作中要处理链表排序、外部排序这类场景,归并排序都是绕不开的基础功。我会从分治思路讲起,带你把递归实现、迭代实现、复杂度推导、工程选型一次聊透,最后附上我踩过的几个坑。
1. 归并排序的整体设计与思路拆解
1.1 分治思想:为什么“先拆再合”就能排序?
很多人第一次看归并排序都会有个疑问:拆到只剩一个元素,和排序有什么关系?一个元素当然是有序的;关键在“合”。当我们把两个已经各自有序的子数组合并成一个有序数组时,这个合并操作本身是线性时间 O(n) 的。于是整件事的逻辑就变成了:把一个大的无序问题,递归地拆成两个规模一半的子问题,先让子问题有序,再通过一次线性扫描把两个有序子问题归并成一个有序整体。
这个“递归拆、线性合”的结构,用数学语言描述就是递推式 T(n) = 2T(n/2) + O(n)。展开后每一层合并的总工作量都是 O(n),一共有 log₂n 层,所以总时间复杂度稳定在 O(n log n)。
这里我想对比一下归并排序和快速排序的分治差异,因为它们虽然都叫“分治”,但思路正好相反:
- 快排是“先难后易”:partition 阶段就要做大量比较和交换,把基准值放到正确位置;递归回到上层时,数组已经大体有序,所以合并(其实是“什么都不用做”)极其轻松。
- 归并是“先易后难”:拆的时候只是计算 mid,几乎什么都不做;真正费工夫的是回溯阶段每一次 merge,需要一次额外的线性扫描和临时数组。
理解这个对比很重要。它会直接影响你对两个算法“哪一步最耗时”的直觉。快排的耗时分摊在分解层,归并的耗时分摊在合并层。因为合并必须扫过所有元素,归并排序天然无法做到像快排那样“原地”排序,需要额外 O(n) 空间。
1.2 合并过程的底层逻辑:核心是“两个有序数组的归并”
归并排序真正的灵魂不是“分”,而是“合”。只要你理解了“两个有序数组合并成一个有序数组”这个子过程,归并排序就理解了一大半。
假设有两个有序数组 A 和 B,长度分别是 m 和 n。合并的思路特别朴素:用两个指针 i、j 分别指向 A 和 B 的开头,比较 A[i] 和 B[j],谁小就把谁放到结果数组里,然后对应指针前进一位。当一个数组被取完,就把另一个数组剩下的部分直接拼到结果末尾。这个过程每个元素只会被比较和移动一次,所以是 O(m+n)。
我一般用“两队人按身高排队”来给学生讲这个场景:两队人已经各自从矮到高排好了,现在要把两队合成一队仍然从矮到高。你只需要每次看队首两个人谁矮,让矮的先出来站到新队里,直到某一队空了,另一队全体跟上就行。归并排序里的 merge 函数,干的就是这件事。
这个子过程“稳定”是天然的:因为当 A[i] 和 B[j] 相等时,我们可以约定先取 A 的元素(也就是左半部分的元素),这样就保证相等元素的相对顺序不会改变。这一条是归并排序稳定性的来源,也是后续写代码时最容易出错的点,后面我会详细讲。
2. 核心实现与代码拆解
2.1 递归版归并排序:Java 与 Python 双版本
先给出最标准的递归实现。我用 Java 写一版教学风格的,重点在于可读性和边界清晰。
public class MergeSort { public static void mergeSort(int[] arr, int left, int right) { if (left >= right) { return; // 区间只有一个元素或为空,天然有序 } int mid = left + (right - left) / 2; // 防止 left + right 溢出 mergeSort(arr, left, mid); // 排序左半区间 mergeSort(arr, mid + 1, right); // 排序右半区间 merge(arr, left, mid, right); // 合并两个有序区间 } private static void merge(int[] arr, int left, int mid, int right) { int[] temp = new int[right - left + 1]; // 临时数组,长度等于区间长度 int i = left; // 左半区间指针 int j = mid + 1; // 右半区间指针 int k = 0; // 临时数组指针 while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { // 注意是 <=,相等时取左半部分,保证稳定性 temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } // 左半区间还有剩余 while (i <= mid) { temp[k++] = arr[i++]; } // 右半区间还有剩余 while (j <= right) { temp[k++] = arr[j++]; } // 把临时数组的内容拷贝回原数组 for (int p = 0; p < temp.length; p++) { arr[left + p] = temp[p]; } } public static void main(String[] args) { int[] arr = {38, 27, 43, 3, 9, 82, 10}; mergeSort(arr, 0, arr.length - 1); for (int num : arr) { System.out.print(num + " "); } } }如果你更习惯 Python,简洁版本长这样:
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 resultPython 版本因为切片操作每次都创建新数组,空间消耗比 Java 版本更大,但写起来直观,适合快速验证思路。真正追求性能时,更推荐用“单数组 + 索引区间”的方式,避免反复切片。
2.2 merge 细节决定成败:边界、临时数组与稳定性
网上抄一段归并排序代码很容易,但能写出“没有隐形 bug”的版本,需要的恰恰是几个细节。我把它们逐个拆开讲。
边界条件:left >= right而不是left == right。递归到区间只有一个元素时返回即可。但如果传入空区间(比如某些实现里可能出现),left > right也必须能优雅退出。我习惯写成if (left >= right) return;,这样两个边界都能覆盖。
mid 的计算:用left + (right - left) / 2而不是(left + right) / 2。这在绝大多数面试场景下不会被追问,但如果数组长度极大(超过 int 上限的一半),left + right可能溢出,得到负数 mid。用差值除二就是绝对的稳妥写法。这也是《Effective Java》里推荐的经典写法。
临时数组的创建位置:merge 方法里新建临时数组是最容易理解的做法,但每次都 new 一个数组,频繁分配内存会导致性能明显下降。行业里的通用优化是:在递归入口一次性申请一个长度为 n 的全局临时数组,merge 时只在该数组的不同区间片段上操作,不复用则已,一用到底。我后面专门有一节会讲这个优化。
稳定性:<=与<的区别。合并时,如果左区间值小于右区间值,自然取左;但如果相等呢?如果你想保持稳定性,应该取左区间的值,所以条件必须是<=。如果你写成<,遇到相等元素时会先取右区间的,这样相等元素的原始先后顺序就被打乱了。很多教学代码在这个细节上不严谨,面试官恰恰喜欢在这里抠。
3. 实操过程与核心环节实现
3.1 一次完整的归并排序手算演练
光说理论容易飘,我们用数组[38, 27, 43, 3, 9, 82, 10]完整走一次归并排序的递归过程,搞清楚每一层的区间划分和合并动作。
第一步,递归拆分(只记录 mid 和区间):
- 原始区间 [0, 6]
- 拆成 [0, 3] 和 [4, 6]
- [0, 3] 再拆成 [0, 1] 和 [2, 3]
- [0, 1] 拆成 [0, 0] 和 [1, 1]
- [2, 3] 拆成 [2, 2] 和 [3, 3]
- [4, 6] 拆成 [4, 4] 和 [5, 6]
- [5, 6] 拆成 [5, 5] 和 [6, 6]
第二步,自底向上合并:
- 合并 [0,0] 和 [1,1]:比较 38 和 27,27 小先出,38 后出,得到
[27, 38] - 合并 [2,2] 和 [3,3]:比较 43 和 3,3 先出,43 后出,得到
[3, 43] - 合并 [0,1] 和 [2,3]:现在两个区间分别是
[27, 38]和[3, 43],开始双指针比较:3 < 27,取 3;27 < 43,取 27;38 < 43,取 38;43 落单,取 43。得到[3, 27, 38, 43] - 合并 [4,4] 和 [5,6]:9 和
[82, 10]内部先合并得到[10, 82],再与 9 合并:9 < 10 取 9,10 < 82 取 10,82 落单,得到[9, 10, 82] - 最后合并左半
[3, 27, 38, 43]和右半[9, 10, 82]:3 < 9 取 3,9 < 27 取 9,10 < 27 取 10,27 < 82 取 27,38 < 82 取 38,43 < 82 取 43,82 落单,最终得到[3, 9, 10, 27, 38, 43, 82]
看完这个手算过程,你就能理解为什么归并排序是确定性的:它的比较路径不依赖于数组初始分布,完全由区间拆分决定,因此最坏情况、最好情况、平均情况时间复杂度都是 O(n log n)。这也正是它在某些对“性能波动”敏感的系统中更受青睐的原因。
3.2 性能实测与工程选型:归并排序何时登场?
我自己跑过一轮简单基准测试,对不同算法排序 10 万级随机整数,结果大致如下:
| 算法 | 随机数据 | 几乎有序数据 | 逆序数据 | 是否稳定 | 额外空间 |
|---|---|---|---|---|---|
| 归并排序 | 约 20 ms | 约 18 ms | 约 20 ms | 是 | O(n) |
| 快速排序(基本写法) | 约 12 ms | 约 8 ms | 栈溢出或极慢 | 否 | O(log n) 栈 |
| 堆排序 | 约 18 ms | 约 15 ms | 约 18 ms | 否 | O(1) |
数据只是观感,不同机器差异很大,但规律是明显的:快排在随机场景下通常最快,因为它局部性好、缓存命中率高;归并排序的优势不在绝对速度,而在“平均即最坏、最坏即平均”的稳定性,以及它对链表这类非随机访问结构的友好性。
工程实战里,归并排序最典型的三块阵地是:
- 链表排序。链表无法像数组那样 O(1) 随机访问,快排的 partition 需要频繁移动指针,效率大打折扣;而归并排序只需要遍历链表和改 next 指针,既能达到 O(n log n),又能保持稳定。Java 的
Collections.sort()对 List 的默认排序,底层走的就是 TimSort,本质上是归并排序的改进版本。 - 外部排序。当数据量远超内存,必须用磁盘存储时,归并排序几乎是唯一的选择。它可以把大文件切成若干小文件分别排序,再用多路归并合并出完整有序文件。数据库系统处理海量排序时,底层的“归并段 + 多路归并”思路,就来自这里。
- 对稳定性有强需求的场景。比如先按姓名排序,再按年龄排序,要求最终结果年龄相同的人内部仍按姓名有序。这时候不稳定排序会破坏第一轮排序结果,而归并排序因为是稳定排序,可以放心链式调用。
工程选型还有一个判断技巧:如果你不能确定输入数据的分布,又对性能波动极度敏感,选归并排序比选快排更让人安心。我用了一个“龟兔赛跑”的类比来记这件事:兔子快但是可能睡觉(快排的最坏情况),乌龟慢但从不抄近道出错(归并的确定性)。
4. 常见问题与排查技巧实录
4.1 递归变迭代:自底而上的归并排序怎么写?
递归版本最大的隐患是栈深度。对长度 100 万的数组,递归深度大约是 log₂1000000 ≈ 20 层,其实完全没问题。但如果是单链表形式的归并排序,某些实现递归深度会随链表长度线性增长,就危险了。此外,如果面试官要求你写迭代版,你得能反应过来。
自底向上的归并排序思路是:先从 size=1 的子数组开始两两合并,再 size=2、size=4 逐步扩大,直到整个数组有序。它不需要递归,只需要两层循环:
public static void mergeSortIterative(int[] arr) { int n = arr.length; int[] temp = new int[n]; for (int size = 1; size < n; size *= 2) { for (int left = 0; left < n - size; left += 2 * size) { int mid = left + size - 1; int right = Math.min(left + 2 * size - 1, n - 1); merge(arr, temp, left, mid, right); } } }注意right的取值用了Math.min,因为最后一组可能凑不满 2 * size 个元素。这里最容易忽略的就是右边界越界。我曾经在迭代版上踩过一次,合并最后一段时把越界下标写进了临时数组,调试了半天才定位到是边界没收敛。如果你写自底向上版本,默认先把数组长度打印出来,把left、mid、right三个值在每个外层循环里核一遍,能省太多事。
4.2 实战排错速查表:写完代码一定要对照检查
我整理了一张归并排序常见问题速查表,当你发现排序结果不对、性能奇怪的时候,按顺序逐条排查:
| 症状 | 可能原因 | 处理方法 |
|---|---|---|
| 排序结果少元素或多了重复元素 | 临时数组长度算错,或复制回原数组的偏移量写错 | 检查temp长度应该是right - left + 1,拷贝时用arr[left + p] |
| 相等的元素顺序被打乱 | merge 里比较用了<而不是<= | 改成<=,让相等时优先取左半区间的元素 |
| 大数据量时递归栈溢出 | 递归深度过大,或者递归终止条件不严谨 | 检查终止条件,必要时改用自底向上迭代版 |
| 性能比预期差很多 | 每次递归都新建临时数组,频繁分配内存 | 复用同一个临时数组,在递归入口一次申请 |
| mid 出现负数 | (left + right)溢出 | 改用left + (right - left) / 2 |
| 只排了一半,另一半乱序 | 递归调用区间写错,比如右半调用传成了(mid, right)而非(mid+1, right) | 重新核对区间下标,永远是[left, mid]和[mid+1, right] |
| 基于链表的归并排序出现环 | 合并链表时没把尾部 next 置空 | 合并结束后显式将尾节点 next 指向 null |
这份速查表是拿时间换来的。我早期有一次面试,手写归并排序时把mid + 1错写成mid,结果两个区间重叠了一个元素,合并结果里出现重复数字,面试官一眼就看出来了。从那以后我养成了“先写两个递归调用,再写合并函数,最后写边界”的固定顺序,减少漏写。
还有一个小技巧我强烈推荐:写完归并排序后,别只跑一遍正常用例。跑一遍逆序数组[5,4,3,2,1],跑一遍含重复元素的数组[2,3,1,2,3],再跑一遍空数组和单元素数组。这四组能覆盖绝大多数 bug 触发条件。
4.3 关于额外空间的两个进阶优化
归并排序被吐槽最多的问题就是它需要 O(n) 的额外空间。我来分享两个实践中常用的优化思路。
第一个是复用临时数组。我在 2.2 节提到的“一次申请,全局复用”,代码上大致是先在mergeSort入口创建temp,然后把temp作为参数传入递归和 merge。这样空间开销从“每次合并都新建数组”变成“总共只占用一次 O(n) 空间”,实际运行时间也能明显改善。如果面试官追问“归并排序的空间复杂度是多少”,标准的 O(n) 回答里隐含的前提就是这种复用写法;如果你每次都new int[],空间开销会叠加到更多,即使理论上复杂度仍然是 O(n)。
第二个是原地归并。有些论文里的技巧可以在 O(1) 额外空间下完成归并,但实现极其复杂,常数因子很大,工程上基本不用。我对这类技巧的态度是:理解存在即可,不用死磕。面试中如果被问“能否原地归并”,你可以坦率地说“理论上有原地归并方案,但复杂度常数很大,实际工程里一般用临时数组换取简洁和稳定”,这个回答反而比硬写一个易错的复杂实现更显成熟。
4.4 归并排序的“远亲”:从归并排序到 TimSort
最后聊一个我在实际工程里体会很深的东西:归并排序并没有停留在教科书里,它进化出了应用极广的改进版 TimSort。
TimSort 的核心思路是:先扫描数组中已经有序的“run”片段,把这些天然有序的片段当作归并的初始子数组,再用归并的方式把它们合并。它的最佳时间复杂度可以降到 O(n),也就是说,对于几乎有序的数据,它会比普通归并排序快很多。Java 的Arrays.sort()在对象类型排序、Python 的list.sort()用的都是 TimSort 的变体。
我为什么要在讲归并排序时提它?因为理解归并排序的 merge 操作和递归框架后,再看 TimSort 的“以天然有序段为基础做多路归并”,逻辑几乎是平滑过渡的。算法学习最有趣的体验之一,就是发现一个基础算法在真实系统中以另一种形态“活着”。
我自己现在写排序相关的代码时,除非是面试或教学场景,否则几乎不会手写归并排序,而是直接调用语言自带基于 TimSort 的排序接口。但归并排序的思想——把大问题拆到可解,再用线性合并把答案组装回来——在写归并、写外部排序、写并行计算时反复用到。它会变成你思考问题的一种底层习惯。
提示:学习归并排序,不要只背模板,一定要亲手把 merge 写一遍,再手动跑一次拆分合并全过程。能把“为什么相等时取左边”这样的细节讲清楚,才说明你真正掌握了它。