K-Way Merge Pattern: Merge K Sorted Sequences with a Min-Heap
A min-heap tracks one pointer per sorted list. Pop the global minimum, advance that list's pointer, repeat.
Abstract AlgorithmsIntermediate
For developers with some experience. Builds on fundamentals.
Estimated read time: 16 min
AI-assisted content. This post may have been written or enhanced with AI tools. Please verify critical information independently.
TLDR: K-Way Merge uses a min-heap with exactly one entry per sorted input list. Each entry stores the current element's value plus the coordinates to find the next element in that list. Pop the minimum (global smallest), append it to output, push the next element from the same list. Repeat until the heap is empty. Time: O(N log K) for N total elements across K lists.
๐ Merging K Server Log Files Without Loading All of Them in Memory
You have K sorted log files โ one from each server in your cluster. Each file records events in ascending timestamp order. You need to merge them into one unified timeline for analysis. The brute-force approach (concatenate all files, then sort) loads every log line into memory and sorts O(N log N). For 50 servers with 1 million lines each, that is 50 million records in RAM and 50M ร 25 โ 1.25 billion comparisons.
K-Way Merge solves this in O(N log K): it reads exactly one line at a time per file, always selecting the globally earliest timestamp using a min-heap. Memory use is O(K) โ just K pointers into K files, not the full content. For K=50, that is 50 heap entries regardless of how many lines each file has.
This is not just a log-file trick. Whenever you have K already-sorted sequences that need to be combined into one sorted sequence, K-Way Merge is the optimal algorithm. It is the core of database merge-join operations, external sort algorithms, and distributed reduce phases.
๐ The Min-Heap Pointer: Always Knowing Which List Has the Next Element
The central question in merging K sorted lists is: which list's current front element is the globally smallest? Answering this naively requires comparing all K front elements โ O(K) per step, O(NK) total. A min-heap answers it in O(log K) per step.
The key mental model: the min-heap is a priority queue of "current positions" across all K lists, ordered by the value at each position. At any moment, the heap root tells you exactly which list holds the global minimum.
Here is the pattern with concrete language:
- Seed the heap โ push the first element from every non-empty list, tagged with
(value, listIndex, elementIndex). - Pop the minimum โ the heap root is the globally smallest element across all lists. Append it to the output.
- Advance the pointer โ push the next element from the same list (using the saved
listIndexandelementIndex + 1). If that list is exhausted, push nothing. - Repeat until the heap is empty.
The element wrapper (value, listIndex, elementIndex) is what makes this work for arrays. For linked lists, you store the ListNode reference directly (its .val is the value; .next is the next pointer). Either way, the pattern is identical.
โ๏ธ The Element Wrapper: Tracking Value, List Index, and Element Index
For array-based problems, you need to encode three pieces of information in each heap entry: the current value (for comparison), the list it came from (to find the next element), and the position within that list (to advance the pointer).
In Java, an int[] of length 3 is the lightest-weight wrapper:
// heap entry: { value, listIndex, elementIndex }
int[] entry = new int[]{ lists[listIdx][elemIdx], listIdx, elemIdx };
The min-heap comparator orders entries by entry[0] (the value):
PriorityQueue<int[]> minHeap = new PriorityQueue<>(
Comparator.comparingInt(a -> a[0])
);
Worked example โ merging three sorted arrays:
Lists: [1, 4, 7], [2, 5, 8], [3, 6, 9]
| Step | Heap (sorted for clarity) | Popped | Output so far |
| Seed | [(1,0,0), (2,1,0), (3,2,0)] | โ | [] |
| 1 | [(2,1,0), (3,2,0), (4,0,1)] | (1,0,0) | [1] |
| 2 | [(3,2,0), (4,0,1), (5,1,1)] | (2,1,0) | [1,2] |
| 3 | [(4,0,1), (5,1,1), (6,2,1)] | (3,2,0) | [1,2,3] |
| 4 | [(5,1,1), (6,2,1), (7,0,2)] | (4,0,1) | [1,2,3,4] |
| ... | |||
| 9 | [] | (9,2,2) | [1,2,3,4,5,6,7,8,9] |
At every step, the heap has exactly K entries (or fewer, once a list is exhausted). The heap never needs to know about elements beyond the current front of each list.
๐ง Deep Dive: How the K-Way Merge Min-Heap Maintains Global Order
Internals: Min-Heap State and the Pointer Advancement Loop
Each heap entry is a triple (value, listIdx, elemIdx). The heap orders by value โ a min-heap, so the root always holds the triple with the smallest value across all K lists.
After popping the root (v, l, e):
- Append
vto the output. - If
lists[l]has an element at indexe + 1, push(lists[l][e+1], l, e+1). - Otherwise, that list is exhausted โ nothing is pushed, and the heap shrinks by 1.
Invariant: at all times, the heap contains exactly the "current front element" of every non-exhausted list. This invariant is established by seeding and maintained by the push-after-pop rule. Because all input lists are sorted, the globally smallest remaining element is always at the heap root.
For linked lists, replace (value, listIdx, elemIdx) with ListNode references. The comparator compares node.val; advancing the pointer is node.next. The logic is identical โ the wrapper is just a node reference instead of a coordinate triple.
Performance Analysis: O(N log K) for K Lists of Total N Elements
| Operation | Time | Explanation |
| Seed (K entries) | O(K log K) | K initial offer() calls |
| Each pop + push cycle | O(log K) | One poll() + at most one offer() |
| All N cycles | O(N log K) | N elements processed, each in O(log K) |
| Total | O(N log K + K log K) = O(N log K) | K log K is dominated for N โซ K |
| Space | O(K) | Heap holds at most K entries |
Comparison table:
| Approach | Time | Space | When it wins |
| K-Way Merge (heap) | O(N log K) | O(K) | K lists, large N, streaming or out-of-memory |
| Concatenate + sort | O(N log N) | O(N) | K is large (K โ N), all data fits in RAM |
| Sequential two-way merge | O(N ร K) | O(N) | Exactly 2 lists โ simpler code |
The O(K) space advantage is decisive for external merge sort: when N is in the billions (database tables, HDFS files), you cannot load everything into RAM. K-Way Merge streams through the data, keeping only K active pointers in the heap.
๐ Watching Three Sorted Arrays Merge via Min-Heap State
The flowchart below shows the full K-Way Merge loop from seeding to output:
flowchart TD
A([Start: K sorted lists]) --> B[Seed min-heap with first element of each non-empty list]
B --> C{Heap is empty?}
C -- No --> D[Pop root: smallest value + list coordinates]
D --> E[Append value to merged output]
E --> F{Next element exists in that list?}
F -- Yes --> G[Push next element with updated index]
F -- No --> H[List exhausted do not push]
G --> C
H --> C
C -- Yes --> I([Merged output is fully sorted])
For three arrays [1,4,7], [2,5,8], [3,6,9], the heap state after each pop cycles through all three lists in a round-robin fashion because the values are interleaved. In practice, lists with smaller values are drained faster and a list's pointer advances multiple steps before another list contributes โ the heap rebalances automatically.
๐ Real-World Applications: Log Aggregation, External Sort, and Database Merges
Distributed log aggregation โ When a Kubernetes cluster routes logs from 100 pods through Fluentd or Fluent Bit to a central sink, each pod's log stream is already sorted by timestamp. K-Way Merge combines them in O(N log 100) โ just 6.6ร the cost of writing each event once. The alternative (sort all events after collecting) requires all events in memory and costs O(N log N).
External sort โ When a dataset is too large for RAM (terabyte-scale database tables), the external sort algorithm works in two phases. Phase 1: split the file into K sorted runs that each fit in RAM. Phase 2: K-Way Merge combines those runs into one sorted file. This is exactly the algorithm used by PostgreSQL's disk sort, MySQL's filesort, and Apache Spark's sort-based shuffle.
Database merge join โ SQL engines execute JOIN between two sorted relations using a two-way merge (K=2 case). When joining more than two pre-sorted partitions, the general K-Way Merge applies. The index merge access path in MySQL explicitly implements this for multi-index scans.
Case study โ Apache Hadoop's merge sort phase:
In Hadoop's MapReduce shuffle, each mapper produces a sorted spill file. Multiple spill files are then merged by the reducer using K-Way Merge. The number of files merged at once (K) is controlled by mapreduce.task.io.sort.factor (default 10). See the OSS section for how to tune this.
โ๏ธ Trade-offs: K-Way Merge vs Two-Pointer vs Concatenate-and-Sort
The decision between approaches hinges on whether your lists are already sorted and how large K is:
| Concern | K-Way Merge | Sequential Two-Way Merge | Concatenate + Sort |
| Time | O(N log K) | O(N ร K) โ each of K merges scans all prior output | O(N log N) |
| Space | O(K) | O(N) โ must store merged output at each step | O(N) |
| Streaming | โ Yes | โ Yes | โ Requires all data |
| Input must be sorted | โ Yes (required) | โ Yes (required) | โ No (sorts for you) |
| Code simplicity | Medium | Low (two-pointer loop) | Trivial |
Failure mode: unsorted input. K-Way Merge is only correct when each input list is sorted. If any list has unsorted elements, the heap's invariant breaks silently โ the output looks reasonable but has wrong order. Always sort individual lists (or verify they are sorted) before running K-Way Merge.
Failure mode: K very large. If K approaches N (e.g., K = N/10), the heap itself costs O(K log K) = O(N log N) to maintain. At that point, concatenate-and-sort with O(N log N) is comparable in time and simpler to implement.
๐งญ Decision Guide: When to Use K-Way Merge
| Situation | Recommendation |
| Use when | K sorted lists (arrays, linked lists, files) must be combined into one sorted output, especially when K โช N. |
| Avoid when | Input lists are not pre-sorted (just concatenate and sort instead); or K is so large that heap overhead dominates. |
| Alternative | Two-pointer merge for K=2; Arrays.sort on concatenated array when N is small and code simplicity matters. |
| Edge cases | Empty lists in the input (skip them during seeding); K=1 (return the single list unchanged); duplicate values across lists (the heap handles them correctly โ equal values are fine). |
๐งช Four Problems: Merge K Lists, Kth Smallest, Sorted Matrix, Smallest Range
Example 1 โ Merge K Sorted Linked Lists
Given an array of K sorted linked lists, merge them into one sorted list.
import java.util.PriorityQueue;
public class MergeKSortedLists {
static class ListNode {
int val;
ListNode next;
ListNode(int val) { this.val = val; }
}
public ListNode mergeKLists(ListNode[] lists) {
PriorityQueue<ListNode> minHeap = new PriorityQueue<>(
(a, b) -> a.val - b.val
);
// Seed: push the head of each non-null list
for (ListNode node : lists) {
if (node != null) minHeap.offer(node);
}
ListNode dummy = new ListNode(0);
ListNode current = dummy;
while (!minHeap.isEmpty()) {
ListNode smallest = minHeap.poll();
current.next = smallest;
current = current.next;
if (smallest.next != null) minHeap.offer(smallest.next); // advance pointer
}
return dummy.next;
}
}
The dummy sentinel node eliminates a null-check for the head of the merged list. The comparator (a, b) -> a.val - b.val is safe here because ListNode.val is an int with bounded range in practice; for safety, use Integer.compare(a.val, b.val).
Example 2 โ Kth Smallest Number in M Sorted Lists
Given M sorted arrays, find the Kth smallest number across all of them combined.
import java.util.PriorityQueue;
import java.util.Comparator;
public class KthSmallestMSortedLists {
public int kthSmallest(int[][] lists, int k) {
// heap entry: { value, listIndex, elementIndex }
PriorityQueue<int[]> minHeap = new PriorityQueue<>(
Comparator.comparingInt(a -> a[0])
);
for (int i = 0; i < lists.length; i++) {
if (lists[i].length > 0) {
minHeap.offer(new int[]{lists[i][0], i, 0});
}
}
int count = 0;
while (!minHeap.isEmpty()) {
int[] entry = minHeap.poll();
int val = entry[0], listIdx = entry[1], elemIdx = entry[2];
if (++count == k) return val;
if (elemIdx + 1 < lists[listIdx].length) {
minHeap.offer(new int[]{lists[listIdx][elemIdx + 1], listIdx, elemIdx + 1});
}
}
return -1; // k exceeds total element count
}
}
This is the pure K-Way Merge loop: pop the minimum, count it, push the next from the same list. Stop as soon as count reaches K.
Example 3 โ Kth Smallest Number in a Sorted Matrix
Given an NรN matrix where every row and column is sorted in ascending order, find the Kth smallest element.
import java.util.PriorityQueue;
import java.util.Comparator;
public class KthSmallestMatrix {
public int kthSmallest(int[][] matrix, int k) {
int n = matrix.length;
// Treat each row as one sorted list
PriorityQueue<int[]> minHeap = new PriorityQueue<>(
Comparator.comparingInt(a -> a[0])
);
// Seed with the first element of each row (at most K rows matter)
for (int i = 0; i < Math.min(n, k); i++) {
minHeap.offer(new int[]{matrix[i][0], i, 0});
}
int count = 0;
while (!minHeap.isEmpty()) {
int[] entry = minHeap.poll();
int val = entry[0], row = entry[1], col = entry[2];
if (++count == k) return val;
if (col + 1 < n) {
minHeap.offer(new int[]{matrix[row][col + 1], row, col + 1});
}
}
return -1;
}
}
The matrix is treated as N sorted rows. The heap merges those rows using the standard K-Way Merge loop. Seeding only min(n, k) rows is an optimization: the Kth smallest cannot come from row index โฅ k.
Example 4 โ Smallest Range Covering Elements from K Lists
Given K sorted lists, find the smallest range [start, end] such that at least one element from each list falls in the range.
import java.util.*;
public class SmallestRange {
public int[] smallestRange(List<List<Integer>> nums) {
// heap entry: { value, listIndex, elementIndex }
PriorityQueue<int[]> minHeap = new PriorityQueue<>(
Comparator.comparingInt(a -> a[0])
);
int maxVal = Integer.MIN_VALUE;
// Seed: one element per list; track current maximum
for (int i = 0; i < nums.size(); i++) {
int val = nums.get(i).get(0);
minHeap.offer(new int[]{val, i, 0});
maxVal = Math.max(maxVal, val);
}
int[] result = {minHeap.peek()[0], maxVal};
while (!minHeap.isEmpty()) {
int[] entry = minHeap.poll();
int val = entry[0], listIdx = entry[1], elemIdx = entry[2];
// If any list is exhausted, we cannot cover all K lists further
if (elemIdx + 1 >= nums.get(listIdx).size()) break;
int nextVal = nums.get(listIdx).get(elemIdx + 1);
minHeap.offer(new int[]{nextVal, listIdx, elemIdx + 1});
maxVal = Math.max(maxVal, nextVal);
// Current range: [minHeap.peek()[0], maxVal]
if (maxVal - minHeap.peek()[0] < result[1] - result[0]) {
result[0] = minHeap.peek()[0];
result[1] = maxVal;
}
}
return result;
}
}
This problem adds a twist: instead of outputting all merged elements, you track the range [heap_root, current_max] that covers one element from every list (the min is the heap root; the max is tracked separately). When that range shrinks, update the answer. Stop as soon as any list is exhausted โ at that point no range can cover all K lists.
๐ ๏ธ Apache Hadoop's Merge Sort Phase: K-Way Merge at Scale
Apache Hadoop's MapReduce shuffle is one of the largest-scale deployments of K-Way Merge in production. Here is how it works:
Phase 1 (map-side spills): Each mapper sorts its output into in-memory buffers. When a buffer fills, it is spilled to disk as a sorted file. A single mapper may produce dozens of spill files.
Phase 2 (merge): Before data is sent to reducers, the spill files are merged. Hadoop uses K-Way Merge with K controlled by:
<!-- mapred-site.xml -->
<property>
<name>mapreduce.task.io.sort.factor</name>
<value>10</value> <!-- default: merge 10 sorted files at a time -->
</property>
Phase 3 (reduce-side merge): The reducer receives sorted partitions from multiple mappers. It applies K-Way Merge again across those partitions before passing values to the user's reduce() function.
The entire shuffle โ billions of key-value pairs sorted across hundreds of nodes โ reduces to repeated applications of K-Way Merge with the heap size bounded by io.sort.factor. Increasing this value reduces the number of merge passes needed for very large numbers of spills, at the cost of more open file handles and heap memory.
For a full deep-dive on Hadoop's shuffle and sort internals, see the Apache Hadoop MapReduce documentation on the shuffle and sort phase.
๐ Lessons Learned: Off-by-One Errors and Edge Cases in K-Way Merge
1. Forgetting to check bounds before pushing the next element. The most common runtime error is ArrayIndexOutOfBoundsException or NullPointerException when advancing the pointer. Always guard: if (elemIdx + 1 < lists[listIdx].length) before pushing. For linked lists: if (node.next != null).
2. Seeding null/empty lists. If any of the K input lists is null or empty, skip it during seeding. Pushing a null node or out-of-bounds index into the heap immediately corrupts the comparator.
3. Using a.val - b.val as a comparator for integer overflow. Safe only when values are bounded away from Integer.MIN_VALUE. For general cases, use Integer.compare(a.val, b.val) to eliminate overflow risk.
4. Confusing Kth smallest with Kth largest. K-Way Merge naturally produces the globally smallest first (because it uses a min-heap). To find the Kth largest, either count from the end (total elements minus K) or reverse the merge order with a max-heap โ but max-heap K-Way Merge only works when all input lists are sorted in descending order.
5. Not handling K=1 as a special case in production code. When K=1, the heap contains a single entry at all times, and every poll/push is O(1) effectively (log 1 = 0). The algorithm is correct but the heap overhead is unnecessary โ return the single list unchanged.
๐ Summary: K-Way Merge in Five Bullets
- Core idea: A min-heap acts as a global priority queue across K sorted lists. It always knows the next smallest element in O(log K), regardless of how many total elements remain.
- Time: O(N log K) โ each of the N elements is pushed and popped exactly once, costing O(log K) per operation.
- Space: O(K) โ the heap holds exactly one active entry per non-exhausted list, independent of N.
- The wrapper pattern: Always store
(value, listIndex, elementIndex)or aListNodereference in the heap so you can advance the pointer after each pop. - Production use: K-Way Merge powers database external sort, Apache Hadoop/Spark shuffle merge, and distributed log aggregation โ anywhere K sorted streams must be unified without loading all data into memory.
๐ Related Posts
Test Your Knowledge
Ready to test what you just learned?
AI will generate 4 questions based on this article's content.

