合并 K 个有序链表:堆里始终是各链的头
下一节点只能是某条链当前的头。最小堆每次弹出全局最小,再把该链后继补进堆。两两分治合并也能做到 O(N log K)。
把至多 K 个非空链头放进最小堆。每次弹出堆顶接到结果尾,若它有后继则入堆。堆大小 ≤ K,每个节点入堆出堆各一次,时间 O(N log K)。也可以像归并那样两两合并,层数 log K,时间相同。空间堆是 O(K),分治递归是 O(log K)。
给你一个数组,里面是 K 条已经升序的链表,把它们合成一条升序链表。某条链可以为空。
主例 lists = [[1,4,5],[1,3,4],[2,6]]。三条链的头是 1、1、2。合成结果是 1→1→2→3→4→4→5→6。
两条链会比头、取较小。K 条链时「较小」来自 K 个头发。本文要回答:为什么用堆维护这 K 个候选,以及弹出之后补谁。
K 个头都是候选,缺一个取最小的结构
两条有序链合并时,下一节点一定是两条当前头里较小的那个。K 条链没有改变这句话:下一节点一定是 K 个当前头里最小的那个。扫描这 K 个头要 O(K),每个节点都扫一次就是 O(NK)。N 大、K 也不小时,这笔比较费。
缺的是「从 K 个候选里反复取最小,并且每次只换其中一个」。最小堆正好干这个:堆里放各链当前头,堆顶就是全局最小。弹出一个头,只有这条链的后继需要补进来,其余链的头原位不动。单次调整 O(log K)。
两两分治也能补上这个缺口。先把 K 条两两按 LC21 合并,得到 K/2 条,再合并,共 log K 层;每一层走过的节点总数是 N,总时间同样 O(N log K)。堆更直观看见「这一步最小从哪条链来」;分治不需要手写堆,递归深度 log K。主例演示走堆。
一开始把三条非空链头 1、1、2 入堆。堆顶是 1。演示第一帧标的就是这个状态:三条链的头下标都是 0,候选集合 {1,1,2}。
堆里永远不超过 K 个每条链在堆里最多一个当前头。弹出后才可能把后继补进去。空链一开始就不要入堆。
弹出最小,只补这一条的后继
循环直到堆空。弹出堆顶,接到结果链的尾巴。若这个节点还有 Next,把 Next 入堆,它成为这条链新的候选头。没有 Next,这条链耗尽,堆的宽度减一。因为每条链自己有序,后继不会比刚弹出的值更小,所以结果链始终升序。
用手走主例。堆 {1,1,2},弹出第一条链的 1,结果是 1,补进 4。堆变成 {1,2,4}。演示第一帧:heads 变成 [1,0,0],pick=0。再弹出第二条链的 1,结果是 1→1,补进 3。堆变成 {2,3,4}。演示第二帧。下一次弹出 2,补进 6;再弹出 3,补进 4;再依次弹出 4、4、5、6。堆空,结果 1→1→2→3→4→4→5→6。
哨兵 dummy 省掉「第一次接节点」的分叉,tail 始终指着结果的最后一个节点。K=0 或全是空链,堆一开始就是空,返回 dummy.Next 即空。K=1 时堆里只有一条链的头,过程退化成把这一条原样接过去。
Go:container/heap 最小堆
type minHeap []*ListNodefunc (h minHeap) Len() int { return len(h) }func (h minHeap) Less(i, j int) bool { return h[i].Val < h[j].Val }func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }func (h *minHeap) Push(x any) { *h = append(*h, x.(*ListNode)) }func (h *minHeap) Pop() any {old := *hn := len(old)*h = old[:n-1]return old[n-1]}func mergeKLists(lists []*ListNode) *ListNode {h := &minHeap{}for _, l := range lists { if l != nil { heap.Push(h, l) } }dummy := &ListNode{}tail := dummyfor h.Len() > 0 {node := heap.Pop(h).(*ListNode)tail.Next = nodetail = nodeif node.Next != nil { heap.Push(h, node.Next) }}return dummy.Next}
1堆元素是节点指针,Less 比值,堆顶是当前最小头。
2只把非空链头放进去。空链不占候选。
3弹出的节点接到 tail 后面,tail 跟上。
4有后继才补进堆。主例弹出第一条的 1 之后补 4,堆里仍是三条候选。
总结
堆里放各链当前头,弹最小、补后继。主例依次弹出 1、1、2,合成 1→1→2→3→4→4→5→6。
- 下一节点只能来自某条链的头。线性扫 K 个头是 O(NK),堆是 O(N log K)。
- 后继入堆合法,因为每条链自己升序。
- 两两分治时间相同。堆宽 O(K),分治栈 O(log K)。