Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°21

Streaming Median

Medium Data StructuresHeapsSystems

Latency dashboards do this every second: maintain the median of a stream without re-sorting everything per event. The classic two-heap trick — half the numbers below, half above, the median always at the boundary.

The problem

Given nums, a stream of numbers, return a list of the running median after each element arrives. The median of an odd-sized prefix is the middle value; of an even-sized prefix, the mean of the two middle values. Target O(log n) per element with two heaps: a max-heap holding the smaller half and a min-heap holding the larger half, rebalanced so their sizes differ by at most one.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement running_median(nums) → list of medians after each element, using a max-heap for the low half and a min-heap for the high half.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input nums = [2, 1, 3]
Output [2.0, 1.5, 2.0]
median after each arrival (the TypeScript version prints [2, 1.5, 2])
EXAMPLE 2
Input nums = [1, 2, 3, 4]
Output [1.0, 1.5, 2.0, 2.5]
even prefixes average the middle two
EXAMPLE 3
Input nums = [5, 5, 5]
Output [5.0, 5.0, 5.0]
edge case: duplicates of the boundary value are fine
EXAMPLE 4
Input nums = [9, 7, 5, 3]
Output [9.0, 8.0, 7.0, 6.0]
a decreasing stream forces a root across on every second arrival
CONSTRAINTS
  • 1 ≤ len(nums)
  • Even-sized prefix → mean of the two middle values (may be fractional).
  • Aim for O(log n) per element — the two-heap invariant, not a re-sort.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Invariant: every element in the low half ≤ every element in the high half, and sizes differ by ≤ 1.
  2. Push each new number onto one heap, then rebalance by moving a root if sizes drift.
  3. Python has only a min-heap (heapq) — store negated values to simulate the max-heap.
  4. Median: odd count → root of the bigger heap; even → average of both roots.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious approach keeps every value seen so far and, after each arrival, sorts the prefix and reads the middle element, or averages the middle two. It is correct by the definition of the median. It costs O(n log n) per arrival, so O(n² log n) for the whole stream, and O(n) space. Keeping one list sorted with bisect.insort is better but still O(n²) overall: the binary search is O(log n), yet each insert shifts up to n elements. Either way the cost per event grows with the stream, which is exactly what a latency dashboard updating every second cannot afford.

The approach

The median depends only on the boundary between the lower and upper halves, so keep the halves in two heaps and never sort. The reference uses low, a max-heap of the smaller half (Python’s heapq is a min-heap, so it stores negated values and the max is -low[0]), and high, a min-heap of the larger half.

Two invariants hold after every arrival: every value in low is ≤ every value in high, and len(low) equals len(high) or exceeds it by one. A new x goes to low if low is empty or x <= -low[0], otherwise to high, which keeps the ordering. Then at most one root moves to restore the sizes: if low is two ahead its max moves to high, and if high is ahead its min moves to low. The median is -low[0] when low is larger, otherwise the mean of the two roots.

Walk through an example

Streaming [5, 15, 1, 3, 8] moves a root in both directions, at step 4 and again at step 5, and returns [5.0, 10.0, 5.0, 4.0, 5.0].

stepxgoes torebalancelow (max-heap)high (min-heap)median
15low (empty)none[5][]5.0
215high (15 > 5)none[5][15]10.0
31low (1 ≤ 5)none[1, 5][15]5.0
43low (3 ≤ 5)low is 3 vs 1: move 5 to high[1, 3][5, 15]4.0
58high (8 > 3)high is 3 vs 2: move 5 to low[1, 3, 5][8, 15]5.0

Complexity

Each arrival costs at most three heap operations, O(log n), and reading the median is O(1), so the stream costs O(n log n) time and O(n) space for the two heaps. Re-sorting after each arrival is O(n² log n) in total, and a single sorted list maintained with bisect.insort is O(n²), because each insert can shift up to n elements.

Edge cases to test

  • One element: running_median([7]) returns [7.0]. An empty stream returns [], but reading a median from empty heaps raises, so a live API needs a guard.
  • Values equal to the boundary go to low, because the test is x <= -low[0], and the rebalance still holds the sizes: [5, 5, 5] returns [5.0, 5.0, 5.0] and [1, 1, 2, 2] returns [1.0, 1.0, 1.0, 1.5].
  • A decreasing stream sends every value to low and forces a move to high on every second arrival: [9, 7, 5, 3] returns [9.0, 8.0, 7.0, 6.0].
  • Negative values work unchanged, since negation is only an encoding: [-3, -1, -2] returns [-3.0, -2.0, -2.0].
  • Averages are floats: [0.1, 0.2] gives 0.15000000000000002 as its second median, and Python’s / turns large integers into floats too, so [10**17, 10**17 + 1] gives 1e+17 instead of the exact 100000000000000000.5. Use fractions.Fraction if exact medians matter.

Common mistakes

  • Re-sorting the whole stream on each query — O(n log n) instead of O(log n) incremental.
  • Letting the two heaps drift out of balance, so the tops no longer straddle the median.
  • Getting a heap’s ordering direction backwards (lower half must be a max-heap).

Follow-ups an interviewer asks next

