Skip to content
← All posts

Merge K Sorted Lists: Min Heap Merge

8 min read
leetcodeproblemhardheap

Merge K Sorted Lists is LeetCode 23, rated hard, and one of the most classic heap problems you will see in interviews. You are given k sorted linked lists and need to merge them into a single sorted linked list. If you already know how to merge two sorted lists, this problem asks: how do you scale that to k?

The brute force approach of merging lists one at a time works but is slow. The optimal solution uses a min heap to always pick the smallest head across all k lists, giving you O(n log k) time. There is also a divide-and-conquer approach that matches the same complexity by recursively merging pairs.

The problem

You are given an array of k linked lists, each sorted in ascending order. Merge all the linked lists into one sorted linked list and return it.

Example: lists = [[1,4,5], [1,3,4], [2,6]]

The merged result should be [1, 1, 2, 3, 4, 4, 5, 6].

List 0145List 1134List 226min-heap112merged result11234456
The min-heap holds the head of each list. Pop the smallest, advance that list, push the new head.

Why a min heap is perfect here

Think about what you need at each step: you have k list heads, and you want the smallest one. That is exactly what a min heap does. It gives you the minimum in O(1) and lets you insert a new element in O(log k).

The algorithm:

  1. Push the head node of each non-empty list into a min heap.
  2. Pop the minimum from the heap. Append it to the result.
  3. If that node has a next pointer, push the next node into the heap.
  4. Repeat until the heap is empty.

At any point, the heap holds at most k nodes (one from each list). Each of the n total nodes gets pushed and popped exactly once, giving you O(n log k) total work.

Visual walkthrough

Let's trace through merging [1,4,5], [1,3,4], and [2,6]. Watch how the heap always surfaces the global minimum.

Step 1: Push the head of each list into the min-heap.

L0145L1134L226min-heap112resultempty

Heap initialized with heads: [1, 1, 2]. The root (1 from L0) is the smallest.

Step 2: Pop 1 (from L0). Append to result. Push L0's next value (4).

L0145L1134L226min-heap142result1

Popped 1, pushed 4. Heap rebalances: [1, 4, 2]. Next smallest is 1 from L1.

Step 3: Pop 1 (from L1). Append to result. Push L1's next value (3).

L0145L1134L226min-heap243result11

Popped 1, pushed 3. Heap: [2, 4, 3]. Next smallest is 2 from L2.

Step 4: Pop 2 (from L2). Append to result. Push L2's next value (6).

L0145L1134L226min-heap346result112

Popped 2, pushed 6. Heap: [3, 4, 6]. Next smallest is 3 from L1.

Step 5: Pop 3 (from L1). Push 4. Pop 4 (from L0). Push 5. Pop 4 (from L1).

L0145L1134L226min-heap56result112344

Three more pops. L1 is now exhausted. Heap: [5, 6]. Almost done.

Step 6: Pop 5, pop 6. Both lists exhausted. Heap is empty. Done!

L0145L1134L226min-heapresult11234456

Final merged result: [1, 1, 2, 3, 4, 4, 5, 6]. We processed all 8 nodes.

The key observation: even though we have three separate lists, the heap always knows the global minimum. We never have to scan all k heads manually. The heap does that bookkeeping for us in O(log k) per operation.

Approach 1: Min heap

Here is the clean Python solution using heapq. Since Python's heap compares tuples lexicographically, we store (node_value, list_index, node) to break ties. The list index prevents comparison errors when two nodes have the same value.

import heapq

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

def merge_k_lists(lists: list[ListNode | None]) -> ListNode | None:
    heap = []
    for i, node in enumerate(lists):
        if node:
            heapq.heappush(heap, (node.val, i, node))

    dummy = ListNode(0)
    curr = dummy

    while heap:
        val, i, node = heapq.heappop(heap)
        curr.next = node
        curr = curr.next
        if node.next:
            heapq.heappush(heap, (node.next.val, i, node.next))

    return dummy.next

Let's break it down:

  • Initialization. Push the head of each non-empty list. The tuple (val, i, node) lets Python compare by value first, then by list index to break ties.
  • Main loop. Pop the smallest node, wire it into the result, and push its successor if one exists. The heap never holds more than k elements.
  • Dummy head. The sentinel node avoids special-casing the first insertion. We return dummy.next at the end.

Time: O(n log k) where n is the total number of nodes across all lists. Each node is pushed and popped once, and each heap operation costs O(log k).

Space: O(k) for the heap, plus O(1) extra since we reuse the existing nodes.

Why do we need the list index i in the tuple? Python's heapq compares tuples element by element. If two nodes have the same value, it tries to compare the third element (the node itself). Since ListNode does not implement comparison operators, that would crash. The unique index i breaks the tie before Python ever reaches the node.

Approach 2: Divide and conquer

Instead of a heap, you can merge lists in pairs, like a tournament bracket. Merge list 0 with list 1, merge list 2 with list 3, and so on. Then merge the results in pairs again. Each round halves the number of lists, and there are O(log k) rounds.

Each round processes all n nodes (across the remaining lists), so the total work is O(n log k). Same complexity, different style.

def merge_k_lists_dc(lists: list[ListNode | None]) -> ListNode | None:
    if not lists:
        return None

    while len(lists) > 1:
        merged = []
        for i in range(0, len(lists), 2):
            l1 = lists[i]
            l2 = lists[i + 1] if i + 1 < len(lists) else None
            merged.append(merge_two(l1, l2))
        lists = merged

    return lists[0]

