Search Tech Journey

Find topics, journeys and posts

6-month learning plan29 / 130
back to blog
pythonbeginner 50m read

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.

🧩DSAM03 · Data Structures & Algorithms· Session 029 of 130 90 min

🎯 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.

You will be able to
  • 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

A heap is a hospital triage queue
🌍 Real world

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.

💻 Code world

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

Why heap wins for ‘give me the next best thing’
  • 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

  1. 1964
    J. W. J. Williams invents heapsort
    First formal description of the binary heap, published in Communications of the ACM. Includes both the sort and the priority-queue abstraction.
  2. 1978
    Fibonacci 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.
  3. 1994
    Python heapq module
    Ships as part of the standard library. Uses a plain array + integer arithmetic — no classes, no allocations. Ridiculously fast.
  4. 2006
    Google's original MapReduce top-K
    Distributed top-K using per-mapper heaps of size K, then a reducer merges. The dominant streaming-analytics pattern.
  5. 2015
    Cron / Celery / Airflow all use heaps
    Every job scheduler stores pending tasks in a min-heap keyed by run_at timestamp. Peek = next job to run.
  6. 2022
    Kafka Streams windowing
    Event-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

11
Append at end of array

Preserves the ‘complete binary tree’ shape — every level filled left to right.

22
Compare with parent

parent index = (i-1) // 2. If child < parent (min-heap), swap.

33
Repeat

Continue until parent <= child, or you reach the root.

44
Cost

At most log₂(n) swaps — height of the tree.

Sift-down (pop) — replace root with last, bubble down

11
Save the root value (this is what you return)

It's the min.

22
Move the last array element to index 0

Keeps the tree complete.

33
Compare with the smaller child

children = 2i+1, 2i+2. If smallest child < parent, swap.

44
Repeat downward

Continue until both children ≥ parent, or you're at a leaf.

55
Cost

At most log₂(n) comparisons + swaps.

Top-K frequent: heap of size K beats sorting

Sort then take K

The naive way

  • O(n log n) time
  • Memory holds all n items
  • Fine for one-time offline compute
Min-heap of size K

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
Quickselect

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

1. Initialise
dist[start] = 0, dist[all others] = ∞. Push (0, start) onto heap.
init
2. Pop (d, u) from heap
u is the unvisited node with the currently-smallest tentative distance.
pop
3. Skip if stale
If d > dist[u], we already found a shorter path — ignore.
check
4. Relax outgoing edges
For each neighbor v with edge weight w: if dist[u] + w < dist[v], update dist[v] and push (new_dist, v).
relax
5. Repeat until heap empty
At the end, dist[v] is the shortest-path distance from start to every node.
loop

Common misconception
✗ What most people think

"A heap is a sorted structure. The smallest is at the top, so the array must be roughly in order."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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)
From first principles
Start with the question

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?

  1. 1
    Sift-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
  2. 2
    In 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
  3. 3
    So 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
  4. 4
    That 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
  5. 5
    Repeated 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

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.

Mental modelTournament bracket, loosely enforced

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 heapq is min-only. For max-heap, push negated keys or (-priority, item) tuples — and give tuples a tiebreaker so comparison never reaches an unorderable payload.
🔔 Fires when you see

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.

The tradeoff

You need the top K items out of a stream of N, where N is large. Sort everything, or maintain a size-K heap?

Sort all N, take first K
+ you gain trivially correct, one line, and gives you the full ranking if you later want K+1 or the median; sorting is extremely well optimised
− you pay O(N log N) time and, fatally, O(N) memory — all N must be resident at once, so it does not work on a stream at all
pick when N fits comfortably in memory and you may need more than just the top K — in practice, N in the low millions on one machine
Size-K min-heap over the stream
+ you gain O(N log K) time and only O(K) memory; works on an unbounded stream, single pass, never materialises N; K is typically tiny so log K is ~nothing
− you pay you get exactly the top K unordered-until-you-drain-them, and nothing else — no median, no rank of item K+1; and the per-element comparison overhead is worse than sort's tight inner loop when N is small
pick when N does not fit in memory, or N is far larger than K — the classic threshold being that K is a fixed small number while N grows
Quickselect (partial selection)
+ you gain expected O(N) — asymptotically better than both — and gives the top K in one in-place pass
− you pay requires all N in memory, mutates the input, has an O(N²) worst case without careful pivoting, and is not incremental
pick when all N is already in memory, K is a large fraction of N, and you will do this once — e.g. computing a percentile cutoff over a materialised batch
What a senior engineer actually does

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

