S029 · Heaps & Priority Queues
The data structure behind top-K, Dijkstra, event schedulers, and every job runner — how heapq works, when to use it, and the classic patterns that come up in every senior interview.
🎯 Use Python's heapq fluently for top-K, streaming median, event scheduling, and the canonical Dijkstra loop — and know when a heap beats sorting.
Why this session exists
A heap is a partially-sorted tree that gives you O(1) access to the smallest (or largest) element and O(log n) insert/pop. It's what powers Dijkstra's shortest-path, Kafka's ordering, cron/Celery job schedulers, streaming top-K analytics, load balancers, and every ‘find the k smallest / largest’ query. If you learn ONE algorithmic tool from S023–S034, make it two: hashmap and heap. This is heap day.
- Explain a binary heap in one sentence — and why it lives in an array instead of a tree of nodes.
- Use heapq.heappush / heappop confidently, and simulate a max-heap with negated values.
- Solve top-K frequent, k-th largest, and streaming median from memory.
- Write Dijkstra's shortest-path with a min-heap in ~15 lines.
- Recognise ‘schedule the next event’ patterns as priority-queue problems.
Prerequisites
- S028 · Trees & BSTs — a heap IS a complete binary tree.
- S024 · Hashmaps — combined with heaps for top-K by key.
- S027 · Recursion — heapify is post-order recursion under the hood.
(a) Intuition · 5 min
Emergency room: patients arrive at random. The next person seen is always the most critical, regardless of arrival order. Adding a patient is O(log n) — nurse walks them through triage. Pulling the next patient is O(log n) — reorganise so the next-most-critical bubbles up.
The queue doesn't need to be fully sorted — it just needs to always yield the highest priority next. That partial ordering is way cheaper to maintain than full sorting.
A min-heap: every parent is ≤ both children. Root = the minimum. Insert = append to end + sift-up. Pop = take root, move last element to root, sift-down. Both operations O(log n) — the height of a complete tree.
The killer trick: a heap is stored in an ARRAY, not a tree of nodes. Node at index i has children at 2i+1 and 2i+2. No pointers, cache-friendly, fast.
Heap vs sorted list vs BST
- Sorted list — O(n) insert (shift). O(1) get_min. Great only if inserts are rare.
- Balanced BST — O(log n) insert, O(log n) get_min. General-purpose but heavier constant factors and much more code.
- Heap — O(log n) insert, O(log n) pop_min, O(1) peek_min. Cannot query arbitrary keys, only the extreme. Perfect fit for priority queues.
- Rule: if you always want ‘the best’ or ‘the worst’ and don't need to search by value, use a heap.
Timeline — the rise of the heap
- 1964J. W. J. Williams invents heapsortFirst formal description of the binary heap, published in Communications of the ACM. Includes both the sort and the priority-queue abstraction.
- 1978Fibonacci heap (Fredman & Tarjan)Amortised O(1) decrease-key. Makes Dijkstra O(E + V log V). Beautiful theoretical result, rarely used in practice due to constants.
- 1994Python heapq moduleShips as part of the standard library. Uses a plain array + integer arithmetic — no classes, no allocations. Ridiculously fast.
- 2006Google's original MapReduce top-KDistributed top-K using per-mapper heaps of size K, then a reducer merges. The dominant streaming-analytics pattern.
- 2015Cron / Celery / Airflow all use heapsEvery job scheduler stores pending tasks in a min-heap keyed by run_at timestamp. Peek = next job to run.
- 2022Kafka Streams windowingEvent-time processing uses heaps to hold late-arriving events until watermarks pass — heap keyed by event timestamp.
(b) Visual walkthrough · 15 min
Heap as an array — the index arithmetic that avoids pointers
Sift-up (insert) — bubble the new element toward the root
Preserves the ‘complete binary tree’ shape — every level filled left to right.
parent index = (i-1) // 2. If child < parent (min-heap), swap.
Continue until parent <= child, or you reach the root.
At most log₂(n) swaps — height of the tree.
Sift-down (pop) — replace root with last, bubble down
It's the min.
Keeps the tree complete.
children = 2i+1, 2i+2. If smallest child < parent, swap.
Continue until both children ≥ parent, or you're at a leaf.
At most log₂(n) comparisons + swaps.
Top-K frequent: heap of size K beats sorting
The naive way
- O(n log n) time
- Memory holds all n items
- Fine for one-time offline compute
The streaming way
- O(n log K) time — big win when K << n
- Memory holds only K items
- Works on streams — no need to see all data at once
- Standard for top-100 trending / top-error-messages
The pointer trick
- O(n) average, O(n²) worst
- In-place, no heap needed
- Great when you need the exact k-th value, not the top-K in order
Dijkstra with a heap — the shape of the loop
Dijkstra in five lines
"A heap is a sorted structure. The smallest is at the top, so the array must be roughly in order."
A heap is almost entirely unsorted. It guarantees exactly one thing: every parent is ≤ (or ≥) its children. That says nothing about siblings, nothing about cousins, and nothing about the array order beyond position 0. Two elements in different subtrees have no defined relationship at all.
Because heappop repeatedly gives you sorted output, so it feels like the sorting already happened. It didn't — each pop does O(log n) work to re-establish the invariant. That partial-order weakness is the whole point: maintaining a total order costs O(n log n), maintaining "parent ≤ children" costs O(n) to build and O(log n) per update. You bought a cheaper structure by asking for a weaker guarantee.
Look at the raw array — it is not sorted, and printing it is the fastest cure for this myth:
import heapq, random
xs = list(range(20)); random.shuffle(xs)
heapq.heapify(xs)
print(xs) # NOT sorted - only xs[0] is meaningful
print(xs == sorted(xs)) # almost always False
# but the invariant does hold everywhere:
ok = all(xs[i] <= xs[2*i+1] for i in range((len(xs)-1)//2))
print('heap invariant holds:', ok)Why does heapify build a heap in O(n), when inserting n elements one at a time costs O(n log n)? Both end with a valid heap over the same n items — where did the log go?
- 1Sift-down at a node costs work proportional to that node's height — the distance to the bottom of the tree, not to the root.forced by · sift-down moves an element downward, and it can move at most as far as the leaves are below it
- 2In a complete binary tree, about half the nodes are leaves with height 0, a quarter have height 1, an eighth have height 2, and so on.forced by · each level up halves the node count — that is what "binary" means
- 3So total heapify work is the sum over heights h of (number of nodes at height h) × h, which is n × Σ (h / 2^(h+1)).forced by · work per node is its height, and node counts decay geometrically with height
- 4That series converges to a constant (it sums to 1), so the total is O(n), not O(n log n).forced by · a geometrically decaying weight beats a linearly growing cost; the tall, expensive nodes are exponentially rare
- 5Repeated insertion has the opposite profile: each insert sifts up from a leaf, and most elements are inserted when the tree is already near full depth.forced by · sift-up cost is depth, and most nodes are deep — the many-cheap/few-expensive asymmetry runs the wrong way
Therefore bottom-up heapify is O(n) and n incremental inserts is O(n log n). The difference is purely which direction you sift, because that decides whether the expensive nodes are the rare ones or the common ones.
And note what this predicts: if you have all the data up front, always heapify rather than looping heappush. And it explains why heapsort is O(n log n) overall despite the O(n) build — the n pops each cost O(log n), and that is where the log n comes from, not the construction.
Picture a tournament bracket where every match result is recorded, but nobody ranked the losers. You know the champion — top of the tree — and you know each winner beat the two players below them. You know nothing about how two eliminated players compare.
When the champion leaves, you don't re-run the tournament. You promote the last player into the empty slot and let them lose their way back down, one match per level: O(log n). That single operation is the whole data structure.
- Only element 0 is guaranteed. Everything else is partially ordered — do not read meaning into the array.
- Push and pop are O(log n); peek is O(1); build-from-array is O(n); arbitrary search is O(n) — a heap cannot find things.
- Stored as a flat array, no pointers: children of i are 2i+1 and 2i+2, parent is (i-1)//2. Contiguous, cache-friendly.
- Python's
heapqis min-only. For max-heap, push negated keys or(-priority, item)tuples — and give tuples a tiebreaker so comparison never reaches an unorderable payload.
Fire this model the moment you see: "top K" or "K largest/smallest" · a scheduler picking the next-due task · Dijkstra or A* · merging many sorted streams (log-structured merge, sorted run merge) · rate limiting by next-available time · any "always process the most urgent thing" queue.
You need the top K items out of a stream of N, where N is large. Sort everything, or maintain a size-K heap?
The heap is the default for anything streaming or distributed, and the reason is memory, not time. O(K) state per node is what lets each mapper/executor compute a local top-K and ship only K rows to the reducer — a heap is what makes top-K a cheap distributed aggregate instead of a full shuffle-and-sort.
The trap to avoid: building a size-N heap when you only need K. That is O(N) memory again and you have gained nothing over sorting. The bound must be on the heap, not on the stream — cap it at K and evict on every push.
(c) Hands-on · 25 min
Save as heaps.py. Zero deps beyond stdlib.
"""heaps.py — the interview canon for heaps + a mini Dijkstra.
Run: python heaps.py
"""
from __future__ import annotations
import heapq
from collections import Counter
from typing import Any
# ------------------------------------------------------------------
# 1) Top-K frequent — min-heap of size K
# ------------------------------------------------------------------
def top_k_frequent(nums: list[int], k: int) -> list[int]:
"""Return the K most frequent values. O(n log k) time, O(k) heap memory."""
counts = Counter(nums)
heap: list[tuple[int, int]] = [] # (freq, value); freq is the sort key
for val, freq in counts.items():
heapq.heappush(heap, (freq, val))
if len(heap) > k:
heapq.heappop(heap) # drop the least-frequent
return [v for _, v in heap]
# ------------------------------------------------------------------
# 2) K-th largest — same pattern, single number out
# ------------------------------------------------------------------
def kth_largest(nums: list[int], k: int) -> int:
heap: list[int] = []
for x in nums:
heapq.heappush(heap, x)
if len(heap) > k:
heapq.heappop(heap)
return heap[0] # k-th largest = smallest in the heap of top K
# ------------------------------------------------------------------
# 3) Streaming median — two heaps trick
# ------------------------------------------------------------------
class MedianFinder:
"""Add numbers one at a time, get median in O(log n).
Trick: two heaps.
- lower: max-heap of the smaller half (stored as negated values in Python's min-heap)
- upper: min-heap of the larger half
Invariant: len(lower) == len(upper) or len(lower) == len(upper) + 1.
"""
def __init__(self) -> None:
self.lower: list[int] = [] # max-heap (negated)
self.upper: list[int] = [] # min-heap
def add(self, num: int) -> None:
# Push into the correct half
if not self.lower or num <= -self.lower[0]:
heapq.heappush(self.lower, -num)
else:
heapq.heappush(self.upper, num)
# Rebalance so sizes differ by at most 1, and lower may be exactly one larger
if len(self.lower) > len(self.upper) + 1:
heapq.heappush(self.upper, -heapq.heappop(self.lower))
elif len(self.upper) > len(self.lower):
heapq.heappush(self.lower, -heapq.heappop(self.upper))
def median(self) -> float:
if len(self.lower) > len(self.upper):
return float(-self.lower[0])
return (-self.lower[0] + self.upper[0]) / 2
# ------------------------------------------------------------------
# 4) Merge K sorted lists — heap of head pointers
# ------------------------------------------------------------------
def merge_k_sorted(lists: list[list[int]]) -> list[int]:
"""O(n log k) where n = total elements, k = number of lists."""
heap: list[tuple[int, int, int]] = [] # (value, list_idx, item_idx)
for i, lst in enumerate(lists):
if lst:
heapq.heappush(heap, (lst[0], i, 0))
out: list[int] = []
while heap:
val, i, j = heapq.heappop(heap)
out.append(val)
if j + 1 < len(lists[i]):
heapq.heappush(heap, (lists[i][j + 1], i, j + 1))
return out
# ------------------------------------------------------------------
# 5) Dijkstra's shortest path — heap of (dist, node) with lazy deletion
# ------------------------------------------------------------------
def dijkstra(graph: dict[int, list[tuple[int, int]]], start: int) -> dict[int, float]:
"""graph: node -> list of (neighbor, edge_weight). Returns dist[node] for all reachable."""
dist: dict[int, float] = {start: 0}
heap: list[tuple[float, int]] = [(0.0, start)]
while heap:
d, u = heapq.heappop(heap)
if d > dist.get(u, float("inf")):
continue # stale entry — skip
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float("inf")):
dist[v] = nd
heapq.heappush(heap, (nd, v))
return dist
# ------------------------------------------------------------------
# 6) Event scheduler — priority queue by run-at timestamp
# ------------------------------------------------------------------
class Scheduler:
"""Minimal cron-like scheduler. Heap of (run_at, id, callback)."""
def __init__(self) -> None:
self._heap: list[tuple[float, int, Any]] = []
self._next_id = 0
def schedule(self, run_at: float, callback) -> None:
heapq.heappush(self._heap, (run_at, self._next_id, callback))
self._next_id += 1 # tie-breaker to keep tuples comparable
def next_due(self, now: float):
"""Return the next callback due by ‘now’, else None."""
if self._heap and self._heap[0][0] <= now:
return heapq.heappop(self._heap)[2]
return None
if __name__ == "__main__":
print("top_k_frequent [1,1,1,2,2,3] k=2 :", top_k_frequent([1, 1, 1, 2, 2, 3], 2))
print("kth_largest [3,2,1,5,6,4] k=2 :", kth_largest([3, 2, 1, 5, 6, 4], 2))
mf = MedianFinder()
for x in [1, 5, 2, 10, 7]:
mf.add(x)
print("median after [1,5,2,10,7]:", mf.median()) # 5
lists = [[1, 4, 7], [2, 5, 8], [3, 6, 9]]
print("merge_k_sorted :", merge_k_sorted(lists))
graph = {
1: [(2, 7), (3, 9), (6, 14)],
2: [(3, 10), (4, 15)],
3: [(4, 11), (6, 2)],
4: [(5, 6)],
5: [],
6: [(5, 9)],
}
print("dijkstra from 1 :", dijkstra(graph, 1)) # {1:0, 2:7, 3:9, 4:20, 6:11, 5:20}
sched = Scheduler()
sched.schedule(100, lambda: "task A")
sched.schedule(50, lambda: "task B")
print("next_due at t=60 :", sched.next_due(60)()) # task B (earlier)Anatomy of the script
What each function teaches
def k_closest(points: list[tuple[int, int]], k: int) -> list[tuple[int, int]]:
import heapq
heap: list[tuple[int, tuple[int, int]]] = []
for x, y in points:
d = x*x + y*y # squared distance — no sqrt
heapq.heappush(heap, (-d, (x, y))) # negate so max is at top
if len(heap) > k:
heapq.heappop(heap)
return [p for _, p in heap]
print(k_closest([(1, 3), (-2, 2), (5, 8), (0, 1)], 2)) # e.g. [(-2, 2), (0, 1)]Same pattern as top_k_frequent but with negation because we want the K smallest distances via a max-heap of size K.
(d) Production reality · 15 min
A production scheduler with 500,000 pending jobs. Naive impl: scan the whole job list every second looking for ‘next due’. That's a 500k-element scan per tick — the scheduler itself burned a full CPU core.
Naive routing engine: run BFS on the road graph. Works, but ignores that highways are much faster than side streets. Directions to a nearby suburb send you down a country lane parallel to the freeway.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three in one minute each, no notes:
- What's a heap, and what one thing does it do well?
- Why is a heap stored in an array instead of a tree with pointers?
- Give a real production system that uses a heap — and why.
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.