LeetCode #23 — Hard

Merge k Sorted
Lists

A complete visual walkthrough of the min-heap approach — from brute force to the optimal O(N log k) solution.

Solve on LeetCode
The Problem

Problem Statement

You are given an array of k linked-lists lists, each linked-list is sorted in ascending order.

Merge all the linked-lists into one sorted linked-list and return it.

Example 1:

Input: lists = [[1,4,5],[1,3,4],[2,6]]
Output: [1,1,2,3,4,4,5,6]
Explanation: The linked-lists are:
[
  1->4->5,
  1->3->4,
  2->6
]
merging them into one sorted linked list:
1->1->2->3->4->4->5->6

Example 2:

Input: lists = []
Output: []

Example 3:

Input: lists = [[]]
Output: []

Constraints:

  • k == lists.length
  • 0 <= k <= 104
  • 0 <= lists[i].length <= 500
  • -104 <= lists[i][j] <= 104
  • lists[i] is sorted in ascending order.
  • The sum of lists[i].length will not exceed 104.
Patterns Used

Roadmap

  1. Brute Force Approaches
  2. The Core Insight — Min-Heap
  3. Algorithm Walkthrough
  4. Alternative: Divide and Conquer
  5. Edge Cases
  6. Full Annotated Code
  7. Interactive Demo
  8. Complexity Analysis
Step 01

Brute Force Approaches

Before optimizing, let's see two straightforward approaches and understand why they fall short.

Approach A: Collect, Sort, Build

Gather all values from every list into an array, sort the array, then build a new linked list from the sorted values.

List 1
1
4
5
List 2
1
3
4
List 3
2
6
↓ collect all ↓
1
4
5
1
3
4
2
6
↓ sort ↓
1
1
2
3
4
4
5
6

Time: O(N log N) where N = total number of nodes across all lists. Space: O(N) for the array. This ignores the sorted structure entirely.

Approach B: Merge One by One

Merge list 1 with list 2, then merge the result with list 3, and so on. Each merge is O(n), but the accumulated list keeps growing.

Round 1: merge(list1, list2) → length up to 2n
Round 2: merge(result, list3) → length up to 3n
...
Round k-1: merge(result, listK) → length N

Total work: 2n + 3n + ... + kn = O(kN). If k is large, this is slow. We need something that considers all k lists simultaneously without redundant re-scanning.

💡

The problem with both approaches: they don't exploit the fact that each list is already sorted. We want an approach that always picks the global minimum efficiently from the k list heads.

Step 02

The Core Insight — Min-Heap

At any moment, the next node in the merged result must be the smallest head among all k lists. A min-heap lets us find that minimum in O(log k) time instead of O(k).

The Pipeline

Think of k conveyor belts feeding into a single funnel (the heap). The heap always holds the front item from each belt and lets the smallest one through.

k Lists

[1,4,5]
[1,3,4]
[2,6]

Min-Heap

at most k nodes
peek min: O(1)
push/pop: O(log k)

Result

sorted linked list
built one node
at a time

The Algorithm in Three Lines

1. Initialize
Add the head node of each non-empty list to the heap. The heap now has at most k elements.
2. Extract minimum
Pop the smallest node from the heap. Append it to the result linked list.
3. Advance and push
If the popped node has a next node, push that next node into the heap. Repeat step 2.

Why O(log k) per node? The heap never has more than k elements (one from each list). Every push/pop on a heap of size k costs O(log k). We do this N times total, giving O(N log k) overall.

Step 03

Algorithm Walkthrough

Let's trace through the example: lists = [[1,4,5], [1,3,4], [2,6]]. We track the heap state and result at each step.

Initialization

Push the head of each list into the heap.

Lists (heads underlined):
L1:
1
4
5
L2:
1
3
4
L3:
2
6
Heap (min at top):
1
↙ ↘
1
2
Result: []

Steps 1-4

Pop 1 (from L1), push 4 (L1's next)
Heap: [1, 2, 4]   Result: [1]
Pop 1 (from L2), push 3 (L2's next)
Heap: [2, 3, 4]   Result: [1, 1]
Pop 2 (from L3), push 6 (L3's next)
Heap: [3, 4, 6]   Result: [1, 1, 2]
Pop 3 (from L2), push 4 (L2's next)
Heap: [4, 4, 6]   Result: [1, 1, 2, 3]

Steps 5-8

Pop 4 (from L1), push 5 (L1's next)
Heap: [4, 5, 6]   Result: [1, 1, 2, 3, 4]
Pop 4 (from L2), no next — L2 exhausted
Heap: [5, 6]   Result: [1, 1, 2, 3, 4, 4]
Pop 5 (from L1), no next — L1 exhausted
Heap: [6]   Result: [1, 1, 2, 3, 4, 4, 5]
Pop 6 (from L3), no next — L3 exhausted
Heap: []   Result: [1, 1, 2, 3, 4, 4, 5, 6]
Final merged list:
1
1
2
3
4
4
5
6
Step 04

Alternative: Divide and Conquer

Instead of a heap, we can pair up lists and merge them recursively, like merge sort. Each round halves the number of lists.

Merge in Rounds

ROUND 0 — k LISTS
[1,4,5] [1,3,4] [2,6]
↓ pair up and merge ↓
ROUND 1 — ⌈k/2⌉ LISTS
[1,1,3,4,4,5] [2,6]
↓ pair up and merge ↓
DONE — 1 LIST
[1,1,2,3,4,4,5,6]

Each round does O(N) total work (every node is compared once). There are O(log k) rounds, giving the same O(N log k) overall time.

Heap vs. Divide & Conquer

Approach Time Space Notes
Collect + Sort O(N log N) O(N) Ignores sorted structure
Merge one by one O(kN) O(1) Redundant re-scanning
Min-Heap O(N log k) O(k) Optimal, straightforward
Divide & Conquer O(N log k) O(log k) Optimal, recursive
🎯

When to use which? The heap approach is simpler to implement and works well in interviews. Divide and conquer uses slightly less space (O(log k) stack vs. O(k) heap) and can be faster in practice due to cache locality during pairwise merges.

Step 05

Edge Cases

Empty Lists Array (k = 0)

When lists is an empty array, the loop that seeds the heap pushes nothing, so the heap starts empty. The while (!pq.isEmpty()) loop never executes, and dummy.next remains null. The function returns null immediately — correct by definition since there are no nodes to merge.

Array Contains null / Empty Lists Mixed with Valid Lists

The seeding loop explicitly guards with if (head != null), so any null entries in lists are silently skipped and never pushed onto the heap. Only nodes from valid, non-empty lists enter the priority queue. The result is identical to running the merge on the non-null sublists alone — no special handling is required downstream.

Single List (k = 1)

With exactly one non-null list, every node from that list is pushed onto the heap one at a time (head first, then each successive node as the previous one is popped). The heap never holds more than one element simultaneously, so no real comparison or reordering happens. The output linked list is a reconstruction of the single input list in the same order — effectively a pass-through at O(n log 1) = O(n) cost.

All Lists Have One Element Each

All k single-node lists are pushed into the heap during initialization, giving a heap of size k. Each poll() extracts the global minimum; since node.next == null for every node, nothing is re-inserted. The heap drains in exactly k pops, producing a sorted list of k elements. This is the case where the heap reaches its maximum initial size and every comparison is useful.

Lists of Vastly Different Lengths

The algorithm handles skewed lengths naturally. A list with 10,000 nodes keeps feeding the heap one node at a time as its predecessors are popped, while shorter lists drain early and stop contributing. The heap size never exceeds k (one representative per list), regardless of how unequal the lengths are. Total work is O(N log k) where N is the total node count — there is no penalty for imbalance.

Step 06

Full Annotated Code

The priority queue (min-heap) approach in Java. Clean, concise, and handles all edge cases.

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {

        // Min-heap ordered by node value
        PriorityQueue<ListNode> heap =
            new PriorityQueue<>((a, b) -> a.val - b.val);

        // Seed the heap with the head of every non-empty list
        for (ListNode node : lists) {
            if (node != null) heap.offer(node);
        }

        // Dummy head simplifies list-building logic
        ListNode dummy = new ListNode(0);
        ListNode curr = dummy;

        while (!heap.isEmpty()) {
            // Extract the global minimum
            ListNode min = heap.poll();

            // Append to result list
            curr.next = min;
            curr = curr.next;

            // If this list has more nodes, push the next one
            if (min.next != null) {
                heap.offer(min.next);
            }
        }

        // Return the real head (skip dummy)
        return dummy.next;
    }
}
📝

Comparator note: (a, b) -> a.val - b.val orders by ascending value. This is safe here because LeetCode constrains values to [-10^4, 10^4], so integer overflow won't occur. For arbitrary integers, use Integer.compare(a.val, b.val) instead.

Divide & Conquer Alternative

class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        if (lists == null || lists.length == 0) return null;
        return divide(lists, 0, lists.length - 1);
    }

    private ListNode divide(ListNode[] lists, int lo, int hi) {
        if (lo == hi) return lists[lo];
        int mid = (lo + hi) / 2;
        ListNode left  = divide(lists, lo, mid);
        ListNode right = divide(lists, mid + 1, hi);
        return merge(left, right);
    }

    // Standard merge of two sorted lists
    private ListNode merge(ListNode a, ListNode b) {
        ListNode dummy = new ListNode(0), tail = dummy;
        while (a != null && b != null) {
            if (a.val <= b.val) { tail.next = a; a = a.next; }
            else                { tail.next = b; b = b.next; }
            tail = tail.next;
        }
        tail.next = (a != null) ? a : b;
        return dummy.next;
    }
}
Step 07

Interactive Demo

Enter multiple sorted lists and watch the min-heap merge them step by step. See the heap contents, the extracted minimum, and the result building in real time.

⚙ Min-Heap Merge Visualizer


Step 08

Complexity Analysis

Time
O(n × log k)
Space
O(k)

Every node is pushed into and popped from the min-heap exactly once. The heap never holds more than k entries (one per list), so each insertion and extraction costs O(log k). With N total nodes across all lists, the total work is O(N log k). The heap itself uses O(k) space.

Approach Breakdown

BRUTE FORCE
O(N · k) time
O(1) space

Merges lists one by one sequentially. Each merge scans up to N nodes, and we perform k-1 merges. The intermediate merged list grows each round, so total comparisons approach N·k. Only pointer variables are used.

DIVIDE & CONQUER
O(N log k) time
O(log k) space

Recursively splits the k lists in half, merges pairs bottom-up. There are log k levels of merging, and each level processes all N nodes exactly once. The recursion stack uses O(log k) frames.

MIN-HEAP
O(N log k) time
O(k) space

Each of the N nodes is pushed into and popped from a heap of size k exactly once. Each heap operation costs O(log k), giving O(N log k) total. The heap holds one node per list, using O(k) memory.

🎯

The heap always holds exactly k elements -- one per list -- so each extraction is O(log k). Brute force merges lists one by one, scanning up to N nodes k times. Both the heap and divide-and-conquer approaches achieve O(N log k) by reducing the k-way decision to a logarithmic operation at each step.

Coach Notes

Common Mistakes

Review these before coding to avoid predictable interview regressions.

Losing head/tail while rewiring

Wrong move: Pointer updates overwrite references before they are saved.

Usually fails on: List becomes disconnected mid-operation.

Fix: Store next pointers first and use a dummy head for safer joins.