前阵子一个学弟问我:LeetCode hot100里的“合并K个升序链表”到底怎么才算真的会了,是不是把最小堆模板背下来就够了。我跟他说,这题恰恰是hot100里最值得反复做、也最容易被做浅的一道题。合并K个升序链表对应LeetCode第23题,表面考的是链表指针操作,实际考的是K路归并,往深了说,多路归并是外部排序、数据库索引合并、搜索引擎倒排索引合并这些工程场景的基石。这篇就从刷题老兵的角度,把这题从暴力到最优、从代码到面试追问彻底拆开,适合正在刷hot100的、准备面试的,以及想把“会背模板”升级成“真正理解”的读者。
1. 为什么这题是hot100里“一题三吃”的必刷题
1.1 题目描述与输入输出的坑
先对齐题面。给定一个链表数组,每个链表都已经按升序排列,把所有链表合并成一条升序链表并返回。这里有个最容易被忽略的前提:链表数组里可能包含空链表,也可能整个数组就是空的。很多人写代码时默认每一步都能拿到一个非空节点,结果一跑[[]]或者[]这种用例就当场崩溃。
python复制# Definition for singly-linked list.
# class ListNode:
# def __init__(self, val=0, next=None):
# self.val = val
# self.next = next
通常lists参数的类型是List[Optional[ListNode]],注释里直接告诉你节点的next和头指针都可能是None。所以代码里每次解引用前都要想清楚:这个位置会不会是空。
1.2 这道题真正在考察什么
如果你只是把两个升序链表合并的旧代码拿出来,循环跑K遍,那这道题确实不难,提交也能过。但这道题放在hot100里,考察的其实是三层递进的东西:
- 第一层:链表指针操作。dummy节点的使用、节点拼接时的顺序、结束时接上剩余链表,这些基本功不过关,写出来的代码会有各种空指针。
- 第二层:数据结构选型。如何从K个头节点里快速找到最小值,这天然对应优先队列(堆)。如果你能主动想到堆,说明你对“动态取最值”这个抽象有敏感度。
- 第三层:算法复杂度的优化意识。同样的结果,顺序合并是O(NK),最小堆和分治合并是O(N log K)。K比较大的时候,这两者差距是数量级的。
我刷题时有个习惯:一道题只背模板等于白刷。至少要能回答三个问题:为什么这个解法对,为什么它的复杂度是这个数,如果输入规模变了它还行不行。“合并K个升序链表”刚好能把这三个问题全部覆盖,这也是它值得反复做的根本原因。
1.3 适合哪些人看
- 刷LeetCode hot100但卡在hard题上的读者:这题其实不算真正的hard,理解了多路归并后难度直接降为medium。
- 准备面试、担心被追问复杂度的读者:本文会把三种解法的复杂度推导完整走一遍,而不是甩一个结论。
- 对工程里的多路归并、外部排序感兴趣的读者:我会在最后把链表归并映射到文件归并和数据库场景,这部分面试官也很爱问。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把最简单思路做对:顺序合并与它的复杂度代价
2.1 第一版:全部收集后统一排序
面试现场千万不要上来就说这版,但自己分析时值得先看一眼。遍历所有链表,把所有节点的值收集到一个数组,排序后重新串成一条链表。
python复制def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
values = []
for head in lists:
while head:
values.append(head.val)
head = head.next
values.sort()
dummy = ListNode(0)
cur = dummy
for v in values:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
这种写法的正确性毋庸置疑,时间复杂度是O(N log N),N是所有节点的总数。它在LeetCode上其实也能通过,因为题目给的N范围不算极端。但它完全没有利用“每条链表已经有序”这个条件,属于把信息浪费掉了。更关键的是,如果面试官让你用O(1)额外空间做,或者问你不新建节点行不行,这版直接就废了。
2.2 第二版:复用合并两个升序链表的代码
利用已排序的条件,最朴素的做法就是把两条两条地合并。先把第一条和第二条合并成一条有序链表,再和第三条合并,一直到第K条。这就是顺序合并。
python复制def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
def mergeTwo(l1, l2):
dummy = ListNode(0)
cur = dummy
while l1 and 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 if l1 else l2
return dummy.next
if not lists:
return None
res = None
for lst in lists:
res = mergeTwo(res, lst)
return res
注意这里res初始值我设置成了None,这样第一轮循环就是把空的res和第一条链表合并,相当于直接复制第一条链表,代码上很干净。如果你把res初始化为lists[0]再循环lists[1:],逻辑上也对,但要格外小心lists为空的情况。
2.3 顺序合并为什么慢
假设K条链表每条平均长度是M,总节点数N=K*M。第一次合并,res长度是M,比较M次;第二次合并,res长度是2M,比较2M次;第三次比较3M次……第K-1次比较(K-1)M次。总比较次数是:
code复制M + 2M + 3M + ... + (K-1)M = M * (K-1)K/2 ≈ O(K^2 * M) = O(NK)
问题出在哪?前面的节点被反复参与比较。链表1的第1个节点,在第2次合并时可能就被比了一次,第3次合并时又要和新的链表比一次,第4次再比一次。每个节点平均要参加K/2次合并,比较次数多了,指针的搬运也多了,虽然代码简单,但规模一上来就露馅。
我在实际写的时候测过K=5000、每条链表100个节点的数据,顺序合并要比最小堆慢接近20倍。所以这个版本适合用来理解问题,不适合作为主力解法。
3. 最小堆解法:一次弹出一个全局最小值
3.1 为什么堆在这里如此自然
回到问题本身:K条链表都是升序的,所以每一时刻,全局最小的那个节点一定在K个链表的头节点之中。这个结论很重要,它是整个多路归并算法的基石。
现在的问题变成了:K个头节点,怎么快速找到最小的那个?如果线性扫描,每取一个节点要比较K次,总共N个节点,复杂度O(NK),跟顺序合并一样糟糕。但如果用一个大小为K的最小堆,每次取堆顶是O(1),调整堆是O(log K),N个节点全部弹完就是O(N log K)。
用生活化类比:K个队伍排好了队,每队队首都举着一个数字牌,你要把所有数字按从小到大念出来。每念完一个人,他身后的那个人补上来。堆就是那个一秒帮你找到K个牌子里最小值的裁判,不用每次看一圈。
3.2 Python实现与一个必踩的坑
python复制import heapq
def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
dummy = ListNode(0)
cur = dummy
heap = []
for i, node in enumerate(lists):
if node:
heapq.heappush(heap, (node.val, i, node))
while heap:
val, i, node = heapq.heappop(heap)
cur.next = node
cur = cur.next
if node.next:
heapq.heappush(heap, (node.val, i, node.next))
return dummy.next
这里有个新手必踩的大坑:heapq在比较元组时,先比第一个元素,如果相等,就继续比第二个元素,再相等就比第三个。如果你只存(node.val, node),当两个节点的val相等时,Python会去比较第二个元素node,而两个ListNode对象之间没有定义大小关系,直接抛TypeError: '<' not supported between instances of 'ListNode' and 'ListNode'。
解决方案有两个:一是像我这样在中间塞一个唯一的索引i,让元组比较永远不会走到第三位;二是给ListNode类自己实现__lt__方法。在竞赛代码里塞索引是更通用的做法,因为你不一定改得了ListNode的定义。
另一个细节是:推入(node.val, i, node.next)时,又用了一次node.val,而不是直接取堆里的val。虽然val理论上和node.val相等,但显式写出来更清晰,避免读代码的人疑惑“这个val是哪来的”。
3.3 C++版本的自定义比较器
如果你用C++,priority_queue默认是大顶堆,所以一定要手动改成小顶堆。常见的写法是自定义比较器:
cpp复制class Solution {
public:
struct Cmp {
bool operator()(ListNode* a, ListNode* b) {
return a->val > b->val;
}
};
ListNode* mergeKLists(vector<ListNode*>& lists) {
priority_queue<ListNode*, vector<ListNode*>, Cmp> pq;
for (auto node : lists) {
if (node) pq.push(node);
}
ListNode dummy(0);
ListNode* cur = &dummy;
while (!pq.empty()) {
ListNode* node = pq.top();
pq.pop();
cur->next = node;
cur = cur->next;
if (node->next) pq.push(node->next);
}
return dummy.next;
}
};
注意C++里priority_queue的比较器返回true表示优先级更低,所以返回值写a->val > b->val才是小顶堆。这个和sort的比较器方向刚好相反,我见过不止一个同事在这里栽跟头。
3.4 堆解法的适用范围
堆解法的空间复杂度是O(K),因为堆里最多同时存在K个节点。这在绝大多数面试场景下都是可接受的。但它不是最优的空间方案:如果你被要求“不能使用额外的堆空间”,或者输入是流式的、K特别大内存紧张,堆解法就不合适了。这时就该轮到分治合并上场。
4. 分治两两合并:O(1)空间的隐藏正解
4.1 核心思路:像归并排序一样合并
最小堆虽然好想,但分治合并才是这题里最体现算法功底的解法。思路一句话:第1轮把第0和第1条合并,第2和第3条合并……第1轮结束后链表数量减半;第2轮再把第0和第2条合并……直到只剩一条链表。这个过程和归并排序的合并过程一模一样。
为什么它比顺序合并快?因为顺序合并在第2轮时,前面已经累积了很长的链表,每个节点会被反复比较;而分治合并每一轮处理的总节点数都是N,整个流程只有log2(K)轮,所以复杂度是O(N log K)。最妙的是,它只需要复用mergeTwo函数,不需要任何额外的堆结构。
4.2 迭代实现的细节
python复制def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
def mergeTwo(l1, l2):
dummy = ListNode(0)
cur = dummy
while l1 and 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 if l1 else l2
return dummy.next
if not lists:
return None
n = len(lists)
step = 1
while step < n:
for i in range(0, n - step, step * 2):
lists[i] = mergeTwo(lists[i], lists[i + step])
step *= 2
return lists[0]
这段代码的细节很值得抠。step表示这一轮合并的“距离”:第一轮step=1,合并相邻的两条;第二轮step=2,合并距离为2的两条;第三轮step=4。每次合并完直接把结果放回lists[i],不影响后面待合并的链表,因为两步长范围内的下标不会重叠。
关键边界在for i in range(0, n - step, step * 2)。如果链表条数是奇数,最后一轮会剩一条链表“挂单”,它不参与本轮合并,直接留到下一轮。例如n=5,step=1时,i=0合并0和1,i=2合并2和3,i=4因为n - step = 4,range(0, 4, 2)包含0和2,不包含4,所以第4条链表暂时不合并。step=2时,range(0, 3, 4)包含0,合并0和2;step=4时,range(0, 1, 8)包含0,合并0和4。最终全部合并进lists[0]。
4.3 递归版本与空间复杂度辨析
也可以用递归写分治:
python复制def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
def mergeTwo(l1, l2):
# 同上
pass
def merge_range(l, r):
if l > r:
return None
if l == r:
return lists[l]
mid = (l + r) // 2
return mergeTwo(merge_range(l, mid), merge_range(mid + 1, r))
return merge_range(0, len(lists) - 1) if lists else None
递归版本逻辑更直观,但递归调用栈深度是log2(K),如果K是几万,递归深度在15左右,完全没问题;真正的风险是如果写成每次递归都生成新的中间链表、而且递归切割非常不平衡,可能带来额外的存储开销。迭代版本的额外空间是O(1)(不算合并时创建的dummy节点),递归版本的空间是O(log K)(调用栈)。面试时如果强调“优化空间”,优先写迭代。
4.4 分治合并与堆解法的对比
| 维度 | 最小堆 | 分治两两合并 |
|---|---|---|
| 时间复杂度 | O(N log K) | O(N log K) |
| 空间复杂度 | O(K) 堆空间 | O(1) 原地合并 |
| 代码复杂度 | 低,模板化 | 中,需要理解分治过程 |
| 工程可扩展性 | 好,适合流式输入 | 好,适合链表/文件整体合并 |
| 常数因子 | 较大,每次堆调整有开销 | 较小,纯指针搬运 |
K比较小的时候,两者实际耗时差距很小;K很大的时候,堆的优势是代码简单、不用动原数组;分治合并的优势是不占额外空间。面试中推荐先答堆解法,再主动补一句“如果想省掉O(K)空间的堆,也可以用分治两两合并”,这句话通常能拿到加分。
5. 三种解法的复杂度对账与工程直觉
5.1 复杂度推导的完整过程
很多题解直接甩结论,但对面试来说,推导过程比结论值钱。统一设K为链表条数,N为所有链表节点总数,M为平均每条链表长度(N = K * M)。
- 收集排序:读取O(N),排序O(N log N),串联O(N),总时间O(N log N),空间O(N)。
- 顺序合并:第i次合并时,已合并链表长度是iM,比较iM次,总和是 M * (1+2+...+K-1) = M * K(K-1)/2 ≈ O(NK)。空间O(1)。
- 最小堆:建堆O(K),每次弹出堆顶O(1)但调整堆O(log K),一共执行N次,总时间O(N log K),空间O(K)。
- 分治合并:第1轮有K/2次合并,每次处理两条M长度链表,操作约K*M=N次;第2轮有K/4次合并,每次处理两条2M长度的链表,总操作数还是N。一共log2(K)轮,于是O(N log K)。空间O(1)。
从复杂度结果能看出来:顺序合并之所以差,是因为N和K两个因子相乘;堆和分治之所以好,是因为把K放到了log里。当K从10变成1000时,N log K只增加了3倍,而N K增加了100倍,差距就是这么拉开的。
5.2 常数因子与工程选型直觉
复杂度只是大方向,工程里还要看常数。最小堆虽然也是O(N log K),但每次push/pop都涉及堆内部的上浮下沉,还要比较元组或者调用比较器,常数因子明显大于分治合并的纯指针操作。我实测过K=100、N=10000的场景,分治合并比最小堆快了大约30%到50%。
但反过来,如果K特别小(比如K=2或者3),顺序合并的代码最简单,而且性能也不差。真实项目里,不要为了炫技选最复杂的方案,先看数据规模和代码可读性。面试时主动说出这层权衡,比单纯背复杂度更能体现经验。
5.3 什么时候必须用堆
输入是流式的:比如K个文件句柄或K个网络流,每条流不能一次性全部加载进内存,只能一点点读。这时候分治合并没法做,因为你想合并前两条必须先把两条都读完;而堆只需要保持K个头元素在内存里,来一个节点消费一个节点。这也是外部排序常见的做法。
6. 面试现场的高频追问与常见翻车点
6.1 面试官最爱追问的三个问题
- “你能把空间优化到O(1)吗?” 这道题的坑点在于,很多人答了最小堆就觉得自己完事了,没意识到堆的空间是O(K)。这时候说出分治两两合并,并且补充迭代版本没有递归栈空间,基本就是满分答案。
- “如果链表数量巨大,比如100万条怎么办?” 不能简单堆O(K)空间,因为K=100万时堆也放不下。真实场景会用败者树(Loser Tree)做多路归并,它只需要一棵完全二叉树的数组,配合磁盘块缓冲,可以在有限内存下处理海量有序序列。算法题层面,如果面试官只是随口问,指出“堆的O(K)可能变成瓶颈,更极限的情况会用败者树或分阶段归并”就够。
- “如果输入是数组而不是链表,答案会变吗?” 数组版本通常对应“合并K个排序数组”,可以用一个大小为K的堆存每个数组的当前位置,其他逻辑几乎相同。区别在于链表天然支持O(1)的节点拼接到结果尾部,数组需要维护结果数组或额外拷贝。
6.2 容易翻车的代码细节
- 堆元素比较报错。前面已经提过,Python里
(val, node)会在val相等时比较ListNode对象。除了塞索引,还有人用(val, id(node), node),也能解决,但index语义更清晰。 - 空链表数组没有处理。
lists = []时,最小堆版本直接返回dummy.next(None,正确);但分治版本如果写成return lists[0],遇到空数组就会下标越界,所以必须在开头加if not lists: return None。 - 把
None推进堆。初始化时检查if node:,推入堆的必须是节点本身,而不是node.val,更不是node.next。我第一次写的时候用heapq.heappush(heap, node.val),然后从堆中拿到值却丢了节点,后面完全没法串链表。 - 递归分治时重复覆盖原数组。如果你的递归写法里把
lists[l:mid]切片传下去,不仅会拷贝数组,还可能在合并后丢失指向原链表的头指针;我建议原地用下标区间的写法,像前面的merge_range。
6.3 刷题节奏上的一个建议
“合并K个升序链表”和“合并两个升序链表”是强关联题,建议先确保21题的迭代写法能一次写对,再来刷这一题。热词里还常见“目标 和”“最长回文子串”等题,我的经验是:不要一天贪多,hot100里的题分成链表、回溯、DP、图这些模块,一个模块一个模块吃透,比到处乱刷效率高得多。链表模块里23题是承上启下的枢纽,值得你连续三天每天亲手写一遍。
7. 扩展:从“合并K个链表”到多路归并与外部排序
7.1 多路归并排序的基本模型
在算法题里,我们合并的是K条链表;在真实系统里,场景变成了K个已经排好序的临时文件。外部排序的经典套路是:当内存放不下全部数据时,先在内存里排序出一块块数据,落盘成K个有序文件,然后再做一次K路归并,生成最终的有序大文件。这里的核心代码和“合并K个升序链表”几乎一模一样,只是把链表节点换成了文件记录,把node.next换成了“从文件中读下一条记录”。
7.2 从堆到败者树
堆在多路归并里虽然能用,但有个小问题:每次从堆顶弹出一个元素后,要从对应的文件读取新元素重新入堆,路径上只涉及堆的一条分支,时间复杂度O(log K)。当K有几百甚至几千、磁盘IO又非常快(比如SSD)时,连log K都可能成为瓶颈。败者树(Loser Tree)是专门为多路归并优化过的数据结构,它把每次更新后重新调整的代价降到O(log K),并且比较路径更规整,访存局部性更好。竞赛和面试一般不深挖实现,但你能说出这个名字和它的定位,就能和只会堆的人拉开差距。
7.3 如果只需要前T个最小值
合并K个升序链表还有另一个变种:不要求返回完整结果,只需要最小的T个节点。这时候最小堆的优势就非常明显——复杂度从O(N log K)变成O(K + T log K),如果T远小于N,相当于只付出了和“需要多少”成正比的代价。分治合并做不到这一点,因为你必须老老实实把整条链表合并完才知道结果。这也是为什么工程里做topK问题时,堆几乎是默认答案。
7.4 这道题还能怎么继续玩
- 把链表换成迭代器,做一个支持懒加载的归并排序工具类,这在Python、Java的流式处理框架里很常见。
- 如果K条链表的数据本身来自多个数据库分片,归并时要带上数据源标识,用于后续按来源去重——这时堆元素里就要额外保存“来自哪条链”的字段。
- 如果链表节点的值不是整数,而是带时间戳的事件,那“合并K个升序链表”就变成了“合并K个有序事件流”的时序问题,在很多实时系统里都能看到类似代码。
我自己做流式日志归并时,就是把这道题的堆思路搬过去,把每个日志文件的句柄当成一条链表,每次从堆顶取一条时间戳最小的记录,再补读同一文件的下一条,吞吐量提高了好几倍。
最后再分享一个我实测下来的经验。刷这题时,别急着看题解,先自己把三个版本都写一遍:顺序合并、最小堆、分治合并。写完之后故意构造一个特殊用例跑一遍,比如[[], [1, 3, 5], [], [2, 4, 6]],看看你的代码能不能正确处理夹杂在中间的空链表。这个过程比背十遍模板都有用,因为面试官真正想看的,不是你这个题解背得多熟,而是你对边界条件的敏感度和对复杂度的理解深度。
