Algorithm

Find Median from Data Stream

Heaps & Priority Queues Pattern

Find Median from Data Stream

Design a class supporting two operations on a stream of integers.

addNum(num) takes the next integer from the stream. findMedian() returns the median of everything added so far, as a floating-point value.

The median is the middle value once the numbers are placed in order: with an odd count it is the single middle number, and with an even count it is the average of the two middle numbers. Both operations may be called many times, in any interleaving, and findMedian is only called after at least one number has been added.

CONSTRAINTS
  • -10⁵ <= num <= 10⁵
  • At most 5 × 10⁴ calls in total to addNum and findMedian.
  • findMedian is never called before the first addNum.
  • Numbers arrive one at a time and cannot be re-read in bulk.
  • Follow-up: how would you optimise if every number were in the range [0, 100]?
EXAMPLE 1
Input: addNum(1), addNum(2), findMedian()
Output: 1.5
Two numbers in order are 1 and 2. An even count has no single middle value, so the median is the average of the two middles: (1 + 2) / 2.
EXAMPLE 2
Input: addNum(1), addNum(2), addNum(3), findMedian()
Output: 2.0
Three numbers in order are 1, 2, 3. An odd count has exactly one middle position, holding 2.
EXAMPLE 3
Input: addNum(6), addNum(1), addNum(5), findMedian(), addNum(4), findMedian()
Output: 5.0 then 4.5
After three numbers the order is 1, 5, 6 and the middle is 5 — note the arrival order 6, 1, 5 is irrelevant, only the sorted order matters. Adding 4 gives 1, 4, 5, 6, and the two middles average to 4.5. A single new number can move the median.
EXAMPLE 4
Input: addNum(-1), addNum(-3), findMedian()
Output: -2.0
In order the numbers are -3 and -1, and their average is -2.0. Negative values behave exactly like any others; the median is not required to be a value that was actually added.
Can findMedian be called after every single addNum, or only occasionally?
Assume the worst: alternating calls. That is what rules out any design that does expensive work per query and hopes queries are rare.
Can the same value arrive more than once?
Yes, and duplicates each count as their own element. Four copies of 7 make the median 7, not a set of size one.
Should findMedian return a float even when the count is odd and the answer is a whole number?
Yes, return a floating-point value in both cases. Returning an integer for odd counts is a real source of type errors, and integer division on the even branch silently truncates — (3 + 4) / 2 must be 3.5, not 3.
Is there a bound on how many numbers arrive? Must everything be stored?
Here it is at most 5 × 10⁴, so storing all of them is fine. Ask anyway: in a genuinely unbounded stream you cannot keep every number, and the problem becomes approximate-median estimation — a completely different design worth naming to show you spotted the distinction.

The word doing the damage is stream. If all the numbers were handed to you at once, this would be a one-line problem: sort, read the middle. Instead they arrive one at a time and the answer may be demanded after any of them. So the real question is not "how do I find a median" but "what can I maintain as numbers arrive, so that the middle is always instantly available?"

Two honest attempts, and the exact cost of each

The first attempt keeps everything in a list and sorts on demand.

python
def add_num(self, num):
    self.data.append(num)               # O(1)

def find_median(self):
    self.data.sort()                    # O(N log N) every single query
    n = len(self.data)
    mid = n // 2
    if n % 2 == 1:
        return float(self.data[mid])
    return (self.data[mid - 1] + self.data[mid]) / 2.0

Adding is instant, but every query re-sorts from scratch. Under alternating calls with 5 × 10⁴ numbers, that is tens of thousands of sorts over lists tens of thousands long — billions of operations for an answer that barely moves between calls. The waste is glaring: the list was already sorted a moment ago, and one new number cannot un-sort it.

That thought leads to attempt two. Keep the list sorted at all times, and slot each newcomer into its proper place. Binary search finds the correct index in O(log N) — but inserting there means physically shifting every later element one position right, which is O(N).

python
import bisect

def add_num(self, num):
    bisect.insort(self.data, num)       # O(log N) to locate, O(N) to shift

def find_median(self):
    n = len(self.data)
    mid = n // 2
    if n % 2 == 1:
        return float(self.data[mid])
    return (self.data[mid - 1] + self.data[mid]) / 2.0

Queries are now free, and the total cost drops a lot in practice — memory moves are fast. But asymptotically we are still linear per operation, and the reason is instructive: we are paying to maintain a total order over all N numbers when the query only ever reads the one or two elements in the middle. The ranking of the 400th smallest against the 401st is computed, stored, and never used.

The reframe: guard a border, not an order

Stop thinking about a sorted list and think about a split point. Cut the numbers into two piles of equal size, where every number in the left pile is less than or equal to every number in the right pile. The piles are unordered internally — they are just sets — and yet the median is fully determined by them: if the piles are equal in size, it is the average of the largest number on the left and the smallest on the right; if the left pile holds one extra, it is that largest-on-the-left.

That is the entire insight. The median depends only on the two values sitting at the border. Everything deep inside either pile is irrelevant to the answer and does not need to be ordered at all.

Now the mechanical question, the same one asked in Kth Largest: which structure gives instant access to the extreme of a pile? The left pile needs its maximum on tap, so it is a max-heap. The right pile needs its minimum on tap, so it is a min-heap. Two heaps, facing each other across the border, with the median wedged between their roots.

