合并K个升序链表是算法面试中的经典问题,它考察了我们对数据结构的选择和操作能力。想象一下,你手头有多个已经按从小到大排好序的链表(比如多个班级的成绩单),现在需要把它们合并成一个大的有序链表。最直观的做法可能是两两合并,但这样效率不高。更聪明的做法是使用优先队列(堆)来帮助我们高效地完成这个任务。
优先队列就像一个智能的排队系统,它总是能让我们快速拿到当前最小的元素。在这个问题中,我们先把每个链表的第一个元素放入优先队列,然后每次取出最小的元素连接到结果链表,再把这个元素所在链表的下一个节点放入队列。这样就能保证我们总是处理当前最小的元素,最终得到一个完整的有序链表。
优先队列(通常用堆实现)能在O(1)时间获取最小/最大元素,插入和删除操作是O(log n)时间复杂度。对于合并K个链表的问题,我们需要频繁地获取当前最小的元素,这正是优先队列的强项。
对比其他方法:
在C++中,我们需要为优先队列定义一个比较函数,因为默认的优先队列是最大堆,而我们需要最小堆。代码中的cmp结构体重载了()运算符:
cpp复制struct cmp {
bool operator()(const ListNode *a, const ListNode *b) {
return a->val > b->val; // 实现最小堆
}
};
这里的小技巧是:虽然看起来是a>b,但实际上这样定义会让优先队列把较小的值放在顶部。这是因为C++的优先队列默认是最大堆,通过反转比较逻辑来实现最小堆。
cpp复制priority_queue<ListNode*, vector<ListNode*>, cmp> pq;
for(auto node : lists) {
if(node) {
pq.push(node);
}
}
这段代码做了两件事:
注意:必须检查node是否为空,否则会引发运行时错误。这是实际编码中常见的边界情况。
cpp复制ListNode head; // 哑节点
head.val = 0;
head.next = nullptr;
ListNode *tail = &head;
while(pq.size() > 0) {
ListNode *p = pq.top();
pq.pop();
tail->next = p;
tail = p;
if(p->next) {
pq.push(p->next);
}
}
return head.next;
这里使用了链表操作中常用的"哑节点"技巧:
设K是链表数量,N是总节点数:
输入中可能包含空链表,必须跳过它们:
cpp复制if(node) {
pq.push(node);
}
注意我们只是重新连接节点指针,没有创建新节点。如果需要深拷贝,需要额外分配内存。
这种合并有序序列的技术在实际中有广泛应用:
虽然我们以C++为例,其他语言也有类似实现:
Python使用heapq模块:
python复制import heapq
def mergeKLists(lists):
heap = []
for l in lists:
if l:
heapq.heappush(heap, (l.val, l))
dummy = ListNode(0)
curr = dummy
while heap:
val, node = heapq.heappop(heap)
curr.next = node
curr = curr.next
if node.next:
heapq.heappush(heap, (node.next.val, node.next))
return dummy.next
Java使用PriorityQueue:
java复制PriorityQueue<ListNode> queue = new PriorityQueue<>((a,b) -> a.val - b.val);
在面试中手写这段代码时要注意:
我实际测试了不同方法在100个链表,每个链表1000个节点时的表现:
可以看到优先队列法在大数据量时优势明显。但当K很小时(如K=2),简单方法可能更高效。
为了巩固这个技巧,可以尝试解决这些变种问题:
在实际实现这个算法时,我总结了几个关键点:
这个算法展示了如何巧妙利用数据结构来解决看似复杂的问题。优先队列在这里就像一个智能的调度员,总能帮我们找到当前最小的元素,使得整个合并过程高效有序。掌握这种思路,可以解决许多类似的排序和选择问题。