DSA DSA In Depth
0%
design heapsrunning-mediantop-k

Priority Queue Patterns: Running Median (Two Heaps), K-Way Merge & Top-K

7 min read Module 3.1: Binary Trees, Traversals, BSTs & Priority Heaps Updated Oct 23, 2026
📄 Cheat Sheet
Find Median from Data Stream — Heap & Priority Queue Pattern
NeetCode Find Median from Data Stream — Heap & Priority Queue Pattern Watch on YouTube ↗

Demonstrates the opposing Max-Heap + Min-Heap partition invariant for O(log N) streaming median updates and O(1) median queries.

0:00 / --:--

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 at lo.top().
  • hi (Min-Heap): Stores the larger $\lfloor N/2 \rfloor$ elements, exposing the smallest element of the upper half at hi.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)$!

Binary & d-Ary Heap Dual View, Floyd O(N) Build & Indexed PQ
Floyd Build O(N) · Sift O(log_d 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].

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Leaves [3..6] are trivially valid 1-node heaps (0 work for 50% of nodes!).
🌳 Live 2D Hierarchical Tree Topology — Binary & d-Ary Heap Dual View, Floyd O(N) Build & Indexed PQ Visited/Active Pivot/Root Resolved
Root 45 [0] p=[0] 32 [1] p=[0] 18 [2] p=[1] 29 [3] p=[1] 12 [4] p=[2] 9 [5] p=[2] 25 [6]
Tree Levels:   L0: [45]  |  L1: [32, 18]  |  L2: [29, 12, 9, 25]
[0]45Root
[1]32p=[0]
[2]18p=[0]
[3]29p=[1]
[4]12p=[1]
[5]9p=[2]
[6]25p=[2]
Heap Size N
7
Internal Nodes
i = 2 down to 0
Total Sift Swaps
0
Bound ∑ h_i
≤ 2N − 2 log₂N
[Step 1] Starting Floyd bottom-up heapify from last internal node i = ⌊7/2⌋ - 1 = 2 down to 0.
lo.top() = max(Lower Half) Max-Heap (lo) Size = ⌈N/2⌉ hi.top() = min(Upper Half) Min-Heap (hi) Size = ⌊N/2⌋ Invariant 1: lo.top() ≤ hi.top() Invariant 2: |lo| == |hi| or |lo| == |hi| + 1 Odd N: Median = lo.top() Even N: Median =lo.top() + (hi.top()-lo.top())/2.0 General p-Quantile: keep |lo| = ⌈p·N⌉
flowchart LR In["addNum(x)"] --> Step1["lo.push(x) hi.push(lo.pop()) (Enforces lo.top() <= hi.top())"] Step1 --> Step2{"hi.size() > lo.size() ?"} Step2 -- "Yes" --> Step3["lo.push(hi.pop()) (Restores 0 <= |lo|-|hi| <= 1)"] Step2 -- "No" --> Done["Ready for O(1) findMedian()"] Step3 --> Done

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 lo Without Routing Through hi Violates Ordering: A common bug is checking if (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 against hi.top() / lo.top()) so that $\max(\text{lo}) \le \min(\text{hi})$ holds unconditionally.
  • Signed Integer Overflow in (lo.top() + hi.top()) / 2.0: If lo.top() and hi.top() are 32-bit INT_MAX, adding them in int arithmetic overflows before casting to double. Cast to double or int64_t before adding.
Check yourself Question 1 of 4
Concept Check Dual-Heap Invariants for Streaming Median recall

In the Two-Heaps Streaming Median algorithm using a Max-Heap `lo` and a Min-Heap `hi`, what two invariants must hold after every `addNum(x)` operation?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

When RocksDB merges K sorted SSTable iterators during compaction, why does it use Knuth's Tournament Loser Tree instead of a standard binary Min-Heap of size K?

Key Takeaways

Two opposing heaps (Max-Heap lo for the lower half, Min-Heap hi for the upper half) maintain any streaming quantile in O(log N) update time and O(1) query time.
Sliding Window Median combines two heaps with a lazy-deletion frequency table to achieve O(log W) amortized updates without indexed node handles.
Streaming Top-K uses a fixed-capacity K Min-Heap whose root acts as an O(1) gatekeeper threshold, achieving O(N + K log K log(N/K)) expected time on random streams.
Merging K sorted streams of N total elements takes O(N log K) time and O(K) space; Tournament Loser Trees cut key comparisons in half from 2 log₂ K to log₂ K.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue