Part VI · More Patterns Pattern 20 4 problems

K-way Merge and Two Heaps

A heap answers one question fast: what is the smallest thing right now? K-way merge asks it of K sorted lists at once. Two heaps ask it from both sides of the middle, which gives you a running median.

Pattern 9 used a heap to keep the K best items. It named two other heap moves and left them for later. This page covers both. They share one tool, PriorityQueue<TElement, TPriority>. In C# the element and its priority are separate. The queue only ever compares priorities, so a node never needs to be comparable. The Python version of this page is here.

Contents

  1. When to use
  2. Core idea
  3. The templates
  4. Common mistakes
  5. Merge k Sorted Lists
  6. Kth Smallest Element in a Sorted Matrix
  7. Find Median from Data Stream
  8. Smallest Range Covering Elements from K Lists
  9. Recap

When to use

The trigger. You have several sorted sources and need their items in one global order, or you need the middle of data that keeps arriving. “Merge K sorted”, “k-th smallest across sorted rows”, “median of a stream” and “smallest range that hits every list” are the classic phrasings.

Core idea

K-way merge: a heap of heads

The smallest item overall must be the head of one of the K lists, because each list is sorted. So keep a min-heap with exactly one entry per list: its current head. Pop the smallest, emit it, and push the next item from the same list. The heap never holds more than K entries, so each step costs O(log K). With N items in total, the merge costs O(N log K).
list 0 1 4 7 list 1 2 5 list 2 3 6 9 dashed: already emitted amber: each list's current head (3,2) (4,0) (5,1) min-heap of heads, one entry per list merged so far 1 2 3 pop 3 from list 2, then push 6, the next item of list 2
Figure 20.1 — The heap only ever holds one head per list, and its top is the smallest unused item.

Reading the figure. Each row is one sorted list. Dashed cells are already in the output. The amber cells are the current heads, and the heap holds exactly those three, with priority (value, list). The heap top is the next item to emit. After it leaves, only list 2 moves forward, so only one push follows.

Ties are not stable. PriorityQueue pops equal priorities in no fixed order. For plain values that is fine. If you want a fixed order, make the priority a tuple: (value, listIndex). ValueTuple compares field by field, so the list index ends every tie. Only one entry per list sits in the heap at a time, so each priority is unique. Unlike Python, you never need the index to stop a crash. The queue never compares the elements, only the priorities.

Two heaps: one for each half

Split the numbers into a lower half and an upper half. Keep the lower half in a max-heap, so its biggest value is on top. Keep the upper half in a min-heap, so its smallest value is on top. Those two tops sit on either side of the median. Keep the sizes equal, or the lower half one bigger. Then the median is the lower top, or the average of both tops. PriorityQueue is a min-heap. For the max-heap, this page passes a reversed comparer: Comparer<int>.Create((a, b) => b.CompareTo(a)).
lower half (max-heap) upper half (min-heap) 3 1 5 15 top = max top = min reversed comparer: 3 on top plain min-heap median = (3 + 5) / 2 = 4.0
Figure 20.2 — The two heap tops sit either side of the median, so reading it costs O(1).

Reading the figure. The left tree is the lower half, with its largest value on top. The right tree is the upper half, with its smallest value on top. The amber tops face each other across the dashed median line. Every value on the left is at most every value on the right. So the median comes from the tops alone.

Why not negate? Python stores -value to fake a max-heap. In C# that breaks on int.MinValue. Its negation overflows back to int.MinValue, so the order is silently wrong. A reversed comparer has no such edge, and the stored values stay readable in the debugger.

The templates

