← All Posts
DSA · Heaps · Part 9 of 17

Two Heaps & the Running Median

A single heap privileges one end. If you need the middle — the median — you need two, arranged back to back. This technique is the answer to Find Median from Data Stream, and it generalises to any problem where you must maintain a balanced split of a growing multiset.

Two Heaps, Facing Each Other

Split the elements into a lower half and an upper half:

        lo (max-heap)              hi (min-heap)
      [1, 3, 5, 8]                 [9, 11, 14, 20]
              ^                     ^
              |                     |
          lo.top() = 8          hi.top() = 9
                  \             /
                   the median lives here

The two roots are the two elements straddling the middle. With an even total the median is their average; with an odd total it is the root of whichever heap was allowed to grow larger. Both are O(1) reads.

The two invariants. (1) Ordering: every element in lo is <= every element in hi. (2) Balance: lo.size() equals hi.size() or exceeds it by exactly one. Maintain both and the median is always at your fingertips. Every bug in this technique is one of these two invariants slipping.

Insertion: Push, Shuttle, Rebalance

The clean formulation always routes the new element through both heaps, which makes the ordering invariant automatic and removes all the comparison branching people usually write:

#include <queue>
#include <vector>
#include <functional>

class MedianFinder {
    std::priority_queue<int> lo;                                             // max-heap
    std::priority_queue<int, std::vector<int>, std::greater<int>> hi;        // min-heap

public:
    void addNum(int num) {
        lo.push(num);                    // always enters the lower half first
        hi.push(lo.top());               // its largest migrates to the upper half
        lo.pop();

        if (hi.size() > lo.size()) {     // keep lo >= hi in size
            lo.push(hi.top());
            hi.pop();
        }
    }

    double findMedian() const {
        if (lo.size() > hi.size()) return lo.top();
        return (lo.top() + hi.top()) / 2.0;
    }
};

Why the shuttle works: pushing into lo then immediately moving lo.top() to hi guarantees that whatever ends up in hi is at least as large as everything remaining in lo — regardless of where the new value belonged. You never compare against hi.top() at all. The cost is one extra pair of heap operations, bought in exchange for eliminating a class of edge-case bugs.

Then the single rebalance restores invariant (2). Because we always push to lo and move exactly one element across, hi can only ever exceed lo by one, so one corrective move is always enough.

A Trace

add 5    lo:[5]        hi:[]         median 5
add 2    lo:[2]        hi:[5]        median 3.5
add 8    lo:[5,2]      hi:[8]        median 5
add 1    lo:[2,1]      hi:[5,8]      median 3.5
add 9    lo:[5,2,1]    hi:[8,9]      median 5

Walk the third step to see the shuttle in action. Before it, lo = [2], hi = [5]. Push 8 into lo, so lo = [8,2] and its root is 8. Move that root to hi, giving lo = [2], hi = [5,8]. Now hi is bigger, so pull its root back: lo = [5,2], hi = [8]. Sizes are 2 and 1, the ordering holds, and the median is lo.top() = 5.

Complexity

OperationCost
addNumO(log n) — a constant number of heap operations
findMedianO(1) — read one or two roots
SpaceO(n) — every element is stored exactly once

Compare with the alternatives: keeping a sorted vector gives O(1) median but O(n) insertion from the shift; sorting on every query is O(n log n) per call. An order-statistic tree matches the two-heap bounds and also supports arbitrary rank queries, but it is far more code and slower in practice for the median-only case.

Sliding Window Median

The hard extension: maintain the median over a window of size k, which requires removing the element leaving the window. Heaps have no efficient arbitrary erase — the problem Part 12 exists to solve.

Two workable approaches:

Lazy deletion. Keep a hash map of values pending removal. When a root is scheduled for deletion, pop it and decrement the count instead of returning it. Sizes must then be tracked separately from heap.size(), because the heaps contain ghosts:

std::unordered_map<int, int> pending;      // value -> how many copies to discard

auto prune = [&](auto& heap) {
    while (!heap.empty()) {
        auto it = pending.find(heap.top());
        if (it == pending.end() || it->second == 0) break;
        if (--it->second == 0) pending.erase(it);
        heap.pop();
    }
};

Prune only at the roots, only when you need to read them. Ghosts buried in the interior are harmless — they will surface eventually, and pruning them early would cost a scan.

Two multisets. Replace the heaps with std::multiset, using rbegin() of the lower set and begin() of the upper. Erasure by iterator is then O(log k) and exact. Slower by a constant factor than heaps, but dramatically simpler to get right — usually the better trade in an interview.

The overflow trap. (lo.top() + hi.top()) / 2.0 overflows when both roots are near INT_MAX. The sum wraps before the division promotes it. Write lo.top() / 2.0 + hi.top() / 2.0, or cast one operand to long long first. This is a genuine hidden test case in several online judges.

The Pattern Beyond Medians

Two heaps facing each other maintain any partition of a multiset into a bottom-p and top-q by rank. Adjust the balance invariant and you get:

Practice