题干与适用场景
给定数组 lists,其中保存 k 个单链表的头节点。请把全部节点合并成一个按非递减顺序排列的 链表。任意输入链表都可能为空,数值可以为负数或重复值,所有输入节点总数记为 N。
假设每个输入链表无环、已经有序,且不同输入不共享节点。实现可以重新连接现有节点,不能为每个 数值都创建替代节点。不同输入链表中的相等值不要求保持特定顺序。数组为空或所有头节点都是 None 时返回 None。目标时间为 O(N log k),辅助空间为 O(k)。
Input:
1 -> 4 -> 5
1 -> 3 -> 4
2 -> 6
Output:
1 -> 1 -> 2 -> 3 -> 4 -> 4 -> 5 -> 6这是一道链表题,因此指针所有权也是契约的一部分。如果调用方要求输入链表保持不变,算法选择 可以不变,但输出必须创建 N 个新节点,输出空间会变为 O(N)。
面试官考察点
第一项信号是能否利用输入已有序。把全部值摊平后排序可以得到正确结果,但需要 O(N log N) 时间和 O(N) 额外存储。每输出一个节点就扫描全部当前头节点,虽然利用了有序性,仍需 O(Nk)。强回答会先问:下一个全局最小值可能落在哪个小集合中?
第二项信号是候选头节点不变量。对每条未耗尽链表,只有第一个尚未合并的节点可能成为下一个 输出;因为链表有序,它后面的每个节点都不小于这个头节点。因此,最小堆只需保存每条未耗尽 链表的一个候选头,就能把最多 k 个候选的线性扫描变成大小不超过 k 的堆删除与插入。
第三项信号是证明和复杂度核算。回答应说明:选出的节点为什么是全局最小值、为什么只压入它的 后继就能恢复不变量、为什么每个节点恰好输出一次,以及堆为什么不会超过非空链表数。只说“用 优先队列”没有展示核心推导。
第四项信号是实现细节。在 Python 中,如果两个堆条目的数值优先级相同,元组比较不能继续比较 ListNode 对象。加入唯一序号可以消除这个问题。复用节点时,代码先保存原后继,再断开并追加 当前节点,使已构造前缀只有一个明确所有者,不会临时指向尚未合并的链表。
最后一项信号是能否在两种最优方案间做选择。最小堆和均衡两两合并都能达到 O(N log k)。 最小堆把候选集合写得更明确,也容易扩展到迭代器或流;分治只使用普通的双链表合并,在保存头 节点的数组之外可以只用常数级指针空间。应根据输入契约选择更容易解释的方案。
回答前需要澄清的问题
- 可以修改并复用输入节点吗? 可以就重新连线,只保留
O(k)堆状态;不可以就创建输出
节点,并把 O(N) 输出空间与算法辅助状态分开汇报。
- 所有输入都保证有序且无环吗? 基础算法依赖这两点。验证有序性需要
O(N),检测环也会
增加工作,不能默认塞进基础解法。
k具体计算什么? 令m为非空链表数。堆最多保存m个元素,因此m >= 2时更
精确的界是 O(N log m),零或一条非空链表时为线性。
- 相等值是否要保持跨链表顺序? 基础题只要求数值有序。稳定性契约必须先定义来源顺序,再
把它编码进堆键。
- 可以使用语言标准库的优先队列吗? 除非面试官还要单独考察堆实现,通常可以。应先确认,
不要直接花面试时间手写二叉堆。
- 输入是完整链表还是惰性迭代器? 最小堆都能处理,但迭代器版本只能在当前值被取出后推进
对应来源。
- 输入头节点数组应保持什么状态? 下面的代码不会修改数组项,但会重新连接其中的节点。
如果调用方同时观察两者,应明确这是所有权转移。
30 秒回答框架
“每条有序链表只有第一个未合并节点可能成为下一个全局最小值,所以我会把这些候选头节点放入 最小堆。每次弹出最小节点并追加到结果,然后只压入它保存下来的后继。不变量是:堆恰好包含每条 未耗尽链表的一个候选头;因此弹出的节点一定安全,补上其来源的后继又恢复不变量。N 个节点各 弹出一次,最多各压入一次,堆大小不超过 k,所以时间是 O(N log k),辅助空间是 O(k)。 实现会复用节点,加入唯一序号防止相等值触发节点对象比较,并测试空输入、重复值、负数、长度 差异和单链表。主要替代方案是同复杂度的均衡两两合并。”
分步骤深入解答
先列出直接方案,找到重复工作:
| 方案 | 时间 | 辅助空间 | 重复或丢弃的信息 | |---|---:|---:|---| | 摊平数值、排序、重建 | O(N log N) | O(N) | 丢弃每个输入已经有序这一条件 | | 每个节点扫描最多 k 个头 | O(Nk) | O(1) | 重复执行 N 次线性最小值查找 | | 逐条合并进累积结果 | 最坏 O(Nk) | O(1) | 早期节点在后续合并中反复遍历 | | 均衡两两合并 | O(N log k) | O(1) 指针空间 | 每一层合并处理全部节点一次 | | 候选头最小堆 | O(N log k) | O(k) | 通过堆操作选择下一个来源 |
顺序合并的成本容易被低估。假设 k 条链表长度接近 L,工作量按 2L + 3L + ... + kL 增长,即 O(Lk²)。因为 N = Lk,所以最坏为 O(Nk)。两两合并 通过分轮保持平衡,每个节点最多参加 ceil(log₂ k) 层合并,避免不断增长的累积结果。
对最小堆方案,在每次删除堆顶前维持下面的不变量:
For each non-exhausted input list:
the heap contains exactly its first unmerged node.
For each exhausted input list:
the heap contains no node from that list.
The result contains every previously removed node exactly once,
in non-decreasing order.初始化把每个非空头节点压入堆,因此不变量成立。假设某轮开始时不变量成立。任何未合并节点要么 是堆中的候选头,要么位于某个候选头之后。因为每条输入有序,更深的节点不可能小于该候选头。 所以堆中最小条目不大于任何未合并节点,可以安全追加。
取出节点后,只有它所属链表失去代表。先保存该节点的原后继,断开并追加该节点;如果后继存在, 就把后继压入堆。其他来源的候选头保持有效,不变量恢复。每轮输出一个节点,恰好 N 轮后所有 链表耗尽、堆为空,由此证明结果有序、节点完整且算法终止。
下面的 Python 实现把单调递增序号放在元组第二项。序号唯一,所以数值相等时,元组比较不会 继续到不可排序的节点对象。
from __future__ import annotations
from dataclasses import dataclass
from heapq import heappop, heappush
from itertools import count
@dataclass
class ListNode:
val: int
next: ListNode | None = None
def merge_k_lists(lists: list[ListNode | None]) -> ListNode | None:
heap: list[tuple[int, int, ListNode]] = []
sequence = count()
for head in lists:
if head is not None:
heappush(heap, (head.val, next(sequence), head))
dummy = ListNode(0)
tail = dummy
while heap:
_, _, node = heappop(heap)
next_node = node.next
node.next = None
tail.next = node
tail = node
if next_node is not None:
heappush(heap, (next_node.val, next(sequence), next_node))
return dummy.next初始有 m 次插入,其中 m <= k 是非空链表数。每个节点删除一次,除各链表尾节点外,每个 节点还可能引发一次后继插入。堆最多有 m 个条目,操作成本为 O(log m)。m >= 2 时总时间 为 O(N log m),通常写作 O(N log k);m <= 1 时遍历为 O(N)。堆、序号计数器、哑节点 和指针使用 O(m) 辅助空间。返回的是原节点,不属于新增算法存储。
断开 node.next 不会丢失后继,因为后继已先保存。这样已合并前缀不会临时指向尚未赢得堆选择 的来源节点,指针所有权更清楚。下一次追加会为尾节点设置新后继。在无环且各输入节点互不共享的 契约下,算法不会修改数值,也不会重复压入同一个节点。
测试应覆盖结构,而不只覆盖一组正常数值:
def build(values: list[int]) -> ListNode | None:
dummy = ListNode(0)
tail = dummy
for value in values:
tail.next = ListNode(value)
tail = tail.next
return dummy.next
def values(head: ListNode | None) -> list[int]:
result: list[int] = []
while head is not None:
result.append(head.val)
head = head.next
return result
cases = [
([], []),
([[]], []),
([[1, 4, 5], [1, 3, 4], [2, 6]], [1, 1, 2, 3, 4, 4, 5, 6]),
([[], [-3, -1, 2], [], [-3, 7]], [-3, -3, -1, 2, 7]),
([[5]], [5]),
]
for raw_lists, expected in cases:
actual = values(merge_k_lists([build(items) for items in raw_lists]))
assert actual == expected, (raw_lists, expected, actual)生产级验证还应记录所有输入节点的身份,用 visited 集合遍历输出,并证明三件事:没有环、恰好有 N 个不同节点身份、相邻数值非递减。这能发现只比较数值时可能遗漏的重复插入、节点丢失和指针环。
当面试官重点考察指针操作、没有优先队列,或希望减少堆存储时,选择均衡两两合并。当来源以 迭代器形式提供、活跃来源数会变化,或明确展示“下一个全局候选”的机制更易理解时,选择最小堆。 在基础契约下两者都是正确的最优回答,关键是说明选择理由。
高质量示范回答
“我会复用输入节点,并假设每条链表有序、无环且互不共享。令 N 为总节点数、m 为非空链表 数。下一个输出只可能是 m 个当前头节点之一,因为更深的节点不小于所在链表的头。因此我会把 每条非空链表的一个头节点放进最小堆。
不变量是:堆恰好包含每条未耗尽链表的第一个未合并节点,输出包含所有已弹出节点且保持有序。 每次删除最小项,保存并断开它的后继,追加当前节点,再压入后继。弹出节点一定安全,因为其他 任何未合并节点都位于某个不大于它的堆候选之后;补入后继又恢复每条链表一个候选的不变量。
Python 堆条目写成 (value, sequence, node)。唯一序号避免相等优先级继续比较节点对象;题干 没有要求跨链表稳定顺序,所以不额外声称稳定性。每个节点弹出一次、最多压入一次,堆最多有 m 项,因此时间是 O(N log m),通常写作 O(N log k),辅助空间是 O(m);零或一条非空 链表时为线性。
我会测试空数组、全部为空、单链表、长度差异、负数和相等值。因为解法重新连接指针,还会验证 节点身份和无环。均衡两两合并是主要替代方案,同样是 O(N log k),只使用双链表合并原语; 如果题目重点是指针实现或禁止标准库堆,我会选择它。”
常见错误
- 直接摊平并排序 → 忽略输入已有序,花费
O(N log N)和输出存储 → **维护每个有序来源
的一个候选头。**
- 每个节点都扫描
k个头 → 最小值选择变成O(Nk)→ **使用大小不超过k的最小堆
或均衡两两合并。**
- 依次合并进不断增长的结果 → 早期节点在之后多次被遍历 → 按轮次均衡合并。
- 把所有节点都压入堆 → 堆增至
N,工作退化为O(N log N)→ **每个来源只压入一个当前
节点。**
- Python 堆保存
(value, node)→ 相等值会比较不可排序的节点对象 → 加入唯一数值序号。 - 保存后继之前就推进或改线 → 可能丢失剩余链表 → 先保存后继,再断开并追加。
- 因为复用节点就声称
O(1)空间 → 堆仍保存最多k个条目 → **分开核算输出分配和辅助
状态。**
- 只验证输出数值 → 环、重复节点或节点丢失可能未被发现 → **检查节点身份、数量、有序性和
无环。**
- 没有确认就加入排序与环检测 → 实现扩大了契约并改变成本 → **先声明假设,只在要求时增加
验证。**
追问及应对
为什么堆顶就是下一个全局最小值?
每条未耗尽有序链表提供第一个未合并节点。其他节点都位于某个候选头之后,不可能小于这个候选 头。因此,最小候选头不大于任何未合并节点。弹出它是安全的,压入它的后继就恢复该来源的覆盖。
如果相等值必须按输入链表顺序稳定输出,怎么改?
先精确定义稳定性,再把堆键写成数值加来源链表下标。因为每个来源同时只有一个节点在堆中, 来源下标解决跨链表相等值,链表内部顺序天然保持。基础实现中的序号只保证可比较性,不保证这项 更强策略。
什么时候分治优于最小堆?
已经有双链表合并、面试重点是指针操作,或不能用优先队列时,采用均衡两两合并。每轮处理一次 全部剩余节点,共 O(log k) 轮。对于惰性来源和活跃来源数变化,最小堆更直接。
如果输入链表必须保持不变呢?
保留相同的候选选择逻辑,但每弹出一个数值就创建新节点。时间仍为 O(N log k)。候选选择状态 仍是 O(k),必要的输出分配是 O(N);两项应分别说明,不能把输出空间隐藏掉。
如果有一万个链表槽位,但只有五条非空呢?
初始化需要扫描一次 k 个槽位,之后堆最多只有 m = 5 项。精确时间为 O(k + N log m),辅助空间为 O(m)。只写 O(N log k) 仍是安全上界,但没有体现跳过空头 节点的收益。
如果输入是有序迭代器而不是链表呢?
从每个非空迭代器读取一个值,与来源身份一起放入堆。产出最小值后只推进该来源并插入下一个值。 候选头证明不变,结果可以惰性生成,内存与活跃来源数成正比,而不是与总数值数成正比。
弹出节点有后继时,可以使用 heapreplace 吗?
如果已经单独执行 heappop,旧堆顶已经离开,不能再 replace。另一种实现可以先查看堆顶,在 该来源有后继时一次替换;来源耗尽时仍需单独分支。面试中先弹出再压入更容易证明,渐进复杂度 相同。
除了示例,怎样测试指针正确性?
合并前保存每个输入节点身份。遍历结果时拒绝重复身份,确认恰好 N 个节点、每对相邻值有序, 且身份集合完全相同。再随机生成有序链表,把结果数值与可信的摊平排序 oracle 比较;oracle 用于 测试,不代表生产解法复杂度。