我这些年面试过不少人,也带过一些刚入行的同事。每次聊到排序,只要对方能15分钟内把“二路归并排序(合并排序)”写对、讲清楚,我基本会在他简历上多画一个勾。不夸张地说,这个算法就是数据结构与算法里“分治思想”的门面担当。它不像快排那样看运气,不像堆排那样难调,更不像冒泡排序那样只能活在教科书里。归并排序的好处是:稳定、时间复杂度固定在O(n log n)、思路直观到可以用一张纸讲明白。不管是考研、刷题、面试,还是以后做大数据量排序,它都是一块绕不开的基石。
这篇文章我就从实际写代码和调试的角度出发,把二路归并排序的来龙去脉、实现细节、复杂度推导、常见坑和经典扩展全部拆开揉碎讲一遍。新手可以照着一步步复现,有基础的朋友也可以重点看后面的逆序对、链表归并这些进阶内容。
1. 二路归并排序的核心思路与方案选型
1.1 分治到底拆了什么:从“二分”到“合并”
归并排序的核心只有两句话:先拆,拆到不能再拆;再合,合出来的序列就是有序的。
这句话听起来简单,但真正理解“为什么拆完再合就有序了”才是关键。拿一个长度为5的数组举例:[4, 5, 3, 1, 2]。
第一步拆:从中间切成两半,变成[4, 5, 3]和[1, 2]。 第二步继续拆:[4, 5, 3]切成[4, 5]和[3],[1, 2]切成[1]和[2]。 第三步继续拆:[4, 5]切成[4]和[5]。这时候数组变成5个单元素子数组:[4]、[5]、[3]、[1]、[2]。
单元素数组有一个天然属性——它自己就是有序的。这是整个递归的出口,也是整个算法的地基。接下来开始合并:
- 合并
[4]和[5],得到[4, 5] - 合并
[4, 5]和[3],得到[3, 4, 5] - 合并
[1]和[2],得到[1, 2] - 最后合并
[3, 4, 5]和[1, 2],得到[1, 2, 3, 4, 5]
整个过程像不像把一副打乱的扑克牌分成两摞,分别排好序,再把两摞牌一张一张地抽出来合成一摞有序的牌?归并排序的“二路”就是指每次合并两个有序子序列。三路归并、多路归并也存在,但所有思路都是在这个基础上扩展出来的。
这里有一个很多人没想通的问题:为什么拆开再合,就一定比直接排序快?因为合并两个有序序列的开销是线性的——只需要每个元素比较一次就能选出来,而排序一个无序序列的开销至少是O(n log n)。分治把“大问题排序”变成了“小问题排序 + 线性合并”,递归下去以后,每一层合并的总代价都是O(n),一共log n层,总成本就是O(n log n)。所谓分治,本质上就是把一个难啃的骨头切成好啃的小块,啃完再拼回来。
1.2 为什么不选“原地”硬排序:空间换时间的取舍
很多初学者会问:为什么归并排序不能像插入排序那样原地交换,非要开一个临时数组?答案很简单:两个有序子序列合在一起,必须有一个中转站存放中间结果。
想象一下你面前有两堆已经排好序的扑克牌,现在要合成一堆有序的。你会怎么做?一定是拿在手里一张一张比较,把较小的那张放到桌面上。这个“桌面”就是临时数组。如果不允许额外空间,你又不能破坏两堆牌内部的有序性,操作复杂度会急剧上升。
有人可能听说过“原地归并排序”,就是把两个有序段在同一个数组里来回交换。说实话,这种写法面试做秀可以,实际工程里没人爱用:代码难写、常数巨大、还容易写出隐藏bug。归并排序用O(n)的临时数组,换来的是代码清晰、逻辑简单、行为可预测。在内存以GB计的今天,为了省几MB空间把代码搞到天怒人怨,是典型的得不偿失。
另外,归并排序在工程中被大量使用还有一个原因:稳定。两个相等元素的相对顺序在排序后不会改变。这点在Java的Arrays.sort对对象排序、数据库排序、多关键字排序里特别重要。快排虽然平均情况下更快,但它是稳定的吗?不是。所以如果你需要稳定排序,又对时间有硬性要求,归并几乎是唯一的选择。
2. 从递归到迭代:两种实现写法与细节分析
2.1 递归版实现与合并函数拆解
先给一份可以直接跑的C++实现,这是归并排序最经典的形态:
#include <vector> using namespace std; void merge(vector<int>& arr, int left, int mid, int right) { vector<int> tmp(right - left + 1); int i = left; // 左半部分起点 int j = mid + 1; // 右半部分起点 int k = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { tmp[k++] = arr[i++]; } else { tmp[k++] = arr[j++]; } } while (i <= mid) { tmp[k++] = arr[i++]; } while (j <= right) { tmp[k++] = arr[j++]; } for (int p = 0; p < k; p++) { arr[left + p] = tmp[p]; } } void mergeSort(vector<int>& arr, int left, int right) { if (left >= right) { return; } int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); }这份代码里最核心的是merge函数。我说几个细节:
第一,mid = left + (right - left) / 2。有些教材写(left + right) / 2,在left和right都很大时可能整数溢出。写成这种带差值的形式是纯防御性写法,面试时不写这个至少也得知道它会溢出。
第二,while (i <= mid && j <= right)。两个指针i和j分别指向左右两个子数组的当前位置,谁小谁先进临时数组。这个比较是归并排序的灵魂:两个有序数组合并时,你永远只需要看两个数组当前最小元素,取走较小的那个,整体结果就一定有序。
第三,为什么最后要把tmp里的数据拷回原数组?因为归并排序是递归的,上一层还要把这一层的结果当作左半部分或右半部分继续合并。如果只排序临时数组而不写回原数组,上一层拿到的还是乱序数据,整个算法就断了。这就像流水线上一个工位干了活却忘了传给下一个工位,整条线全停。
2.2 迭代版实现:不用递归也能归并
递归版好理解,但递归有一个天然短板:栈深度跟数组长度相关,数组特别大时可能爆栈。迭代版(也叫自底向上归并)能把这个问题完全绕开。
迭代版的思路是反过来想:既然单元素数组天然有序,那就从长度为1的子数组开始,两两合并成有序的2元素子数组;然后4个一组合并成有序的4元素子数组;以此类推,直到整体有序。
void mergeSortIterative(vector<int>& arr) { int n = arr.size(); for (int gap = 1; gap < n; gap *= 2) { for (int left = 0; left < n; left += 2 * gap) { int mid = min(left + gap - 1, n - 1); int right = min(left + 2 * gap - 1, n - 1); if (mid < right) { merge(arr, left, mid, right); } } } }这段代码里唯一需要动脑子的是mid和right的边界处理。当数组长度不是2的整数次幂时,最后一组可能凑不满,这时候min函数确保下标不越界。if (mid < right)是为了避免最后一组只有一个子数组、没必要合并的情况。
我用一张简单的手算过程来说明gap的变化。假设数组长度是6,gap从1开始:
- gap=1:合并
[0..0]和[1..1]、[2..2]和[3..3]、[4..4]和[5..5],得到3个长度为2的有序组。 - gap=2:合并
[0..1]和[2..3],[4..5]落单不用处理,得到1个长度为4和一个长度为2的有序组。 - gap=4:合并
[0..3]和[4..5],注意这里的mid是min(0+4-1, 5)=3,right是min(0+8-1, 5)=5,合并后全数组有序。
迭代版的时间复杂度和递归版完全一样,但省掉了递归调用栈。实战中如果你处理的是超大数组,迭代版会让人更安心。
2.3 稳定性和边界条件,这两个细节决定成败
稳定性这个词听起来抽象,举一个具体例子就明白了。假设有一个学生成绩表,先按学号排好序,现在想按成绩排序,要求成绩相同的人仍然按学号顺序排列。这就要用到稳定排序。
归并排序的稳定性完全取决于merge里的一行判断。看代码:
if (arr[i] <= arr[j]) { tmp[k++] = arr[i++]; }注意用的是<=而不是<。当左半部分元素等于右半部分元素时,我们优先取左半部分。因为左半部分在原始数组中本来就排在右半部分前面,所以相等元素的相对顺序保住了。如果这里写成<,遇到相等元素时右半部分会先被取走,等于把两个相同元素的顺序颠倒了,稳定性就没了。
这一个符号,就是稳定与不稳定之间的距离。很多人手写归并时图省事写成<,表面上看排序结果没错,但严格来说写出的已经不是稳定归并了。面试官如果追问一句“你这个稳定吗”,答不上就很尴尬。
边界条件方面最常见的坑有三个:
- 递归出口写成
left == right而不是left >= right。如果传入mergeSort(arr, 0, 0)没问题,但传入一个空区间比如mergeSort(arr, 0, -1)就会死循环或越界。 merge里的临时数组大小写错。应该是right - left + 1,有人写成arr.size(),浪费空间不说,拷贝回去时arr[left + p]和tmp[p]的对应关系容易搞错。- 返回拷贝时以
k为准而不是以临时数组长度为准。因为两个子数组可能出现一个先耗尽的情况,最终有效元素就是k个。用临时数组的size()做循环也可以,但多拷几个未初始化的位置就有隐患。
3. 复杂度分析与排序全家桶对比
3.1 时间复杂度为什么稳定在 O(n log n)
归并排序的时间复杂度推导是数据结构考试里最常考的内容之一。设对n个元素排序的时间为T(n),那么:
- 分解:把数组对半分,耗时为常数O(1)。
- 解决:递归排序左右两个半区,耗时为2T(n/2)。
- 合并:扫描一遍所有元素并拷贝回原数组,耗时为O(n)。
于是得到递推式:T(n) = 2T(n/2) + O(n)。
这个递推式展开后每一层都是O(n),一共log2(n)层,所以T(n) = O(n log n)。更直观的理解方式:每次合并要处理的元素总数固定是n个,而有log n层合并,总工作量就是n乘以log n。
归并排序有一个其他O(n log n)排序都没有的特点:时间复杂度和输入数据的初始顺序无关。最好情况是O(n log n),最坏情况也是O(n log n)。不像快排,最坏退化到O(n^2);也不像插入排序,最好虽然O(n),但平均和最坏都是O(n^2)。这种可预测性在实时系统、数据库等对响应时间有硬性要求的场景里非常宝贵。
3.2 和快排、堆排、插入排序放在一张桌上对比
| 排序算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n^2) | O(n^2) | O(1) | 稳定 | 教学示例 |
| 选择排序 | O(n^2) | O(n^2) | O(1) | 不稳定 | 数据量极小 |
| 插入排序 | O(n^2) | O(n^2) | O(1) | 稳定 | 数据量小且近有序 |
| 快速排序 | O(n log n) | O(n^2) | O(log n) | 不稳定 | 一般排序首选 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 需要原地排序 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 要求稳定或链表排序 |
这个表我建议直接背下来,面试时能省很多思考时间。关键是看懂表背后的逻辑:归并和堆排在最坏情况下的表现都比快排好,但快排的常数因子小,实际跑起来在大多数场景反而更快。所以工程上快排占据主流,而归并排序在需要稳定性、处理链表、处理超大文件外排序这三个方向稳稳地坐着第一把交椅。
3.3 空间复杂度到底高在哪:O(n)的临时数组和递归栈
归并排序的空间复杂度经常被一句话概括为O(n),但更严谨地说,它由两部分组成:
一部分是合并时创建的临时数组。每次merge最多需要right - left + 1个额外空间,最大的一次出现在最后一轮合并,大小接近n。所以这部分空间是O(n)。
另一部分是递归调用栈。递归深度是log2(n)层,每层保存的信息量是常数,所以栈空间是O(log n)。两者取大,整体空间复杂度就是O(n)。
有一个小优化技巧很多人不知道:可以在递归函数外复用同一个临时数组,在合并时只拷贝需要的区间。这样做的好处是避免每次递归都重新申请、释放内存,能显著减少常数时间。伪代码思路如下:
void mergeSort(vector<int>& arr, vector<int>& tmp, int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; mergeSort(arr, tmp, left, mid); mergeSort(arr, tmp, mid + 1, right); int i = left, j = mid + 1, k = left; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) tmp[k++] = arr[i++]; else tmp[k++] = arr[j++]; } while (i <= mid) tmp[k++] = arr[i++]; while (j <= right) tmp[k++] = arr[j++]; for (int p = left; p <= right; p++) arr[p] = tmp[p]; }外层调用时创建一次tmp,内部所有递归共用同一个。对追求极致性能的场景,这算是一个值得掌握的细节。
4. 常见问题与排查技巧实录
4.1 面试和作业里最典型的三个报错现场
第一个问题是数组越界。症状是程序运行到一半崩溃,或者排序结果里出现随机大数。排查方法很简单:在merge开头打印left、mid、right,检查是否满足left <= mid < right。我见过最离谱的情况是有人把递归出口写成if (left == right) return,然后主调函数传了(arr, 0, n-1),当数组长度为偶数时,某次分解会出现left > right,直接一路递归到栈溢出。
第二个问题是数组最后几个元素没有参与排序。这通常发生在迭代版里。原因是对mid和right的边界处理不对。举个实际例子:数组长度为7,gap=2时,left=4这一组,mid = min(4+2-1, 6) = 5,right = min(4+4-1, 6) = 6,看起来没问题;但left=6时,mid = 6,right = 6,mid < right不成立,跳过合并——这是对的,因为只有一个元素不需要合。真正容易出错的是left = 0, gap = 4时,mid = min(3, 6) = 3,right = min(7, 6) = 6,能正常合并。如果这里把mid算成left + gap / 2,就全乱了。
第三个问题是排序结果正确但顺序不稳定。这在代码层面上表现为整个排序没问题,但相等元素的相对位置变了。不用debug,直接检查merge里的比较符号。写成<就一定会破坏稳定性,改成<=则恢复。
我做了一张速查表,可以贴在电脑旁边:
| 异常现象 | 最可能原因 | 快速修复 |
|---|---|---|
| 栈溢出 | 递归出口不是left >= right | 改递归出口 |
| 部分元素没排序 | 迭代版mid/right边界算错 | 检查min边界 |
| 排序结果含随机值 | 临时数组大小或拷贝范围错 | 用right - left + 1创建tmp |
| 相等元素顺序变 | merge里用了< | 改成<= |
| 大数据量时性能骤降 | 每次递归都创建临时数组 | 改为复用同一个tmp |
4.2 逆序对问题:归并排序的高频扩展
如果说归并排序本尊在面试中出现的概率是30%,那“用归并排序求逆序对”就是20%的附加题。这题的好玩之处在于:你如果不学归并排序,暴力解法是O(n^2);学了归并排序,顺手就能优化到O(n log n)。
逆序对的定义是:一个数组中,如果i < j且arr[i] > arr[j],那么(i, j)就是一个逆序对。比如[3, 1, 2]中,逆序对是(3,1)和(3,2),共2个。
为什么归并排序能数逆序对?因为在merge过程中,当右半部分的arr[j]小于左半部分的arr[i]时,左半部分从i到mid的所有元素都比arr[j]大,也就是说这次合并直接数出了mid - i + 1个逆序对。
long long mergeCount(vector<int>& arr, int left, int mid, int right) { vector<int> tmp(right - left + 1); int i = left, j = mid + 1, k = 0; long long count = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { tmp[k++] = arr[i++]; } else { count += mid - i + 1; // 关键:一次数出一批逆序对 tmp[k++] = arr[j++]; } } while (i <= mid) tmp[k++] = arr[i++]; while (j <= right) tmp[k++] = arr[j++]; for (int p = 0; p < k; p++) arr[left + p] = tmp[p]; return count; }笔试里这题还有两个常见变体:一是求“重要逆序对”,条件是arr[i] > 2 * arr[j],解法是在merge里嵌套一个二分查找;二是数组元素可能是重复的,此时注意用<=判断,避免把相等元素误算成逆序对。这个扩展题我建议每个人都亲手写一遍,它能把你对归并排序的理解从“会写”提升到“会用”。
4.3 链表归并排序:不用额外数组的经典应用
数组版归并排序需要O(n)额外空间,但链表的归并排序可以做到O(1)额外空间。这听起来像是魔法,其实只是因为链表不需要一整块连续内存来存放临时数据。
链表归并的核心操作有两个:找中点和合并两个有序链表。找中点用快慢指针,慢指针走一步快指针走两步,快指针到末尾时慢指针刚好在中间。合并则是一个标准的双链表合并循环。
struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) { ListNode dummy(0); ListNode* tail = &dummy; while (l1 && l2) { if (l1->val <= l2->val) { tail->next = l1; l1 = l1->next; } else { tail->next = l2; l2 = l2->next; } tail = tail->next; } tail->next = l1 ? l1 : l2; return dummy.next; } ListNode* sortList(ListNode* head) { if (!head || !head->next) return head; ListNode* slow = head; ListNode* fast = head->next; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } ListNode* right = sortList(slow->next); slow->next = nullptr; ListNode* left = sortList(head); return mergeTwoLists(left, right); }这里面有一个很精妙的细节:slow->next = nullptr必须在递归排序右半部分之后再做,因为递归右半部分时需要用到slow->next作为右半部分的头节点。这个顺序写反了,整个链表就会断成两截。我当时第一次写链表归并就被这个顺序坑过,调了半小时才反应过来。
链表归并在实际生产环境中有个直接应用:数据库的外部排序和某些编程语言标准库里的链表排序。Java的Collections.sort对链表实现就是归并排序的思路,而不是快排。
4.4 外排序与超大文件归并思路
归并排序还有一个经常被忽略的应用场景——外排序。什么叫外排序?就是数据量大到内存装不下,必须把一部分数据放在磁盘上。典型场景是数据库对几十GB的文件做排序。
外排序的做法和迭代版归并很像,只是“元素”换成了“文件块”:
- 把大文件切成若干块,每块大小刚好能加载进内存。
- 对每块在内存里用快排或归并排好序,写回磁盘。这时候磁盘上有多个有序的小文件。
- 对所有这些有序小文件做K路归并,每次从K个文件中各取一条最小的记录,放到输出缓冲区。
K路归并可以用败者树或堆来优化取最小值的速度。你可能会想,为什么外排序一定要用归并而不是快排?因为快排在归并阶段需要频繁随机访问数据,而磁盘的随机访问速度比顺序访问慢好几个数量级。归并排序的优势是只做顺序读写,对磁盘这种机械结构特别友好。
这个场景说明了归并排序的另一个武器:它对存储介质的访问模式非常友好。虽然我们在单机小数组上看不出这个优势,但放大到分布式存储、大规模数据的场景,顺序IO就是命根子。
5. 我对归并排序的几点实战体会
最后聊一点个人经验。我招人时喜欢问归并排序,不是因为它难,而是因为“会不会写”不重要,“能不能把mergesort和mergetwo sorted arrays讲清楚”才重要。如果你能主动讲到稳定性是靠<=实现的、讲到空间复杂度O(n)主要是临时数组而不是递归栈、讲到链表版本可以省空间、讲到外排序用它是因为顺序IO友好,那基本上就能说明你的数据结构是真正学进去了。
在实际编码过程中,我的习惯是先写merge函数,单独测试它。因为merge是整个算法的发动机,它对了,递归或者迭代的外壳只要不写错边界就稳。测试时用几个经典用例:长度1、长度2、长度3、奇数长度、偶数长度、全部相等、倒序输入。别小看这些用例,我见过太多人写归并排序后拿[5,4,3,2,1]测一遍看结果对就提交,结果换个[1,2,3,4,5]直接越界崩溃。
另一个建议是:把这个算法从数组迁移到链表,亲手实现一次。这个练习会把你对“归并思想”的理解从“背模板”变成“真懂行”。我身边很多同事都反馈,做完这个练习再回去看递归版的数组归并,有种豁然开朗的感觉。
最后说一个应试或面试的小技巧:归并排序的递归版可以五分钟写完,但如果面试官接着问“怎么用O(1)空间实现”,不要慌,先回答链表场景下的O(1)归并,再说明数组原地归并的代价是常数因子爆炸,通常不值得。这样的回答既展示了知识面,又体现了工程判断力,比闷头硬写原地归并强得多。