面试官:讲讲归并排序 —— 为什么它能在面试里反复出现?

📌 前置知识:理解「分治思想(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)**的核心:

  1. :把大文件切成若干块,每块读进内存用快排/归并排好,写回磁盘(称为「归并段 / run」)
  2. :用「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 堆排(稳/快/省空间)

📚 延伸阅读


如果这篇文章对你有帮助,欢迎点赞 👍、收藏 📂,有疑问可以在评论区交流!关注我,算法面试系列持续更新中。


相关推荐
kyriewen16 分钟前
别再只给人写页面了:AI 已经开始自己点你的按钮、填你的表单
前端·javascript·人工智能
天天喝旺仔19 分钟前
Vue 3 组合式 API:从 Options 迁移到 script setup
前端·javascript·vue.js
知了清语21 分钟前
以测试为盾,重构为矛——老旧前端系统迭代中的“测试先行”实践总结
前端
攀小黑1 小时前
vue3+Web Speech API 封装了一个弹窗语音输入
前端·macos·xcode
roamingcode1 小时前
4.5 小时,从一句话需求到可安装的 Chrome 插件:一次 AI 结对开发的完整复盘
前端·人工智能·chrome·claude·codex
meilindehuzi_a1 小时前
LangChain.js 对话 Memory 实战:History 持久化、截断与摘要压缩
java·javascript·langchain
小幸运2481 小时前
FastAPI异步请求 vs 同步请求:一个电商订单系统
面试
江畔柳前堤1 小时前
具身智能全景深度指南(2026年9月版):从“会聊天的AI“到“能干活的机器“
大数据·javascript·图像处理·人工智能·分布式·智慧城市·原型模式
️学习的小王1 小时前
Windows Claude Code 接入 Playwright‑MCP,调用本机Edge浏览器(避坑完整教程)
前端·windows·edge