📌 前置知识:理解「分治思想(Divide & Conquer)」、数组的基本操作即可。本文所有代码均在 Node 22 下验证通过(随机数据 + 边界 + 稳定性 + 链表 + 逆序对共 813 项用例全绿)。
🎬 面试现场还原
面试官:「你了解哪些排序算法?说说归并排序吧。」
很多人张口就来:「归并就是分治嘛,把数组对半分,排好再合起来。」
这段话只对了一半。如果面试官接着追问下面这几个,你能接住几个?
- 合并两个有序数组时,为什么
left[i] <= right[j]用的是<=而不是<?(这关系到「稳定性」) - 归并排序的空间复杂度到底是多少?为什么不能是 O(1)?
- 「链表排序」为什么优先考虑归并而不是快排?
- 归并排序除了排序,还能顺手求逆序对?怎么做?
- 海量数据(内存放不下)的排序,归并起什么作用?
归并排序是面试里性价比极高的一道题:原理简单,但能往很深的地方挖。下面我们把它彻底讲透。
🧠 核心思想:分而治之
归并排序的灵魂只有三个字:分、排、合。
code复制
css
[38, 27, 43, 3, 9, 82, 10]
/ \
[38, 27, 43, 3] [9, 82, 10]
/ \ / \
[38, 27] [43, 3] [9, 82] [10]
/ \ / \ / \ |
[38] [27] [43] [3] [9] [82] [10]
\ / \ / \ / |
[27, 38] [3, 43] [9, 82] [10]
\ / \ /
[3, 27, 38, 43] [9, 10, 82]
\ /
[3, 9, 10, 27, 38, 43, 82]
- 分(Divide) :把数组从中间一分为二,递归直到子数组长度为 1(天然有序)
- 合(Merge) :把两个已排序的子数组合并成一个有序数组
关键在于:合并两个「已排序」数组只需要 O(n) 时间,这是归并高效的根本。
🛠️ 第一块基石:merge(合并两个有序数组)
这是整个算法的核心,也是最该写对的地方。
js复制
css
// 合并两个【已排序】数组,返回新数组(保持稳定性)
function merge(left, right) {
const res = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) {
// 注意:这里是 <= 而不是 <,保证相等时优先取左段元素 → 稳定
if (left[i] <= right[j]) res.push(left[i++]);
else res.push(right[j++]);
}
// 剩余部分直接拼接(已经是有序的)
return res.concat(i < left.length ? left.slice(i) : right.slice(j));
}
⚠️
<vs<=是稳定性分水岭 :用<=时,当left[i] === right[j],取左段元素。这保证了「值相等时,原本在左半边的元素先输出」,从而维持相对顺序 → 稳定排序 。若改成<,相等时取右段,稳定性就被破坏。
🚀 第二块基石:递归分治
把 merge 套进递归里,就是完整的自顶向下(Top-Down)归并排序:
js复制
css
function mergeSort(arr) {
if (!Array.isArray(arr)) throw new TypeError('Expected an array');
// 返回新数组,不修改原数组(面试加分点:明确副作用)
return divide(arr.slice());
function divide(a) {
if (a.length <= 1) return a; // 长度为 1 天然有序,递归出口
const mid = (a.length / 2) | 0; // 中间下标(向下取整)
const left = divide(a.slice(0, mid));
const right = divide(a.slice(mid));
return merge(left, right);
}
function merge(left, right) {
const res = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) res.push(left[i++]);
else res.push(right[j++]);
}
return res.concat(i < left.length ? left.slice(i) : right.slice(j));
}
}
mergeSort([38, 27, 43, 3, 9, 82, 10]) → [3, 9, 10, 27, 38, 43, 82] ✅
📊 复杂度与边界(面试官埋的坑位表)
| 维度 | 结论 | 说明 |
|---|---|---|
| 最好时间 | O(n log n) | 即使已排序也跑满分治 |
| 最坏时间 | O(n log n) | 不随输入退化,这是它强于快排的地方 |
| 平均时间 | O(n log n) | 同左 |
| 空间复杂度 | O(n) + 递归栈 O(log n) | 辅助数组 + 调用栈 |
| 稳定性 | ✅ 稳定 | merge 用 <= 保证 |
| 比较排序 | 是 | 基于元素两两比较 |
| 原地(in-place) | ❌ 不是 | 需要额外数组 |
💡 为什么没有「最好/最坏」之分? 因为无论输入如何,归并的递归树深度都是
log n,每一层合并都要遍历全部 n 个元素。所以它的耗时非常可预测------这对工程来说是个巨大优点(不会因为某个「毒数据」退化成 O(n²))。
边界情况一览
| 输入 | 结果 |
|---|---|
[] 空数组 |
[] |
[1] 单元素 |
[1] |
[1,2,3,4] 已排序 |
正常返回 |
[5,4,3,2,1] 逆序 |
正常返回 |
[2,2,2,2] 全相同 |
正常返回(稳定) |
| 含负数/0 | 正常返回 |
🔥 进阶实现(面试官追问时甩出来加分)
1. 原地归并(节省空间到 O(n) 仅剩递归栈)
递归版会反复 slice 产生大量临时数组。用「辅助数组 + 原地合并」可避免反复分配:
js复制
scss
function mergeSortInPlace(arr) {
if (!Array.isArray(arr)) throw new TypeError('Expected an array');
const n = arr.length;
const temp = new Array(n); // 只分配一次辅助数组
sort(0, n - 1);
return arr;
function sort(lo, hi) {
if (lo >= hi) return;
const mid = (lo + hi) >> 1;
sort(lo, mid);
sort(mid + 1, hi);
combine(lo, mid, hi);
}
function combine(lo, mid, hi) {
for (let k = lo; k <= hi; k++) temp[k] = arr[k]; // 拷贝到辅助数组
let i = lo, j = mid + 1;
for (let k = lo; k <= hi; k++) {
if (i > mid) arr[k] = temp[j++]; // 左段耗尽
else if (j > hi) arr[k] = temp[i++]; // 右段耗尽
else if (temp[i] <= temp[j]) arr[k] = temp[i++];
else arr[k] = temp[j++];
}
}
}
注意:这里「原地」只是不额外增长空间 ,空间复杂度仍是 O(n)(那个
temp),只是常量系数小得多,且不会反复分配/释放。
2. 自底向上(迭代)归并 ------ 消灭递归栈
递归有 O(log n) 的栈开销。自底向上从长度为 1 的子段开始两两合并,用循环消除递归:
js复制
ini
function mergeSortBottomUp(arr) {
const a = arr.slice();
const n = a.length;
const temp = new Array(n);
// sz: 当前待合并的子段长度,1 → 2 → 4 → 8 ...
for (let sz = 1; sz < n; sz <<= 1) {
for (let lo = 0; lo < n - sz; lo += sz << 1) {
const mid = lo + sz - 1;
const hi = Math.min(lo + (sz << 1) - 1, n - 1);
combine(lo, mid, hi);
}
}
return a;
function combine(lo, mid, hi) { /* 同原地版的 combine */ }
}
好处:没有递归栈,最坏情况下不会栈溢出;也更好理解「归并本身就是一层层往上合并」的本质。
3. 通用版(支持比较器 + 对象数组)
面试常要求「按对象的某个字段排序」:
js复制
css
function mergeSortBy(arr, compare) {
const cmp = compare || ((a, b) => (a < b ? -1 : a > b ? 1 : 0));
return divide(arr.slice());
function divide(a) {
if (a.length <= 1) return a;
const mid = (a.length / 2) | 0;
return merge(divide(a.slice(0, mid)), divide(a.slice(mid)));
}
function merge(left, right) {
const res = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) {
if (cmp(left[i], right[j]) <= 0) res.push(left[i++]);
else res.push(right[j++]);
}
return res.concat(i < left.length ? left.slice(i) : right.slice(j));
}
}
// 按年龄排序,相同年龄保持原顺序(稳定)
const users = [ { name: 'A', age: 30 }, { name: 'B', age: 20 }, { name: 'C', age: 30 }, { name: 'D', age: 25 },];
mergeSortBy(users, (a, b) => a.age - b.age);
// → [{B:20},{D:25},{A:30},{C:30}] // A 仍在 C 前,稳定
🔗 高频实战:链表归并排序
这是归并排序在面试里最常出现的变形------「给你一个无序链表,要求 O(n log n) 时间、O(1) 空间排序」。
为什么链表用归并、不用快排?
- 链表随机访问 O(n) ,快排需要频繁
arr[mid],在链表上很贵 - 归并的
merge在链表上是 O(1) 额外空间 的(改指针即可),数组版却要 O(n) 辅助数组 - 所以「链表归并」反而比「数组归并」更省空间
js复制
ini
function ListNode(val, next) {
this.val = val;
this.next = next || null;
}
function sortList(head) {
if (!head || !head.next) return head; // 0 或 1 个节点,天然有序
// 快慢指针把链表切成两半
let slow = head, fast = head, prev = null;
while (fast && fast.next) {
prev = slow;
slow = slow.next;
fast = fast.next.next;
}
prev.next = null; // 切断
const left = sortList(head); // 递归排左半
const right = sortList(slow); // 递归排右半
return mergeList(left, right);
}
function mergeList(l, r) {
const dummy = new ListNode(0); // 哨兵节点简化边界
let cur = dummy;
while (l && r) {
if (l.val <= r.val) { cur.next = l; l = l.next; }
else { cur.next = r; r = r.next; }
cur = cur.next;
}
cur.next = l || r; // 拼接剩余
return dummy.next;
}
sortList是 LeetCode 148 题「排序链表」的标准解,也是大厂面试的常客。强烈建议背熟这个快慢指针切分 + 链表 merge 的模板。
🧮 经典扩展:用归并求逆序对
面试冷门但极其加分的一题:「统计数组中逆序对的数量」。核心洞察:在 merge 时,一旦右段元素 right[j] 被选中,说明它比左段剩余的 所有元素都小,逆序对 += 左段剩余长度。
js复制
css
function countInversions(arr) {
let cnt = 0;
divide(arr.slice());
return cnt;
function divide(a) {
if (a.length <= 1) return a;
const mid = (a.length / 2) | 0;
const left = divide(a.slice(0, mid));
const right = divide(a.slice(mid));
return merge(left, right);
}
function merge(left, right) {
const res = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) res.push(left[i++]);
else {
cnt += left.length - i; // 左段 [i..] 全大于 right[j]
res.push(right[j++]);
}
}
return res.concat(i < left.length ? left.slice(i) : right.slice(j));
}
}
countInversions([2, 4, 1, 3, 5]); // → 3((2,1)(4,1)(4,3))
时间复杂度仍是 O(n log n),比暴力 O(n²) 快得多。这题也是「归并排序不只是排序」的最佳证明。
💾 工程视角:外部排序与多路归并
当数据量远大于内存(比如 100GB 文件),归并排序是**外部排序(External Sorting)**的核心:
- 分:把大文件切成若干块,每块读进内存用快排/归并排好,写回磁盘(称为「归并段 / run」)
- 合 :用「K 路归并 」------同时打开 K 个有序段,每次从 K 个段头里取最小的元素写入结果文件,用一个最小堆维护 K 个段头的当前最小值
这其实就是把「合并两个有序数组」推广成「合并 K 个有序流」。数据库、搜索引擎的排序底层大多走这套逻辑。
🥊 归并 vs 快排 vs 堆排(面试必问对比)
| 算法 | 平均时间 | 最坏时间 | 空间 | 稳定 | 备注 |
|---|---|---|---|---|---|
| 归并排序 | O(n log n) | O(n log n) | O(n) | ✅ | 耗时最稳,链表友好 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | ❌ | 平均最快,但可被毒数据打爆 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | ❌ | 空间最优,缓存不友好、慢 |
实际工程怎么选?
- V8 / Chrome 的
Array.prototype.sort:小数组用插入排序,大数组用 TimSort(归并的变种) - 多数语言标准库:混合排序(IntroSort = 快排 + 堆排兜底 + 小数组插入)
- 链表:归并排序(LeetCode 148 标准解)
- 海量数据:外部多路归并
💡 一句话记忆:归并胜在「稳」和「可预测」,快排胜在「平均快」,堆排胜在「省空间」。
🎯 面试官可能追问的 8 个问题(含标准答法)
Q1:为什么归并排序是稳定的,快排不是?
A :归并的 merge 在相等时用 <=,保证相同值的元素保持原相对顺序;快排的 partition 是「挖坑填数」式交换,会打乱相等元素的相对位置。
Q2:空间复杂度 O(n) 能降到 O(1) 吗?
A :不能真正降到 O(1) 。合并两个有序段需要「看两边」,left[i] 与 right[j] 比较时不能覆盖还没比较的元素,必然需要辅助空间。所谓「原地归并」只是减少常数(反复用同一个 temp 数组),本质仍是 O(n)。链表版归并除外(改指针,O(1))。
Q3:为什么链表排序用归并不用快排?
A :见上文------链表随机访问 O(n) 让快排的 mid 取用很贵;而链表 merge 是 O(1) 空间(改指针)。所以链表场景归并全面占优。
Q4:归并排序的最好/最坏时间为什么一样?
A :递归树深度恒为 log n,每层合并都遍历全部元素,与输入是否有序无关,因此都是 O(n log n)。这叫「比较次数与输入无关」。
Q5:递归版的递归栈会爆吗?
A :递归深度 log n,1 百万数据也才约 20 层,一般不会爆栈。若极端追求安全,可改用「自底向上迭代版」彻底消除递归。
Q6:能边排序边求逆序对吗?
A :能,见上文 countInversions,在 merge 时累计「左段剩余长度」,时间 O(n log n)。
Q7:归并排序是 stable 的,对对象数组怎么保证?
A :比较器返回 0(相等)时,按 <= 逻辑取左段元素,原数组里排在前的对象先进入结果,相对顺序不变。
Q8:海量数据排序怎么做?
A:外部排序 = 内存内分段排序 + 多路归并(K 路用最小堆),归并是核心步骤。
📋 面试答题结构总结
被问到「讲讲归并排序」时,建议这样组织,层层递进:
code复制
scss
1️⃣ 一句话定义:分治,先分后合,合并两个有序数组
2️⃣ 画递归树 / 讲清楚 divide → merge 的流程
3️⃣ 写出 merge 函数,强调 <=(稳定性关键点)
4️⃣ 复杂度:时间 O(n log n) 三态一致;空间 O(n)
5️⃣ 边界:空/单元素/已排序/逆序/重复都正常
6️⃣ 进阶甩亮点:原地版 / 自底向上 / 链表版 / 逆序对 / 外部排序
7️⃣ 对比收尾:归并 vs 快排 vs 堆排(稳/快/省空间)
📚 延伸阅读
- LeetCode 148 - 排序链表(链表归并必刷)
- LeetCode 315 - 计算右侧小于当前元素的个数(归并求逆序对变体)
- V8 引擎排序实现(TimSort)(看工业级怎么把归并用起来)
如果这篇文章对你有帮助,欢迎点赞 👍、收藏 📂,有疑问可以在评论区交流!关注我,算法面试系列持续更新中。