How would you report the median of only the last k values?
Now values leave as well as arrive. Keep the two heaps plus a dictionary of pending deletions, discard a root when it is marked as deleted (lazy deletion), and track each heap’s live size for the balancing rule. Alternatively, keep one balanced sorted structure with O(log k) insert and delete. Either way each step costs O(log k).
What about the 99th percentile instead of the median?
The same idea works for any fixed quantile: size the heaps so low holds the bottom 99% and read the boundary root. For dashboards over huge streams, approximate sketches such as t-digest or KLL keep bounded memory and answer every quantile with a controlled error.
The values are integers from 0 to 100. Can you do better?
Yes. Keep 101 counters and the total n. Each insert is O(1), and the median comes from walking the counters until the running sum passes n/2, which is at most 101 steps, a constant. Latency histograms with fixed buckets answer percentile queries the same way.
The stream is spread across many servers. How do you get a global median?
Exact medians do not merge: each server’s median says little about the global one. Either send every value to one place, or have each server keep a mergeable summary, such as a t-digest, a KLL sketch or a histogram with shared bucket edges, and merge the summaries into an approximate global median.

Where this shows up

The two-heap running median powers real-time percentile and latency monitoring, moving-median smoothing, and any “median so far” over a stream you cannot re-scan. The balance-two-structures idea generalizes to maintaining running quantiles online.

Reference solution

Reference solution — Python
import heapq

def running_median(nums):
    low, high = [], []   # low: max-heap via negation; high: min-heap
    out = []
    for x in nums:
        if not low or x <= -low[0]:
            heapq.heappush(low, -x)
        else:
            heapq.heappush(high, x)
        if len(low) > len(high) + 1:
            heapq.heappush(high, -heapq.heappop(low))
        elif len(high) > len(low):
            heapq.heappush(low, -heapq.heappop(high))
        if len(low) > len(high):
            out.append(float(-low[0]))
        else:
            out.append((-low[0] + high[0]) / 2)
    return out


print(running_median([2, 1, 3]))   # -> [2.0, 1.5, 2.0]
Reference solution — TypeScript
function runningMedian(nums: number[]): number[] {
  // Simple array-backed heaps keep the code short; the invariant is what matters.
  const low: number[] = [];  // max at index 0 (stored negated)
  const high: number[] = []; // min at index 0
  const push = (h: number[], v: number) => {
    h.push(v);
    let i = h.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (h[p] <= h[i]) break;
      [h[p], h[i]] = [h[i], h[p]];
      i = p;
    }
  };
  const pop = (h: number[]): number => {
    const top = h[0];
    const last = h.pop() as number;
    if (h.length) {
      h[0] = last;
      let i = 0;
      for (;;) {
        const l = 2 * i + 1, r = 2 * i + 2;
        let s = i;
        if (l < h.length && h[l] < h[s]) s = l;
        if (r < h.length && h[r] < h[s]) s = r;
        if (s === i) break;
        [h[s], h[i]] = [h[i], h[s]];
        i = s;
      }
    }
    return top;
  };
  const out: number[] = [];
  for (const x of nums) {
    if (low.length === 0 || x <= -low[0]) push(low, -x);
    else push(high, x);
    if (low.length > high.length + 1) push(high, -pop(low));
    else if (high.length > low.length) push(low, -pop(high));
    out.push(low.length > high.length ? -low[0] : (-low[0] + high[0]) / 2);
  }
  return out;
}

console.log(runningMedian([2, 1, 3]));   // -> [2, 1.5, 2]

Questions about Streaming Median

How do two heaps find the median of a data stream?

They split the numbers seen so far into a lower half and an upper half. The lower half lives in a max-heap, so its largest value is on top, and the upper half lives in a min-heap, so its smallest value is on top. As long as every value in the lower half is at most every value in the upper half, and the sizes differ by at most one, the median sits at the boundary: the top of the larger heap, or the average of both tops when the sizes are equal. Each new number goes into one heap, and at most one top moves across to restore the sizes.

Why is the lower half a max-heap and the upper half a min-heap?

Because the median depends only on the values next to the boundary between the halves: the largest of the lower half and the smallest of the upper half. A max-heap returns the largest of the lower half in constant time, and a min-heap returns the smallest of the upper half in constant time. With the directions backwards, each root holds the wrong extreme, the smallest of the lower half and the largest of the upper half, and the reported medians are wrong. Python only has a min-heap, so the reference stores negated values in the lower heap to make it behave as a max-heap.

What is the time complexity of the streaming median?

Each new value costs one heap push and at most one move between heaps, which is a pop plus a push, so every arrival is O(log n), and reading the median is O(1) because it only looks at the two roots. Over a stream of n values that is O(n log n) time and O(n) space, since every value is stored. Re-sorting the prefix after every arrival costs O(n log n) per step instead, and even a single sorted list with binary-search insertion costs O(n) per step, because elements have to shift.

Can you find a streaming median without storing every value?

Not exactly. An algorithm that reports the exact median of everything seen so far must keep enough information to recover any value that could still become the median, which in general means storing all of them. If an approximate answer is acceptable, quantile sketches such as t-digest and KLL keep a small summary of the stream in bounded memory and report the median, or any percentile, with a small error. When the values come from a small range of integers, one counter per value gives the exact median in constant memory.

▶  Watch it explained
Finished this one? 0 / 76 Challenges done
NextReservoir Sampling Next on the “Structures you build” ladder · rung 5 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges