Fanpilot

ProblemsHeaps

Merge k Sorted Lists

HardLeetCode 235 min read

You'll keep the front node of every list in a min-heap, so the smallest is always one pop away. You need linked list nodes and Python's heapq. Related chapter: heaps.

1. The problem

You get k sorted linked lists. Merge them into one sorted linked list, keeping every value, including repeats. Some lists may be empty. Our version reuses the input nodes and changes their next links. This is LeetCode 23. The tables below show each list as its values.

Input Answer Why
[[1,4],[1,3],[2]] [1,1,2,3,4] All occurrences remain
[[],[5]] [5] Empty list contributes nothing
[] [] No nodes

2. A correct baseline

class ListNode:
    def __init__(self,val=0,next=None):
        self.val,self.next=val,next

def build(values):
    head=None
    for v in reversed(values): head=ListNode(v,head)
    return head

def values(head):
    result=[]
    while head:
        result.append(head.val); head=head.next
    return result

def baseline(lists):
    return build(sorted(v for head in lists for v in values(head)))

assert values(baseline([build([1,4]),build([1,3]),build([2])]))==[1,1,2,3,4]
assert values(baseline([None,build([5])]))==[5]
assert baseline([]) is None

Pulling out every value and sorting takes O(N log N) time for N nodes in total, plus O(N) extra space. On a million nodes, that's about 20 million comparisons.

The bottleneck. It sorts everything from scratch, ignoring that each list is already sorted.

3. The observation

Take [1,4] and [2,3]. The smallest value overall must be at the front of some list: here, 1 or 2. Take the 1. Now the fronts are 4 and 2, and the next smallest is again one of the fronts.

So you only ever need to compare the front node of each list. Keep those fronts in a min-heap, which hands back the smallest in O(log k) time. Pop the smallest, add it to the output, and push the next node from its list. Each heap entry also stores its list's index. When two values tie, Python compares the index next, instead of trying to compare two nodes, which would raise an error.

4. The solution and a trace

import heapq

class ListNode:
    def __init__(self,val=0,next=None):
        self.val,self.next=val,next

class Solution:
    def mergeKLists(self, lists: list) -> object:
        """Sorted acyclic lists; reuse their nodes in the merged chain."""
        heap=[(node.val,i,node) for i,node in enumerate(lists) if node]
        heapq.heapify(heap)
        dummy=ListNode(); tail=dummy
        while heap:
            _,i,node=heapq.heappop(heap)
            following=node.next
            tail.next=node; tail=node
            if following:
                heapq.heappush(heap,(following.val,i,following))
        tail.next=None
        return dummy.next

solve=Solution().mergeKLists
assert values(solve([build([1,4]),build([1,3]),build([2])]))==[1,1,2,3,4]
assert values(solve([None,build([5])]))==[5]
assert solve([]) is None

Trace [[1,4],[2,3]]:

Front values for [[1,4],[2,3]] Taken New front values
1,2 1 4,2
4,2 2 4,3
4,3 3 4
4 4 empty

5. Why it works

What stays true. The heap holds the first unused node of every list that isn't finished.

The output is sorted. Each list is sorted, so the smallest front value is the smallest value left anywhere. Taking it next keeps the output sorted. Pushing its next node keeps the heap holding every list's front.

Nothing is lost or repeated. Each node enters the heap once and leaves once, so each appears in the output exactly once.

6. Cost, edge cases, and failure

Cost. Building the heap takes O(k) time. Each of the N nodes is popped and pushed once, at O(log k) each. That's O(N log k) total. With a million nodes in 10,000 lists, that's about 14 steps per node, instead of about 20 for sorting everything. The heap holds at most k nodes, so O(k) extra space. The only new node is the dummy placeholder at the start.

Edge cases. Empty lists are skipped when the heap is built. LeetCode defines ListNode for you; this lesson defines it so the code runs. The lists must not share nodes or contain cycles.

Where it fails. If an input list isn't sorted, its front isn't its smallest, and the output comes out unsorted:

assert sorted([4,1])==[1,4]
assert values(solve([build([4,1])]))==[4,1]  # Deliberately unsorted input breaks the frontier argument.
Going further: random checks, related problems, and a reconstruction exercise

Check it against the baseline

import random
rng=random.Random(23)
for _ in range(250):
    arrays=[sorted(rng.randint(-5,5) for _ in range(rng.randrange(8))) for _ in range(rng.randrange(8))]
    expected=values(baseline([build(a) for a in arrays]))
    assert values(solve([build(a) for a in arrays]))==expected

Related problems

Problem What changes What follows
Merge Two Sorted Lists Only two lists Compare the two fronts directly; no heap needed
Sort List One unsorted list Looks similar, but there are no sorted lists to merge yet. Use merge sort: split in half, sort each half, then merge.

Reconstruct it

Explain why you only need each list's front node. Show why the heap entries need the list index when two values tie.