Two Heaps

Hardheap · priority queue · median · streaming · greedy⏱ 6 min readUpdated

Keep a max-heap of the smaller half and a min-heap of the larger half so the boundary element (median or next-best choice) is always one peek away.

PatternTwo Heaps

Instinct When you need the middle of a changing collection, or best-of-one-pile fed by another pile, split it into a max-heap low half and a min-heap high half.

Memory hack Small fries in a max-heap, big fish in a min-heap; the median lives on the shoreline between them.

At a glance

When to use it

  • You need the median or another middle value of numbers that keep arriving
  • Values enter and leave a window and the middle must be ready after every move
  • One pool of items unlocks as a threshold grows and you must pick the best unlocked item
  • Re-sorting after every insert would be far too slow

Key idea

Split the data into a max-heap holding the smaller half and a min-heap holding the larger half, keeping their sizes within one; the boundary sits on the two tops, so you read it in O(1) and update in O(log n) instead of re-sorting in O(n log n) each time.

Typical complexity

O(log n) per insert, O(1) per median read, O(n) space

How to spot it If the problem says numbers arrive one by one (or a window slides, or options unlock over time) and asks for the median or the best currently available choice, think Two Heaps first.

The instinct

A single heap only tells you about one extreme: the smallest or the largest. Many problems care about the boundary between two groups instead: the median of a stream, the middle of a sliding window, or "the most profitable project I can currently afford". Two heaps put that boundary on top of both heaps at once.

Think of the data as sorted and then cut in half. The left half goes into a max-heap, so its biggest value (the left edge of the cut) is on top. The right half goes into a min-heap, so its smallest value (the right edge of the cut) is on top. Whatever sits at the cut is now readable in O(1) and maintainable in O(log n).

Recognise it

Signal in the problem Why it points here
"median of a data stream" / numbers arrive one at a time You can't re-sort on every insert; two heaps keep the middle ready
"median of every window of size k" Same middle query, plus removals as the window slides
"addNum / findMedian" style class design Online inserts mixed with order-statistic queries
"with capital W, pick at most k projects to maximise profit" One heap gates what is affordable, the other picks the best affordable item
"schedule tasks: among those that are ready, take the best" Pool of locked items (min by unlock time) feeds a pool of unlocked items (max by value)
"balance two groups", "smaller half / larger half" The structure literally is two halves
Find the k-th element where k moves with the input size A fixed-size boundary split is a generalised median

Not this pattern if...

  • You need the k-th largest for a fixed k: one heap of size k is enough (Top-K pattern).
  • Data is static and you need the median once: sort or quickselect.
  • You merge several already-sorted lists: that is K-Way Merge.
  • You need arbitrary rank queries over a value range with updates: a Fenwick or segment tree fits better.

Memory hack

Tip

Small fries in a max-heap, big fish in a min-heap; the median lives on the shoreline. Each heap faces the shoreline: the max-heap shows its largest small fry, the min-heap shows its smallest big fish. You only ever look at the water's edge.

Analogy: a see-saw with two kids. The left kid holds the lighter half of the numbers, the right kid the heavier half. After every new number you add it to one side and, if one side gets more than one extra, hand the edge number across. The pivot of the see-saw is the median.

Say it out loud in the interview:

  1. "Low half goes in a max-heap, high half in a min-heap; every low value is at most every high value."
  2. "Insert, then rebalance so sizes differ by at most one, with the extra on the low side."
  3. "The answer is the low top (odd count) or the average of both tops (even count)."

How it works

Every insert follows the same two-step dance: route the value through one heap so ordering stays correct, then rebalance sizes. A neat trick is to always push into low, move low's top to high, and if high became bigger, move its top back. That guarantees both invariants with no if-chains.

Trace for the stream 5, 2, 8, 3:

Add low (max-heap) high (min-heap) Median
5 [5] [] 5
2 [2] [5] 3.5
8 [5, 2] [8] 5
3 [3, 2] [5, 8] 4

