HANDS-ON · 75 minutes
Heaps & Priority Queues
Top-K, merge-K, and running median with heapq — grounded in the task scheduler from Brandon's Python orchestration framework.
Why Heaps Win Where Sorting Fails
When I built the task scheduler for my Python orchestration framework, I kept running into a specific problem: I needed to repeatedly pull the highest-priority job from a queue that was constantly receiving new entries. My first attempt sorted the entire queue on each insertion — and on a realistic load of a few hundred concurrent tasks, the scheduler spent more CPU time resorting the list than actually running jobs.
The correct tool is a heap. A heap gives you the minimum (or maximum) element in O(1) and accepts new elements in O(log N) — without ever sorting the full collection. Once I replaced the sort with heapq, the scheduler overhead dropped to noise.
The three patterns that cover nearly every heap problem are:
- Top-K: keep a size-K min-heap while streaming N elements — O(N log K)
- Merge-K sorted lists: use a heap to pick the globally smallest item across K iterators — O(N log K)
- Running median: maintain a max-heap of the lower half and a min-heap of the upper half — O(log N) per insert, O(1) read
The Heap Invariant and heapq
A heap is a complete binary tree with one invariant: every parent is smaller than or equal to both its children. That single rule is what makes the minimum always available at the root in O(1).
Think of it like a tournament bracket played in reverse: the winner (smallest) sits at the top, and every match below it only needs to satisfy the local parent-child relationship, not a total order across all nodes. That is why heapify runs in O(N) while sorting the same data takes O(N log N) — you only fix local violations, not global order.
Python's heapq module implements a min-heap over a plain list. You never build the tree structure yourself — the list IS the tree, with element at index i having children at 2i+1 and 2i+2.
heapq Core Operations
When this bites: heapq is NOT thread-safe. In my orchestration scheduler, I had multiple coroutines calling heappush and heappop on the same heap concurrently — which silently corrupted the heap invariant. Wrap every heap access in asyncio.Lock() (or a threading lock in multi-threaded code) if more than one coroutine or thread can touch the heap.
graph TD A["1 (root — minimum)"] B["3"] C["2"] D["4"] E["5"] F["9"] G["6"] A --> B A --> C B --> D B --> E C --> F C --> G style A fill:#4ade80,stroke:#16a34a
The green root is always the minimum. Every edge points from parent to child, and the parent is always smaller. This is the heap invariant. Notice that siblings (B and C, or D and E) have no required relationship — there is no global left-to-right order, only the local parent-child rule.
Three Patterns That Cover Almost Everything
The goal: find the K largest (or smallest) elements from a stream of N, using O(K) space.
The trick: a min-heap of size K. Push each element; when the heap exceeds K, pop the minimum. After N elements, the heap contains the K largest.
Top-K Largest Elements
Why a min-heap for the K largest? Because you want to evict the smallest element when the heap overflows — and the min-heap always puts the smallest at the root. Pop it, and you are left with the K largest seen so far.
Pattern 2: Merge K Sorted Lists
You have K sorted lists (from K database shards, K log files, K API pages) and need to merge them into one sorted stream. The naive approach — concatenate and sort — is O(N log N). The heap approach is O(N log K), which matters when K is small and N is large.
Merge K Sorted Lists
The heap always holds at most K elements — one per list. Every pop produces the next element of the merged output, and every push advances one list's pointer.
Pattern 3: Running Median
Maintain a running median as numbers stream in. The two-heap split is the classic approach: a max-heap holds the lower half of seen values, a min-heap holds the upper half. The invariant: the max-heap may be at most one element larger.
Running Median with Two Heaps
Analogy — the median as a balance point: imagine two stacks of coins. The left stack has the lower half of values, the right stack has the upper half. The median is the coin at the top of the left stack (odd total) or the average of the two top coins (even total). Adding a coin means dropping it onto the right stack first, then rebalancing the heights so neither stack is more than one taller.
Mid-Module Checkpoint
Close the code and try these from memory:
- You want the 5 most expensive log events from a stream of a million. Which heap type do you use, and what do you do when the heap grows beyond 5?
- Why is
heapifyfaster than Nheappushcalls on the same data? - In merge-K sorted lists, what does each element in the heap represent?
- In the running-median two-heap pattern, what does it mean when the max-heap top is larger than the min-heap top?
Reflective prompt: The task scheduler in my orchestration framework uses a heap for priority, but priorities can change dynamically — a task's urgency might increase after it has already been pushed. heapq has no "update priority" operation. How would you handle this? Think about the trade-offs between marking old entries as invalid and rebuilding, vs using a different data structure entirely. There is no single right answer — this is a real design decision in real schedulers.
Test Your Understanding
Application-level questions on heap mechanics and the three core patterns
1. Python's heapq is always a min-heap. You want the top-3 largest elements from a stream of a million numbers. Which approach has the best time and space complexity?
- A)Sort the full stream and take the last 3
- B)Keep a size-3 min-heap; pop when size exceeds 3
- C)Use heapq.nlargest(3, stream) after collecting all elements
- D)Build a max-heap of all N elements and pop 3 times
Show Answer
Correct Answer: B
A size-K min-heap processes each element in O(log K) and stores only K elements — O(N log K) total, O(K) space. Sorting is O(N log N) with O(N) space. nlargest collects all elements first before sorting; the bounded min-heap is the right pattern for a true stream.
2. You push (-3, 'c'), (-1, 'a'), (-2, 'b') onto an empty heapq heap and then call heappop(). What do you get?
- A)(-1, 'a')
- B)(-3, 'c')
- C)(-2, 'b')
- D)(3, 'c') — heapq un-negates automatically
Show Answer
Correct Answer: B
heapq is a min-heap: -3 < -2 < -1, so (-3, 'c') is the root. Popping returns (-3, 'c'), which represents original value 3 — the largest. You negate on push, negate the result on pop.
3. You are merging K sorted lists with N total elements using a min-heap. What is the time complexity?
- A)O(N log N)
- B)O(N log K)
- C)O(K log N)
- D)O(N * K)
Show Answer
Correct Answer: B
The heap holds at most K elements (one per list). Each of the N pops costs O(log K). Total: O(N log K). This beats sorting all elements together (O(N log N)) when K is much smaller than N.
4. In the two-heap running-median pattern, what invariant must hold after each insertion?
- A)Both heaps must always be the same size
- B)The max-heap (lower half) may hold at most one more element than the min-heap (upper half)
- C)The min-heap (upper half) must always hold the median
- D)The two heaps are rebalanced by sorting every 10 insertions
Show Answer
Correct Answer: B
The lower max-heap may have at most one more element. The median is either the max-heap top (odd total) or the average of both tops (even). Re-balance by moving the max-heap top to the min-heap when sizes diverge by more than one.
5. A problem: after each integer inserted into a growing collection, return the current median. Which approach is correct?
- A)Sorted list with bisect.insort — O(log N) search but O(N) insert due to shifting
- B)Two heaps (max-heap + min-heap) — O(log N) insert, O(1) median
- C)Full sort after each insert — O(N log N) insert
- D)A Counter sorted by value — O(N) median read
Show Answer
Correct Answer: B
Two heaps give O(log N) per insert and O(1) median. bisect.insort is O(N) insert because shifting a list is O(N). Full sort is always worse for a stream.
6. You have a list of 10,000 integers. Which is faster: heapq.heapify(lst) or pushing each element with heapq.heappush?
- A)Both are O(N log N) — no difference
- B)heapify is O(N); N pushes is O(N log N) — heapify wins
- C)N pushes is O(N); heapify is O(N log N) — pushes win
- D)heapify is O(log N); the rest depends on cache
Show Answer
Correct Answer: B
heapq.heapify uses a bottom-up sift-down and runs in O(N) time. N successive pushes each cost O(log N), totalling O(N log N). When you have all the data upfront, always heapify.
7. A job scheduler must always run the highest-priority pending task. Tasks arrive dynamically. Which is the right data structure?
- A)A sorted list — O(1) read, O(N) insert
- B)A heap — O(log N) insert, O(1) peek, O(log N) pop
- C)A deque — O(1) append and popleft
- D)A Counter — O(1) most_common
Show Answer
Correct Answer: B
A heap gives O(log N) insert and O(log N) pop while keeping the minimum (or maximum with negation) at the root in O(1). A sorted list insert is O(N) due to element shifting. A deque has no priority ordering.
Build It Yourself
Problem: Given an integer array nums and an integer k, return the kth largest element in the array. You must solve it in O(N log K) time — do not sort the full array.
Starter
Hint 1: The kth largest is the smallest element among the top-K largest. Think about which heap type keeps the smallest of the top-K at its root.
Hint 2: Maintain a min-heap of exactly K elements. Push each number, and if the heap grows larger than K, pop the minimum. After processing all numbers, the root is your answer.