Template A — merge K sorted arrays with a heap of heads
public static class KWayMerge
{
    /// <summary>All values from K sorted arrays, in one sorted list.</summary>
    /// <param name="arrays">Arrays sorted ascending. Any of them may be empty.</param>
    /// <returns>Every value, in ascending order.</returns>
    /// <example>MergeSortedArrays([[1, 4, 7], [2, 5], [3, 6, 9]]) returns
    /// [1, 2, 3, 4, 5, 6, 7, 9].</example>
    public static List<int> MergeSortedArrays(int[][] arrays)
    {
        // Element: (which array, position in it). Priority: (value, array index).
        // The array index breaks ties, so equal values pop in a fixed order.
        // Position 0 is each array's head. Empty arrays have no head, so skip them.
        var seed = arrays
            .Select((arr, index) => (arr, index))
            .Where(p => p.arr.Length > 0)        // > 0: the array has a head
            .Select(p => ((p.index, Pos: 0), (p.arr[0], p.index)));
        // This constructor heapifies in O(K), cheaper than K separate pushes.
        var heap = new PriorityQueue<(int List, int Pos), (int Value, int List)>(seed);

        var merged = new List<int>();

        // Invariant: heap holds the next unused item of every array not yet used up,
        // so its top is the smallest unused item overall.
        while (heap.TryDequeue(out var at, out var pri))
        {
            merged.Add(pri.Value);

            // Pos + 1 is the next item in the same array. Push it if it exists.
            int next = at.Pos + 1;
            if (next < arrays[at.List].Length)
            {
                heap.Enqueue((at.List, next), (arrays[at.List][next], at.List));
            }
        }
        return merged;
    }
}

The BCL has no lazy merge like Python’s heapq.merge. arrays.SelectMany(a => a).Order() gives the same list in one line, but it costs O(N log N) and ignores that the inputs are sorted. Write the heap loop in the interview, then mention the one-liner as the simple fallback.

Template B — two heaps around the middle
public static class TwoHeaps
{
    /// <summary>The median after each new number arrives.</summary>
    /// <param name="stream">The numbers, in arrival order.</param>
    /// <returns>One median per number, as a double.</returns>
    /// <example>RunningMedians([5, 15, 1, 3]) returns [5.0, 10.0, 5.0, 4.0].</example>
    public static List<double> RunningMedians(int[] stream)
    {
        // Max-heap of the small half: the reversed comparer puts the largest on top.
        var lower = new PriorityQueue<int, int>(Comparer<int>.Create((a, b) => b.CompareTo(a)));
        var upper = new PriorityQueue<int, int>();   // min-heap of the large half
        var medians = new List<double>();

        // Invariant at the top of each pass: every value in lower is at most every
        // value in upper, and lower.Count is upper.Count or upper.Count + 1.
        foreach (int value in stream)
        {
            // Step 1: push into lower, then move lower's largest across.
            // So upper always gets the right value, and the order between halves holds.
            lower.Enqueue(value, value);
            int moved = lower.Dequeue();
            upper.Enqueue(moved, moved);

            // Step 2: rebalance. Upper may now be one too big. Move its smallest back.
            if (upper.Count > lower.Count)
            {
                int back = upper.Dequeue();
                lower.Enqueue(back, back);
            }

            // Step 3: read the tops.
            if (lower.Count > upper.Count)
            {
                medians.Add(lower.Peek());                       // odd count: lower top
            }
            else
            {
                // Even count: average the two tops. The cast to long stops int
                // overflow on the sum. / 2.0 forces floating division, so 1 and 2
                // give 1.5, not 1.
                medians.Add(((long)lower.Peek() + upper.Peek()) / 2.0);
            }
        }
        return medians;
    }
}
before adding 1 lower upper 5 15 median 10.0 1. push 1 into lower lower upper 1 5 15 sizes 2 and 1 1. move lower's max up lower upper 1 5 15 sizes 1 and 2 2. move 5 back down lower upper 1 5 15 median 5.0
Figure 20.3 — Push into lower, move its max up, then rebalance. Order between the halves can never break.

Reading the figure. Each column is one moment while 1 is added. Blue cells are the lower half and violet cells the upper half. The amber cell is the value that just moved. The move up always sends the largest value of the lower half, so the order between halves holds. The move back only fixes the sizes.

The push-then-move dance in step 1 is the trick. It avoids comparing the new value with either top by hand, and it can never break the order between halves. Each value is passed twice, as element and as priority. That looks odd, but it is how PriorityQueue<int, int> works.

Common mistakes

The problems

1. Merge k Sorted Lists Hard

