title: Merge k Sorted Lists
slug: merge-k-sorted-lists
difficulty: hard
leetcode_id: 23
leetcode_url: https://leetcode.com/problems/merge-k-sorted-lists/
categories:
  - linked-lists
  - heap
patterns:
  - heap

function_signature: "def merge_k_lists(lists: list[ListNode | None]) -> ListNode | None:"

test_cases:
  visible:
    - input: { lists: [[1, 4, 5], [1, 3, 4], [2, 6]] }
      expected: [1, 1, 2, 3, 4, 4, 5, 6]
    - input: { lists: [] }
      expected: null
    - input: { lists: [[]] }
      expected: null
  hidden:
    - input: { lists: [[1]] }
      expected: [1]
    - input: { lists: [[1, 2], [3, 4]] }
      expected: [1, 2, 3, 4]
    - input: { lists: [[5], [1], [3]] }
      expected: [1, 3, 5]
    - input: { lists: [[-1, 0], [-2, 1]] }
      expected: [-2, -1, 0, 1]

description: |
  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.

constraints: |
  - `k == lists.length`
  - `0 <= k <= 10^4`
  - `0 <= lists[i].length <= 500`
  - `-10^4 <= lists[i][j] <= 10^4`
  - `lists[i]` is sorted in **ascending order**
  - The sum of `lists[i].length` will not exceed `10^4`

examples:
  - input: "lists = [[1,4,5],[1,3,4],[2,6]]"
    output: "[1,1,2,3,4,4,5,6]"
    explanation: "Merge three sorted lists: [1,4,5] + [1,3,4] + [2,6] = [1,1,2,3,4,4,5,6]"
  - input: "lists = []"
    output: "[]"
    explanation: "No lists to merge."
  - input: "lists = [[]]"
    output: "[]"
    explanation: "Single empty list."

explanation:
  intuition: |
    Imagine you have k piles of sorted cards, and you want to combine them into one sorted pile. At each step, you need to pick the smallest card among all the piles' top cards.

    The naive approach — scan all k top cards each time — takes O(k) per pick. With N total cards, that's O(N × k).

    Think of it like this: we need a data structure that efficiently gives us the minimum among k elements and lets us update when we take one. A **min-heap** does exactly this in O(log k) time!

    The algorithm:
    1. Add the head of each list to a min-heap
    2. Pop the smallest node, add it to the result
    3. If that node has a next, push it to the heap
    4. Repeat until the heap is empty

    Each node is pushed and popped exactly once, giving O(N log k) total.

  approach: |
    We solve this using a **Min-Heap (Priority Queue)**:

    **Step 1: Initialise the heap**

    - Create an empty min-heap
    - Add the head node of each non-empty list
    - Use `(node.val, index, node)` tuples for proper ordering (index breaks ties)

    &nbsp;

    **Step 2: Build the result list**

    - Create a `dummy` node for easy construction
    - While the heap is not empty:
      - Pop the minimum node
      - Append it to the result list
      - If the popped node has a `.next`, push that next node to the heap

    &nbsp;

    **Step 3: Return the merged list**

    - Return `dummy.next`

    &nbsp;

    The heap always contains at most k nodes (one from each list), so each push/pop is O(log k). With N total nodes, total time is O(N log k).

  common_pitfalls:
    - title: Not Handling Empty Lists
      description: |
        Some lists in the input might be empty (`None`). Adding `None` to the heap will cause errors.

        Filter out empty lists or check before pushing: `if node: heappush(...)`.
      wrong_approach: "heappush(heap, (node.val, i, node)) without checking"
      correct_approach: "if node: heappush(heap, (node.val, i, node))"

    - title: Python Heap Comparison with ListNode
      description: |
        Python's `heapq` compares tuples element by element. If two nodes have the same value, it tries to compare the nodes themselves, which fails (no `__lt__` defined).

        Use `(value, index, node)` tuples where `index` is unique — this breaks ties deterministically.
      wrong_approach: "(node.val, node) — fails when values are equal"
      correct_approach: "(node.val, unique_index, node)"

    - title: Not Advancing to the Next Node
      description: |
        After popping a node and adding it to the result, push its **next** node to the heap, not the same node. Otherwise, you'll process the same node forever.
      wrong_approach: "heappush(heap, (node.val, i, node)) after popping"
      correct_approach: "heappush(heap, (node.next.val, i, node.next))"

  key_takeaways:
    - "**Min-heap for k-way merge**: Efficiently find the minimum among k candidates in O(log k)"
    - "**This is the merge step of external merge sort**: Same pattern for merging k sorted files"
    - "**Total time O(N log k)**: Each of N nodes is pushed/popped once, each operation is O(log k)"
    - "**Divide and conquer alternative**: Pair up lists and merge, reducing k by half each round — same complexity"

  time_complexity: "O(N log k). Each of the N total nodes is pushed and popped from the heap once, and each heap operation is O(log k)."
  space_complexity: "O(k). The heap holds at most k nodes at any time (one from each list)."

solutions:
  - approach_name: Min-Heap
    is_optimal: true
    code: |
      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 = []

          # Add first node from each non-empty list
          # Use index for tie-breaking (avoids comparing ListNode objects)
          for i, node in enumerate(lists):
              if node:
                  heapq.heappush(heap, (node.val, i, node))

          dummy = ListNode()
          current = dummy

          while heap:
              # Pop the smallest node
              val, i, node = heapq.heappop(heap)

              # Add to result list
              current.next = node
              current = current.next

              # Push next node from the same list
              if node.next:
                  heapq.heappush(heap, (node.next.val, i, node.next))

          return dummy.next
    explanation: |
      **Time Complexity:** O(N log k) — N nodes, each with O(log k) heap operations.

      **Space Complexity:** O(k) — Heap holds at most k nodes.

      The min-heap always contains the smallest unprocessed node from each list. We pop the minimum, add it to our result, and push its successor. The index in the tuple provides stable tie-breaking for equal values.

  - approach_name: Divide and Conquer
    is_optimal: true
    code: |
      def merge_k_lists(lists: list[ListNode | None]) -> ListNode | None:
          if not lists:
              return None

          def merge_two(l1: ListNode | None, l2: ListNode | None) -> ListNode | None:
              """Merge two sorted lists into one."""
              dummy = ListNode()
              current = dummy

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

              current.next = l1 or l2
              return dummy.next

          # Repeatedly merge pairs until one list remains
          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]
    explanation: |
      **Time Complexity:** O(N log k) — log k rounds, each processing N nodes total.

      **Space Complexity:** O(1) — Aside from the merged lists (reusing nodes).

      Pair up lists and merge each pair, reducing k to k/2 each round. After log k rounds, one list remains. Each round processes all N nodes, giving O(N log k) total — same as the heap approach but without extra heap space.