For Sliding Window Median you must also remove the element leaving the window. Java's PriorityQueue.remove(Object) is O(k), which is usually fine for interviews; the faster option is lazy deletion: record the outgoing value in a "to delete" map, adjust a logical size counter, and discard it only when it surfaces at a heap top.

For IPO, the two heaps play different roles: a min-heap of projects keyed by required capital, and a max-heap of profits for the projects you can afford. Each round, move every newly affordable project across, then take the best profit.

Java template

java
import java.util.*;

class MedianKeeper {
    private final PriorityQueue<Integer> low = new PriorityQueue<>(Collections.reverseOrder()); // max-heap
    private final PriorityQueue<Integer> high = new PriorityQueue<>();                            // min-heap

    public void add(int x) {
        low.offer(x);
        high.offer(low.poll());                 // route the biggest small value to the high side
        if (high.size() > low.size()) {
            low.offer(high.poll());             // keep low.size() == high.size() or +1
        }
    }

    public void remove(int x) {                 // O(n) removal; swap for lazy deletion if needed
        if (!low.isEmpty() && x <= low.peek()) low.remove(x); else high.remove(x);
        if (low.size() > high.size() + 1) high.offer(low.poll());
        else if (high.size() > low.size()) low.offer(high.poll());
    }

    public double median() {
        if (low.size() > high.size()) return low.peek();
        return ((long) low.peek() + high.peek()) / 2.0;   // long avoids int overflow
    }
}

class TwoPoolGreedy {
    // "Unlock then pick best": e.g. projects gated by capital, value = profit
    static long pickBest(int rounds, long budget, int[] cost, int[] value) {
        int n = cost.length;
        Integer[] idx = new Integer[n];
        for (int i = 0; i < n; i++) idx[i] = i;
        Arrays.sort(idx, (a, b) -> Integer.compare(cost[a], cost[b]));   // locked pool, cheapest first
        PriorityQueue<Integer> ready = new PriorityQueue<>((a, b) -> Integer.compare(value[b], value[a]));
        int p = 0;
        for (int r = 0; r < rounds; r++) {
            while (p < n && cost[idx[p]] <= budget) ready.offer(idx[p++]); // unlock everything affordable
            if (ready.isEmpty()) break;
            budget += value[ready.poll()];                                 // take the best unlocked item
        }
        return budget;
    }
}

Complexity

Operation Time Space
Insert into median keeper O(log n) O(n) total
Read median O(1) -
Remove arbitrary value (PriorityQueue.remove) O(n) -
Remove with lazy deletion O(log n) amortised O(n) extra map
Sliding Window Median, n elements, window k O(n log k) lazy, O(n·k) with remove O(k)
Two-pool greedy (IPO) O(n log n) O(n)

Variations

  • Streaming median: pure insert + query. Example: Find Median from Data Stream.
  • Windowed median: insert, delete the outgoing element, rebalance, query. Example: Sliding Window Median.
  • Unlock-then-pick greedy: one structure releases candidates as a threshold grows, a max-heap chooses among them. Example: IPO.
  • Percentile, not median: keep sizes in a ratio such as 9:1 so the low top is the 90th percentile.
  • TreeMap / multiset alternative: two TreeMap<value,count> halves give O(log n) deletion without lazy bookkeeping; handy in Java when duplicates are allowed.

Common mistakes

Warning

Averaging two int tops with (a + b) / 2 overflows for values near Integer.MAX_VALUE and truncates the .5. Cast to long and divide by 2.0.

  • Forgetting that new PriorityQueue<>() is a min-heap; the low half needs Collections.reverseOrder().
  • Rebalancing only on size and not on order, so a value larger than high.peek() ends up in low. Routing every insert through the other heap prevents this.
  • With lazy deletion, reading a heap top without first purging stale entries, or comparing real sizes instead of logical sizes.
  • Using the comparator (a, b) -> b - a, which overflows for large negative and positive values; use Integer.compare.
  • In IPO-style problems, re-scanning all projects every round (O(n·k)) instead of sorting by cost once and advancing a pointer.

All practice problems

3 problems on this site use this pattern, easiest first. Solve them in order and say the instinct out loud before you code.

My notes saved in this browser