latency.lab
Algorithms · Topic 09

Heap / Priority Queue

Level: foundationTool: bounded top-k, and merging sorted frontiersTime: ~65 min

By the end you can

Almost every problem in this topic is secretly the same question in disguise: "give me the best (or worst) k things, without fully sorting everything." A heap answers that question in O(log n) per operation instead of paying for a full sort — and recognizing when a problem has that shape is most of the skill here.

What a heap actually is

A heap is a complete binary tree, stored flat in an array, satisfying one invariant: every parent is ≤ every child (min-heap) or ≥ every child (max-heap). That's a much weaker guarantee than a sorted array — a heap only promises the root is the smallest (or largest) element, not that anything else is in any particular order. That weaker guarantee is exactly what makes push/pop cheap: fixing the invariant after a change only ever touches one root-to-leaf path, which is O(log n), rather than re-sorting everything.

The habit to build

Before reaching for a heap, ask: "do I need the whole ordering, or do I just need repeated access to the current best (or worst) element while things change?" If it's the latter — and it usually is, in this topic — a heap gives you that for O(log n) per change instead of paying O(n log n) to re-sort after every update.

1
3
2
7
5
0
1
2
3
4
≡
1 3 2 7 5
A min-heap stored flat in an array is the exact same tree, just indexed instead of pointer-linked: index i's children live at 2i+1 and 2i+2. Index 0 (value 1) is guaranteed ≤ both children — nothing is promised about 3 vs. 2's relative order, or anything deeper.

std::priority_queue is a max-heap by default

std::priority_queue<int> gives you the largest element on top. For a min-heap, flip the comparator: std::priority_queue<int, std::vector<int>, std::greater<int>>. This trips people up constantly, because the comparator you pass answers "does a come before b" in the sense of sorted order, but a max-heap is what you get from the default (less) comparator — it feels backwards the first few times.

bounded-heap-pattern.cpp
// The single most common shape in this entire topic: keep only the best
// k elements seen so far, in O(log k) per element instead of O(log n).
std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap; // smallest on top
for (int x : nums) {
    min_heap.push(x);
    if (min_heap.size() > k) min_heap.pop(); // evict the current worst of the top-k
}
// min_heap.top() is now exactly the k-th LARGEST element seen -- everything
// that got popped was, at the moment it was popped, worse than every
// candidate still in the heap.

The bounded-heap-of-size-k pattern

Kth Largest Element in a Stream, Kth Largest Element in an Array, K Closest Points to Origin, and Top K Frequent Elements are all the exact same trick wearing different clothes: cap a heap at size k, and whatever's left when you're done is the answer. The heap holds the opposite extreme of what you're keeping — a min-heap when you want the k largest, a max-heap when you want the k closest (smallest distances) — because you always want to evict the worst of your current best-k candidates, and "worst of the best" sits on top of the heap that orders by the opposite direction.

The frontier-expansion pattern (multi-way merge)

Find K Pairs with Smallest Sums, Kth Smallest Element in a Sorted Matrix, and Super Ugly Number all share a different shape: instead of one already-known collection, you have several sorted sequences (rows of a matrix, or one sequence per prime) and want to walk them in combined sorted order without merging all of them upfront. Seed a heap with each sequence's current smallest candidate; every time you pop one, push that same sequence's next value. This touches only as many elements as you actually need — never the full cross product or the full matrix.

Two heaps, split down the middle

Find Median from Data Stream needs the middle of a constantly-changing collection, which neither pattern above directly gives you. The fix: keep two heaps — a max-heap for the lower half, a min-heap for the upper half — balanced so their sizes never differ by more than one. The median is then just the top of one heap, or the average of both tops. IPO uses two heaps for a different reason: one sorted structure to track "what's newly affordable," and a max-heap to always grab the most profitable currently affordable option — two different jobs, each suited to a different structure.