Problem

You are given k sorted linked lists. Merge them into one sorted linked list and return its head.

The idea

Seed a min-heap with the head node of every non-empty list. Pop the smallest node, link it onto the result, and push that node’s Next. A dummy head node removes the “is the result empty yet” special case. The priority is (value, listIndex), so ties pop in a fixed order. The node itself is the element, and it is never compared.

Solution

/// <summary>A singly linked list node for this page's merge problems.</summary>
public class ListNode(int val = 0, ListNode? next = null)
{
    public int Val = val;            // default 0: only the dummy head uses it
    public ListNode? Next = next;

    /// <summary>Linked list holding the values, in order.</summary>
    /// <param name="values">Values to link. Empty input gives null.</param>
    /// <returns>The head node, or null.</returns>
    /// <example>ListNode.Build([1, 2]) returns 1 -&gt; 2.</example>
    public static ListNode? Build(int[] values)
    {
        ListNode? head = null;
        // Build from the back, so each new node points at the one built before.
        // i starts at Length - 1, the last index.
        for (int i = values.Length - 1; i >= 0; i--)
        {
            head = new ListNode(values[i], head);
        }
        return head;
    }

    /// <summary>The values of a linked list, as a List.</summary>
    /// <param name="head">First node, or null for an empty list.</param>
    /// <returns>The values in order.</returns>
    /// <example>ListNode.Values(ListNode.Build([1, 2])) returns [1, 2].</example>
    public static List<int> Values(ListNode? head)
    {
        var output = new List<int>();
        // Invariant: output holds every value before head.
        for (var node = head; node is not null; node = node.Next)
        {
            output.Add(node.Val);
        }
        return output;
    }
}

public static class MergeKLists
{
    /// <summary>Merge k sorted linked lists into one sorted linked list.</summary>
    /// <param name="lists">Heads of sorted linked lists. Any of them may be null.</param>
    /// <returns>The head of the merged list, or null if every list is empty.</returns>
    /// <example>Merge([Build([1, 4, 5]), Build([1, 3, 4]), Build([2, 6])]) gives
    /// 1 1 2 3 4 4 5 6.</example>
    public static ListNode? Merge(ListNode?[] lists)
    {
        // Element: the node. Priority: (value, list index). The index makes
        // equal values pop in list order. Nodes are never compared.
        var heap = new PriorityQueue<(ListNode Node, int List), (int, int)>();
        for (int index = 0; index < lists.Length; index++)
        {
            // Skip empty lists: they have no head.
            if (lists[index] is { } head)
            {
                heap.Enqueue((head, index), (head.Val, index));
            }
        }

        var dummy = new ListNode();      // placeholder before the real head
        var tail = dummy;                // last node of the merged list so far

        // Invariant: dummy.Next .. tail is sorted and holds every popped node.
        // heap holds the current head of every list not yet used up.
        while (heap.TryDequeue(out var entry, out _))   // _ : priority, not needed
        {
            tail.Next = entry.Node;
            tail = entry.Node;

            // Same index: the next node comes from the same list, and that list
            // has no other entry in the heap, so the priority stays unique.
            if (entry.Node.Next is { } next)
            {
                heap.Enqueue((next, entry.List), (next.Val, entry.List));
            }
        }
        return dummy.Next;               // skip the placeholder
    }
}

Walkthrough

Lists [1, 4, 5], [1, 3, 4], [2, 6]. Heap priorities shown as (value, index):

step heap, smallest first output seed (1,0) (1,1) (2,2) pop (1,0) (1,1) (2,2) (4,0) 1 pop (1,1) (2,2) (3,1) (4,0) 1 1 pop (2,2) (3,1) (4,0) (6,2) 1 1 2 pop (3,1) (4,0) (4,1) (6,2) 1 1 2 3 4 more pops empty 1 1 2 3 4 4 5 6
Figure 20.4 — Each pop emits the smallest head and pushes the next node from the same list.

Reading the figure. Each row is the state after one step. Heap entries show the priority (value, list index), smallest first. The amber entry is the next to pop. The violet entry was just pushed from the list that lost its head. Notice row 2: two entries tie on value 1, and the list index settles the order. Green cells are the merged output.