Key Insightthis is the third distinct use of a heap in this section, and it is the one worth internalising. Kth Largest used a heap as a bounded filter, Task Scheduler used it as a re-prioritising queue, and here it is a border guard — the answer lives at the boundary between two sets, so you keep the boundary elements permanently on top.
Keeping the two invariants true

Two properties must hold after every single addition, and the code exists only to restore them:

1. Order — every value in the left pile is at most every value in the right pile. Without this the border values are not the middles and the answer is meaningless.
2. Balance — the two sizes differ by at most one. Without this the border drifts away from the true middle. We adopt the convention that when the count is odd, the extra element sits on the left.

A new number could belong on either side, and checking which requires a comparison and then possibly a fix-up — easy to get wrong at boundaries. There is a two-step trick that handles every case without any comparison at all:

python
import heapq

class MedianFinder:
    def __init__(self):
        self.small = []     # left pile, max-heap via negated values
        self.large = []     # right pile, min-heap

    def add_num(self, num):
        # Step 1: everything enters on the left.
        heapq.heappush(self.small, -num)
        # Step 2: hand the left's largest over to the right.
        #         This is what guarantees order: whatever ends up
        #         on the left is no bigger than anything on the right.
        heapq.heappush(self.large, -heapq.heappop(self.small))
        # Step 3: if the right outgrew the left, hand its smallest back.
        if len(self.large) > len(self.small):
            heapq.heappush(self.small, -heapq.heappop(self.large))

    def find_median(self):
        if len(self.small) > len(self.large):
            return float(-self.small[0])                  # odd: the extra sits left
        return (-self.small[0] + self.large[0]) / 2.0     # even: average the border
Step 2 is the clever part, and it is worth understanding rather than memorising. Whatever value we push into the left pile, the element that immediately gets promoted to the right is the largest thing the left pile now contains — possibly the newcomer itself, possibly an older resident it displaced. Either way, the element that crosses the border is the correct one, so the order invariant is restored without ever comparing the new number to anything explicitly. Step 3 then only repairs sizes, and moving the right pile's smallest cannot break the order invariant, since that value is already the least of the right side.

Note that small stores negated values because Python provides only a min-heap; negating flips the comparison so the largest original value has the smallest stored key and sits at the root. Every read of it negates back.

Each addition performs a constant number of heap operations, so addNum is O(log N) and findMedian is O(1) — it only peeks at two roots. Memory is O(N), which is unavoidable since any exact median needs the data.

Worked example

Adding 6, 1, 5, then 4, querying along the way. The left pile is written as real values, not their negated storage.

- add 6. Pushed left, giving left [6]. The left's max, 6, moves right: left is empty, right is [6]. The right is now bigger, so 6 comes back: left [6], right empty. Median is the left root, 6.0, correct for a single number.
- add 1. Pushed left, left [6, 1]. Its max 6 moves right: left [1], right [6]. Sizes are equal, no rebalance. Median is (1 + 6) / 2 = 3.5, matching the sorted pair 1, 6.
- add 5. Pushed left, left [1, 5]. Its max 5 moves right: left [1], right [5, 6]. The right is bigger, so its min 5 comes back: left [1, 5], right [6]. Median is the left root 5.0 — and the sorted data is 1, 5, 6, so 5 is right. Watch what just happened: 5 travelled to the right pile and immediately returned. That round trip is the mechanism placing it correctly with no comparison logic.
- add 4. Pushed left, left [1, 5, 4]. Its max 5 moves right: left [1, 4], right [5, 6]. Sizes equal. Median is (4 + 5) / 2 = 4.5, matching the sorted data 1, 4, 5, 6.

At no point was anything sorted, and at no point did more than the two border values matter.

The follow-up, and the general lesson

The follow-up asks what changes if every number lies in [0, 100]. This is the same escape hatch that appeared in Top K Frequent: when the key range is small and integral, stop comparing and start counting. Keep an array of 101 tallies plus a running total; addNum becomes a single increment, O(1). To find the median, walk the 101 buckets accumulating counts until you pass the halfway mark — a fixed 101 steps regardless of how many billions of numbers have streamed through. The log vanishes because the value range, not the data size, bounds the work.

The reflex to train is broader than heaps. When a problem asks for a value maintained over a stream, ask what is the least I can maintain that still determines the answer? For the median it is a border between two sets. For a running maximum it is one number. For a sliding-window maximum it is a monotonic deque. Each time, the win comes from noticing that the full sorted order was never the thing being asked for — and refusing to pay for it.

Interactive Strategy Visualization

Median Finder

Symmetric dual-heap architecture for real-time tracking

Small Half (Max-Heap)Large Half (Min-Heap)10Incoming
Dynamic Balancing
PHASE: SEED
Process 10. Fresh start: we place the first element in the Small Half (Max-Heap).
SMALL SIZE0
LARGE SIZE0

Pro Tip: Stream Balancing

The migration strategy (Push L → Pop Max L → Push R) is a robust way to ensure that any incoming number is correctly filtered through the median boundary.

"We peek at the roots to get the median in O(1) time."

O(N log N) Per-Query Sort
O(N) Per-Insert Shift
O(log N) Two Balanced Heaps