Find Median from Data Stream is the classic interview test of whether you reach for the right data structure. Numbers arrive one at a time, and after each one you must be able to report the median instantly. The trick: split the data into two heaps that meet in the middle.
Problem. Design a structure that supports two operations on a stream of numbers: add_num(x) adds
a number, and find_median() returns the median of everything added so far. The median is the middle
value when the count is odd, or the average of the two middle values when it is even.
Example: add 5, 3, 8, 2 in order. After 5 the median is 5; after 3 it is 4.0; after 8 it
is 5; after 2 it is 4.0.
The slow way first
The naive idea: keep all the numbers in a list, and on every find_median sort the list and pick the middle. Sorting is O(n log n) per query — and you might query after every insert, so this is painfully slow on a long stream.
A small improvement is to keep the list sorted and insert each new number into place. But shifting elements to make room is O(n) per insert. We want both operations fast.
The idea: two heaps meeting in the middle
Keep the lower half of the numbers in a max-heap and the upper half in a min-heap. Then:
- the largest of the lower half is at the max-heap's top,
- the smallest of the upper half is at the min-heap's top,
and those two tops are exactly the middle of the sorted data. If we keep the two heaps balanced (sizes differing by at most one), the median is just a peek at the top(s) — O(1).
Python only has a min-heap (heapq), so we fake a max-heap by storing negated values: the most-negative entry (the true max) floats to the top.
Walk through it
Step through the animation as 5, 3, 8, 2 stream in. Each number first lands on a side (lower if it is <= the current max of the lower half, otherwise upper). Then a rebalance moves one element across if a heap grew too big. Watch how the max-heap top and min-heap top hug the middle, so the median label updates in O(1) after every insert.
Pseudocode
lo = max-heap # lower half
hi = min-heap # upper half
add_num(x):
if lo is empty or x <= top(lo):
push x onto lo
else:
push x onto hi
# rebalance so |lo| and |hi| differ by at most 1, lo never smaller
if size(lo) > size(hi) + 1: move top(lo) -> hi
elif size(hi) > size(lo): move top(hi) -> lo
find_median():
if size(lo) > size(hi): return top(lo)
return (top(lo) + top(hi)) / 2The Python solution
import heapq
class MedianFinder:
def __init__(self):
self.lo = [] # max-heap (store negatives)
self.hi = [] # min-heap
def add_num(self, num):
if not self.lo or num <= -self.lo[0]:
heapq.heappush(self.lo, -num)
else:
heapq.heappush(self.hi, num)
# rebalance so sizes differ by at most 1
if len(self.lo) > len(self.hi) + 1:
heapq.heappush(self.hi, -heapq.heappop(self.lo))
elif len(self.hi) > len(self.lo):
heapq.heappush(self.lo, -heapq.heappop(self.hi))
def find_median(self):
if len(self.lo) > len(self.hi):
return -self.lo[0]
return (-self.lo[0] + self.hi[0]) / 2self.lois a max-heap simulated by pushing-num, so-self.lo[0]is its true maximum.- The
if num <= -self.lo[0]check routes a number to the correct half before rebalancing. - The two rebalance branches move a single element across whenever a heap gets too big, keeping
loeither equal to or exactly one larger thanhi. find_medianis O(1): iflois bigger the median is its top; otherwise the count is even and we average the two tops.
Complexity
| Case | Time | Notes |
|---|---|---|
| Sort on every query | O(n log n) (moderate) | per find_median |
| add_num (two heaps) | O(log n) (fast) | one push + maybe one move |
| find_median (two heaps) | O(1) (fast) | just peek the top(s) |
O(n) (moderate)We hold every number across the two heaps, so space is O(n), but each insert is only O(log n) and every median read is O(1).
When this pattern shows up
Whenever a problem needs the running middle, the top-k, or a value that depends on order while data keeps arriving, think heaps. Two balanced heaps for the median, a single heap for "k largest / k smallest," and a heap of size k for streaming top-k are all the same family of move.
The order matters: push the new number first, then rebalance. And remember Python only has a min-heap,
so the max-heap must store negatives — forgetting to negate on the way out (using self.lo[0] instead of
-self.lo[0]) is the most common bug here.
Practice
After adding 5, 3, 8 the lower half is {3, 5} and the upper half is {8}. What is the median, and why?
1. Why use a max-heap for the lower half and a min-heap for the upper half?
2. How does Python simulate a max-heap with heapq?
3. What is the time complexity of find_median in this design?
4. Why must we rebalance after inserting?