TimeO(N log k)SpaceO(k) for the heap

Edge cases to raise

Say this out loud: “The next node overall is the head of one of the k lists, so I keep a heap of just the heads. Each pop costs log k. The node is the element and (value, index) is the priority, so nodes are never compared and ties are fixed.”

2. Kth Smallest Element in a Sorted Matrix Medium

Problem

An n × n matrix has every row and every column sorted in ascending order. Return the k-th smallest element, counting duplicates.

The idea

Each row is a sorted list, so this is a K-way merge over n rows that stops early. Seed the heap with the first cell of each row. Pop k - 1 times, pushing the cell to the right each time. The heap top is then the k-th smallest. Only the first min(n, k) rows can matter, because the k-th smallest cannot sit below row k.

Solution

public static class KthSmallestInMatrix
{
    /// <summary>The k-th smallest value in a matrix with sorted rows and columns.</summary>
    /// <param name="matrix">A non-empty n x n matrix. Rows and columns ascend.</param>
    /// <param name="k">Rank to find, 1 to n * n.</param>
    /// <returns>The k-th smallest value, duplicates counted.</returns>
    /// <exception cref="ArgumentOutOfRangeException">k is outside 1..n * n.</exception>
    /// <example>Find([[1, 5, 9], [10, 11, 13], [12, 13, 15]], 8) returns 13.</example>
    public static int Find(int[][] matrix, int k)
    {
        int n = matrix.Length;
        // 1 is the smallest rank. n * n is the count of cells, the largest rank.
        ArgumentOutOfRangeException.ThrowIfLessThan(k, 1);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(k, n * n);

        // Seed with column 0 of each row. Element: (row, col). Priority: the value.
        // Rows past k cannot hold the answer, so Math.Min(n, k) rows is enough.
        var heap = new PriorityQueue<(int Row, int Col), int>();
        for (int row = 0; row < Math.Min(n, k); row++)
        {
            heap.Enqueue((row, 0), matrix[row][0]);   // 0: first column
        }

        // Pop k - 1 times: after that the smallest k - 1 are gone, and the top
        // of the heap is the k-th smallest.
        // Invariant: heap holds the next unused cell of every seeded row.
        for (int popped = 0; popped < k - 1; popped++)
        {
            var (row, col) = heap.Dequeue();
            // col + 1 is the next cell to the right. Push it if the row has one.
            if (col + 1 < n)
            {
                heap.Enqueue((row, col + 1), matrix[row][col + 1]);
            }
        }

        // The top's priority is its value. TryPeek returns both parts.
        heap.TryPeek(out _, out int answer);
        return answer;
    }
}

Walkthrough

Matrix rows [1, 5, 9], [10, 11, 13], [12, 13, 15], k = 8:

1 pop 1 5 pop 2 9 pop 3 r0 10 pop 4 11 pop 5 13 pop 7 r1 12 pop 6 13 answer 15 r2 Seed: column 0, so 1, 10, 12. Each pop pushes the cell to its right. Pops 1 to 7 take 1, 5, 9, 10, 11, 12, 13. The heap top is now row 2’s 13. That is the 8th smallest.
Figure 20.5 — Seven pops remove the seven smallest cells, and the heap top is the 8th.

Reading the figure. Each cell shows its value and the pop that removed it. Blue cells were popped. The order snakes across rows, because the heap always holds the next cell of each row. The green cell is on top of the heap after k - 1 = 7 pops. The grey 15 is never touched.

The two 13s tie, and PriorityQueue may pop either one first. The answer is the value 13 either way, so this problem does not need a tie-break.

TimeO(min(n, k) + k log min(n, k))SpaceO(min(n, k))

Edge cases to raise

Say this out loud: “Each row is a sorted list, so this is a K-way merge that stops after k pops. I only seed the first min(n, k) rows, because the answer cannot be deeper than row k.”

3. Find Median from Data Stream Hard

Problem

