30. Running Median of Response Latency
Problem
An operational dashboard for a real-time voice pipeline streams in the end-to-end response latency of every request as it completes, and needs the running median at any moment without ever re-sorting the full history collected so far.
Maintain the running median using two heaps: a max-heap for the lower half of latencies seen so far, and a min-heap for the upper half, so the median is O(1) to read after each O(log n) insert.
Source: src/30_latency_median_tracker.py
class LatencyMedianTracker:
def add(self, latency: float) -> None: ...
def median(self) -> float: ... # raises ValueError if empty
>>> t = LatencyMedianTracker()
>>> t.add(120.0); t.median()
120.0
>>> t.add(80.0); t.median()
100.0
>>> t.add(200.0); t.median()
120.0
Step-by-Step Approach
- Maintain two heaps:
_lo, a max-heap holding the smaller half of all values seen so far (implemented as a min-heap of negated values, since Python'sheapqis min-heap only), and_hi, a min-heap holding the larger half. - Keep the invariant that every value in
_lois ≤ every value in_hi, and the two heaps' sizes never differ by more than 1. - On
add(latency), always push into_lofirst, regardless of the new value's actual magnitude. - Immediately rebalance: pop
_lo's max and push it into_hi. This single push-then-promote sequence is what guarantees the cross-heap ordering invariant holds after every insert, no matter which heap the new value "should" end up in. - If that left
_histrictly larger than_lo, pop_hi's min back into_lo— this restores the size-balance invariant (sizes differ by at most 1). - On
median(): if_lois strictly larger, the median is its top (the extra element from an odd-sized stream). Otherwise the streams are balanced and the median is the average of both heaps' tops.
The key insight is the "push into _lo, then always promote its max into _hi, then rebalance sizes" sequence — it looks like it does unnecessary work, but it's exactly what keeps both the value-ordering invariant and the size-balance invariant correct after every single insert, without ever needing to compare the new value against the current median explicitly.
Reference solution
import heapq
class LatencyMedianTracker:
# _lo is a max-heap (values stored negated) holding the smaller half;
# _hi is a min-heap holding the larger half. Kept within size 1 of each
# other so the median is always at the top of one or both heaps.
def __init__(self):
self._lo: list[float] = []
self._hi: list[float] = []
def add(self, latency: float) -> None:
# always push into _lo first, then rebalance by promoting its max
# into _hi -- guarantees every value in _lo <= every value in _hi
heapq.heappush(self._lo, -latency)
heapq.heappush(self._hi, -heapq.heappop(self._lo))
if len(self._hi) > len(self._lo):
heapq.heappush(self._lo, -heapq.heappop(self._hi))
def median(self) -> float:
if not self._lo and not self._hi:
raise ValueError("no latencies added")
if len(self._lo) > len(self._hi):
return float(-self._lo[0])
return (-self._lo[0] + self._hi[0]) / 2.0
Key Functions & Tricks
- Negated min-heap as a max-heap — Python's
heapqonly implements a min-heap, so negating values on push/pop simulates a max-heap. heapq.heappush/heapq.heappop— O(log n) per call, dominatingadd()'s cost.- Push-then-promote-then-rebalance sequence — the three-step dance in
add()that maintains both invariants unconditionally, regardless of where the new value belongs. - Size-balance invariant (differ by at most 1) — what makes
median()an O(1) lookup instead of requiring a scan. len(self._lo) > len(self._hi)branch inmedian()— handles the odd-total-count case where one heap has the extra element.
How to Recognize This Pattern
The signal is "maintain the median (or any fixed-rank statistic) of a value stream that keeps growing, with repeated queries interleaved with inserts" — if you only needed the median once, sorting once would do, but repeated queries on a growing stream call for an incremental structure. Two heaps generalize past the median to any percentile by changing the size ratio the invariant enforces (e.g. keep _lo three times the size of _hi for the 75th percentile). A common variation adds a remove(value) operation for a sliding-window median, which two plain heaps can't support efficiently (no O(log n) arbitrary removal) and instead needs lazy deletion with a companion hash set, or a balanced BST / order-statistics structure. A common pitfall is forgetting the size-rebalancing step after the push-then-promote, which silently breaks the O(1) median lookup by letting one heap grow unboundedly larger than the other.