Three Canonical Systems Patterns Built on Priority Queues
1. Dual Opposing Heaps for Streaming Order Statistics (Median & $p$-th Quantile)
To maintain the exact median of a dynamic stream of $N$ numbers, partition the elements into two halves:
lo(Max-Heap): Stores the smaller $\lceil N/2 \rceil$ elements, exposing the largest element of the lower half atlo.top().hi(Min-Heap): Stores the larger $\lfloor N/2 \rfloor$ elements, exposing the smallest element of the upper half athi.top().
Lazy Deletion for Sliding Window Median: What if elements also expire as a window of width $W$ slides across the stream? Deleting an arbitrary expired element from an unindexed heap takes $O(W)$, but we can achieve $O(\log W)$ amortized time using lazy hash-map deletion: maintain an int balance counter of *valid* elements in lo minus hi, record expired values in delayed[val]++, and only physically pop elements when they bubble up to lo.top() or hi.top()!
2. Streaming Top-$K$ Elements: Opposing Sign Min-Heap vs Quickselect
To find the $K$ largest elements in a stream of $N \gg K$ items, do not use a Max-Heap of size $N$ ($O(N)$ memory). Instead, maintain a Min-Heap of fixed capacity $K$ storing the $K$ largest elements seen so far. The root minHeap.top() acts as the gatekeeper threshold (the $K$-th largest element): any incoming $x \le \text{minHeap.top()}$ is discarded in $O(1)$ time without touching the heap!
3. External $K$-Way Merge with Loser Trees (Tournament Trees)
Merging $K$ sorted runs totaling $N$ elements with a standard binary Min-Heap of size $K$ takes $O(N \log_2 K)$ time, performing $2 \log_2 K$ key comparisons per emitted record. In RocksDB, LevelDB, and Apache Arrow, engineers replace the binary heap with Knuth's Tournament Loser Tree: internal nodes store the loser of each match while the winner propagates up, reducing comparisons per element by $50\%$ to strictly $\log_2 K$!
| Algorithmic Pattern | Update / Per-Element Time | Query Time | Auxiliary Space |
|---|---|---|---|
| Two Heaps Running Median | O(log N) (≤ 3 heap ops) | O(1) | O(N) |
| Sliding Window Median (Two Heaps + Lazy Map) | O(log W) Amortized | O(1) | O(W) |
| Streaming Top-$K$ Gatekeeper Min-Heap | O(1) Reject / O(log K) Admit | O(1) K-th / O(K log K) Sorted | O(K) Words |
| $K$-Way Sorted Merge (Heap / Loser Tree) | O(log K) per item | O(N log K) Total | O(K) Words |
Formal Dual-Heap Invariant & Random-Stream Top-$K$ Harmonic Bound
Dual-Heap Partition Invariant: At the end of every insertion into the Running Median structure:
$$\text{(1) Order: } \max(\text{lo}) \le \min(\text{hi}) \qquad \text{(2) Balance: } 0 \le |\text{lo}| - |\text{hi}| \le 1$$
For any general quantile $q \in (0, 1)$ (such as $p99$ where $q = 0.99$), replace the size balance invariant with $|\text{lo}| = \lceil q N \rceil$ and $|\text{hi}| = N - \lceil q N \rceil$; then lo.top() always holds the exact $q$-th order statistic!
Random-Stream Top-$K$ Expected Complexity: If an $N$-element stream arrives in uniformly random order, the $i$-th element ($i > K$) enters the Top-$K$ Min-Heap with probability $K / i$. Thus the expected number of $O(\log K)$ heap updates across the entire stream is $\sum_{i=K+1}^N \frac{K}{i} = K(H_N - H_K) = O(K \log(N/K))$, meaning total work is $O(N + K \log K \log(N/K))$—strictly linear $O(N)$ whenever $K = o(N / \log N)$!
Observe how implicit array indices (parent = ⌊(i−1)/d⌋, child_k = d·i+k) eliminate pointer overhead, why Floyd’s bottom-up siftDown sums ∑ 2^(h−k)·k ≤ 2N work, and how an Indexed PQ maintains pos[id] ⇄ heap[i].
Invariant & State
Hardware Reality: Loser Trees in RocksDB Compaction
During RocksDB LSM-tree compaction or external merge sort, an iterator merges $K$ sorted SSTable streams. In a standard binary Min-Heap of size $K$, siftDown must compare the two children against each other (a[left] < a[right]) and then compare the smaller child against the parent at every level—costing $2 \log_2 K$ string/slice key comparisons per row. In a Tournament Loser Tree, the replacement element walks upward from its leaf to the root, comparing itself only once per level against the stored loser of that match ($log_2 K$ comparisons total) with deterministic bottom-up array indices that eliminate data-dependent sibling branches!
Implementation: Two-Heap Streaming Median & $K$-Way Merge
// C++20: Overflow-Safe Running Median Finder via Dual Opposing Heaps
#include <queue>
#include <vector>
#include <functional>
class MedianFinder {
std::priority_queue<long long> lo; // Max-Heap for lower half
std::priority_queue<long long, std::vector<long long>, std::greater<>> hi; // Min-Heap for upper half
public:
void addNum(long long x) {
lo.push(x);
hi.push(lo.top());
lo.pop();
if (hi.size() > lo.size()) {
lo.push(hi.top());
hi.pop();
}
}
double findMedian() const {
if (lo.size() > hi.size()) {
return static_cast<double>(lo.top());
}
// Overflow-safe midpoint calculation
return static_cast<double>(lo.top()) + ( static_cast<double>(hi.top()) - static_cast<double>(lo.top()) ) * 0.5;
}
};
Failure Modes & Adversarial Pitfalls
- Pushing into
loWithout Routing ThroughhiViolates Ordering: A common bug is checkingif (lo.size() <= hi.size()) lo.push(x); else hi.push(x);without comparing $x$ against the opposite heap's root. Always route through the opposing heap (or compare againsthi.top()/lo.top()) so that $\max(\text{lo}) \le \min(\text{hi})$ holds unconditionally. - Signed Integer Overflow in
(lo.top() + hi.top()) / 2.0: Iflo.top()andhi.top()are 32-bitINT_MAX, adding them inintarithmetic overflows before casting todouble. Cast todoubleorint64_tbefore adding.