Design a class with AddNum(num), which adds a number from a stream, and FindMedian(), which returns the median of every number so far. Both should be fast for millions of calls.

The idea

Sorting on every call is O(n log n). Inserting into a sorted List<int> is O(n). Two heaps give O(log n) to add and O(1) to read. The lower half lives in a max-heap built with a reversed comparer. The upper half lives in a min-heap. Lower is allowed one extra item, so with an odd count the median is the lower top.

Solution

/// <summary>Running median with two heaps.</summary>
/// <remarks>lower is a max-heap holding the smaller half. upper is a min-heap holding
/// the larger half. lower.Count is upper.Count or upper.Count + 1, and every lower
/// value is at most every upper value.</remarks>
/// <example>Add 1 and 2, then FindMedian() returns 1.5. Add 3, then it returns 2.0.</example>
public class MedianFinder
{
    // Reversed comparer: b before a, so the largest value sits on top.
    private readonly PriorityQueue<int, int> lower =
        new(Comparer<int>.Create((a, b) => b.CompareTo(a)));
    private readonly PriorityQueue<int, int> upper = new();   // plain min-heap

    /// <summary>Add num to the stream in O(log n).</summary>
    /// <param name="num">The new number.</param>
    /// <example>finder.AddNum(5) stores 5.</example>
    public void AddNum(int num)
    {
        // Push into lower, then move lower's largest to upper. This keeps every
        // lower value at most every upper value, whatever num was.
        lower.Enqueue(num, num);
        int moved = lower.Dequeue();
        upper.Enqueue(moved, moved);

        // Upper may now hold one more than lower. Move its smallest back,
        // so lower is equal or exactly 1 bigger.
        if (upper.Count > lower.Count)
        {
            int back = upper.Dequeue();
            lower.Enqueue(back, back);
        }
    }

    /// <summary>Median of every number added so far, in O(1).</summary>
    /// <returns>The median as a double.</returns>
    /// <exception cref="InvalidOperationException">No number has been added.</exception>
    /// <example>After adding 1 and 2, FindMedian() returns 1.5.</example>
    public double FindMedian()
    {
        // 0: lower is empty only when both are, because lower is never smaller.
        if (lower.Count == 0)
        {
            throw new InvalidOperationException("no numbers added yet");
        }

        // Odd count: lower holds the extra one, and its top is the median.
        if (lower.Count > upper.Count)
        {
            return lower.Peek();
        }

        // Even count: average the two middle values. long stops overflow on the
        // sum. / 2.0 is floating division, so 1 and 2 give 1.5, not 1.
        return ((long)lower.Peek() + upper.Peek()) / 2.0;
    }
}

Walkthrough

Add 1, 2, 3. Heaps shown as plain values:

add 1 lower upper 1 empty median 1.0 1 went up, then came back add 2 lower upper 1 2 median (1 + 2) / 2 = 1.5 2 moved up to upper add 3 lower upper 1 2 3 median 2.0 3 went up, 2 came back
Figure 20.6 — Lower keeps the extra item on odd counts, so its top is the median.

Reading the figure. Each column is the state after one AddNum. Blue cells are the lower half and violet the upper half, shown sorted. The amber cells are the two heap tops, the only values FindMedian reads. With an odd count, lower is one bigger and its top is the answer.

AddNumO(log n)FindMedianO(1)SpaceO(n)

Edge cases to raise

Say this out loud: “I keep the smaller half in a max-heap and the larger half in a min-heap, with the lower one allowed one extra. The max-heap uses a reversed comparer, not negation, so int.MinValue is safe. Every new number goes through lower to upper, then I rebalance. The median is one top or the average of both.”

4. Smallest Range Covering Elements from K Lists Hard

Problem

You have k sorted lists of integers. Find the smallest range [a, b] that includes at least one number from every list. A range is smaller if b - a is smaller, or if the widths tie and a is smaller.

The idea

Pick one number from each list. The range they cover runs from their min to their max. To shrink it, the only useful move is to raise the min, because raising anything else can only widen it. So keep a heap of the current pick from each list, which gives the min in O(1), and track the max by hand. Pop the min, record the range if it is the best yet, and replace it with the next number from the same list. Stop when any list runs out, because then no pick covers every list.

