Conceptual Foundation & Intuitive Analogy
🧭 Conceptual Foundation & Pattern Intuition
Sorting an entire array of size requires time. However, when we only need the -th extreme element (largest or smallest), full sorting performs massive amounts of redundant work ordering the remaining elements. A Binary Heap dynamically maintains a partial ordering where the parent node is always smaller than or equal to its children (in a min-heap). By capping a min-heap to capacity , we can stream elements through the heap: whenever size exceeds , we pop the minimum element. The survivor at the root after elements is mathematically guaranteed to be the -th largest element in time and space.
🏟️ The Analogy: A VIP Club with Only K Seats
Imagine a luxury VIP lounge that has exactly seats reserved for the wealthiest patrons. As guests arrive at the door:
- The first guests enter and take seats immediately.
- The lounge manager constantly keeps the poorest person among the current VIPs sitting right next to the exit door (this is the min-heap root).
- When a new guest arrives, if they have more money than the person by the door, the poorest guest must leave the lounge, and the new guest sits down.
- If a guest arrives with less money than the person by the door, they are turned away immediately.
When the entire line of guests has passed, who is sitting by the door? The poorest person inside the lounge — which is exactly the -th wealthiest person in the city!
🔑 Why It Works
The part that surprises most people: to find the largest elements, you use a min-heap, not a max-heap! The min-heap's root exposes the smallest among the top , making it trivial to identify and evict the weakest survivor in time.
My heap holds the best k items seen so far, and the item at its top is the current cut-off — the bar a new item must beat to get in.
Heap maintains extreme element at root heap[0]. heappushpop maintains size bounded to K in O(log K) time.
heapq.heappush(h, val); if len(h) > k: heapq.heappop(h) — peek min/max in O(1).
The 2 Mental Models of Heap & Priority Queue
Select heap structure based on stream processing and capacity constraints.
Quickselect (Kth Largest Element)
| 1 | import random |
| 2 | |
| 3 | |
| 4 | def find_kth_largest(nums: list[int], k: int) -> int: |
| 5 | """Kth Largest Element in an Array: quickselect with a random pivot and a three-way partition. |
| 6 | Time: O(N) average, O(N^2) worst case | Space: O(1) |
| 7 | """ |
| 8 | target = len(nums) - k # The k-th largest sits at index n - k once sorted ascending |
| 9 | lo, hi = 0, len(nums) - 1 # target always stays inside nums[lo..hi] |
| 10 | |
| 11 | while True: |
| 12 | # 1. Pick a Random Pivot: |
| 13 | # A fixed nums[hi] is always the largest value on a sorted input, so a random pick means no input can force the worst case. |
| 14 | pivot = nums[random.randint(lo, hi)] # Random pivot: no fixed input can force the worst case |
| 15 | lt, i, gt = lo, lo, hi # < pivot: nums[lo..lt-1] | == pivot: nums[lt..i-1] | > pivot: nums[gt+1..hi] |
| 16 | |
| 17 | # 2. Three-Way Partition Around the Pivot: |
| 18 | # Every value lands in exactly one of three blocks; a value equal to the pivot only moves i, since it already sits in the middle block. |
| 19 | while i <= gt: |
| 20 | if nums[i] < pivot: |
| 21 | nums[lt], nums[i] = nums[i], nums[lt] # Grow the smaller block |
| 22 | lt += 1 |
| 23 | i += 1 |
| 24 | elif nums[i] > pivot: |
| 25 | nums[i], nums[gt] = nums[gt], nums[i] # Grow the larger block |
| 26 | gt -= 1 # The value swapped into nums[i] is unread: i stays |
| 27 | else: |
| 28 | i += 1 # Equal to pivot: it stays in the middle block |
| 29 | |
| 30 | # 3. Keep Only the Side That Can Hold target: |
| 31 | # The partition put every copy of pivot exactly where sorting would, so target's rank decides which single side to keep. |
| 32 | if target < lt: |
| 33 | hi = lt - 1 # The answer is among the smaller values |
| 34 | elif target > gt: |
| 35 | lo = gt + 1 # The answer is among the larger values |
| 36 | else: |
| 37 | return pivot # nums[lt..gt] all equal pivot, and target is one of them |
Quickselect finds the value that would sit at index target = len(nums) - k if nums were sorted, without sorting it: pick a pivot, partition nums[lo..hi] into the values smaller than pivot (nums[lo..lt-1]), equal to it (nums[lt..gt]) and larger (nums[gt+1..hi]), then keep only the block that holds target. This is correct because a partition puts every copy of pivot exactly where sorting would: nothing before lt is larger and nothing after gt is smaller. So if lt <= target <= gt the answer is pivot; otherwise the answer's rank is decided inside one side alone, and hi = lt - 1 or lo = gt + 1 throws the other side away for good. The trap is the pivot choice: a fixed nums[hi] (or nums[lo]) is always the largest (or smallest) value of a sorted input, so while target lies below it each pass removes a single value: asking a sorted input for its smallest value (k = n) rescans almost the whole array n times; pivot = nums[random.randint(lo, hi)] means no input can force that.
Gotcha 1 of 4
- The trap
Gotcha 1: Using the fixed pivot
nums[hi]instead of a random oneMistakepivot = nums[hi]Fixpivot = nums[random.randint(lo, hi)]What happens: On a sorted input,
nums[hi]is always the largest value. Iftargetlies below it, each pass removes only that one value. With 10^5 values andk = 105, the passes scan about 5 * 10^9 values in total. The answer stays right, so this mistake has no example. A random pivot means no input can force this.
2Top-K Heap
stream of N elements ➔ keep size-K min-heap ➔ O(N log K) filteringTop K Elements (Min-Heap of Size K)
| 1 | import heapq |
| 2 | import itertools |
| 3 | from collections import Counter |
| 4 | from typing import Callable, Iterable, List, Optional, Tuple, TypeVar |
| 5 | |
| 6 | Item = TypeVar("Item") |
| 7 | |
| 8 | _arrivals = itertools.count() # arrival numbers for keep_k, shared by every call |
| 9 | |
| 10 | |
| 11 | def keep_k(items: Iterable[Item], k: int, key: Callable[[Item], float], min_heap: Optional[List[Tuple[float, int, Item]]] = None) -> List[Item]: |
| 12 | """Top-K Heap: the k items with the largest key(item), in any order. |
| 13 | The heap is a min-heap, so its root is the weakest item kept and the first one to evict. |
| 14 | For a stream (Kth Largest Element in a Stream), pass the same min_heap list on every call with the new items. |
| 15 | Then min_heap[0][2] is the k-th largest item seen so far. |
| 16 | Precondition: k >= 1. For the k smallest keys, pass the negated key. |
| 17 | Maximum Subsequence Score needs the sum of the kept keys at each step, so it keeps its own running sum. |
| 18 | N = the items. T = the cost of one key call. |
| 19 | Time: O(N * (T + log K)) | Space: O(K) heap |
| 20 | """ |
| 21 | if min_heap is None: |
| 22 | min_heap = [] # Min-heap of (key, arrival, item); the root is the weakest item kept |
| 23 | |
| 24 | for item in items: |
| 25 | # 1. Push Every Candidate: |
| 26 | # Each entry is (key, arrival, item); arrival is a number never used before, so two items with an equal key are never compared. |
| 27 | heapq.heappush(min_heap, (key(item), next(_arrivals), item)) |
| 28 | |
| 29 | # 2. Evict the Weakest When Over Capacity: |
| 30 | # A popped item is no bigger than k others already seen, so it can never belong in the final top k. |
| 31 | if len(min_heap) > k: |
| 32 | heapq.heappop(min_heap) |
| 33 | |
| 34 | # 3. Return the Kept Items: |
| 35 | # Every item still in min_heap is one of the k biggest keys seen, in no particular order. |
| 36 | return [item for _, _, item in min_heap] |
To keep the k biggest items, keep a min-heap of size k: push each (count, value) pair, and when min_heap grows past k, pop its root, the weakest item kept. A popped item is no bigger than k others already seen, so it can never be in the top k, and after the last push min_heap holds exactly the k biggest, with the k-th biggest at the root. The trap is the heap direction: the k largest need a min-heap and the k smallest a max-heap, because the root must be the item you evict next.
Gotcha 1 of 3
- The trap
Gotcha 1: Keeping the
klargest in a max-heapMistakeheapq.heappush(min_heap, (-count, value))Fixheapq.heappush(min_heap, (count, value))What happens: Python's
heapqis a min-heap only. Pushing-countturns it into a max-heap, so the root is the most frequent item. When the heap grows pastk,heappopthen removes the most frequent item. The heap ends with thekleast frequent values. To keep theklargest, the root must be the smallest item kept, so use a min-heap.Example: Top K Frequent Elements (LC 347): nums = [1, 1, 1, 2, 2, 3], k = 2Mistake: returns [2, 3]Fix: returns [1, 2]
Built on the mental models above
- K-Way MergeK-Way Merge
The heap holds one entry per sorted source, and each pop pushes the next entry of the same source.
- Best-First HeapBest-First Heap Frontier (Kth Smallest in a Sorted Matrix)
The next entry is built from the popped entry (the next cell of its row) instead of being read from a list.
- Most Frequent FirstGreedy Scheduling with a Heap (Reorganize String / Task Scheduler)
A cooldown buffer holds the item just used until it may be used again.
- Two HeapsTwo Heaps (Running Median)
A second heap holds the other half of the values, and the two sizes stay within 1 of each other.
- Greedy with RegretGreedy with Regret (Heap-Based Undo)
Every item is taken first, and the most expensive taken item is removed when the limit is broken.
- Ready HeapEvent Simulation with a Ready Heap (Single-Threaded CPU, Task Scheduling)
A clock decides which items have arrived, and only arrived items are in the heap.
The Heap & Priority Queue Decision Rule
When facing an unseen problem, ask in this order:
Use a size-K min-heap for K largest (or max-heap for K smallest) in O(N log K) time.
Partition around a random pivot and continue only into the side that holds rank k: O(N) on average.
Maintain a size-K heap storing current head pointers from each list.
Seed the heap with the first cell of each of the first min(n, k) rows; each pop pushes only the next cell of its row, so the top is always the smallest unused value.
Maintain lower max-heap and upper min-heap with size balance.
Pop most frequent, place, decrement, and re-enqueue after cooldown.
Take each item greedily and push it on a heap; when the deadline or budget breaks, undo the earlier choice whose removal helps most (the longest course, or the smallest climb moved from a ladder to bricks).
Sort by arrival, release every arrived item into a heap keyed by the choosing rule, pop the best, and jump the clock to the next arrival when the heap is empty.
Pattern Architecture & Invariant Design Masterclass
A heap is a container with exactly one promise: you can always see and remove the smallest item in it, cheaply, no matter how many items are inside. Adding costs O(log n); removing the smallest costs O(log n); looking at the smallest costs O(1). Nothing else is fast — you cannot ask a heap for the second-smallest, cannot search it, cannot index it. It is deliberately a narrow tool.
That narrowness is the point. Reach for a heap when the question has this shape: "I keep receiving items, and at every moment I need the best-so-far, but I do not need the whole thing sorted." Sorting gives you far more than you asked for and charges O(n log n) for it. A heap charges only for what you use.
The Admission Threshold Invariant (Top-K Inversion)
The counter-intuitive move that makes top-K problems work is the Admission Threshold Invariant:
To keep the
klargest items, maintain a min-heap of sizek.
The root of a min-heap (heap[0]) is the smallest of the topksurvivors seen so far. It represents the minimum qualification bar: any newly arriving element only earns a spot in the topkif it strictly beats the root (x > heap[0]). If it does, evicting the root removes the weakest candidate, and pushingxrestores the threshold in .
Conversely, to keep theksmallest items, maintain a max-heap of sizekwhere the root acts as the ceiling threshold.
🔬 Variable Anatomy: The 7 Variables Every Heap / Priority Queue Uses
To connect abstract priority queue mechanics with concrete Python syntax, here is the universal code anatomy showing what every variable does:
import heapqimport itertoolsfrom collections import Counter def heapMasterTemplate( items: list[int], k: int) -> tuple[list[int], float]: # ========================================================================= # 1. THE STORAGE & HEAP BUFFERS # ========================================================================= heap = [] # 🧵 Primary Bounded Heap (stores (priority_key, payload)) small = [] # 📉 Max-Heap (stores negated values for lower half in median) large = [] # 📈 Min-Heap (stores positive values for upper half in median) counter = itertools.count() # 🔢 Monotonic Unique Tie-Breaker (prevents payload comparison errors) # ========================================================================= # 2. BOUNDED TOP-K SURVIVOR SELECTION PIPELINE (O(N log K)) # ========================================================================= # Count frequencies: items -> (count, val) freq_map = Counter(items) for val, freq in freq_map.items(): # Push (priority_key, tie_breaker, payload) tuple heapq.heappush(heap, (freq, next(counter), val)) # ⚠️ CRITICAL: Evict the weakest survivor when size exceeds k if len(heap) > k: heapq.heappop(heap) # Discards the minimum frequency among current Top-K top_k_survivors = [val for _freq, _idx, val in heap] # ========================================================================= # 3. DUAL-HEAP STREAMING BALANCING (O(log N) insert, O(1) median) # ========================================================================= for num in items: # Step 1: Always push negated to small (max-heap) heapq.heappush(small, -num) # Step 2: Route max of small to large (min-heap) to enforce small <= large heapq.heappush(large, -heapq.heappop(small)) # Step 3: Rebalance sizes so len(small) >= len(large) if len(large) > len(small): heapq.heappush(small, -heapq.heappop(large)) # Calculate O(1) median median = float(-small[0]) if len(small) > len(large) else (-small[0] + large[0]) / 2.0 return top_k_survivors, median Variable Role Reference:
| Variable | Role in Code | Question It Answers | ❌ What Happens If Omitted or Mismanaged? |
|---|---|---|---|
heap | Python list with heapq | "What is the active working set of candidates or survivors?" | ❌ Without a heap, finding extremums continuously requires sorting or repeated linear scans. |
k | Capacity bound integer | "How many top survivors am I strictly permitted to keep?" | ❌ Without enforcing len(heap) > k: heappop(), memory grows to and time degrades to . |
priority_key | First tuple element | "What exact quantitative metric determines relative priority?" | ❌ In Python, tuple heaps compare index 0 first. Putting payload before key sorts by payload instead of priority! |
counter | itertools.count() | "How do we break ties when two items share identical priority keys?" | ❌ When priority keys tie and payloads are unorderable objects, Python raises TypeError: '<' not supported. |
small | Max-Heap (negated list) | "Which elements represent the smaller 50% of the stream?" | ❌ Forgetting to negate values when pushing to small converts it into a min-heap, corrupting streaming median splits. |
large | Min-Heap list | "Which elements represent the larger 50% of the stream?" | ❌ Without rebalancing sizes between small and large, median lookups read from the wrong half. |
cooldown | Temporary holding tuple | "Which high-frequency item is resting until adjacent slots pass?" | ❌ In greedy reorganization (LC 767), without a holding buffer, the highest frequency item is repicked immediately. |
🧠 The Heap is NOT a Sorted Array:
heap[0]is guaranteed minimal in , but iteratingheap[1..]is NOT in sorted order. If sorted output is required, sort the final elements in .
🌟 The 3 Dimensions of Heap / Priority Queue & The "Weakest Survivor" Principle
Every Heap / Priority Queue algorithm is defined by 3 Key Dimensions:
┌─────────────────────────────────────────────────────────────────────────────┐│ THE 3 DIMENSIONS OF HEAP & PRIORITY QUEUES │├───────────────────┬───────────────────────────────────┬─────────────────────┤│ Dimension │ Mechanism in Code │ Question It Answers │├───────────────────┼───────────────────────────────────┼─────────────────────┤│ 1. CAPACITY BOUND │ `len(heap) > k: heappop()` │ "Is the working set ││ (Memory Cap) │ Bounded size-K vs Unbounded size-N│ memory bounded?" │├───────────────────┼───────────────────────────────────┼─────────────────────┤│ 2. EXTREMUM POLAR │ Min-Heap (`heapq`) vs │ "Are we evicting the││ (Inversion) │ Max-Heap (Negated key `-val`) │ minimum or maximum?"│├───────────────────┼───────────────────────────────────┼─────────────────────┤│ 3. STREAM DYNAMICS│ Single Filter, Dual-Heap Median, │ "How do incoming ││ (Multi-Heap) │ or K-Way Merge Pointer Array │ streams interact?" │└───────────────────┴───────────────────────────────────┴─────────────────────┘ How this plays out in actual problems:
🟢 Scenario A: Bounded Top-K Selection (Min-Heap of Size K)
- When finding the largest elements from items, sorting the entire array costs time and space.
- The Rule: Maintain a min-heap of capacity . For each item,
heappush(heap, item). Iflen(heap) > k,heappop(heap)evicts the smallest item. - The Result: Time collapses to and auxiliary space stays fixed at .
🔵 Scenario B: Two-Heap Streaming Median (Small Max-Heap + Large Min-Heap)
- In a continuous stream of numbers, sorting on every query costs .
- The Rule: Partition numbers into two halves:
small(max-heap) andlarge(min-heap). Route new numbers throughsmallintolarge, then rebalance sizes solen(small) >= len(large). - The Result: Insertion takes and median queries take instant time!
🟣 Scenario C: K-Way Sorted Stream Merging (Tournament Frontier)
- Given sorted linked lists, pairwise merging takes time.
- The Rule: Push the head node of each of the lists into a size- min-heap
(node.val, list_idx, node). Pop the global minimum, advance itsnode.next, and push back into the heap. - The Result: Merges all total elements in optimal time.
💡 The Ultimate Takeaway Decision Rule:
When analyzing an extremum or ordering problem, ask: "Do I need the whole dataset sorted, or only the extremums / dynamic balance?"
- If Top-K Largest ➔ Size- Min-Heap ().
- If Running Median ➔ Dual Balanced Heaps (Max-Heap + Min-Heap in query).
- If K Sorted Streams ➔ Size- Tournament Min-Heap ().
- If Cooldown Reorganization ➔ Max-Heap with temporary holding cooldown buffer.
🎯 The 5 Essential Heap Paradigms & Archetypes
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│ THE 5 CORE HEAP ARCHETYPES │├────────────────────────┬──────────────────────┬───────────────────────────────┬──────────────────────────────────┤│ Archetype │ State Management │ Heap Inversion / Key Rule │ Invariant / Eviction Rule │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 1. Bounded Top-K │ Size-$K$ Min-Heap │ Score key first in tuple: │ If `len(heap) > k: heappop()` ││ (LC 347 / LC 215) │ `heap = []` │ `(count, idx, val)` │ Root is cut-off threshold │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 2. Dual-Heap Median │ `small` (Max-Heap) & │ Negate values for `small`: │ Invariant: `0 <= len(small) - ││ (LC 295 / LC 480) │ `large` (Min-Heap) │ `heappush(small, -x)` │ len(large) <= 1`; small <= large │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 3. K-Way Merge │ Size-$K$ Min-Heap of │ Current list head pointer: │ Extract minimum, advance `next`, ││ (LC 23 / LC 373) │ list head nodes │ `(node.val, list_i, node)` │ replenish heap with new head │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 4. Greedy Reorganize │ Max-Heap with │ Negate frequency for max-heap:│ Pop highest frequency, place, ││ (LC 767 / LC 621) │ Cooldown Buffer │ `(-freq, char)` │ hold in cooldown, re-push later │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 5. Regret Scheduling │ Max-Heap of past │ Durations in Max-Heap: │ If `total_time > deadline`: ││ (LC 630 / LC 1642) │ durations taken │ `heappush(max_h, -duration)` │ evict longest duration taken │└────────────────────────┴──────────────────────┴───────────────────────────────┴──────────────────────────────────┘ 🏆 1. Bounded Min-Heap for Top-K Frequent Elements (LC 347 & 215)
- Mental Model: Count each value, then apply the Admission Threshold above to the counts: push
(freq, num)and pop the root whenever the heap grows past . - Code: see the Top-K Heap card.
⚖️ 2. Dual-Heap Dynamic Running Median (LC 295)
- Mental Model: Divides the dynamic number line into two equal halves with a max-heap on the left and a min-heap on the right.
- Code: see the Two Heaps card.
🔀 3. K-Way Sorted Stream Merging (LC 23)
- Mental Model: A tournament bracket where list heads compete; the winner is added to the merged output and replaced by its successor.
- Code: see the K-Way Merge card.
🔄 4. Greedy Frequency Reorganization (LC 767)
- Mental Model: Greedily pick the most frequent character, place it, and hold it in a temporary variable until the next character is chosen to prevent adjacent duplicates.
- Code: see the Most Frequent First card.
⏱️ 5. Greedy Regret Scheduling (LC 630 — Course Schedule III)
- Mental Model: When deadlines are violated, regret past decisions by evicting the course with the longest duration to free up maximum remaining time.
- Code: see the Greedy with Regret card.
🌳 Evaluating Heap Topologies, State Management & Frontier Management
Understanding the performance and memory mechanics of binary heaps ensures reliable implementation:
1. Linear Heapify vs Repeated Heappush
heapq.heapify(lst)runs in mathematically guaranteed linear time using bottom-up sift-down.- Repeatedly calling
heappushtimes takes time. When the full array is available upfront, always useheapify().
2. Tuple Sorting & Tie-Breaking Invariants
- Python compares tuples element-by-element (
tuple[0], thentuple[1]). Always place the numeric sort key first. - Always include a monotonic counter (
itertools.count()) as a middle field if payloads cannot be compared with<.
💡 Staff SWE Architectural Invariant:
A heap is an extremum filter, not a sorting algorithm. Use a bounded min-heap of size to compress memory from to , and never inspect heap elements beyondheap[0]expecting sorted order.
Understanding Invariants: Bounded Frontiers & Evicting the Weakest
To retain the K largest elements, maintain a min-heap of size K. The root heap[0] always holds the smallest of the top K candidates seen so far — the threshold a new element must beat to enter.
A heap is not a sorted list; it is a constant-time extremum window with logarithmic replacement. Sift-down restores heap order in O(log K) after eviction.
Keeps working space bounded to O(K) memory and each insertion at O(log K) time.
Guarantees max(lower_half) <= min(upper_half), providing exact median access in O(1) time.
Common Questions
"For 'top K largest' problems, why do we use a min-heap capped at size K — isn't that backwards when the goal is the largest values?"
It looks backwards until you ask what job the heap actually needs to do cheaply, which is not "find the largest" — it's "know who to evict."
A heap of size
Kthat already holds your bestKcandidates-so-far needs to answer one question every time a new candidate arrives: "is this new value bigger than the weakest survivor currently in my top K — and if so, who do I throw out?" The thing you need fast, constant-time access to is the weakest member of the set you're keeping, because that's the one at risk of being evicted.A max-heap capped at
Kputs the strongest candidate at the root — which tells you nothing about who's about to get kicked out. You'd have to scan allKelements to find the weakest one to evict, defeating the point of using a heap at all. A min-heap capped atKputs exactly the value you need at the root: the smallest of the current topK. When a new value beats it,heapq.heappop()removes the weakest survivor inO(log K), and the new value gets pushed in its place — the heap's own ordering invariant does the "find the weakest" work, with no separate search. The heap is "min" because the question being asked at every step is "who is the minimum among my current best," not "what is the overall largest."
Coach discussion points
- 1.
"You want the
klargest, and you reached for a min-heap. Explain that to me as if I think it is a bug." — The single highest-signal question here. The correct answer is about which item you want to evict, not about which item you want to keep. - 2.
"You said
O(n log n). What is the largest your heap ever gets?" — Surfaces thelog kvslog ndistinction. Students routinely write the bounded version and then analyse the unbounded one. - 3.
"Can you beat
O(n log k)for Top-K Frequent? What property of the input would you need?" — Frequencies are bounded byn, so they can index buckets directly:O(n). Tests whether the student can see past the pattern they just applied. - 4.
"Same problem, but the input is a stream of a billion items that will not fit in memory. What changes?" — Almost nothing, and that is the answer worth arriving at: the heap solution is already
O(k)space and single-pass. A sort-based solution dies here. This is the moment the pattern's real value lands. - 5.
"Trapping Rain Water in 1D used two pointers. Why doesn't that generalize to 2D?" — Because a 2D cell has no single "inward" direction; water can leak through any of 4 sides. The min-heap frontier always expands from the currently weakest boundary point, which is the correct generalization — this is the moment students should recognize Dijkstra's relaxation order hiding inside a water-trapping problem.
- 6.
"Why is it safe to finalize a cell's water contribution the instant it's popped from the heap, before the rest of the grid is processed?" — Because everything still in the heap or unvisited can only be reached through walls at least as tall as the one just popped (min-heap ordering guarantees this), so no future discovery can lower the wall this cell was flooded against.
- 7.
"In IPO, why is it always correct to greedily fund the single most profitable affordable project, instead of considering the best sequence of k projects together?" — Because capital
wonly ever grows as projects complete, so any project affordable now remains affordable in every future round — nothing is ever lost by taking the best option now, and an exchange argument shows no alternative ordering can beat it. - 8.
"You used two heaps in IPO like you did for the running median. Are they solving the same kind of problem?" — No: the running median's two heaps split data by value (smaller half vs. larger half); IPO's two heaps split candidates by eligibility (not-yet-affordable vs. affordable-ranked-by-profit). Recognizing the two heaps play structurally different roles, despite the superficial "two heaps" similarity, is the point of this question.
Complexity analysis
Top-K — Time O(n log k), Space O(n + k).
Counting frequencies is one O(n) pass. Then each of the d distinct values does one push and possibly one pop, each O(log k) because the heap never exceeds k + 1 entries — O(d log k), and d ≤ n. This is strictly better than O(n log n) whenever k ≪ n, which is the whole reason to prefer it. The space is O(n) for the frequency map plus O(k) for the heap; the frequency map dominates, so say O(n) and be ready to break it down.
Running median — O(log n) per insertion, O(1) per query, O(n) space.
Each insertion does a constant number of pushes and pops on heaps of size ≤ n. Reading the median touches only the two tops.
K-way merge — Time O(N log k), Space O(k), where N is the total number of elements across all lists and k is the number of lists. Every element is pushed once and popped once from a heap capped at k.
Why log k and not log n — say this precisely. A heap operation costs O(log(heap size)). Bounding the heap at k is what turns log n into log k. A student who says O(n log n) for Top-K has written the bounded heap and then analysed an unbounded one — the code is right and the understanding is not, and that is exactly what the coach's follow-up question is for.
A logging pipeline ingests a hundred million search queries a day and must display "today's top 10 searches" on a dashboard that refreshes constantly. It cannot sort a hundred million entries every refresh, and it cannot hold them all in memory. It holds exactly ten: whenever a new query's count beats the smallest of the current ten, that smallest one is dropped and the new one takes its place. Memory stays flat at ten items regardless of traffic. Every "top N", "N nearest", "N most recent" dashboard in production is this, and so is an OS scheduler picking the highest-priority runnable process.
💡 Don't worry if two-heap balancing or custom comparator logic feels subtle right now! Heaps give you constant-time access to extremes while maintaining logarithmic insertion. As you progress through the Archetype Variants (from Kth Largest Element and Top K Frequent to Find Median from Data Stream and Merge K Sorted Lists), each hands-on challenge will cement this intuition until using heapq.heappush() and bounded-size heaps becomes second nature.
Using a max-heap to keep the top K (The Inversion Bar Trap). The naive instinct is "I want the largest, so max-heap". But in a max-heap of size K, the root is the strongest item—and evicting the root throws away the very best answer! You must use a min-heap: the root
heap[0]is the weakest of the top K, serving as the qualification bar that incoming items must beat to gain entry. Conversely, finding the K smallest requires a max-heap.Push-pop order and size invariant: Popping before pushing on a heap that has not yet reached capacity . The simplest, bug-free pattern is pushing first, then popping if
len(heap) > k.Full sorting vs bounded heap : Sorting the full array takes time and extra space, but for stream inputs or small , a bounded min-heap maintains time and space.
Building a heap from a list is
O(n), notO(n log n).heapq.heapify(lst)is linear. Pushingnitems one at a time isO(n log n). When you already have all the data, heapify.kequal to the number of distinct values. Nothing is ever evicted; the answer is everything. Verify the loop still terminates correctly.k = 1. The heap holds one item — this is just "find the maximum", and a heap is overkill. Recognising that is a good sign, not a failure.Empty input.
nums = []→ the heap stays empty → return[]. No crash, no special case needed if the loop is written correctly.
Staff+ Systems Architecture & Hardware Considerations
Triggers: "top K frequent / largest / smallest", "continuous dynamic stream median", "merge K sorted streams", "event-driven simulation with earliest timestamp". Need for continuous extreme-element access with logarithmic insertion/deletion.
Stream size , with . Sorting costs ; maintaining a bounded heap of size drops runtime to and memory to .
Using a max-heap when finding the largest elements in a stream (a size- min-heap is optimal); Python's heapq is strictly a min-heap (requiring negated values for max-heaps); heapifying an existing array is , whereas sequential pushes cost .
Conceptual Foundation & Intuitive Analogy
🧭 Conceptual Foundation & Pattern Intuition
Sorting an entire array of size requires time. However, when we only need the -th extreme element (largest or smallest), full sorting performs massive amounts of redundant work ordering the remaining elements. A Binary Heap dynamically maintains a partial ordering where the parent node is always smaller than or equal to its children (in a min-heap). By capping a min-heap to capacity , we can stream elements through the heap: whenever size exceeds , we pop the minimum element. The survivor at the root after elements is mathematically guaranteed to be the -th largest element in time and space.
🏟️ The Analogy: A VIP Club with Only K Seats
Imagine a luxury VIP lounge that has exactly seats reserved for the wealthiest patrons. As guests arrive at the door:
- The first guests enter and take seats immediately.
- The lounge manager constantly keeps the poorest person among the current VIPs sitting right next to the exit door (this is the min-heap root).
- When a new guest arrives, if they have more money than the person by the door, the poorest guest must leave the lounge, and the new guest sits down.
- If a guest arrives with less money than the person by the door, they are turned away immediately.
When the entire line of guests has passed, who is sitting by the door? The poorest person inside the lounge — which is exactly the -th wealthiest person in the city!
🔑 Why It Works
The part that surprises most people: to find the largest elements, you use a min-heap, not a max-heap! The min-heap's root exposes the smallest among the top , making it trivial to identify and evict the weakest survivor in time.
My heap holds the best k items seen so far, and the item at its top is the current cut-off — the bar a new item must beat to get in.
Heap maintains extreme element at root heap[0]. heappushpop maintains size bounded to K in O(log K) time.
heapq.heappush(h, val); if len(h) > k: heapq.heappop(h) — peek min/max in O(1).
The 2 Mental Models of Heap & Priority Queue
Select heap structure based on stream processing and capacity constraints.
Quickselect (Kth Largest Element)
| 1 | import random |
| 2 | |
| 3 | |
| 4 | def find_kth_largest(nums: list[int], k: int) -> int: |
| 5 | """Kth Largest Element in an Array: quickselect with a random pivot and a three-way partition. |
| 6 | Time: O(N) average, O(N^2) worst case | Space: O(1) |
| 7 | """ |
| 8 | target = len(nums) - k # The k-th largest sits at index n - k once sorted ascending |
| 9 | lo, hi = 0, len(nums) - 1 # target always stays inside nums[lo..hi] |
| 10 | |
| 11 | while True: |
| 12 | # 1. Pick a Random Pivot: |
| 13 | # A fixed nums[hi] is always the largest value on a sorted input, so a random pick means no input can force the worst case. |
| 14 | pivot = nums[random.randint(lo, hi)] # Random pivot: no fixed input can force the worst case |
| 15 | lt, i, gt = lo, lo, hi # < pivot: nums[lo..lt-1] | == pivot: nums[lt..i-1] | > pivot: nums[gt+1..hi] |
| 16 | |
| 17 | # 2. Three-Way Partition Around the Pivot: |
| 18 | # Every value lands in exactly one of three blocks; a value equal to the pivot only moves i, since it already sits in the middle block. |
| 19 | while i <= gt: |
| 20 | if nums[i] < pivot: |
| 21 | nums[lt], nums[i] = nums[i], nums[lt] # Grow the smaller block |
| 22 | lt += 1 |
| 23 | i += 1 |
| 24 | elif nums[i] > pivot: |
| 25 | nums[i], nums[gt] = nums[gt], nums[i] # Grow the larger block |
| 26 | gt -= 1 # The value swapped into nums[i] is unread: i stays |
| 27 | else: |
| 28 | i += 1 # Equal to pivot: it stays in the middle block |
| 29 | |
| 30 | # 3. Keep Only the Side That Can Hold target: |
| 31 | # The partition put every copy of pivot exactly where sorting would, so target's rank decides which single side to keep. |
| 32 | if target < lt: |
| 33 | hi = lt - 1 # The answer is among the smaller values |
| 34 | elif target > gt: |
| 35 | lo = gt + 1 # The answer is among the larger values |
| 36 | else: |
| 37 | return pivot # nums[lt..gt] all equal pivot, and target is one of them |
Quickselect finds the value that would sit at index target = len(nums) - k if nums were sorted, without sorting it: pick a pivot, partition nums[lo..hi] into the values smaller than pivot (nums[lo..lt-1]), equal to it (nums[lt..gt]) and larger (nums[gt+1..hi]), then keep only the block that holds target. This is correct because a partition puts every copy of pivot exactly where sorting would: nothing before lt is larger and nothing after gt is smaller. So if lt <= target <= gt the answer is pivot; otherwise the answer's rank is decided inside one side alone, and hi = lt - 1 or lo = gt + 1 throws the other side away for good. The trap is the pivot choice: a fixed nums[hi] (or nums[lo]) is always the largest (or smallest) value of a sorted input, so while target lies below it each pass removes a single value: asking a sorted input for its smallest value (k = n) rescans almost the whole array n times; pivot = nums[random.randint(lo, hi)] means no input can force that.
Gotcha 1 of 4
- The trap
Gotcha 1: Using the fixed pivot
nums[hi]instead of a random oneMistakepivot = nums[hi]Fixpivot = nums[random.randint(lo, hi)]What happens: On a sorted input,
nums[hi]is always the largest value. Iftargetlies below it, each pass removes only that one value. With 10^5 values andk = 105, the passes scan about 5 * 10^9 values in total. The answer stays right, so this mistake has no example. A random pivot means no input can force this.
2Top-K Heap
stream of N elements ➔ keep size-K min-heap ➔ O(N log K) filteringTop K Elements (Min-Heap of Size K)
| 1 | import heapq |
| 2 | import itertools |
| 3 | from collections import Counter |
| 4 | from typing import Callable, Iterable, List, Optional, Tuple, TypeVar |
| 5 | |
| 6 | Item = TypeVar("Item") |
| 7 | |
| 8 | _arrivals = itertools.count() # arrival numbers for keep_k, shared by every call |
| 9 | |
| 10 | |
| 11 | def keep_k(items: Iterable[Item], k: int, key: Callable[[Item], float], min_heap: Optional[List[Tuple[float, int, Item]]] = None) -> List[Item]: |
| 12 | """Top-K Heap: the k items with the largest key(item), in any order. |
| 13 | The heap is a min-heap, so its root is the weakest item kept and the first one to evict. |
| 14 | For a stream (Kth Largest Element in a Stream), pass the same min_heap list on every call with the new items. |
| 15 | Then min_heap[0][2] is the k-th largest item seen so far. |
| 16 | Precondition: k >= 1. For the k smallest keys, pass the negated key. |
| 17 | Maximum Subsequence Score needs the sum of the kept keys at each step, so it keeps its own running sum. |
| 18 | N = the items. T = the cost of one key call. |
| 19 | Time: O(N * (T + log K)) | Space: O(K) heap |
| 20 | """ |
| 21 | if min_heap is None: |
| 22 | min_heap = [] # Min-heap of (key, arrival, item); the root is the weakest item kept |
| 23 | |
| 24 | for item in items: |
| 25 | # 1. Push Every Candidate: |
| 26 | # Each entry is (key, arrival, item); arrival is a number never used before, so two items with an equal key are never compared. |
| 27 | heapq.heappush(min_heap, (key(item), next(_arrivals), item)) |
| 28 | |
| 29 | # 2. Evict the Weakest When Over Capacity: |
| 30 | # A popped item is no bigger than k others already seen, so it can never belong in the final top k. |
| 31 | if len(min_heap) > k: |
| 32 | heapq.heappop(min_heap) |
| 33 | |
| 34 | # 3. Return the Kept Items: |
| 35 | # Every item still in min_heap is one of the k biggest keys seen, in no particular order. |
| 36 | return [item for _, _, item in min_heap] |
To keep the k biggest items, keep a min-heap of size k: push each (count, value) pair, and when min_heap grows past k, pop its root, the weakest item kept. A popped item is no bigger than k others already seen, so it can never be in the top k, and after the last push min_heap holds exactly the k biggest, with the k-th biggest at the root. The trap is the heap direction: the k largest need a min-heap and the k smallest a max-heap, because the root must be the item you evict next.
Gotcha 1 of 3
- The trap
Gotcha 1: Keeping the
klargest in a max-heapMistakeheapq.heappush(min_heap, (-count, value))Fixheapq.heappush(min_heap, (count, value))What happens: Python's
heapqis a min-heap only. Pushing-countturns it into a max-heap, so the root is the most frequent item. When the heap grows pastk,heappopthen removes the most frequent item. The heap ends with thekleast frequent values. To keep theklargest, the root must be the smallest item kept, so use a min-heap.Example: Top K Frequent Elements (LC 347): nums = [1, 1, 1, 2, 2, 3], k = 2Mistake: returns [2, 3]Fix: returns [1, 2]
Built on the mental models above
- K-Way MergeK-Way Merge
The heap holds one entry per sorted source, and each pop pushes the next entry of the same source.
- Best-First HeapBest-First Heap Frontier (Kth Smallest in a Sorted Matrix)
The next entry is built from the popped entry (the next cell of its row) instead of being read from a list.
- Most Frequent FirstGreedy Scheduling with a Heap (Reorganize String / Task Scheduler)
A cooldown buffer holds the item just used until it may be used again.
- Two HeapsTwo Heaps (Running Median)
A second heap holds the other half of the values, and the two sizes stay within 1 of each other.
- Greedy with RegretGreedy with Regret (Heap-Based Undo)
Every item is taken first, and the most expensive taken item is removed when the limit is broken.
- Ready HeapEvent Simulation with a Ready Heap (Single-Threaded CPU, Task Scheduling)
A clock decides which items have arrived, and only arrived items are in the heap.
The Heap & Priority Queue Decision Rule
When facing an unseen problem, ask in this order:
Use a size-K min-heap for K largest (or max-heap for K smallest) in O(N log K) time.
Partition around a random pivot and continue only into the side that holds rank k: O(N) on average.
Maintain a size-K heap storing current head pointers from each list.
Seed the heap with the first cell of each of the first min(n, k) rows; each pop pushes only the next cell of its row, so the top is always the smallest unused value.
Maintain lower max-heap and upper min-heap with size balance.
Pop most frequent, place, decrement, and re-enqueue after cooldown.
Take each item greedily and push it on a heap; when the deadline or budget breaks, undo the earlier choice whose removal helps most (the longest course, or the smallest climb moved from a ladder to bricks).
Sort by arrival, release every arrived item into a heap keyed by the choosing rule, pop the best, and jump the clock to the next arrival when the heap is empty.
Pattern Architecture & Invariant Design Masterclass
A heap is a container with exactly one promise: you can always see and remove the smallest item in it, cheaply, no matter how many items are inside. Adding costs O(log n); removing the smallest costs O(log n); looking at the smallest costs O(1). Nothing else is fast — you cannot ask a heap for the second-smallest, cannot search it, cannot index it. It is deliberately a narrow tool.
That narrowness is the point. Reach for a heap when the question has this shape: "I keep receiving items, and at every moment I need the best-so-far, but I do not need the whole thing sorted." Sorting gives you far more than you asked for and charges O(n log n) for it. A heap charges only for what you use.
The Admission Threshold Invariant (Top-K Inversion)
The counter-intuitive move that makes top-K problems work is the Admission Threshold Invariant:
To keep the
klargest items, maintain a min-heap of sizek.
The root of a min-heap (heap[0]) is the smallest of the topksurvivors seen so far. It represents the minimum qualification bar: any newly arriving element only earns a spot in the topkif it strictly beats the root (x > heap[0]). If it does, evicting the root removes the weakest candidate, and pushingxrestores the threshold in .
Conversely, to keep theksmallest items, maintain a max-heap of sizekwhere the root acts as the ceiling threshold.
🔬 Variable Anatomy: The 7 Variables Every Heap / Priority Queue Uses
To connect abstract priority queue mechanics with concrete Python syntax, here is the universal code anatomy showing what every variable does:
import heapqimport itertoolsfrom collections import Counter def heapMasterTemplate( items: list[int], k: int) -> tuple[list[int], float]: # ========================================================================= # 1. THE STORAGE & HEAP BUFFERS # ========================================================================= heap = [] # 🧵 Primary Bounded Heap (stores (priority_key, payload)) small = [] # 📉 Max-Heap (stores negated values for lower half in median) large = [] # 📈 Min-Heap (stores positive values for upper half in median) counter = itertools.count() # 🔢 Monotonic Unique Tie-Breaker (prevents payload comparison errors) # ========================================================================= # 2. BOUNDED TOP-K SURVIVOR SELECTION PIPELINE (O(N log K)) # ========================================================================= # Count frequencies: items -> (count, val) freq_map = Counter(items) for val, freq in freq_map.items(): # Push (priority_key, tie_breaker, payload) tuple heapq.heappush(heap, (freq, next(counter), val)) # ⚠️ CRITICAL: Evict the weakest survivor when size exceeds k if len(heap) > k: heapq.heappop(heap) # Discards the minimum frequency among current Top-K top_k_survivors = [val for _freq, _idx, val in heap] # ========================================================================= # 3. DUAL-HEAP STREAMING BALANCING (O(log N) insert, O(1) median) # ========================================================================= for num in items: # Step 1: Always push negated to small (max-heap) heapq.heappush(small, -num) # Step 2: Route max of small to large (min-heap) to enforce small <= large heapq.heappush(large, -heapq.heappop(small)) # Step 3: Rebalance sizes so len(small) >= len(large) if len(large) > len(small): heapq.heappush(small, -heapq.heappop(large)) # Calculate O(1) median median = float(-small[0]) if len(small) > len(large) else (-small[0] + large[0]) / 2.0 return top_k_survivors, median Variable Role Reference:
| Variable | Role in Code | Question It Answers | ❌ What Happens If Omitted or Mismanaged? |
|---|---|---|---|
heap | Python list with heapq | "What is the active working set of candidates or survivors?" | ❌ Without a heap, finding extremums continuously requires sorting or repeated linear scans. |
k | Capacity bound integer | "How many top survivors am I strictly permitted to keep?" | ❌ Without enforcing len(heap) > k: heappop(), memory grows to and time degrades to . |
priority_key | First tuple element | "What exact quantitative metric determines relative priority?" | ❌ In Python, tuple heaps compare index 0 first. Putting payload before key sorts by payload instead of priority! |
counter | itertools.count() | "How do we break ties when two items share identical priority keys?" | ❌ When priority keys tie and payloads are unorderable objects, Python raises TypeError: '<' not supported. |
small | Max-Heap (negated list) | "Which elements represent the smaller 50% of the stream?" | ❌ Forgetting to negate values when pushing to small converts it into a min-heap, corrupting streaming median splits. |
large | Min-Heap list | "Which elements represent the larger 50% of the stream?" | ❌ Without rebalancing sizes between small and large, median lookups read from the wrong half. |
cooldown | Temporary holding tuple | "Which high-frequency item is resting until adjacent slots pass?" | ❌ In greedy reorganization (LC 767), without a holding buffer, the highest frequency item is repicked immediately. |
🧠 The Heap is NOT a Sorted Array:
heap[0]is guaranteed minimal in , but iteratingheap[1..]is NOT in sorted order. If sorted output is required, sort the final elements in .
🌟 The 3 Dimensions of Heap / Priority Queue & The "Weakest Survivor" Principle
Every Heap / Priority Queue algorithm is defined by 3 Key Dimensions:
┌─────────────────────────────────────────────────────────────────────────────┐│ THE 3 DIMENSIONS OF HEAP & PRIORITY QUEUES │├───────────────────┬───────────────────────────────────┬─────────────────────┤│ Dimension │ Mechanism in Code │ Question It Answers │├───────────────────┼───────────────────────────────────┼─────────────────────┤│ 1. CAPACITY BOUND │ `len(heap) > k: heappop()` │ "Is the working set ││ (Memory Cap) │ Bounded size-K vs Unbounded size-N│ memory bounded?" │├───────────────────┼───────────────────────────────────┼─────────────────────┤│ 2. EXTREMUM POLAR │ Min-Heap (`heapq`) vs │ "Are we evicting the││ (Inversion) │ Max-Heap (Negated key `-val`) │ minimum or maximum?"│├───────────────────┼───────────────────────────────────┼─────────────────────┤│ 3. STREAM DYNAMICS│ Single Filter, Dual-Heap Median, │ "How do incoming ││ (Multi-Heap) │ or K-Way Merge Pointer Array │ streams interact?" │└───────────────────┴───────────────────────────────────┴─────────────────────┘ How this plays out in actual problems:
🟢 Scenario A: Bounded Top-K Selection (Min-Heap of Size K)
- When finding the largest elements from items, sorting the entire array costs time and space.
- The Rule: Maintain a min-heap of capacity . For each item,
heappush(heap, item). Iflen(heap) > k,heappop(heap)evicts the smallest item. - The Result: Time collapses to and auxiliary space stays fixed at .
🔵 Scenario B: Two-Heap Streaming Median (Small Max-Heap + Large Min-Heap)
- In a continuous stream of numbers, sorting on every query costs .
- The Rule: Partition numbers into two halves:
small(max-heap) andlarge(min-heap). Route new numbers throughsmallintolarge, then rebalance sizes solen(small) >= len(large). - The Result: Insertion takes and median queries take instant time!
🟣 Scenario C: K-Way Sorted Stream Merging (Tournament Frontier)
- Given sorted linked lists, pairwise merging takes time.
- The Rule: Push the head node of each of the lists into a size- min-heap
(node.val, list_idx, node). Pop the global minimum, advance itsnode.next, and push back into the heap. - The Result: Merges all total elements in optimal time.
💡 The Ultimate Takeaway Decision Rule:
When analyzing an extremum or ordering problem, ask: "Do I need the whole dataset sorted, or only the extremums / dynamic balance?"
- If Top-K Largest ➔ Size- Min-Heap ().
- If Running Median ➔ Dual Balanced Heaps (Max-Heap + Min-Heap in query).
- If K Sorted Streams ➔ Size- Tournament Min-Heap ().
- If Cooldown Reorganization ➔ Max-Heap with temporary holding cooldown buffer.
🎯 The 5 Essential Heap Paradigms & Archetypes
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│ THE 5 CORE HEAP ARCHETYPES │├────────────────────────┬──────────────────────┬───────────────────────────────┬──────────────────────────────────┤│ Archetype │ State Management │ Heap Inversion / Key Rule │ Invariant / Eviction Rule │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 1. Bounded Top-K │ Size-$K$ Min-Heap │ Score key first in tuple: │ If `len(heap) > k: heappop()` ││ (LC 347 / LC 215) │ `heap = []` │ `(count, idx, val)` │ Root is cut-off threshold │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 2. Dual-Heap Median │ `small` (Max-Heap) & │ Negate values for `small`: │ Invariant: `0 <= len(small) - ││ (LC 295 / LC 480) │ `large` (Min-Heap) │ `heappush(small, -x)` │ len(large) <= 1`; small <= large │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 3. K-Way Merge │ Size-$K$ Min-Heap of │ Current list head pointer: │ Extract minimum, advance `next`, ││ (LC 23 / LC 373) │ list head nodes │ `(node.val, list_i, node)` │ replenish heap with new head │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 4. Greedy Reorganize │ Max-Heap with │ Negate frequency for max-heap:│ Pop highest frequency, place, ││ (LC 767 / LC 621) │ Cooldown Buffer │ `(-freq, char)` │ hold in cooldown, re-push later │├────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────────────────────────┤│ 5. Regret Scheduling │ Max-Heap of past │ Durations in Max-Heap: │ If `total_time > deadline`: ││ (LC 630 / LC 1642) │ durations taken │ `heappush(max_h, -duration)` │ evict longest duration taken │└────────────────────────┴──────────────────────┴───────────────────────────────┴──────────────────────────────────┘ 🏆 1. Bounded Min-Heap for Top-K Frequent Elements (LC 347 & 215)
- Mental Model: Count each value, then apply the Admission Threshold above to the counts: push
(freq, num)and pop the root whenever the heap grows past . - Code: see the Top-K Heap card.
⚖️ 2. Dual-Heap Dynamic Running Median (LC 295)
- Mental Model: Divides the dynamic number line into two equal halves with a max-heap on the left and a min-heap on the right.
- Code: see the Two Heaps card.
🔀 3. K-Way Sorted Stream Merging (LC 23)
- Mental Model: A tournament bracket where list heads compete; the winner is added to the merged output and replaced by its successor.
- Code: see the K-Way Merge card.
🔄 4. Greedy Frequency Reorganization (LC 767)
- Mental Model: Greedily pick the most frequent character, place it, and hold it in a temporary variable until the next character is chosen to prevent adjacent duplicates.
- Code: see the Most Frequent First card.
⏱️ 5. Greedy Regret Scheduling (LC 630 — Course Schedule III)
- Mental Model: When deadlines are violated, regret past decisions by evicting the course with the longest duration to free up maximum remaining time.
- Code: see the Greedy with Regret card.
🌳 Evaluating Heap Topologies, State Management & Frontier Management
Understanding the performance and memory mechanics of binary heaps ensures reliable implementation:
1. Linear Heapify vs Repeated Heappush
heapq.heapify(lst)runs in mathematically guaranteed linear time using bottom-up sift-down.- Repeatedly calling
heappushtimes takes time. When the full array is available upfront, always useheapify().
2. Tuple Sorting & Tie-Breaking Invariants
- Python compares tuples element-by-element (
tuple[0], thentuple[1]). Always place the numeric sort key first. - Always include a monotonic counter (
itertools.count()) as a middle field if payloads cannot be compared with<.
💡 Staff SWE Architectural Invariant:
A heap is an extremum filter, not a sorting algorithm. Use a bounded min-heap of size to compress memory from to , and never inspect heap elements beyondheap[0]expecting sorted order.
Understanding Invariants: Bounded Frontiers & Evicting the Weakest
To retain the K largest elements, maintain a min-heap of size K. The root heap[0] always holds the smallest of the top K candidates seen so far — the threshold a new element must beat to enter.
A heap is not a sorted list; it is a constant-time extremum window with logarithmic replacement. Sift-down restores heap order in O(log K) after eviction.
Keeps working space bounded to O(K) memory and each insertion at O(log K) time.
Guarantees max(lower_half) <= min(upper_half), providing exact median access in O(1) time.
Common Questions
"For 'top K largest' problems, why do we use a min-heap capped at size K — isn't that backwards when the goal is the largest values?"
It looks backwards until you ask what job the heap actually needs to do cheaply, which is not "find the largest" — it's "know who to evict."
A heap of size
Kthat already holds your bestKcandidates-so-far needs to answer one question every time a new candidate arrives: "is this new value bigger than the weakest survivor currently in my top K — and if so, who do I throw out?" The thing you need fast, constant-time access to is the weakest member of the set you're keeping, because that's the one at risk of being evicted.A max-heap capped at
Kputs the strongest candidate at the root — which tells you nothing about who's about to get kicked out. You'd have to scan allKelements to find the weakest one to evict, defeating the point of using a heap at all. A min-heap capped atKputs exactly the value you need at the root: the smallest of the current topK. When a new value beats it,heapq.heappop()removes the weakest survivor inO(log K), and the new value gets pushed in its place — the heap's own ordering invariant does the "find the weakest" work, with no separate search. The heap is "min" because the question being asked at every step is "who is the minimum among my current best," not "what is the overall largest."
Coach discussion points
- 1.
"You want the
klargest, and you reached for a min-heap. Explain that to me as if I think it is a bug." — The single highest-signal question here. The correct answer is about which item you want to evict, not about which item you want to keep. - 2.
"You said
O(n log n). What is the largest your heap ever gets?" — Surfaces thelog kvslog ndistinction. Students routinely write the bounded version and then analyse the unbounded one. - 3.
"Can you beat
O(n log k)for Top-K Frequent? What property of the input would you need?" — Frequencies are bounded byn, so they can index buckets directly:O(n). Tests whether the student can see past the pattern they just applied. - 4.
"Same problem, but the input is a stream of a billion items that will not fit in memory. What changes?" — Almost nothing, and that is the answer worth arriving at: the heap solution is already
O(k)space and single-pass. A sort-based solution dies here. This is the moment the pattern's real value lands. - 5.
"Trapping Rain Water in 1D used two pointers. Why doesn't that generalize to 2D?" — Because a 2D cell has no single "inward" direction; water can leak through any of 4 sides. The min-heap frontier always expands from the currently weakest boundary point, which is the correct generalization — this is the moment students should recognize Dijkstra's relaxation order hiding inside a water-trapping problem.
- 6.
"Why is it safe to finalize a cell's water contribution the instant it's popped from the heap, before the rest of the grid is processed?" — Because everything still in the heap or unvisited can only be reached through walls at least as tall as the one just popped (min-heap ordering guarantees this), so no future discovery can lower the wall this cell was flooded against.
- 7.
"In IPO, why is it always correct to greedily fund the single most profitable affordable project, instead of considering the best sequence of k projects together?" — Because capital
wonly ever grows as projects complete, so any project affordable now remains affordable in every future round — nothing is ever lost by taking the best option now, and an exchange argument shows no alternative ordering can beat it. - 8.
"You used two heaps in IPO like you did for the running median. Are they solving the same kind of problem?" — No: the running median's two heaps split data by value (smaller half vs. larger half); IPO's two heaps split candidates by eligibility (not-yet-affordable vs. affordable-ranked-by-profit). Recognizing the two heaps play structurally different roles, despite the superficial "two heaps" similarity, is the point of this question.
Complexity analysis
Top-K — Time O(n log k), Space O(n + k).
Counting frequencies is one O(n) pass. Then each of the d distinct values does one push and possibly one pop, each O(log k) because the heap never exceeds k + 1 entries — O(d log k), and d ≤ n. This is strictly better than O(n log n) whenever k ≪ n, which is the whole reason to prefer it. The space is O(n) for the frequency map plus O(k) for the heap; the frequency map dominates, so say O(n) and be ready to break it down.
Running median — O(log n) per insertion, O(1) per query, O(n) space.
Each insertion does a constant number of pushes and pops on heaps of size ≤ n. Reading the median touches only the two tops.
K-way merge — Time O(N log k), Space O(k), where N is the total number of elements across all lists and k is the number of lists. Every element is pushed once and popped once from a heap capped at k.
Why log k and not log n — say this precisely. A heap operation costs O(log(heap size)). Bounding the heap at k is what turns log n into log k. A student who says O(n log n) for Top-K has written the bounded heap and then analysed an unbounded one — the code is right and the understanding is not, and that is exactly what the coach's follow-up question is for.
A logging pipeline ingests a hundred million search queries a day and must display "today's top 10 searches" on a dashboard that refreshes constantly. It cannot sort a hundred million entries every refresh, and it cannot hold them all in memory. It holds exactly ten: whenever a new query's count beats the smallest of the current ten, that smallest one is dropped and the new one takes its place. Memory stays flat at ten items regardless of traffic. Every "top N", "N nearest", "N most recent" dashboard in production is this, and so is an OS scheduler picking the highest-priority runnable process.
💡 Don't worry if two-heap balancing or custom comparator logic feels subtle right now! Heaps give you constant-time access to extremes while maintaining logarithmic insertion. As you progress through the Archetype Variants (from Kth Largest Element and Top K Frequent to Find Median from Data Stream and Merge K Sorted Lists), each hands-on challenge will cement this intuition until using heapq.heappush() and bounded-size heaps becomes second nature.
Using a max-heap to keep the top K (The Inversion Bar Trap). The naive instinct is "I want the largest, so max-heap". But in a max-heap of size K, the root is the strongest item—and evicting the root throws away the very best answer! You must use a min-heap: the root
heap[0]is the weakest of the top K, serving as the qualification bar that incoming items must beat to gain entry. Conversely, finding the K smallest requires a max-heap.Push-pop order and size invariant: Popping before pushing on a heap that has not yet reached capacity . The simplest, bug-free pattern is pushing first, then popping if
len(heap) > k.Full sorting vs bounded heap : Sorting the full array takes time and extra space, but for stream inputs or small , a bounded min-heap maintains time and space.
Building a heap from a list is
O(n), notO(n log n).heapq.heapify(lst)is linear. Pushingnitems one at a time isO(n log n). When you already have all the data, heapify.kequal to the number of distinct values. Nothing is ever evicted; the answer is everything. Verify the loop still terminates correctly.k = 1. The heap holds one item — this is just "find the maximum", and a heap is overkill. Recognising that is a good sign, not a failure.Empty input.
nums = []→ the heap stays empty → return[]. No crash, no special case needed if the loop is written correctly.
Staff+ Systems Architecture & Hardware Considerations
Triggers: "top K frequent / largest / smallest", "continuous dynamic stream median", "merge K sorted streams", "event-driven simulation with earliest timestamp". Need for continuous extreme-element access with logarithmic insertion/deletion.
Stream size , with . Sorting costs ; maintaining a bounded heap of size drops runtime to and memory to .
Using a max-heap when finding the largest elements in a stream (a size- min-heap is optimal); Python's heapq is strictly a min-heap (requiring negated values for max-heaps); heapifying an existing array is , whereas sequential pushes cost .