top_k_frequent
Min-heap of size K. Push everything; when heap grows past K, pop the smallest. What remains = top-K. O(n log K).
top-K
kth_largest
Same shape as top-K but return just the smallest in the surviving heap.
top-K
MedianFinder
The two-heap trick: max-heap of the lower half + min-heap of the upper half. Median = top of one (odd count) or average of both tops (even count). All ops O(log n).
advanced
merge_k_sorted
Heap of one element per list — always pop the smallest, push the next from that list. O(n log k). This is how external merge-sort works.
external merge
dijkstra
Priority-queue Dijkstra with lazy deletion (‘if d > dist[u], skip’). Cleaner than the classic decrease-key version and equally correct.
graph algo
Scheduler
Heap keyed by (run_at, tiebreaker, callback). The tiebreaker matters — without it, Python tries to compare callbacks for ties and TypeErrors.
system
Try itImplement ‘K-closest points to origin’ using a max-heap of size K
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.

💡 Hint · Use squared distance (avoid sqrt). Push (-dist, point) so the FARTHEST is at the top; when the heap exceeds K, pop it. What remains = K closest. O(n log K) — much better than full-sort O(n log n) when K is small.

(d) Production reality · 15 min

War story Cron / Celery / Airflow · every schedulermillions of scheduled jobs
🔥 What broke

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.

🧯 The fix
Store jobs in a min-heap keyed by `run_at`. Peek = O(1) — instantly see if the earliest is due. Pop when due, insert new jobs in O(log n). Airflow's scheduler, Celery's beat, and Python's `sched` module all do exactly this.
🎓 Lesson to steal
Any ‘pick the earliest / latest / smallest / largest’ workload at scale is screaming for a heap. If you find yourself sorting a growing list every tick, you're building a slow priority queue by accident.
Post-mortem
War story Google Maps / Uber / any routing engine· 2019billions of route requests per day
🔥 What broke

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.

🧯 The fix
Replace BFS with Dijkstra (min-heap keyed by travel time). Then upgrade to A* (Dijkstra + geographic heuristic) for interactive latency. Real production engines (OSRM, Google's Contraction Hierarchies) precompute shortcuts, but the core is still a priority queue.
🎓 Lesson to steal
Priority queues aren't just interview theory. Every routing app, every ‘what's next’ query at scale ends up with a heap on the hot path.
Post-mortem
War story Common failure mode · every Python heap usersilent TypeError
🔥 What broke
An engineer pushes tuples like `(priority, some_object)` into a heapq. Fine — until two entries share the same priority. Python compares the second element, finds two `SomeObject` instances with no __lt__ → TypeError, mid-loop, in prod.
🧯 The fix
Always include a unique tiebreaker in the tuple: `(priority, counter, obj)`. `itertools.count()` gives you a monotonically-increasing counter for free. This one-line habit prevents an entire class of bugs.
🎓 Lesson to steal
Python's heapq compares tuples element by element — you need every element to be comparable, or your heap explodes on a rare tie.

Where this shows up in the rest of the plan

Heaps power ‘the next best thing’ everywhere
S030 · Graphs (Dijkstra, A*)
Shortest-path algorithms are heap loops.
S031 · Sorting (heapsort)
O(n log n) sort by heapifying + n pops.
S033 · DP (top-K DP states)
When the state space is huge, a beam-search heap keeps only the K best partials.
S055 · Kafka / Streams
Time-window ordering uses heaps keyed by event timestamp.
S085 · Beam Search in LLMs
Decoder-side beam search maintains a heap of the K best partial sequences.
S127 · Rate Limiting (leaky/token bucket)
Sliding-window rate limiters maintain a min-heap of expiring tokens.

(e) Recall + stretch · 10 min

Recall — click to reveal · click to reveal
★ = stretch question

Explain-out-loud test

Teach these three in one minute each, no notes:

  1. What's a heap, and what one thing does it do well?
  2. Why is a heap stored in an array instead of a tree with pointers?
  3. 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.