Solution

public static class SmallestRange
{
    /// <summary>Smallest [lo, hi] holding at least one value from every list.</summary>
    /// <param name="nums">k non-empty arrays, each sorted ascending.</param>
    /// <returns>[lo, hi] for the smallest such range. Ties go to the smaller lo.</returns>
    /// <exception cref="ArgumentException">nums is empty, or any array is empty.</exception>
    /// <example>Find([[4, 10, 15, 24, 26], [0, 9, 12, 20], [5, 18, 22, 30]]) returns
    /// [20, 24].</example>
    public static int[] Find(int[][] nums)
    {
        // 0: an empty input, or an empty array, leaves nothing to cover it with.
        if (nums.Length == 0 || nums.Any(row => row.Length == 0))
        {
            throw new ArgumentException("need at least one list, and no empty lists");
        }

        // One pick per array. Element: (array index, position). Priority: the value.
        // Position 0 is each array's smallest value.
        var heap = new PriorityQueue<(int List, int Pos), int>();
        int currentMax = int.MinValue;   // int.MinValue: any real value beats it
        for (int index = 0; index < nums.Length; index++)
        {
            heap.Enqueue((index, 0), nums[index][0]);
            // The heap gives the min. The max has to be tracked by hand.
            currentMax = Math.Max(currentMax, nums[index][0]);
        }

        // long.MaxValue: wider than any real range, so the first range always wins.
        // Widths are long because max - min of two ints can overflow an int.
        long bestWidth = long.MaxValue;
        int bestLo = 0, bestHi = 0;      // 0, 0: placeholders, set on the first pop

        // Invariant: heap holds exactly one value from every array, and currentMax
        // is the largest of them. So [heap top, currentMax] covers every array.
        while (true)
        {
            heap.TryDequeue(out var at, out int low);

            // Strict <: on a tie keep the earlier range, whose lo is smaller,
            // because lo only grows as the loop runs.
            long width = (long)currentMax - low;
            if (width < bestWidth)
            {
                (bestWidth, bestLo, bestHi) = (width, low, currentMax);
            }

            // Pos + 1 == Length: this array has no next value, so no later pick can
            // cover it. Every remaining range has been tried.
            int next = at.Pos + 1;
            if (next == nums[at.List].Length)
            {
                return [bestLo, bestHi];
            }

            int value = nums[at.List][next];
            heap.Enqueue((at.List, next), value);
            currentMax = Math.Max(currentMax, value);   // the new pick may be the max
        }
    }
}

Walkthrough

Lists [4, 10, 15, 24, 26], [0, 9, 12, 20], [5, 18, 22, 30]. Picks shown as a set:

[0, 5] w 5 best, width 5 [4, 9] w 5 tie, keep [0, 5] [5, 10] w 5 [9, 18] w 9 [10, 18] w 8 [12, 18] w 6 [15, 20] w 5 [18, 24] w 6 [20, 24] w 4 new best, width 4 0 5 10 15 20 25 30
Figure 20.7 — Raising the minimum each step slides the window right, and [20, 24] is the narrowest it ever gets.

Reading the figure. Each bar is the range of the current picks, one row per step, top to bottom. The white dots are the three picks, one from each list. The left dot is always the heap’s min, and it is the one replaced next. Green bars are a new best. The amber bar ties on width and loses on the left end. The search stops after the last row, because list 1 has run out.

TimeO(N log k)SpaceO(k)

Edge cases to raise

Say this out loud: “I hold one number from each list. The only move that can shrink the range is raising the min, so I pop it from a heap and replace it with the next value from its list. I track the max by hand, and I stop when any list runs out.”

Recap

The six things to carry forward

Where this goes next

Pattern 21, Weighted Shortest Paths, puts the same PriorityQueue at the centre of Dijkstra’s algorithm. There the heap holds the frontier of a graph instead of the heads of K lists. It also leans on lazy deletion, because there is no decrease-key.


← 19 — Bit Manipulation 21 — Weighted Shortest Paths →