01 ยท DSA โ file 06
Heaps, Tries & Advanced Patterns
Heaps answer "give me the best one, right now, repeatedly" โ the shape of schedulers, top-K dashboards, and merge pipelines. Tries answer "search by prefix" โ the shape of autocomplete and routing tables. Plus two patterns (intervals, binary-search-on-answer) that punch far above their weight in interviews.
01Heaps / priority queues
The problem: cheap repeated access to the extreme
You have a stream of items and repeatedly want the smallest (or largest). Options:
Sorted list
Peek O(1), but insert O(n). Over-engineered: you pay to know the full order when you only ask for the front.
Unsorted list
Insert O(1), but extract-min O(n) โ scan everything every time.
Heap
Insert O(log n), extract-min O(log n), peek O(1) โ the sweet spot, via partial order only.
The first-principles insight: a heap is deliberate laziness. Total order costs n log n; "front of the line" order costs log n per op. The heap maintains exactly as much order as the question requires and not one comparison more.
The structure
A min-heap is a complete binary tree (every level full, last level packed left) with one rule โ the heap property: every parent โค its children. Nothing about siblings, nothing about global order. Just: the root is the minimum.
Completeness buys the killer implementation detail: store it in a plain array, no pointers.
[1] array: [1, 3, 2, 7, 4, 5]
/ \ 0 1 2 3 4 5
[3] [2]
/ \ / children of i: 2i+1, 2i+2
[7] [4] [5] parent of i: (i-1) // 2
Insert (sift up): append at the end (keeps completeness), then swap upward while smaller than the parent โ O(height) = O(log n).
Extract-min (sift down): take the root; move the last element to the root (completeness again); swap downward with the smaller child while violating โ O(log n).
extract-min: [1] leaves โ last elem [5] jumps to root โ sifts down
[5] [2]
/ \ โโโบ / \
[3] [2] [3] [5]
Heapify: building a heap in O(n), not O(n log n)
Pushing n items one by one is O(n log n). Heapify is smarter: start from the last non-leaf node and sift down, walking backwards to the root. Why it's O(n): half the nodes are leaves (sift distance 0), a quarter sift โค 1, an eighth sift โค 2โฆ the sum ฮฃ n/2k ยท k converges to ~2n. Most nodes are near the bottom with nowhere to go โ that's the whole trick, and it's why heapq.heapify(list) is O(n).
Python heapq โ the working API
import heapq
h = [5, 1, 4]
heapq.heapify(h) # O(n), in place; h[0] is now the min
heapq.heappush(h, 2) # O(log n)
smallest = heapq.heappop(h) # O(log n)
peek = h[0] # O(1) โ just index, don't pop
heapq.nsmallest(3, data, key=len) # convenience top-K
heapq.heappushpop(h, x) # push then pop โ one sift, ideal for top-K loops
The three gotchas everyone hits:
1. Min-heap only. For a max-heap, negate: push -x, negate on pop.
2. Tuples compare element-wise โ (priority, item) sorts by priority first. If items can tie on priority and aren't comparable (e.g., dicts), add a tiebreaker: (priority, counter, item).
3. No decrease-key. Use lazy deletion: push the updated entry, skip stale ones when popped (exactly as in Dijkstra, file 05 ยง6).
Backend anchor: priority job queues (process priority: high first), the Kubernetes scheduler's queue, timer wheels ("next timeout to fire" = extract-min on deadline), and rate limiters that track the next token refill. Anywhere you'd say "whichever is most urgent next."
02Heap patterns: Top-K, two heaps, k-way merge
Top-K โ and the counterintuitive heap choice
"K largest elements from n items (or a stream)." Sorting is O(n log n) and needs all data in memory. The heap way: keep a MIN-heap of size k โ yes, min โ for the k largest.
The bouncer intuition. The size-k min-heap holds the current top-k, and its root is the weakest member โ the bouncer at the door of the club. A newcomer never has to fight the champions inside; it only has to beat the bouncer. Beats it โ the bouncer is thrown out (pop root), newcomer walks in (push). Doesn't โ rejected at the door, O(1). You only ever compare against the weakest of the best, never the strongest.
def top_k(nums, k):
h = nums[:k]
heapq.heapify(h)
for x in nums[k:]:
if x > h[0]:
heapq.heapreplace(h, x) # evict the weakest, admit x
return h # the k largest, O(n log k) time, O(k) space
O(n log k) beats O(n log n) when k โช n, and it streams โ you never hold more than k items. That's "top 10 endpoints by error rate over a firehose of events" โ a streaming top-K, which is why interviewers love it.
Family members: Kth Largest Element, K Closest Points (key = distance), Top K Frequent (Counter โ heap on counts), Kth Largest in a Stream (persist the size-k heap between calls).
Two heaps (Find Median from Data Stream)
Median = the boundary between the lower half and upper half. So maintain both halves: a max-heap of the lower half and a min-heap of the upper half, sizes balanced within 1. Median = a root (or the mean of both roots). Insert O(log n), median O(1). This "straddle a boundary with opposing heaps" idea generalizes โ IQR tracking, sliding-window medians.
K-way merge
Merge k sorted lists (Merge K Sorted Lists, Smallest Range Covering K Lists): heap of size k holding each list's current head; pop the global min, push that list's next element. O(N log k) for N total elements. This is literally how LSM-tree compaction (RocksDB/Cassandra) and external merge sort work โ say so.
03Tries (prefix trees)
The problem: hashmaps can't do prefixes
A hashmap answers "is cat a key?" in O(1) โ but "list every key starting with ca" requires scanning all keys, because hashing deliberately scatters similar keys apart. When the query is prefix-shaped โ autocomplete, typeahead, spell-check, IP longest-prefix routing โ you want similar keys stored together.
A trie stores strings as paths from the root, one character per edge, so all words sharing a prefix share a path. The prefix ca is a place in the tree; everything below it is exactly the set of completions.
class TrieNode:
__slots__ = ("children", "is_word")
def __init__(self):
self.children = {} # char โ TrieNode
self.is_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
node = node.children.setdefault(ch, TrieNode())
node.is_word = True
def _walk(self, s):
node = self.root
for ch in s:
node = node.children.get(ch)
if node is None:
return None
return node
def search(self, word): # exact word
node = self._walk(word)
return node is not None and node.is_word
def starts_with(self, prefix): # any word with this prefix?
return self._walk(prefix) is not None
Costs โ the headline: insert / search / prefix are O(L) where L is the word's length โ independent of how many words are stored. A million words or a billion: looking up "card" is 4 steps. Autocomplete = walk the prefix in O(L), then DFS the subtree to collect completions.
Trade-off: memory. A node per character plus a dict per node is heavy; production systems compress (radix trees โ merge single-child chains โ as in Linux routing tables and some router FIBs; DAWGs; or just sorted arrays + binary search when data is static).
Interview appearances: Implement Trie (above, verbatim), Design Add-and-Search Word (. wildcard โ DFS branching over all children at that node), Word Search II (trie of the dictionary guides the grid backtracking โ prune the moment the path isn't a prefix of anything), Longest Common Prefix.
04Pattern: intervals
Interval problems (meetings, bookings, on-call schedules, IP ranges, maintenance windows) all yield to one opening move: sort by start time. After sorting, only adjacent relationships matter, and everything becomes a single sweep.
The overlap test โ two intervals overlap iff each starts before the other ends:
a: โโโโโโโโค overlap(a, b) โ a.start < b.end and b.start < a.end
b: โโโโโโโโค (after sorting by start, just: b.start < a.end)
Merge Intervals โ the canonical one:
def merge(intervals):
intervals.sort(key=lambda iv: iv[0])
out = []
for start, end in intervals:
if out and start <= out[-1][1]: # overlaps the last merged one
out[-1][1] = max(out[-1][1], end) # extend (careful: max, not end!)
else:
out.append([start, end])
return out
The classic bug site: extending with end instead of max(prev_end, end). An interval can be swallowed whole โ [1,10], [2,3] โ and writing end would shrink the merged interval from 10 back to 3, corrupting everything after it.
Meeting Rooms II ("minimum rooms needed") โ the interval/heap crossover: sort by start; min-heap of end times = rooms in use; for each meeting, if the earliest-ending room is free (heap[0] <= start), reuse it (pop), always push your end; answer = max heap size. Equivalently: sweep +1/โ1 events and track the running maximum โ max concurrency. That's the same computation as "peak concurrent connections" from access logs โ say so.
Family: Insert Interval, Non-overlapping Intervals (min removals โ greedy: sort by end, keep earliest-ending), Employee Free Time.
05Pattern: binary search on the answer
The most disguised pattern in interviews. Binary search doesn't need an array โ it needs a monotonic yes/no question (full treatment of the template in file 08).
Recognition: the problem asks to "minimize the maximum" or "maximize the minimum" of something โ and checking whether a given candidate value works is easy.
The reframe: instead of computing the answer directly, ask "could the answer be โค x?" If feasibility is monotone (works for x โ works for all larger x), binary search x over its range, paying one O(n) feasibility check per probe.
Koko Eating Bananas (min speed to finish piles in h hours):
import math
def min_eating_speed(piles, h):
def feasible(k): # can speed k finish in time?
return sum(math.ceil(p / k) for p in piles) <= h
lo, hi = 1, max(piles)
while lo < hi:
mid = (lo + hi) // 2
if feasible(mid):
hi = mid # mid works โ try slower
else:
lo = mid + 1 # too slow โ must speed up
return lo # smallest feasible speed
speed: 1 2 3 4 5 6 7 8
feasible: N N N Y Y Y Y Y
โฒ
binary search finds the NโY boundary
O(n log(max_pile)). Family: Split Array Largest Sum / Capacity to Ship Packages in D Days (check: greedily pack under limit x, count chunks), Minimum Days to Make Bouquets, Aggressive Cows / Magnetic Force (maximize-the-minimum flavor โ flip the branches).
Backend anchor: this is capacity planning by bisection โ "what's the minimum number of workers so p99 queue latency stays under SLO?" โ when you can simulate a candidate but not derive the optimum. Same move as git bisect: monotone predicate, halve the range.
06Choosing your tool โ quick table
| Signal in the problem | Reach for |
|---|---|
| "K largest / smallest / closest / most frequent" | heap of size k (opposite polarity!) |
| "Median / percentile of a stream" | two heaps straddling the boundary |
| "Merge k sorted โฆ" | heap of k heads |
| Repeated "most urgent next" | priority queue |
| "Prefix / starts with / autocomplete" | trie |
| Many words to match against a grid/text | trie + DFS |
| Overlapping ranges / meetings / bookings | sort by start + sweep (ยฑ heap of ends) |
| "Minimize the max / maximize the min", feasibility easy to check | binary search on the answer |
07Common interview questions
Q1. Why is heapify O(n) when n pushes are O(n log n)?
Sift-down from the bottom: half the nodes are leaves and move 0; cost ฮฃ nยทk/2k โ 2n. Pushes sift up, where most nodes are far from the root.
Q2. Kth largest element โ approaches and costs?
Sort O(n log n); min-heap of size k O(n log k); quickselect average O(n) (worst O(nยฒ) โ mention it, rarely required to implement).
Q3. Why a MIN-heap for top-K LARGEST?
The root is the weakest of the current top-k โ the eviction candidate, the bouncer at the door. You only ever need to compare newcomers against the weakest, never the strongest.
Q4. Max-heap in Python?
Negate values (or negate the key in a tuple). heapq is min-only.
Q5. Heap vs BST โ why not always a BST?
BST gives full order (O(log n) search, predecessor, range) but needs balancing machinery and pointers; heap gives only min/max but is array-backed, cache-friendly, simpler, with O(n) build. Buy only the order you need.
Q6. Trie vs hashmap for a dictionary?
Hashmap: O(1) exact lookup, no prefix ops. Trie: O(L) lookup plus prefix enumeration, ordered iteration, shared-prefix compression. Prefix queries โ trie.
Q7. Why does a trie node need an end-of-word flag?
Prefixes of stored words have nodes too ("car" inside "card"); the flag distinguishes "word ends here" from "path passes through."
Q8. Merge Intervals โ the subtle bug?
Extending with end instead of max(prev_end, end); fully-contained intervals then corrupt the result.
Q9. Meeting Rooms II in one sentence?
Sort by start; min-heap of end times; reuse the earliest-ending room when free; answer is the heap's max size (= peak concurrency).
Q10. How do you spot binary-search-on-answer?
"Minimize the maximum / maximize the minimum," a numeric answer with a known range, and a cheap monotone feasibility check.
08Self-test
10 questions โ answer out loud before peeking
- Draw the array
[1, 3, 2, 7, 4, 5]as a heap. Where are the children of index i? Now extract-min step by step. - Why does completeness matter for the array encoding? What breaks without it?
- Prove (hand-wavily, as to an interviewer) that heapify is O(n).
- Write streaming top-K largest with heapq from memory. Time, space, and why the heap polarity is "backwards."
- Median from a data stream: which heap holds which half, and what are the two rebalancing cases?
- Write trie insert + prefix search from memory. What's the complexity, and what is it independent of?
- Autocomplete with a trie: describe the two phases and their costs.
- Merge Intervals: the opening move, the overlap condition after sorting, and the
max()bug. - Koko Eating Bananas: what's the search space, the predicate, and the invariant of
lo/hiat termination? - Name three production systems (any stack) that are structurally a heap, a trie, and a k-way merge respectively.
Spot-checks
- Children 2i+1 / 2i+2; pop 1, move 5 to root, sift down (swap with 2, then check the heap property at each level).
- Array slots map to a complete tree with no gaps; holes would break the index arithmetic.
- Leaves sift 0, level above โค 1โฆ; ฮฃ nยทk/2k converges to 2n.
- Heapify first k, then heapreplace when x > h[0]; O(n log k), O(k); root = weakest member = only comparison needed.
- Max-heap lower half, min-heap upper; rebalance when sizes differ by 2, or when a new element lands on the wrong side of the roots.
- O(L) per op โ independent of the number of stored words.
- Walk prefix O(L), then DFS subtree O(size of result set).
- Sort by start; overlap: next.start โค current.end; containment needs max.
- Speeds 1..max(piles); feasible(k) monotone; loop ends with lo == hi == first feasible.
- Priority job scheduler / timer wheel (heap); router longest-prefix table or autocomplete index (trie); LSM compaction or external sort (k-way merge).