A max-heap and min-heap balanced against each other give you a running median in log time.
What the two heaps pattern is
Split a growing collection into two halves around its middle: a MAX-heap holding the smaller half (its root = the largest small element) and a MIN-heap holding the larger half (its root = the smallest large element). The median then lives at the roots: with odd count it is the bigger heap's root; with even count, the average of both roots. Every element in the low heap is <= every element in the high heap - that cross-heap invariant is the pattern.
It solves what a single heap cannot: a heap surfaces only its extreme, but the median is the middle. Two heaps facing each other turn 'the middle' into 'the extremes of two halves'.
How to recognize it
Signals: 'median of a data stream', 'running median over a sliding window', percentile tracking, or any need for the k-th ordered element of a live collection where both smaller and larger elements keep arriving. Also scheduling problems juggling two priorities at once (IPO: cheapest affordable project vs. most profitable available one) - one heap per priority, elements migrating between them.
The test: do you need an ORDER STATISTIC (middle, percentile) of data that changes, or the best element under two different orderings at different moments? Either answer points here.
The add-then-rebalance template
Two invariants after every insertion: ordering (max of low <= min of high) and size (low holds equal count or exactly one more). The bulletproof insertion: always push into low first, then move low's root to high (restores ordering unconditionally), then if high outgrew low, move high's root back. Three heap operations worst case, no branching on the value.
Insertion is O(log n) (a constant number of heap operations); median reads are O(1). For n stream elements: O(n log n) total, O(n) memory. The sliding-window variant needs element removal, which vanilla heaps lack - lazy deletion (tombstone map, purge stale roots on read) keeps the same asymptotics; order-statistic trees are the heavyweight alternative.
Common pitfalls
Max-heap direction: the LOW half needs a max-heap. In min-heap-only languages, negate values going in and out of low - and be disciplined about where the negation happens.
Branching insertions ('if x < low.peek() push low else push high') that skip rebalancing - sizes drift and the median reads garbage. The push-through version cannot drift.
Choosing which heap may be larger and then reading the median from the other one - pick 'low may hold one extra' and read odd-count medians from low, consistently.
Integer overflow in (a + b) / 2 for extreme values - compute low + (high - low) / 2 or use wider types.
Sliding windows: removing an arbitrary element from a heap is O(n) done naively - lazy deletion with a counted tombstone map is the expected answer, including the purge-before-peek step.
Worked examples
1. Find median from data streamhard
Design a structure supporting addNum(num) and findMedian() over a growing stream.
Sorting per query is O(n log n) each; an insertion-sorted array costs O(n) per add. The two-heap split hits O(log n) add and O(1) median.
Keep low (max-heap, smaller half, may hold one extra) and high (min-heap, larger half).
addNum: push into low, migrate low's root to high, then pull back if high became larger - both invariants hold with zero value-dependent branches.
findMedian: low's root when counts differ, mean of both roots when equal. Rehearse the flow until the two invariants are things you can recite.
Remember: Push through the low heap and rebalance by size - the median is then always sitting at a heap root, never computed.
2. Sliding window medianhard
Given an array and window size k, return the median of every k-length window.
Same two-heap core, plus a new requirement: the element leaving the window must be REMOVED - and heaps don't support arbitrary removal.
Lazy deletion: record evictions in a tombstone count-map; physically drop stale elements only when they surface at a root (purge loop before every peek).
Track effective (logical) sizes to keep the balance decision honest - physical heap sizes lie once tombstones accumulate.
Each element is pushed and popped at most once beyond its tombstone bookkeeping: O(n log n) overall. The lazy-deletion idea itself is reusable across many heap problems.
pseudocode
for each new element: add(x) as in stream-median
for each departing element d:
tombstones[d] += 1
decrement logical size of the half d belongs to
rebalance by logical sizes
before any peek:
while tombstones[heap.top] > 0:
tombstones[heap.top] -= 1; heap.pop()
Remember: Heaps cannot delete from the middle - so do not: tombstone departures and evict them lazily when they reach a root.
3. IPO (maximize capital)hard
Given projects with capital requirements and profits, start with capital w and pick at most k projects to maximize final capital. Completing a project adds its profit.
Two competing orderings: affordability (by capital needed) and desirability (by profit). One heap per ordering, with migration between them.
Min-heap of all projects by required capital; each round, move every project whose requirement <= current capital into a max-heap by profit - once affordable, always affordable, so migration is one-way.
Take the profit-heap's root, add its profit to capital, repeat up to k times; stop early if nothing is affordable.
Each project migrates at most once: O(n log n + k log n). The transferable shape: 'unlockable options' in one heap, 'best unlocked option' in the other.
pseudocode
byCapital = min-heap of (capital, profit) for all projects
byProfit = max-heap
repeat k times:
while byCapital not empty and byCapital.top.capital <= w:
byProfit.push(byCapital.pop().profit)
if byProfit empty: break
w += byProfit.pop()
return w
Remember: Two priorities, two heaps: one queues options by unlock condition, the other serves the best unlocked one - and elements flow one way between them.
Check yourself
Try to answer before revealing - retrieving from memory is what makes it stick.
1Why does the smaller half of the data live in a MAX-heap?
Max-heaps are faster for small values
✓ The median-adjacent element of the low half is its LARGEST member - which must be the readable root
To simplify rebalancing
Convention only; a min-heap works equally well
The median sits at the boundary between halves: the largest of the small ones and smallest of the large ones. Each heap must surface its boundary element, so low is a max-heap and high a min-heap.
2What two invariants must hold after every insertion?
Both heaps sorted internally; equal heights
✓ Every low element <= every high element; and sizes differ by at most one (with a fixed side allowed the extra)
Roots are equal; sizes are equal
The newest element is at a root
Ordering makes the roots the true middle boundary; the size constraint pins the median to a known root. The push-through-then-rebalance insertion restores both without inspecting the value.
3In the add operation, why push into low and immediately move low's root to high?
It is the fastest sequence
✓ It guarantees the ordering invariant without any comparisons: whatever crosses over is low's maximum, hence >= all of low and safe for high
It keeps high larger than low
It avoids negating values
The migrated element is by construction the largest of the low side, so placing it in high can never violate cross-heap ordering. The single conditional afterwards fixes only size - value-based branching (a bug magnet) is eliminated.
4How does the sliding-window variant remove elements that leave the window?
Rebuild both heaps each slide
✓ Lazy deletion: count departures in a tombstone map and pop stale entries only when they surface at a root, tracking logical sizes for balance
Binary search inside the heap array
It uses balanced BSTs, not heaps
Arbitrary heap deletion is O(n), but a departed element only matters once it would be read - at a root. Tombstoning defers the cost to a purge-before-peek loop, preserving O(log n) amortized updates.
Frequently asked questions
Which heap should be allowed the extra element?
Either works - what matters is committing. Convention: low may hold one more, so odd-count medians read from low.peek(). Mixing conventions mid-implementation is the classic source of wrong medians on odd counts.
Can two heaps track percentiles other than the 50th?
Yes - rebalance to a ratio instead of equality. For the 90th percentile keep low at ~90% of elements; the boundary roots then bracket that percentile. The insertion machinery is unchanged, only the size rule differs.
When should I use an order-statistic tree instead?
When you need arbitrary ranks (not one fixed percentile), true deletion without tombstones, or range queries. Two heaps are the lighter tool: less code, better constants, but specialized to a single maintained boundary.