def merge_two(l1: ListNode | None, l2: ListNode | None) -> ListNode | None:
    dummy = ListNode(0)
    curr = dummy

    while l1 and l2:
        if l1.val <= l2.val:
            curr.next = l1
            l1 = l1.next
        else:
            curr.next = l2
            l2 = l2.next
        curr = curr.next

    curr.next = l1 if l1 else l2
    return dummy.next

The merge_two function is the standard merge from Merge Sorted Array, just adapted for linked lists. The outer loop repeatedly pairs up lists and merges them until only one remains.

Time: O(n log k). There are O(log k) rounds, and each round does O(n) total work.

Space: O(1) extra (ignoring the merged list of pointers). We reuse the existing nodes without creating new ones. The recursion-free iterative approach avoids stack overhead.

Heap vs divide and conquer

Min heapDivide and conquer
TimeO(n log k)O(n log k)
SpaceO(k) for heapO(1) extra
CodeShorter, one loopTwo functions
Depends onHeap/priority queueTwo-list merge

Both are equally valid for interviews. The heap approach is more elegant and shorter. The divide-and-conquer approach is more space-efficient and does not require knowing the heap API. Pick whichever feels more natural.

If an interviewer asks for a follow-up, the divide-and-conquer approach is a good card to play after presenting the heap solution.

There is also a brute force approach: collect all node values into an array, sort it, then build a new linked list. That runs in O(n log n) time and O(n) space. It works, but it does not leverage the fact that each input list is already sorted. The heap and divide-and-conquer approaches exploit this structure for the O(n log k) improvement.

Edge cases

  • Empty input. lists = []. Return None. Both approaches handle this.
  • All empty lists. lists = [None, None]. No nodes get pushed to the heap. Return None.
  • Single list. lists = [[1,2,3]]. The heap pops nodes one by one and rebuilds the same list. Divide and conquer returns it directly.
  • Lists of different lengths. lists = [[1], [2,3,4,5]]. Works fine. The heap naturally handles lists running out at different times.
  • Duplicate values. lists = [[1,1], [1,1]]. The tie-breaking index in the heap tuple handles this cleanly.

The building blocks

Merge K Sorted Lists is built from two building blocks that show up across many LeetCode problems.

Min heap for global minimum tracking

Push one representative element from each source into the heap. Pop the minimum, process it, and push its replacement. This pattern appears whenever you need to efficiently track the smallest (or largest) element across multiple streams or groups.

The template:

import heapq

heap = []
for i, source in enumerate(sources):
    if source:
        heapq.heappush(heap, (source[0], i, source))

while heap:
    val, i, source = heapq.heappop(heap)
    process(val)
    next_val = advance(source)
    if next_val is not None:
        heapq.heappush(heap, (next_val, i, source))

This same building block powers Kth Largest Element (heap of size k), Meeting Rooms II (heap of end times), and problems like "merge k sorted arrays" or "find the smallest range covering k lists."

Merge two sorted sequences

The merge function is the core of merge sort. Compare heads, pick the smaller one, advance that pointer. Repeat. When one sequence runs out, append the rest of the other.

def merge_two(l1, l2):
    dummy = ListNode(0)
    curr = dummy
    while l1 and l2:
        if l1.val <= l2.val:
            curr.next = l1
            l1 = l1.next
        else:
            curr.next = l2
            l2 = l2.next
        curr = curr.next
    curr.next = l1 if l1 else l2
    return dummy.next

This is the same merge used in Merge Sorted Array (adapted for arrays rather than linked lists). The two-pointer merge is also the foundation for the divide-and-conquer approach: if you can merge two lists, you can merge k lists by pairing them up recursively.

The merge-two-lists building block is so fundamental that it has its own LeetCode problem: Merge Two Sorted Lists (problem 21). If you can write that from memory, the divide-and-conquer solution to Merge K Sorted Lists writes itself.

Complexity summary

ApproachTimeSpaceNotes
Brute force (sort all)O(n log n)O(n)Ignores sorted structure
Merge one by oneO(nk)O(1)Slow for large k
Min heapO(n log k)O(k)Optimal, clean code
Divide and conquerO(n log k)O(1)Optimal, no heap needed

Why you will forget this (and how to fix it)

The high-level idea is easy to remember: "use a heap to always grab the smallest head." But the details trip people up. How do you break ties in the heap? What do you push after popping? How do you handle lists that run out?

For the divide-and-conquer approach, the details are different but equally tricky: how do you handle an odd number of lists? What does the merge-two function look like for linked lists specifically?

These are exactly the kind of implementation details that fade between practice sessions. Spaced repetition fixes this. You type the solution from scratch, check it, then do it again a few days later. After a few reps, the tie-breaking tuple trick and the dummy-head pattern become automatic.

CodeBricks drills both approaches separately. The heap version and the divide-and-conquer version are independent building blocks, each on its own SRS schedule. You reinforce them at exactly the right intervals.

Related posts

  • Merge Sorted Array - The two-pointer merge building block, applied to arrays instead of linked lists
  • Meeting Rooms II - Another min heap problem where the heap tracks "the earliest ending resource"

Visual Learner? See how heaps maintain order with our Heap Data Structure Guide -- the same structure powering the min heap merge.