最近刷牛客网的时候又把 BM5 这道题翻了出来。题目名字叫“合并k个已排序的链表”,看着就是一个入门题的加强版,但真到提交的时候,不少人会在原地卡很久。原因其实不是题面难懂,而是“合并两个有序链表”到“合并k个有序链表”之间,藏着不少关于时间复杂度和代码组织方式的坑。这篇文章我把这道题从头到尾拆一遍:暴力、顺序合并、分治、最小堆四种解法都写清楚,再补上 JavaScript(Node) 在牛客网环境下的提交细节,希望能给正在刷这道题的朋友一点实打实的参考。
1. 先别急着写代码:把题目条件和坑点捋清楚
1.1 题目表面是“合并链表”,实际考的是“多路归并”
BM5 的题面很简洁:给一个包含 k 个链表头节点的数组,每个链表内部都是升序排列,要求把所有节点合并成一个大的升序链表,返回新链表的头节点。
题面简洁归简洁,它其实在考一个很经典的数据结构问题——多路归并(k-way merge)。你如果只是把“合并两个有序链表”的函数拿出来循环调用,代码确实能过,但面试时或者讨论复杂度时就会露怯。多路归并的本质是:从 k 个有序序列中,反复取出当前最小的元素,直到所有元素都被取完。关键是怎么高效地“找到当前最小”。
这个问题在真实业务里也特别常见,最典型的就是外部排序:一堆有序文件或者有序分片要合并成一个大的有序结果集。你不可能把所有数据一次性加载进内存,所以只能靠多路归并的思路一点一点往外取。理解了这层背景,你再回头看 BM5,就会明白它不是在考你会不会写链表,而是在考你对归并逻辑和数据结构的掌握程度。
1.2 空数组、空节点、重复值:边界条件一网打尽
刷题最容易翻车的不是主逻辑,而是边界条件。BM5 里这几个边界情况,我建议在一开始就列出来:
- 传入的 lists 是空数组,即
lists = [],此时应该返回null。 - 某些链表本身就是空链表,也就是
lists[i] = null,合并时需要跳过。 - 所有链表都为空,结果也是
null。 - 链表中允许出现重复值,两个链表里都有节点值相等,合并顺序随便,但必须保证整体升序。
- 只有一个链表,直接返回这个链表,不需要额外复制节点。
记得在牛客网这类在线评测系统里,链表节点通常定义成下面这样:
javascript复制function ListNode(x) {
this.val = x;
this.next = null;
}
有一点你要特别注意:返回的链表必须复用原来的节点,不是 new 一堆新节点然后复制 val。虽然复制节点也能过,但空间复杂度会变高,而且明显不贴合“合并”的语义。实现时用 dummy 头节点来统一操作,这是处理链表题的常规手法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两种“朴素”解法:能跑通,但代价藏得很深
2.1 暴力收集加排序:5分钟内能做出来,但面试官会皱眉
如果你不追求效率,最无脑的解法就是:遍历所有链表,把所有节点放进一个数组,然后对数组按照 val 排序,最后把数组里的节点串成一条新链表。
代码写起来确实快:
javascript复制function mergeKLists(lists) {
if (!lists.length) return null;
const nodes = [];
for (let head of lists) {
let cur = head;
while (cur) {
nodes.push(cur);
cur = cur.next;
}
}
if (!nodes.length) return null;
nodes.sort((a, b) => a.val - b.val);
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].next = nodes[i + 1];
}
nodes[nodes.length - 1].next = null;
return nodes[0];
}
这种写法有两个细节值得说。
第一,排序的比较函数必须写 (a, b) => a.val - b.val。如果直接调 nodes.sort(),JavaScript 的默认排序会把元素转成字符串后按字典序排,10 会排在 2 前面,结果完全错误。这是新手特别容易踩的坑。
第二,链表中节点总数如果记为 N,这个解法的时间复杂度是 O(N log N),空间复杂度是 O(N)。如果 k 个链表每个平均长度是 n,那么 N = k * n。你可能会想,O(N log N) 看上去也不是很差?但问题在于:题目给定的是 k 个已经排好序的链表,这本身就是一种很强的“部分有序”信息,你手动把它打散成数组再整体排序,等于把这份信息浪费掉了。真正的多路归并可以做到 O(N log k),在 k 远小于 N 的场景下,这个差距是实打实的。
所以我的建议是:如果只是本地调试、快速验证数据,暴力方法可以;但你要是想在牛客网上练出真东西,或者应付面试,就不要满足于这种解法。
2.2 顺序两两合并:实现最简单,复杂度曲线却很陡
比暴力稍微“有点算法味道”的做法,是维护一个结果链表 ans,初始为 null,然后依次把第 0 个、第 1 个……第 k-1 个链表和 ans 做两两合并。
先写好合并两个链表的辅助函数:
javascript复制function mergeTwoLists(l1, l2) {
const dummy = new ListNode(0);
let cur = dummy;
while (l1 && l2) {
if (l1.val <= l2.val) {
cur.next = l1;
l1 = l1.next;
} else {
cur.next = l2;
l2 = l2.next;
}
cur = cur.next;
}
cur.next = l1 || l2;
return dummy.next;
}
然后顺序合并:
javascript复制function mergeKLists(lists) {
if (!lists.length) return null;
let ans = null;
for (let i = 0; i < lists.length; i++) {
ans = mergeTwoLists(ans, lists[i]);
}
return ans;
}
不夸张地说,我见过很多人第一次写这道题就是这个版本,因为它确实容易想到。但这个版本的问题藏得很深:合并次数越多,ans 越长,后面的合并成本就越大。
每次合并两个链表的时间复杂度是 O(m + n),其中 m 和 n 是两个链表的长度。第一次合并时 ans 长度为 0,成本约为 n;第二次合并时 ans 长度已经是 n,成本为 2n;第三次成本为 3n……以此类推。总成本是:
code复制n + 2n + 3n + ... + k*n = n * (1 + 2 + ... + k) = O(k^2 * n)
因为总节点数 N = k * n,所以等价于 O(k * N)。
这个复杂度在 k 很小的时候无所谓,比如 k=5 或者 k=10。一旦 k 到了几千,你的代码在牛客网上很可能直接超时。这个方案的空间复杂度倒是很好,O(1),因为全程只是调整指针,没有额外数组。
3. 分治合并:把 k 路归并做成归并排序的形状
3.1 分治思路:两两配对,逐轮合并
既然顺序合并的问题是“后面越合并越贵”,那我们就想办法让合并过程尽量平衡。
这个思路和归并排序如出一辙:把 k 个链表分成两半,左边那一半递归地合并成一个链表,右边那一半也递归地合并成一个链表,最后再把这两个链表用 mergeTwoLists 并起来。也就是把“一次合并一个大链表”拆成“多个规模相近的小合并”。
这样做的好处是,每个链表里的节点不会反复被比较太多次。第一轮两两配对,每个节点参与一次代价为 2n 的合并;第二轮再两两配对,每个节点参与一次代价为 4n 的合并;每一轮下来,参与合并的链表数量减半,但每个链表长度翻倍。所有节点在每一轮都会被扫描一遍,而总共只有 log k 轮。
3.2 递归版与迭代版实现
递归版本非常直观,先找到一个中点 mid,左半边 [left, mid] 合并,右半边 [mid+1, right] 合并,最后再 mergeTwoLists。
javascript复制function mergeKLists(lists) {
if (!lists.length) return null;
return mergeRange(lists, 0, lists.length - 1);
}
function mergeRange(lists, left, right) {
if (left === right) return lists[left];
const mid = (left + right) >> 1;
const l = mergeRange(lists, left, mid);
const r = mergeRange(lists, mid + 1, right);
return mergeTwoLists(l, r);
}
递归版本的代码很干净,但如果你担心递归栈深度,也可以写成迭代,思路是模仿归并排序的自底向上合并:
javascript复制function mergeKLists(lists) {
if (!lists.length) return null;
let step = 1;
while (step < lists.length) {
for (let i = 0; i + step < lists.length; i += step * 2) {
lists[i] = mergeTwoLists(lists[i], lists[i + step]);
}
step *= 2;
}
return lists[0];
}
迭代版需要注意的细节是 i + step < lists.length 这个条件。它保证了只有“存在右半部分可以合并”时才执行合并。如果某轮结束时某个链表落单了,不要碰它,直接进入下一轮。最终 lists[0] 就是合并结果。
我在本地测试时发现,迭代版还有个额外好处:它会在原数组上不断覆盖指针,过程中不会产生额外的链表副本,对内存更友好。而且对于牛客网这种评测平台,迭代版避免了递归函数调用带来的微小开销,性能上限更高。
3.3 复杂度推导:为什么 O(N log k) 比 O(kN) 更优
记总节点数为 N,k 个链表平均长度为 n,所以 N = k * n。分治合并每一轮都要把 N 个节点完整扫描一遍,而总轮数是 log k 层,所以时间复杂度是:
code复制O(N * log k)
空间复杂度主要是递归栈,深度为 log k,所以是 O(log k)。如果你用的是迭代版,连递归栈都不需要,辅助空间可以认为是 O(1)。
对比顺序合并的 O(kN),分治的 O(N log k) 在 k 比较大的时候优势非常明显。举个具体例子,假设 k = 1024,每个链表长度 n = 100,那么总节点数 N = 102400。顺序合并大约要走 1024 * 102400 ≈ 1 亿次节点操作,而分治只需要 102400 * 10 ≈ 100 万次节点操作。这差了整整两个数量级,在牛客网的极限用例下,前者超时,后者轻松通过。
4. 最小堆方案:换一种数据结构的思维
4.1 堆在 k 路归并里的作用
除了分治,另一条经典路线是用最小堆。堆在“多路归并”中扮演的角色是“高效地选出当前最小的节点”。
具体做法是:先把 k 个链表的头节点全部放进最小堆,然后循环地弹出堆顶节点,把它接到结果链表的尾部,接着把这个节点的 next 节点补进堆里。每次弹出和插入的复杂度都是 O(log k),一共要处理 N 个节点,所以总复杂度也是 O(N log k),和分治持平。但堆方案有另一个独特优势:它是“流式”的,不需要等所有链表都准备好就可以开始输出结果,这在数据量极大的外部排序场景里很有用。
4.2 手写最小堆的完整实现
牛客网的 JavaScript(Node) 环境里没有内置的优先队列,你不能 import PriorityQueue。所以我建议你记住一个手写最小堆的模板,关键时候直接套用。
核心思路是用数组存完全二叉树,父节点下标 i,左孩子 2*i+1,右孩子 2*i+2。维护的时候做“上浮”和“下沉”两个操作。
javascript复制class MinHeap {
constructor() {
this.heap = [];
}
size() {
return this.heap.length;
}
push(node) {
this.heap.push(node);
this._shiftUp(this.heap.length - 1);
}
pop() {
if (!this.heap.length) return null;
const top = this.heap[0];
const last = this.heap.pop();
if (this.heap.length) {
this.heap[0] = last;
this._shiftDown(0);
}
return top;
}
_shiftUp(i) {
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.heap[parent].val > this.heap[i].val) {
[this.heap[parent], this.heap[i]] = [this.heap[i], this.heap[parent]];
i = parent;
} else {
break;
}
}
}
_shiftDown(i) {
while (true) {
let minIdx = i;
const left = i * 2 + 1;
const right = i * 2 + 2;
if (left < this.heap.length && this.heap[left].val < this.heap[minIdx].val) {
minIdx = left;
}
if (right < this.heap.length && this.heap[right].val < this.heap[minIdx].val) {
minIdx = right;
}
if (minIdx === i) break;
[this.heap[i], this.heap[minIdx]] = [this.heap[minIdx], this.heap[i]];
i = minIdx;
}
}
}
function mergeKLists(lists) {
const heap = new MinHeap();
for (let head of lists) {
if (head) heap.push(head);
}
const dummy = new ListNode(0);
let cur = dummy;
while (heap.size()) {
const node = heap.pop();
cur.next = node;
cur = cur.next;
if (node.next) heap.push(node.next);
}
return dummy.next;
}
写堆的时候有两个易错点。
第一,_shiftDown 里每次要先找出三个节点(当前节点、左孩子、右孩子)中的最小值,如果最小值就是当前节点,就停止;否则交换当前节点和最小孩子,继续向下处理。这里一定要同时比较左右孩子,不能只看左孩子。
第二,push 进堆的节点不能是 null,所以初始时只把非空头节点放进去。每轮弹出后,也要判断 node.next 是否存在再入堆。
用 [a, b] = [b, a] 这种解构赋值交换数组元素很方便,牛客网 Node 环境支持这个语法,本地跑也没问题。
4.3 三种主流方案横向对比,怎么选
到这里,至少已经有了三种值得对比的方案。我平时刷题时习惯把它们放进一个表里,扫一眼就能定方案:
| 方案 | 时间复杂度 | 空间复杂度 | 代码量 | 适用场景 |
|---|---|---|---|---|
| 暴力收集+排序 | O(N log N) | O(N) | 最少 | 仅用于本地验证数据正确性 |
| 顺序两两合并 | O(kN) | O(1) | 少 | k 很小,如 k <= 10 |
| 分治合并 | O(N log k) | O(log k) | 中 | 通用,推荐优先写这个 |
| 最小堆 | O(N log k) | O(k) | 最多 | k 很大,或需要流式取结果 |
分治和堆理论上打平,但实际做题时,我更推荐分治写法。原因是它只需要递归和 mergeTwoLists,不用维护堆的数据结构,也就不用担心手写堆时出现下标细节错误。最小堆的价值更多体现在 k 很大的场景,以及面试官追问“如果内存装不下所有链表头怎么办”时的思路展示。
5. 牛客网 JavaScript(Node) 提交实战与排雷
5.1 核心代码模式下,函数签名与 ListNode 定义怎么处理
牛客网的 BM5 大多数情况下是核心代码模式,也就是系统已经帮你封装好了输入输出,你只需要补全 mergeKLists 函数。这种情况下 ListNode 通常已经定义好,你在后面直接用就行。
提交的模板一般长这样:
javascript复制/*
* function ListNode(x){
* this.val = x;
* this.next = null;
* }
*/
/**
*
* @param lists ListNode类一维数组
* @return ListNode类
*/
function mergeKLists(lists) {
// 在这里写上你的代码
}
我建议你提交前先确认三件事:
- 函数名一定是
mergeKLists,大小写不能错。 - 参数名是
lists,但实际使用时无所谓,只要位置对就行。 - 返回值是链表头节点,不是数组也不是布尔值。
如果遇到的是 ACM 模式,就需要自己处理输入。输入形式通常是若干行,第一行是 k,后面跟着 k 个有序链表。读取输入后要把每个链表的数值数组构造成链表,再调用合并函数。构造链表的辅助函数可以这样写:
javascript复制function buildList(arr) {
const dummy = new ListNode(0);
let cur = dummy;
for (let val of arr) {
cur.next = new ListNode(val);
cur = cur.next;
}
return dummy.next;
}
这时候输出也不能直接打印链表对象,要遍历一遍把值拼成题目要求的格式。牛客网通常要求输出一行空格分隔的升序序列,你可以这样:
javascript复制function printList(head) {
const res = [];
while (head) {
res.push(head.val);
head = head.next;
}
console.log(res.join(' '));
}
5.2 评测环境里的性能与内存问题
Node 版本的评测环境一般已经支持 ES6 之后的绝大多数语法,const、let、箭头函数、解构赋值、class 都能用。真正会让代码挂掉的,往往不是语法,而是算法复杂度和写代码时的一些坏习惯。
常见问题主要有这几个:
第一,没有开头判空导致访问了 null.val。比如 lists = [] 时直接访问 lists[0],返回 undefined,再用 undefined.val 直接报 TypeError。
第二,在循环里频繁 console.log。很多人本地调试时习惯打日志,提交前没删。在牛客网上,几万条 console.log 会拖慢执行速度,严重的直接超时。调试日志可以留着,但提交前一定要去掉。
第三,递归栈溢出。分治解法的递归深度是 log k,除非 k 到了百万级,否则不会爆栈。但如果你写的是顺序合并且某次递归写错,就可能出现深递归。实际跑下来,分治的递归深度完全安全。
第四,没注意内存。暴力解法的数组会存储所有节点,如果 N 达到几十万,内存占用会明显升高,但不至于 OOM。可如果你在暴力解法里 new 了 N 个新节点,内存就会翻倍,完全不必要。
5.3 本地调试时如何构造测试用例
牛客网只给你看提交结果,很难一步一步调试,所以我习惯在本地用 Node 跑一套完整的测试脚本。这里分享一个我常用的模板:
javascript复制const { performance } = require('perf_hooks');
function buildList(arr) {
const dummy = new ListNode(0);
let cur = dummy;
for (let val of arr) cur.next = new ListNode(val), cur = cur.next;
return dummy.next;
}
function listToArray(head) {
const res = [];
while (head) { res.push(head.val); head = head.next; }
return res;
}
// 测试用例
const lists = [
buildList([1, 4, 5]),
buildList([1, 3, 4]),
buildList([2, 6]),
null
];
const start = performance.now();
const merged = mergeKLists(lists);
const end = performance.now();
console.log(listToArray(merged));
console.log(`耗时: ${(end - start).toFixed(3)}ms`);
本地测试你可以故意构造一些极端情况,比如 10000 个链表、每个链表 10 个节点,对比分治和顺序合并的耗时差距。跑一次你会对复杂度分析有特别直观的感受。
还有一件事:如果测试数据特别大,Node 默认堆内存可能不够,会报类似 JavaScript heap out of memory 的错误。这不是代码问题,是 Node 运行时的内存限制。本地调试时可以用 node --max-old-space-size=4096 test.js 把堆内存临时调大到 4GB。但这只是本地调试的手段,线上评测环境一般不受你的命令行参数控制,所以别指望靠这个“优化”提交结果。真正该做的是想办法降低算法空间消耗。
最后再说一个小技巧。如果你用分治解法,可以顺手处理一个特殊场景:lists.length === 1 时直接返回 lists[0]。这个判断虽然不放也能过,但放上之后可以让递归函数少跑一层,代码意图也更明确。
我刷这道题的体会是:合并 k 个有序链表是真的值得反复练的一道题。它表面是链表操作,实际上串起了分治、堆、复杂度分析、边界处理这一整套基本功。暴力解法让你能跑通,顺序合并让你理解性能瓶颈,分治和堆则让你建立起“如何在有序数据里省比较次数”的思维。把这四种解法都写一遍,再去牛客网提交,你会发现无论面试官怎么变形提问,你都能接得住。
