GO TO A SECTION

↑ ↓ to navigate · Enter to openExplore all sections →
FDEInterviews logo
← 💻 Coding & Engineering Craft
Foundational

Heaps and Top-K

When a problem says top-K, K-th largest, or merge K sorted streams, the answer is almost always a heap. A binary heap gives you the smallest (or largest) element in O(1) and insert/pop in O(log n), which turns a full O(n log n) sort into an O(n log k) scan when you only need the K best. The recurring trick, counterintuitive at first, is to keep a min-heap of size K to find the K largest.

WATCH THE EXPLAINER2:50
Transcript

You need the ten worst latencies out of ten million requests. The first instinct is to sort all ten million and take the top ten, which works, but a full sort wastes work ordering nearly every one of them only to throw them away. When a problem says top K, K-th largest, or merge K sorted streams, the answer is almost always a heap.

A heap is a priority queue. Peek at the smallest in O(1). Insert or pop in O(log n). If you need the full ranking, sort. But most top K questions only need the K best, and a heap capped at K items keeps only those in memory, which is what lets it run over a stream that doesn't fit in RAM.

Here's the move that trips people up. To find the K largest, you keep a min-heap. Its root is then the smallest of your current top K, which makes it exactly the element to evict when a bigger one arrives.

Here it is with K equal to 3, on 5, 1, 9, 3, 7, 2, 8. The first three go straight in, and the root is 1. 3 beats the root, so it replaces 1. 7 beats the new root, 3, and replaces it. Now the root is 5. 2 can't beat 5, so it's discarded, because it could never make the top. 8 beats 5 and takes its place. What's left is 9, 8 and 7, and nothing else was ever kept.

In Python it's eight lines. While the heap holds fewer than K, push. After that, compare each item with the root, and only if it's bigger, call heapreplace, which pops then pushes in a single sift. That's why the comparison comes first. And heapq is a min-heap, so for a max-heap you push negated keys.

The same structure merges K sorted lists. Seed the heap with the head of each list. Pop the smallest, then push the next item from whichever list it came from, and repeat. Each entry carries the value, the list index and the element index, so ties break deterministically and you know where to pull the next element from. Still O(n log k).

Watch for these. Reaching for a max-heap of the whole input defeats the memory win. If values can tie, include a unique tiebreaker, like an index, so Python never tries to compare payloads it can't order. And building a heap from a list is O(n) with heapify, cheaper than n separate pushes.

In the interview, say which heap and why. K largest needs a min-heap, because you evict the root. K smallest needs a max-heap. The code and the trace are on fdeinterviews.com.

TL;DR: A heap is a priority queue: peek the min (or max) in O(1), insert and pop in O(log n). For "top K of a stream of n", keep a min-heap of size K; push each item, and if the heap grows past K, pop the smallest. You end with the K largest in O(n log k) time and O(k) space, without sorting the whole input.

Why a heap instead of sorting

If you need the full ranking, sort: O(n log n). But most "top-K" questions only need the K best, and K is tiny next to n (the 10 worst latencies out of 10 million requests). A full sort wastes work ordering the 9,999,990 you will throw away. A bounded heap pays O(log k) per element and keeps only K in memory, which is what lets it run over a stream that does not fit in RAM.

The two approaches to top-K, side by side:

ApproachTimeSpaceWhen
Full sortO(n log n)O(n)You need the full ranking
Min-heap of size KO(n log k)O(k)K is small next to n, or the data streams

The move that trips people up: to find the K largest, you keep a min-heap. The root is then the smallest of your current top-K, so it is exactly the element to evict when a bigger one arrives.

Top-K from a stream

Top K without sorting the other ten million 1 A stream of n too big to sort, or to hold 2 A min-heap of K the counter-intuitive part 3 Under K? Push and take the next item 4 Else check the root the smallest of your best 5 Bigger? Replace pop and push in one sift 6 Smaller? Discard it can never make the top 7 The K largest and nothing else was kept To find the K LARGEST you keep a MIN-heap. Its root is then the smallest of your current top K, which is exactly the element to evict when a bigger one arrives. A replace primitive pops then pushes in a single sift, which is why the comparison guard comes before it rather than pushing and trimming afterwards. full sort: O(n log n), O(n) space min-heap: O(n log k), O(k) space A full sort wastes work ordering the 9,999,990 you were going to throw away, and needs them all in memory.

The spine above walks one item through the bounded heap, and marks the counter-intuitive step: a min-heap is what finds the largest.

python
import heapq

def top_k(stream, k: int) -> list[int]:
    heap: list[int] = []          # min-heap, size <= k
    for x in stream:
        if len(heap) < k:
            heapq.heappush(heap, x)
        elif x > heap[0]:         # bigger than the current smallest kept
            heapq.heapreplace(heap, x)   # pop min, push x, in one O(log k)
    return sorted(heap, reverse=True)

assert top_k([5, 1, 9, 3, 7, 2, 8], 3) == [9, 8, 7]
assert top_k([4, 4, 4], 2) == [4, 4]
assert top_k([], 3) == []

Each element costs at most O(log k), so the scan is O(n log k) with O(k) memory. Python's heapq is a min-heap; for a max-heap, push negated keys or use tuples. heapreplace is the right primitive here: it pops then pushes in a single sift, which is why the x > heap[0] guard comes first.

Merging K sorted sources

The same structure merges K sorted lists or streams in O(n log k): seed the heap with the head of each source, pop the smallest, and push the next item from whichever source it came from.

python
import heapq

def merge_sorted(lists: list[list[int]]) -> list[int]:
    heap = [(lst[0], i, 0) for i, lst in enumerate(lists) if lst]
    heapq.heapify(heap)           # O(k)
    out = []
    while heap:
        val, li, idx = heapq.heappop(heap)
        out.append(val)
        if idx + 1 < len(lists[li]):
            heapq.heappush(heap, (lists[li][idx + 1], li, idx + 1))
    return out

assert merge_sorted([[1, 4, 7], [2, 5], [3, 6, 9]]) == [1, 2, 3, 4, 5, 6, 7, 9]

The tuple carries (value, list_index, element_index); the extra fields both break ties deterministically and tell you where to pull the next element.

Common pitfalls

  • Max-heap confusion. To keep the K largest, use a min-heap of size K and evict its root. Reaching for a max-heap of the whole input defeats the memory win.
  • Comparing raw objects. Heaps compare by the first tuple element; if values tie, include a unique tiebreaker (an index) so Python never tries to compare unorderable payloads.
  • nlargest for large K. heapq.nlargest(k, data) is perfect for one-shot top-K, but for k close to n a plain sorted() is simpler and not slower.
  • Forgetting the heapify shortcut. Building a heap from a list is O(n) via heapify, cheaper than n individual pushes.

Key takeaways

  • Heap operations: peek O(1), push/pop O(log n). Use it whenever "the best so far" must stay cheap to query.
  • Top-K of n is O(n log k) with a size-K heap, beating an O(n log n) sort when k is small or the data streams.
  • K largest needs a min-heap (evict the root); K smallest needs a max-heap. Say which and why.
  • Merging K sorted sources is the same pattern at O(n log k); carry source indices in the heap tuple for tie-breaking and bookkeeping.

Related concepts

Practise this in questions