skip to content

How do you keep a two-heap median over a fixed trailing window when the expiring sample isn't at a top?

level: seniorimportance: should knowfreq 45%

answer

  1. Heaps only delete from the top
  2. Defer the removal until it surfaces
  3. Mark now, discard when it reaches the top
  4. Physical size stops matching the window
  5. Rebalance on the counts you maintain, not the arrays

basics

~20 s

Heaps remove only their top, so expiring samples are deleted lazily: mark the departing value as pending removal, track logical sizes separately from physical heap sizes, rebalance on the logical counts, and discard stale tops before reading the median.

solid answer

~50 s

A heap gives O(log n) insert and remove-top, but finding an arbitrary element costs O(n), and the sample leaving a trailing window is almost never a top. The standard fix is **lazy deletion**: when a sample expires, decide which half holds it by comparing it against the max-heap's top, record one pending removal for it, and decrement that half's *logical* size. The heaps still physically contain it, so every read first pops tops while they are marked pending. Rebalancing must use the logical counts, never the physical array lengths, or the median formula reads the wrong side. Each element is inserted once and purged once, so the amortised cost per window step stays O(log n), with physical memory temporarily above the window size. The alternative is an indexed heap that stores each element's position and updates it on every sift, giving a true O(log n) delete-by-handle at the price of more bookkeeping.

go deeper

for a junior

Know that heaps expose only their top: insert and remove-top are O(log n), but locating an arbitrary element is a linear scan, which is why a moving window needs extra machinery.

for a middle

Explain lazy deletion end to end — pending marks, purging tops before any read, and the separation of logical from physical sizes — and why the per-step cost is amortised rather than worst case.

for a senior

Show the operational side: bounding the memory slack with a rebuild threshold, marking removals by identity rather than by floating-point value, and choosing lazy deletion over an indexed heap for maintainability.

for a principal

Decide when the pattern stops being the right tool — several percentiles from one window, or a strict per-step latency bound — and justify moving to an order-statistic structure against the cost of the code the team must own.

## Why the window changes the problem A dashboard showing the median latency of the last 10,000 requests, rather than of all time, needs one removal for every insertion once the window is full. Insertion is the operation heaps are built for. Removal is not: a heap orders parents against children only, so the departing sample sits at an unknown position and finding it is a linear scan. Doing that per step turns an O(log n) structure into an O(n) one. ## Lazy deletion The trick is to defer the removal until the element reaches a top, where deletion is cheap. Maintain, besides the two heaps: - a table of **pending removals**, counting how many copies of each departing value are logically gone, - two **logical sizes**, one per half, counting only the elements that are still in the window. When sample `x` falls out of the window: compare it against the max-heap's top to decide which half it is in (`x <= top(lower)` means it is below), increment its pending count, and decrement that half's logical size. Nothing is searched or moved. Before any read of a top — for the median, for routing, or during a rebalance — run a purge loop on that heap: while its top has a positive pending count, pop it and decrement the count. Only a top can be purged, which is exactly the operation a heap supports cheaply. Rebalancing then runs on the **logical** sizes. This is the single most common mistake in the pattern: the physical arrays are bloated with dead elements, so comparing their lengths puts the boundary in the wrong place and returns a wrong median even though every individual step looked correct. ## Cost and memory Each sample is pushed once and popped at most once, so the total work over a stream of length N is O(N log n) — amortised O(log n) per window step, even though a single step may purge a run of stale tops. Memory is the price. A heap may physically hold far more than the window's worth of elements if dead ones are buried deep and never surface. In the worst case, dead elements accumulate until they reach a top. Two mitigations are usual: purge opportunistically at both tops on every step, and rebuild both heaps from the live elements when the physical size exceeds, say, twice the logical size. The rebuild is O(n) but pays for itself over the n steps that filled the slack, so the amortised bound is unchanged. ## Identity, not equality Counting pending removals by *value* works when values are exact and repeats are interchangeable, which for rounded millisecond latencies they are. It gets subtle with floating-point values that are computed rather than stored verbatim: purging keys on equality against a value that was rounded or re-derived can miss. The robust form stores a sequence number with each sample and marks removals by that identifier, so a purge is unambiguous and duplicates cannot confuse the counts. It costs one extra field per element and removes an entire class of "the median drifts after a few hours" bugs. ## The alternative: an indexed heap If you want a hard bound instead of an amortised one, make the heap indexed: alongside the array, keep a map from element handle to its current array position, and update that map inside every sift-up and sift-down. Deleting an arbitrary element then means looking up its position, swapping in the last element, and sifting it in whichever direction it needs — O(log n) worst case, with no dead elements and no memory slack. The cost is that every structural move now writes to two places, which is both slower by a constant factor and much easier to get wrong; forgetting to update the map inside one sift branch produces stale positions and corrupt deletions that surface only under specific input shapes. Lazy deletion is a dozen lines and is the usual choice; the indexed heap earns its keep when memory is tight or when deletions vastly outnumber reads. ## When to abandon the pattern If the window also needs arbitrary order statistics — p50 and p90 and p99 from the same window — the two-heap split stops paying, because it maintains exactly one boundary. A balanced search structure augmented with subtree counts answers any rank in O(log n) with a single copy of the data, and supports removal natively. The two-heap version wins when there is one percentile, reads are frequent, and the implementation must stay small enough for the on-call engineer to read.

  • What breaks if you rebalance on the physical heap sizes instead of the logical ones?
    The arrays hold dead elements that are no longer in the window, so their lengths say nothing about where the median boundary is. Rebalancing on them moves live elements across the boundary for the wrong reason and the read formula picks the wrong side, producing a wrong median with every invariant apparently intact. Logical counts are the ones the median is defined against.
  • How bad can the memory overhead of lazy deletion get?
    Unbounded in principle: a dead element buried deep is only discarded when it becomes a top, which may never happen for a value far from the median. The usual guard is a rebuild — when the physical size passes a multiple of the logical size, rebuild both heaps from the live elements in O(n). That amortises to nothing per step and caps the slack at a chosen factor.
  • Why prefer a sequence number over the raw value when marking a removal?
    Value-keyed pending counts assume you can reproduce the departing key exactly. With computed floating-point durations that is fragile, and with heavy duplicates a miscount silently shifts the boundary. A per-sample sequence number makes each removal refer to one specific element, so purging is unambiguous and duplicate values need no special handling.

saying these in an interview costs you the question

  • Claims a heap can delete an arbitrary element in O(log n) unaided
  • Scans the heap array to find the expiring sample
  • Rebalances using the physical array lengths
  • Says lazy deletion has no memory cost
  • Rebuilds both heaps from scratch on every window step

context