Written by
Abstract Algorithms
@abstractalgorithms
More Posts
HyperLogLog Explained: Counting Billions of Unique Items with 12 KB
TLDR: HyperLogLog estimates the number of distinct elements in a dataset using ~12 KB of memory regardless of cardinality โ with ยฑ0.81% error. The insight: if you hash every element to a random bit string, the maximum length of leading zeros you obse...
Bloom Filters Explained: Membership Testing with Zero False Negatives
TLDR: A Bloom filter is a bit array of m bits + k independent hash functions that sets k bits on insert and checks those same k bits on lookup. If any checked bit is 0, the element is definitely not in the set โ false negatives are mathematically imp...
Count-Min Sketch Explained: Frequency Estimation at Streaming Scale
TLDR: Count-Min Sketch (CMS) is a fixed-size d ร w counter matrix that estimates how often any element has appeared in a stream. Insert: hash the element with each of the d hash functions to get one column per row, increment those d counters. Query: ...
Java 21 to 25: Virtual Threads, Pattern Matching, and Structured Concurrency
TLDR: Java 21 LTS makes virtual threads a production-ready replacement for bounded thread pools โ your newFixedThreadPool(200) can become newVirtualThreadPerTaskExecutor() and handle 10ร the concurrency with no architectural changes. Pattern switch w...