Common mistakes

  • Sorting when you only needed a bounded heap — Kth Largest Element in an Array doesn't need the array fully sorted; a min-heap capped at size k does O(n log k) instead of O(n log n), and the gap widens a lot once k is much smaller than n.
  • Repeatedly rescanning for the current max/min instead of maintaining a heap — the instinct to "just scan for the best one again" turns an O(log n)-per-step problem into O(n) per step, and O(n²) or O(n·k) overall (Last Stone Weight, Kth Largest Element in an Array, IPO all specifically guard against this).
  • Forgetting std::priority_queue defaults to a max-heap — passing no comparator when you actually wanted the smallest element on top is a silent logic bug, not a compile error.
  • Materializing the full cross product when a frontier-expansion heap would do — Find K Pairs with Smallest Sums doesn't need all n₁·n₂ pairs, just the k smallest, and generating everything upfront can blow past a reasonable memory budget for no benefit.

Takeaways

  • A heap trades "fully sorted" for "root is always the current best," and that weaker guarantee is exactly what makes push/pop O(log n) instead of O(n log n) to re-sort.
  • "Give me the best k" is a bounded heap of size k, evicting the worst of the current top-k on every insert — recognize this shape and four separate-looking problems become one pattern.
  • "Combine several sorted sequences without merging them upfront" is frontier expansion: seed one candidate per sequence, and popping one always pushes that same sequence's next value.
  • Two heaps, split down the middle, is how you track a running median — one structure per side, kept balanced.

Try it: the bounded-heap foundation

Kth Largest Element in a Stream is the cleanest version of the size-k-capped-heap pattern — get this automatic here, since Kth Largest Element in an Array, K Closest Points, and Top K Frequent Elements are all the same trick.

Live

Loading starter code…

Try it: frontier expansion

Find K Pairs with Smallest Sums is the clearest version of "seed one candidate per sorted sequence, push the next one when you pop" — the same idea scales to Kth Smallest in a Sorted Matrix and Super Ugly Number.

Live

Loading starter code…

Try it: two heaps, split down the middle

Find Median from Data Stream — the invariant that keeps the split correct is worth sitting with; it's not just about balancing sizes.

Live

Loading starter code…

Keep going

That's 3 of the 12 problems in this topic. Task Scheduler and Reorganize String are worth doing next — both use a max-heap for greedy scheduling, a slightly different flavor from the top-k and frontier patterns above. See the full Heap / Priority Queue set →

Checkpoint · Heap / Priority Queue

5 questions · pass at 70%

Q01
Kth Largest Element in a Stream keeps a MIN-heap capped at size k to answer a question about the LARGEST elements. Why a min-heap, not a max-heap?
WhyThe heap's top needs to be whichever kept element is easiest to justify throwing away next. Since we're keeping the k largest values, the one most at risk of eviction is the smallest of them -- and a min-heap puts exactly that on top, in O(1) to check and O(log k) to actually pop.
Q02
Kth Largest Element in an Array (O(n log k) via a bounded heap) vs. repeatedly scanning for the current max, k times (O(n·k)) -- why does the gap between these matter in practice?
WhyBoth approaches are correct, but at realistic problem sizes the asymptotic gap becomes a real wall-clock gap -- roughly 2 million operations vs. roughly 10 billion. This is exactly the kind of gap this course's stress tests are built to catch.
Q03
Find K Pairs with Smallest Sums seeds its heap with only the first min(n1, k) pairs (one per row of nums1), not all n1 rows. Why is that enough?
WhyBecause nums1 is sorted, later rows only ever start at sums that are greater than or equal to earlier rows' starting sums. Once you have k rows contributing candidates, a not-yet-seeded row's best possible contribution can never beat what's already available, so it never needs to be considered.
Q04
Find Median from Data Stream keeps a max-heap lo (lower half) and a min-heap hi (upper half). What's the actual invariant that has to be maintained on every insert, beyond just "keep the sizes balanced"?
WhyBalanced sizes alone don't guarantee the split is in the right place -- a new value could belong in either half. Routing every insert through lo first and then promoting lo's new top into hi guarantees the cross-heap ordering invariant (everything in lo <= everything in hi) stays true, not just the size balance.
Q05
IPO (Maximize Capital) uses a sort plus a max-heap, and explicitly avoids rescanning all n projects on every one of the k rounds. What does the upfront sort actually buy, given the heap does the "pick the best" part?
WhyThe heap handles "which affordable project is most profitable," but something still has to handle "which projects just became affordable." Sorting by capital requirement once turns that into a single forward-moving pointer across all k rounds, rather than an O(n) affordability re-check every single round.

Finished Heap / Priority Queue?

Pass the quiz to complete it automatically.