合并 K 个升序链表 图解题解
K 条已经排好序的链表,怎样合并得最快?淘汰赛思路,log K 轮搞定。
合并 K 条有序链表,就像淘汰赛:两两配对,每对用双指针比头节点、谁小接谁合成一条,本轮 K 条变成 K/2 条;下一轮再两两合,log K 轮后汇成一条。每个节点在整个过程里只被碰 log K 次,比把所有节点倒进数组重排更快,而且充分利用了「每条链本来就有序」这个信息。
这道题到底在问什么
- 输入
- 链A=1→4→5, 链B=1→3→4, 链C=2→6
- 输出
- 1→1→2→3→4→4→5→6
最优解:为什么这么做
一句话答案:LeetCode 23 合并 K 个升序链表的经典解是最小堆(优先队列):堆里只放每条链当前的头节点,堆顶永远是全局最小,弹出接进结果链、再把它的后继补进堆。N 个节点各进出堆一次,总时间 O(N log K)、空间 O(K);分治两两归并同样能做到 O(N log K)。
从合并两条到合并 K 条,难在哪里
合并两条有序链表时,每一步只需比较两个头节点、取小的接走。链表变成 K 条后,问题的本质没变——结果链的下一个节点,永远是「K 个当前头里最小的那个」。难点只剩一个:怎么快速地从 K 个候选里反复取最小。每次线性扫一遍 K 个头要 O(K),N 个节点总共就是 O(NK),链表条数一多就顶不住了。
为什么逐条合并和全量排序都不划算
一个直觉做法是把第一条和第二条合并,结果再和第三条合并,如此推进。问题在于结果链越合越长,靠前的节点会被反复扫描,最坏总代价同样是 O(NK)。另一个做法是把所有节点值收进数组排序再重建链表,时间 O(N log N)、还要 O(N) 空间,而且完全没利用「每条链本来就有序」这个现成条件——有序意味着全局最小的候选其实只有 K 个头,这条信息被白白浪费了。
为什么最小堆正好卡在这个需求上
「维护一批候选、随时弹出最小、随时补充新候选」正是最小堆(优先队列)的本职:弹出和插入都只要 O(log K)。做法是先把 K 条链的头节点全部入堆,循环里弹出堆顶接到结果链尾部,若被弹出的节点还有后继,就把后继入堆,堆空时合并结束。
整个过程保持一个不变量:堆里装的恰好是每条还没走完的链的当前头。有了这个不变量,堆顶必然是所有剩余节点的全局最小,每次接走堆顶,结果链自然严格升序,正确性就立在这里。
为什么弹出一个只需要补一个
某条链的头被取走后,这条链新的最小值就是原头的 next——链本身有序保证了这一点,更靠后的节点不可能抢在 next 前面成为候选。所以每弹一个只补一个,堆的大小始终不超过 K。这也解释了一个常见错误:把所有节点一次性全塞进堆,结果虽然也对,但堆膨胀到 N 个元素,时间退化成 O(N log N),空间从 O(K) 涨到 O(N),最小堆的精髓恰恰在于堆要小。
不用堆还能怎么做:分治两两归并
分治是等价的另一条路:把 K 条链两两配对合并成约一半,再两两合并,重复 log K 轮;每一轮所有节点被扫一遍,总时间同样 O(N log K),额外空间只有递归栈。两种写法复杂度相同,堆版思路更直白,分治版不依赖堆结构,面试时任选其一并能说出另一种,是很好的加分点。
复杂度与容易翻车的细节
堆版总时间 O(N log K):N 个节点各入堆、出堆一次,每次堆操作 O(log K);空间 O(K)。三个细节:入堆前要跳过空链表,空节点入堆会直接报错;弹出后忘记补后继,那条链剩下的节点就永远丢了;Python 里两个链表节点对象不能直接比较大小,堆元素要写成「值、链序号、节点」三元组,值相等时靠序号打破平局。
▶ 动画逐步走查(共 48 步)——想跟着动画一帧帧对照就展开
- 3思路口诀:K 个头进堆·弹最小·补下一个,堆顶永远是全局最小、循环到堆空即升序结果。下面一步步演给你看。
- 4开始:堆是空的(容量 3,空位用占位表示)。先把 K 个链表的第一个头依次入堆。
- 5把 链A 的头 1 入堆: 先把它放到堆的末尾(下标0),再向上浮到该去的位置。
- 6把 链B 的头 1 入堆: 先把它放到堆的末尾(下标1),再向上浮到该去的位置。
- 7新节点 1 ≥ 父节点 1,不再上浮,就位(标绿)。堆顶 1 仍是当前最小。
- 8把 链C 的头 2 入堆: 先把它放到堆的末尾(下标2),再向上浮到该去的位置。
- 9新节点 2 ≥ 父节点 1,不再上浮,就位(标绿)。堆顶 1 仍是当前最小。
- 10堆顶 1(链A) 是当前所有链头里最小的,把它弹出接走。
- 111 接入结果链(第1个)。把堆末元素 2 暂放到堆顶(标紫),再向下沉到该去的位置。
- 12比较 2 与较小的子节点 1:子节点更小,下沉——交换。
- 13交换完成,2 沉到下标1。
- 142 已不大于子节点,下沉结束(标绿就位),堆顶 1 又是当前最小。
- 15链A 刚被取走一个,补它的下一个 4 入堆: 先把它放到堆的末尾(下标2),再向上浮到该去的位置。
- 16新节点 4 ≥ 父节点 1,不再上浮,就位(标绿)。堆顶 1 仍是当前最小。
- 17堆顶 1(链B) 是当前所有链头里最小的,把它弹出接走。
- 181 接入结果链(第2个)。把堆末元素 4 暂放到堆顶(标紫),再向下沉到该去的位置。
- 19比较 4 与较小的子节点 2:子节点更小,下沉——交换。
- 20交换完成,4 沉到下标1。
- 214 已不大于子节点,下沉结束(标绿就位),堆顶 2 又是当前最小。
- 22链B 刚被取走一个,补它的下一个 3 入堆: 先把它放到堆的末尾(下标2),再向上浮到该去的位置。
- 23新节点 3 ≥ 父节点 2,不再上浮,就位(标绿)。堆顶 2 仍是当前最小。
- 24堆顶 2(链C) 是当前所有链头里最小的,把它弹出接走。
- 252 接入结果链(第3个)。把堆末元素 3 暂放到堆顶(标紫),再向下沉到该去的位置。
- 263 已不大于子节点,下沉结束(标绿就位),堆顶 3 又是当前最小。
- 27链C 刚被取走一个,补它的下一个 6 入堆: 先把它放到堆的末尾(下标2),再向上浮到该去的位置。
- 28新节点 6 ≥ 父节点 3,不再上浮,就位(标绿)。堆顶 3 仍是当前最小。
- 29堆顶 3(链B) 是当前所有链头里最小的,把它弹出接走。
- 303 接入结果链(第4个)。把堆末元素 6 暂放到堆顶(标紫),再向下沉到该去的位置。
- 31比较 6 与较小的子节点 4:子节点更小,下沉——交换。
- 32交换完成,6 沉到下标1。
- 336 已不大于子节点,下沉结束(标绿就位),堆顶 4 又是当前最小。
- 34链B 刚被取走一个,补它的下一个 4 入堆: 先把它放到堆的末尾(下标2),再向上浮到该去的位置。
- 35新节点 4 ≥ 父节点 4,不再上浮,就位(标绿)。堆顶 4 仍是当前最小。
- 36堆顶 4(链A) 是当前所有链头里最小的,把它弹出接走。
- 374 接入结果链(第5个)。把堆末元素 4 暂放到堆顶(标紫),再向下沉到该去的位置。
- 384 已不大于子节点,下沉结束(标绿就位),堆顶 4 又是当前最小。
- 39链A 刚被取走一个,补它的下一个 5 入堆: 先把它放到堆的末尾(下标2),再向上浮到该去的位置。
- 40新节点 5 ≥ 父节点 4,不再上浮,就位(标绿)。堆顶 4 仍是当前最小。
- 41堆顶 4(链B) 是当前所有链头里最小的,把它弹出接走。
- 424 接入结果链(第6个)。把堆末元素 5 暂放到堆顶(标紫),再向下沉到该去的位置。
- 435 已不大于子节点,下沉结束(标绿就位),堆顶 5 又是当前最小。
- 44链B 已经走空,没有后继可补,堆里还剩 2 个头(堆顶 5,标蓝)继续处理。
- 45堆顶 5(链A) 是当前所有链头里最小的,把它弹出接走。
- 465 接入结果链(第7个)。把堆末元素 6 暂放到堆顶(标紫),再向下沉到该去的位置。
- 476 已不大于子节点,下沉结束(标绿就位),堆顶 6 又是当前最小。
- 48链A 已经走空,没有后继可补,堆里还剩 1 个头(堆顶 6,标蓝)继续处理。
- 49堆顶 6(链C) 是当前所有链头里最小的,把它弹出接走。
- 50堆空了,所有节点都已弹出接走。结果链 1→1→2→3→4→4→5→6,全程升序,合并完成。
⚠️ 容易写错的地方
✗ 错:把所有节点一次性全丢进堆
✓ 对:只放每条链的当前头
堆大小应 ≤ K,全放进去退化成 O(N log N) 还费空间
✗ 错:弹出后忘记补后继
✓ 对:弹谁就补谁的 next
不补后继那条链后面的节点就永远进不了堆,结果丢节点
✗ 错:Java 比较器用 a.val - b.val 溢出
✓ 对:值极大时用 Integer.compare
两个 int 相减可能溢出,导致堆序错乱
完整代码(Python / C++ / Java)
Python
import heapq
def mergeKLists(lists):
h = [] # 最小堆,元素 (值, 链序号, 节点)
for i, node in enumerate(lists):
if node: heapq.heappush(h, (node.val, i, node))
dummy = tail = ListNode()
while h:
val, i, node = heapq.heappop(h) # 弹最小
tail.next = node; tail = node
if node.next: # 补后继
heapq.heappush(h, (node.next.val, i, node.next))
return dummy.nextC++
struct Cmp { bool operator()(ListNode* a, ListNode* b){ return a->val > b->val; } };
ListNode* mergeKLists(vector<ListNode*>& lists){
priority_queue<ListNode*, vector<ListNode*>, Cmp> h;
for(auto n : lists) if(n) h.push(n);
ListNode dummy, *tail = &dummy;
while(!h.empty()){
ListNode* node = h.top(); h.pop(); // 弹最小
tail->next = node; tail = node;
if(node->next) h.push(node->next); // 补后继
}
return dummy.next;
}Java
public ListNode mergeKLists(ListNode[] lists){
PriorityQueue<ListNode> h =
new PriorityQueue<>((a, b) -> a.val - b.val); // 最小堆
for(ListNode n : lists) if(n != null) h.offer(n);
ListNode dummy = new ListNode(0), tail = dummy;
while(!h.isEmpty()){
ListNode node = h.poll(); // 弹最小
tail.next = node; tail = node;
if(node.next != null) h.offer(node.next); // 补后继
}
return dummy.next;
}复杂度
时间
O(N log K)
N 个节点各进出堆一次,每次堆操作 O(log K)
空间
O(K)
堆里最多 K 个当前头
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 合并 K 个升序链表 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
不用堆还能怎么合并 K 条链表?+
分治两两归并:把 K 条链两两合并成 K/2 条,再两两合并……共 log K 轮、每轮扫 N,总复杂度同样 O(N log K),且不需额外堆空间。
为什么堆里要带链表序号 i?+
当两个节点值相等时,元组比较会继续比第二项;带上序号 i 保证可比、避免拿节点对象直接比较报错(Python 中两个 ListNode 不可比较)。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 合并 K 个升序链表 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。