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.

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.

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.

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
PRACTICE THIS IN REAL QUESTIONS