Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Streaming Median
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.
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.
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.
Examples and constraints 4 examples
nums = [2, 1, 3][2.0, 1.5, 2.0]nums = [1, 2, 3, 4][1.0, 1.5, 2.0, 2.5]nums = [5, 5, 5][5.0, 5.0, 5.0]nums = [9, 7, 5, 3][9.0, 8.0, 7.0, 6.0]- 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
- Invariant: every element in the low half ≤ every element in the high half, and sizes differ by ≤ 1.
- Push each new number onto one heap, then rebalance by moving a root if sizes drift.
- Python has only a min-heap (heapq) — store negated values to simulate the max-heap.
- 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].
| step | x | goes to | rebalance | low (max-heap) | high (min-heap) | median |
|---|---|---|---|---|---|---|
| 1 | 5 | low (empty) | none | [5] | [] | 5.0 |
| 2 | 15 | high (15 > 5) | none | [5] | [15] | 10.0 |
| 3 | 1 | low (1 ≤ 5) | none | [1, 5] | [15] | 5.0 |
| 4 | 3 | low (3 ≤ 5) | low is 3 vs 1: move 5 to high | [1, 3] | [5, 15] | 4.0 |
| 5 | 8 | high (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 isx <= -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
lowand forces a move tohighon 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]gives0.15000000000000002as its second median, and Python’s/turns large integers into floats too, so[10**17, 10**17 + 1]gives1e+17instead of the exact 100000000000000000.5. Usefractions.Fractionif 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
lowholds 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.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- LRU CacheThe eviction policy behind every size-limited cache: when you run out of room, throw out whatever was used least recently. The trick is O(1) get and put — an ordered hash map (Python dict / JS Map) gives you exactly that. Solve it in Python or TypeScript.Read →
- Token Bucket Rate LimiterThe algorithm inside most production rate limiters — and it never runs a timer. Refill the bucket lazily from the time elapsed since the last request, cap at capacity, spend one token or reject. Two numbers of state per client, exactly like the Redis version. Solve it in Python or TypeScript.Read →
- Sliding-Window Rate LimiterAllow at most N requests per rolling window — the rate limiter that guards real APIs. A sliding log of accepted timestamps gives exact limits without fixed-window bursts. Decide accept/reject for a stream. Solve it in Python or TypeScript, with hidden tests.Read →
- LFU CacheThe cache that evicts what you use least often — and, on ties, least recently. Harder than LRU: track frequency and recency together, still O(1) per op. Replay get/put operations. Solve it in Python or TypeScript, with hidden tests.Read →
- Bloom FilterA tiny bit-array that answers "have I seen this?" in a fraction of a set’s memory — with occasional false positives but never a false negative. Build one with double hashing. Provided hashes keep both languages in sync. Hidden tests.Read →
- HyperLogLog Cardinality EstimatorCount how many distinct items a stream held — billions of them — using a few kilobytes, not a giant set. Turn "the longest run of leading zeros" into an estimate. Powers COUNT(DISTINCT) in Redis and BigQuery. Solve it in Python or TypeScript, with hidden tests.Read →