K-Way Merge
The k-way merge generalizes the two-list merge from Merge Sort: given k already-sorted input streams (lists, files, iterators) holding a total of N elements, produce a single sorted output stream in
O(N log k)time andO(k)extra memory. The dominant pattern is to maintain a min-heap of size k whose elements are “the next-unread item from each input stream”; repeatedly extract the smallest and refill from whichever stream produced it. K-way merge is the engine inside external sorting (sorting data larger than RAM), inside the merge phase of distributed/sharded log processing, and inside event-driven simulations where multiple time-ordered queues are interleaved.
1. Intuition — The Coffee-Counter Analogy
Picture a coffee shop with k queues, each one already sorted by arrival time (each customer in a queue arrived after the one in front of them). The barista wants to serve all customers in true global arrival order. She can’t merge two queues at a time the way you would for k=2; she needs a way to repeatedly answer “which of the k people currently at the head of a queue arrived earliest?”
If she only ever scanned the heads of all k queues each time (linear scan), every “next” decision costs O(k), giving O(N · k) overall. The optimization is to keep a min-heap of (arrival_time, queue_id) pairs — the head element of each queue. Then “earliest of the heads” is just heap[0] in O(1), and replacing the served customer with the next one from the same queue is one heappush in O(log k). Total: N rounds of O(log k) each = O(N log k).
The “k” inside the log is what makes this a beautiful generalization. For k = 2, log k = 1, and we recover the linear-time two-list merge of Merge Sort. For k = N (each list has length 1), we degenerate into a heap sort: O(N log N).
2. Tiny Worked Example
Three sorted lists:
L0 = [1, 4, 7]
L1 = [2, 5, 8]
L2 = [3, 6, 9]
We want a single sorted output [1, 2, 3, 4, 5, 6, 7, 8, 9].
Initial heap — push the head of each list, tagged with the list index and the index within the list:
heap = [(1, 0, 0), (2, 1, 0), (3, 2, 0)] # (value, list_idx, item_idx)
The tuple structure is critical — see §10.1 on tie-breaking. The heap orders primarily by value; ties fall back to list_idx (always distinct), which prevents Python from trying to compare items that may not be comparable.
Iteration 1. Pop (1, 0, 0) → output 1. Push next from L0: (4, 0, 1). Heap: [(2,1,0), (3,2,0), (4,0,1)].
Iteration 2. Pop (2, 1, 0) → output 2. Push next from L1: (5, 1, 1). Heap: [(3,2,0), (4,0,1), (5,1,1)].
Iteration 3. Pop (3, 2, 0) → output 3. Push next from L2: (6, 2, 1). Heap: [(4,0,1), (5,1,1), (6,2,1)].
… and so on. After 9 iterations, every element has been emitted once, in sorted order. Notice the heap stayed size 3 = k throughout — never larger.
3. Pseudocode
k_way_merge(lists): # lists = array of k sorted lists
heap := empty min-heap
for i in 0..k-1:
if lists[i] is non-empty:
heap.push( (lists[i][0], i, 0) ) # (value, list_idx, item_idx)
output := empty list
while heap is not empty:
(value, i, j) := heap.pop_min()
output.append(value)
if j + 1 < length(lists[i]):
heap.push( (lists[i][j+1], i, j+1) )
return output
Two structural points worth noting. First, the heap is seeded with one entry per list before the main loop starts; this is what makes the “k” appear in log k — the heap is bounded by k forever. Second, every pop is followed by exactly one push from the same list the popped element came from — this preserves the invariant that the heap holds the next-unread item from each still-active list.
4. Python Implementation
The straightforward heapq version (clearest for interviews):
import heapq
def k_way_merge(lists):
"""Merge k sorted lists into one sorted list. O(N log k), O(k) extra."""
heap = []
for i, lst in enumerate(lists):
if lst: # skip empty inputs
heapq.heappush(heap, (lst[0], i, 0)) # (value, list_idx, item_idx)
out = []
while heap:
val, i, j = heapq.heappop(heap)
out.append(val)
if j + 1 < len(lists[i]):
heapq.heappush(heap, (lists[i][j + 1], i, j + 1))
return outNote the (value, i, j) tuple. The third field j is the position within lists[i]; it would be sufficient to carry just i and look up the next item via a per-list cursor, but the tuple form is self-contained and easy to reason about. The i middle field is the unique tiebreaker that keeps the heap from ever attempting to compare two lists[i][j] values against each other when their primary keys tie (see §10.1 — this is a recurring heapq trap because heapq always compares tuples lexicographically).
4.1 Iterator-based version (handles streams, not just lists)
The list version assumes random access by index. For streams (files, generators, network sockets), use Python iterators:
import heapq
def k_way_merge_streams(streams):
"""Merge k sorted iterables. Streams need not fit in memory."""
heap = []
iters = [iter(s) for s in streams]
for i, it in enumerate(iters):
try:
heapq.heappush(heap, (next(it), i))
except StopIteration:
pass
while heap:
val, i = heapq.heappop(heap)
yield val # generator output
try:
heapq.heappush(heap, (next(iters[i]), i))
except StopIteration:
passThis is morally what the standard-library heapq.merge does (with extra optimizations); for production code, prefer it.
4.2 LeetCode 23 — linked-list flavor
import heapq
from typing import List, Optional
class ListNode:
def __init__(self, val=0, next=None):
self.val, self.next = val, next
def merge_k_lists(lists: List[Optional[ListNode]]) -> Optional[ListNode]:
heap = []
for i, head in enumerate(lists):
if head:
heapq.heappush(heap, (head.val, i, head))
dummy = ListNode()
tail = dummy
counter = len(lists) # monotonic tiebreaker
while heap:
val, _, node = heapq.heappop(heap)
tail.next = node
tail = node
if node.next:
heapq.heappush(heap, (node.next.val, counter, node.next))
counter += 1 # ensure uniqueness across pushes
return dummy.nextThe monotonic counter is necessary because when two list nodes share a value, falling back to comparing ListNode objects raises TypeError: '<' not supported. The original list index is not a sufficient tiebreaker here either, because two pushes from the same i can both be live in the heap simultaneously if the algorithm allowed it (here it doesn’t, because of the one-pop-one-push invariant — but the monotonic counter is the bulletproof general pattern, see §10.1).
5. Complexity
Let k = number of input lists, N = total number of elements across all of them.
5.1 Time — O(N log k)
- Initial seeding:
kheap pushes, eachO(log k)→O(k log k). - Main loop: exactly
Niterations. Each iteration is oneheappop(O(log k)) plus at most oneheappush(O(log k)). Total:O(N log k). - Sum:
O(k log k + N log k) = O(N log k)(sinceN ≥ kwhenever there’s at least one element per non-empty list).
The k in log k (instead of log N) is the whole point. Sorting all N elements together with a comparison sort is O(N log N) and ignores the structure that the inputs are pre-sorted. The k-way merge exploits the per-list pre-sortedness to shrink the log factor.
5.2 Space — O(k) extra (excluding output)
The heap holds at most k entries at any time (one per still-live list). The output sequence itself is O(N), but if the output is streamed (yielded one element at a time, written to disk/network), the algorithm’s working set is O(k). This is what makes k-way merge usable for N larger than RAM — see §6.1.
5.3 Lower bound — why we can’t do better
Information-theoretically, producing a sorted output from k sorted inputs of total length N requires at least log₂(N! / (n₁! · n₂! · … · n_k!)) comparisons, where n_i is the size of list i. When all lists are roughly equal (n_i ≈ N/k), Stirling’s approximation gives this lower bound as Θ(N log k). So the k-way heap merge is asymptotically optimal in the comparison model. (Reference: Knuth TAOCP Vol. 3, §5.3.2.)
6. Use Cases
6.1 External Merge Sort (sorting data larger than RAM)
The canonical industrial use. To sort a 100 GB file with 8 GB of RAM:
- Run-generation phase. Read the file in 8 GB chunks; sort each chunk in memory (with Quicksort or Tim Sort); write the sorted chunk back to disk as a run. After this phase you have ~13 sorted runs on disk.
- Merge phase. Use a k-way merge across the 13 runs. The heap holds 13 entries (one per run); reading proceeds via buffered I/O so you read a block from each file at a time. Write the merged stream back to a single output file.
Because the merge phase only ever holds O(k) entries in memory plus per-file I/O buffers, it works regardless of total data size. This is exactly how sort(1) on Unix handles big files (it spills to /tmp once data exceeds --buffer-size), and how database engines (PostgreSQL, MySQL) implement ORDER BY for large result sets.
If k is itself huge (say, 1 million runs), the single-pass merge becomes I/O-bound on disk seeks. The standard solution is multi-pass merging: do √k runs of √k-way merges in parallel, then merge their outputs. Knuth §5.4 covers the optimal merge-pattern theory (the “Huffman-tree merge order” — see Huffman Coding).
6.2 Multi-Shard Log Merging
A typical distributed system (Kafka, Elasticsearch, Spanner) stores a logical log split across many shards, each shard internally ordered by timestamp. To replay events globally in time order — for analytics, audit, or replication — you k-way-merge the shards. The “lists” are the shard iterators, and the “values” are events keyed by (timestamp, shard_id) to deterministically break ties.
Latency-sensitive systems use a time-bounded merge that emits an event only after waiting long enough that no shard could plausibly produce an earlier one (a watermark, in stream-processing parlance). Apache Flink, Kafka Streams, and Google Dataflow all wrap k-way merging in watermark machinery for this purpose.
6.3 Priority-Queue-Driven Event Simulation
Discrete-event simulators (network simulators, game engines, hardware simulators) need to process events in time order. When events come from multiple independent sources — say, traffic generators on different network nodes — each source produces events in its own time order, so the global event ordering is a k-way merge. The heap in this case is the event queue; each pop fires the next event, which may schedule new events into the heap.
6.4 Database Range Scans Across Sharded Indexes
A B-tree index range-scan returns rows in order of the indexed column. When the index is sharded across nodes (each shard has its own B-tree), a query like SELECT … ORDER BY ts LIMIT 100 translates to a k-way merge of the per-shard scans, with early termination after 100 rows. Modern column stores (Snowflake, ClickHouse) explicitly use this pattern.
6.5 Smallest-K-in-N-Sorted-Lists (LC 378, LC 632)
LC 378 (kth-smallest in a sorted matrix) and LC 632 (smallest range covering elements from k lists) are both flavor-variants. For LC 378, treat each row as a sorted list and run the merge for k pops. For LC 632, the heap holds one element per list, and you track the running max while popping; whenever you pop, you’ve found the candidate range [min_in_heap, current_max] — minimize that across all states.
7. Variants
7.1 Tournament Tree (Loser Tree)
Knuth’s preferred external-sort merge primitive (TAOCP §5.4.1). Instead of a heap, use a complete binary tree where each internal node holds the loser of the comparison between its subtree’s two streams; the global winner sits “above” the root. After emitting the winner, we replay only the path from the winning leaf to the root — log₂(k) comparisons, but with a constant factor that’s roughly half the heap’s because we don’t sift-down from the top each time. Used in classical tape-sort implementations; rarely beats a binary heap on modern CPUs because the cache behavior of a flat array beats pointer-chased trees.
7.2 Pairwise (Divide-and-Conquer) K-Merge
Repeatedly merge pairs of lists: round 1 produces ⌈k/2⌉ lists, round 2 produces ⌈k/4⌉, etc. After log₂(k) rounds, one list remains. Each round processes all N elements. Total: O(N log k) — same asymptotic cost as the heap version. Often appears as the “alternative implementation” for LC 23. Slightly better cache behavior in some microbenchmarks; worse memory profile (each round materializes all N elements).
7.3 Polyphase Merge
When the number of available scratch tapes/files is fixed (say, 3) but you have many runs to merge, the polyphase merge interleaves merging and run-redistribution to minimize total I/O. Pure history at this point — relevant when reading old systems papers, but no production system uses tape constraints anymore.
7.4 Parallel K-Way Merge
For multicore implementation, partition the value space into p ranges (one per core); each core merges only its portion of each input stream. Tricky load-balancing — the canonical reference is Cole’s “Parallel merge sort” (1988). Most modern systems (GNU parallel, Spark) implement merging via shuffle-then-sort instead.
9. Pitfalls
9.1 Heap Tie-Breaking — The TypeError Trap
When two heap entries have the same primary key, Python falls back to comparing the second tuple element, then the third, etc. If the second element is a ListNode, a dict, or any non-comparable type, you get TypeError: '<' not supported between instances of 'ListNode' and 'ListNode'. The fix is to always include a provably unique, totally-ordered tiebreaker (the list index i, or a monotonic counter) before any potentially-uncomparable payload. This is the single most common bug in interview k-way-merge implementations.
9.2 heapq Is Min-Heap by Default
Python’s heapq primitives (heappush/heappop/heapify) build a min-heap; for decades there was no max-heap API at all. Python 3.14 added explicit *_max variants (heapify_max, heappush_max, heappop_max, heapreplace_max, heappushpop_max), but for portability and for the common case the standard idiom is still: for a k-way descending merge (largest first), negate values on push and on read — heapq.heappush(heap, (-val, i, j)). The negation trick fails for non-numeric values; for those, wrap in a class with __lt__ reversed (or use the tuple-with-negative-sort-key pattern). For lazy descending merges, heapq.merge(..., reverse=True) works directly on inputs sorted largest-to-smallest. See Binary Heap §5.
9.3 Forgetting to Skip Empty Lists at Init
If lists[i] is empty and you blindly push lists[i][0], you get IndexError. Always guard with if lst: (or a try/except StopIteration for iterators).
9.4 Not Re-Pushing After Popping
Every pop must be followed by an attempt to push the next item from the same list. Forgetting this leaves only one element per list ever appearing in the output — an extremely silent bug because the output starts correct (the first k items are right).
9.5 Re-Sorting Each Loop Iteration
A common naive “k-way merge” answer is to put all candidate heads in a list and call min(...) each iteration — this is O(N · k), not O(N log k). The heap is the entire performance argument; do not regress to a linear scan.
9.6 Stable-Order Loss When Using Negation
Negating values for max-heap behavior reverses ordering — including tiebreaker order. If you need stability among equal keys, the negation trick combined with an ascending counter produces reverse stability. Use a class with reversed __lt__ instead, or negate the counter as well.
9.7 Memory Blowup From Eager List Materialization
If you eagerly materialize all N items into the heap up front (instead of streaming one-per-list), memory becomes O(N) and the algorithm is just heap-sort with extra steps. The whole point is O(k) working set; preserve it.
10. Diagram — The Heap as a Front of K Cursors
flowchart LR subgraph Inputs[k sorted input lists] L0["L0: 1, 4, 7"] L1["L1: 2, 5, 8"] L2["L2: 3, 6, 9"] end subgraph Heap[Min-heap size k - one head per list] H["heap = [ 1,L0 2,L1 3,L2 ]"] end L0 -- "head pointer" --> H L1 -- "head pointer" --> H L2 -- "head pointer" --> H H -- "pop smallest, advance that list's head" --> Out["Output: 1, 2, 3, 4, ..."]
What this diagram shows. The heap is conceptually a frontier — the leading edge of k parallel cursors marching through the k input lists. At each step, the smallest of the k frontier values is the next-correct global output, and only the cursor that produced it advances (the other k-1 are still in the right place). The heap merely provides O(log k) access to “smallest of the frontier”; the algorithmic invariant is the frontier itself, which is what makes the working set O(k) rather than O(N). This is also why the algorithm streams: at no point does it need to materialize more than k items.
11. Common Interview Problems
| LeetCode # | Title | Twist |
|---|---|---|
| 23 | Merge k Sorted Lists | Plain vanilla — linked-list flavor |
| 378 | Kth Smallest Element in a Sorted Matrix | Each row = list; pop k times |
| 632 | Smallest Range Covering Elements from K Lists | Track max_in_heap while merging |
| 264 / 313 | Ugly Number II / Super Ugly Number | Merge streams 2x, 3x, 5x |
| 373 | Find K Pairs with Smallest Sums | Heap of pair-streams |
| 786 | K-th Smallest Prime Fraction | Heap of fraction-streams |
| 1675 | Minimize Deviation in Array | Variant: running max/min via heap |
Beyond LeetCode, the same pattern is the expected answer in system-design rounds for external merge sort (run-generation + k-way merge phase, §6.1) and for “merge sorted event streams from N microservices/shards” (the iterator-based variant of §4.1).
12. Open Questions
- When does a tournament/loser tree beat a binary heap in practice on modern hardware? Most published microbenchmarks find them within 10% of each other; the binary heap’s cache locality wins on small
k. - For very large
k(say,k > 10⁵), is the single-pass merge ever I/O-bound enough that two-pass merges (√k by √k) win? Answer is yes for spinning disks; SSDs largely erase the seek-cost gap, but the read-buffer-fit constraint (see the callout above) still forces a bounded fan-in. The specific crossover figure is engine-dependent — verify against the target engine’s spill/merge-order configuration rather than trusting a single round number.
Uncertain uncertain
Verify: the specific
kthreshold at which a two-pass (√k × √k) merge beats a single-pass merge on SSD-backed storage. Reason: this is governed by the merge fan-in vs. per-stream read-buffer size trade-off (each open run needs a sequential read buffer; oncekbuffers no longer fit in the sort memory budget the single pass thrashes), which is workload- and engine-specific — no clean published rule covers it, and the §6.1/§7.2 figures here (“k > 100” for Spark/Snowflake) are folklore, not a cited benchmark. To resolve: measure on the target storage layer, or cite the specific engine’s documentedsort_buffer/spill-fan-in defaults (e.g. PostgreSQL’smaintenance_work_mem-derived merge order).
13. See Also
- Binary Heap — the underlying data structure
- Merge Sort — the two-list (
k = 2) special case - Top K Elements — sister heap-pattern note
- Two Heaps Pattern — paired-heap pattern for medians
- Huffman Coding — uses k-way merge order to minimize total weighted I/O cost
- Tim Sort — production sort that uses run-generation similar to external sort phase 1
- Big-O Notation — for the
log kargument - SWE Interview Preparation MOC