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]

Heap sift stepper โ€” the array and the tree are the same object

Push the scripted values 7, 4, 9, 1, 5, 2 (in order) or pop the min, then Step through each comparison. Green = the node being sifted, amber = the node it's compared against. Watch the array boxes and the tree move in lockstep โ€” same indices, same values.

parent = (i-1)//2  ยท  children = 2i+1, 2i+2

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.

root c d a o t r g d cat โœ“ car โœ“ dog โœ“ card โœ“ = end-of-word flag
Insert cat, car, card, dog. "car" is a word AND a prefix of "card" โ€” that's why the end-of-word flag is needed; node existence alone can't distinguish them. All completions of "ca" live below one node.
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 problemReach 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/texttrie + DFS
Overlapping ranges / meetings / bookingssort by start + sweep (ยฑ heap of ends)
"Minimize the max / maximize the min", feasibility easy to checkbinary 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
  1. 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.
  2. Why does completeness matter for the array encoding? What breaks without it?
  3. Prove (hand-wavily, as to an interviewer) that heapify is O(n).
  4. Write streaming top-K largest with heapq from memory. Time, space, and why the heap polarity is "backwards."
  5. Median from a data stream: which heap holds which half, and what are the two rebalancing cases?
  6. Write trie insert + prefix search from memory. What's the complexity, and what is it independent of?
  7. Autocomplete with a trie: describe the two phases and their costs.
  8. Merge Intervals: the opening move, the overlap condition after sorting, and the max() bug.
  9. Koko Eating Bananas: what's the search space, the predicate, and the invariant of lo/hi at termination?
  10. Name three production systems (any stack) that are structurally a heap, a trie, and a k-way merge respectively.
Spot-checks
  1. Children 2i+1 / 2i+2; pop 1, move 5 to root, sift down (swap with 2, then check the heap property at each level).
  2. Array slots map to a complete tree with no gaps; holes would break the index arithmetic.
  3. Leaves sift 0, level above โ‰ค 1โ€ฆ; ฮฃ nยทk/2k converges to 2n.
  4. Heapify first k, then heapreplace when x > h[0]; O(n log k), O(k); root = weakest member = only comparison needed.
  5. 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.
  6. O(L) per op โ€” independent of the number of stored words.
  7. Walk prefix O(L), then DFS subtree O(size of result set).
  8. Sort by start; overlap: next.start โ‰ค current.end; containment needs max.
  9. Speeds 1..max(piles); feasible(k) monotone; loop ends with lo == hi == first feasible.
  10. Priority job scheduler / timer wheel (heap); router longest-prefix table or autocomplete index (trie); LSM compaction or external sort (k-way merge).