Heaps and Priority Queues
A heap answers one question fast, over and over: “what’s the current
smallest (or largest) item?” It’s the engine behind priority queues,
Dijkstra’s algorithm, and every “top-k” or “k-th largest” interview question.
The elegant part: a heap is a binary tree, but it never needs a single Node
object — it lives entirely inside a flat array.
What you’ll learn
Section titled “What you’ll learn”- The complete binary tree invariant, and the array index math behind it: , , .
- Sift-up (on insert) and sift-down (on pop) — how the heap invariant gets restored after each change.
- Python’s
heapqmodule — always a min-heap, plus the negation trick for a max-heap. - The top-k pattern: maintaining a min-heap of size .
A heap is a tree living inside an array
Section titled “A heap is a tree living inside an array”A binary min-heap keeps one invariant: every parent is both its children. It doesn’t have to be fully sorted — only that one local rule, checked all the way down. Because the tree is always complete (filled left-to-right, level by level, with no gaps), you can store it in a plain array and compute parent/child positions with arithmetic instead of pointers:
For a node at array index (0-indexed):
def left(i):
return 2 * i + 1
def right(i):
return 2 * i + 2
def parent(i):
return (i - 1) // 2
for i in range(6):
p = parent(i) if i > 0 else None
print(f"index {i}: parent={p}, left={left(i)}, right={right(i)}")No pointers, no Node class — just index arithmetic on a Python list.
Sift-up (insert) and sift-down (pop)
Section titled “Sift-up (insert) and sift-down (pop)”Inserting appends the new value at the end, then sifts it up: swap with its parent while it’s smaller, until the invariant holds. Popping removes the root, moves the last element into its place, then sifts it down: swap with the smaller child while it’s bigger than that child.
class MinHeap:
def __init__(self):
self.data = []
def _parent(self, i):
return (i - 1) // 2
def _sift_up(self, i):
while i > 0 and self.data[self._parent(i)] > self.data[i]:
p = self._parent(i)
self.data[i], self.data[p] = self.data[p], self.data[i]
i = p
def _sift_down(self, i):
n = len(self.data)
while True:
left, right = 2 * i + 1, 2 * i + 2
smallest = i
if left < n and self.data[left] < self.data[smallest]:
smallest = left
if right < n and self.data[right] < self.data[smallest]:
smallest = right
if smallest == i:
break
self.data[i], self.data[smallest] = self.data[smallest], self.data[i]
i = smallest
def push(self, value):
self.data.append(value)
self._sift_up(len(self.data) - 1)
def pop(self):
top = self.data[0]
last = self.data.pop()
if self.data:
self.data[0] = last
self._sift_down(0)
return top
heap = MinHeap()
for x in [5, 1, 8, 3, 9, 2]:
heap.push(x)
print("pop order:", [heap.pop() for _ in range(6)])Each sift-up/sift-down walks at most the height of the tree, which for elements is — that’s where the heap’s signature comes from.
Python’s heapq: a min-heap for free
Section titled “Python’s heapq: a min-heap for free”You rarely need to hand-write sift-up/sift-down — heapq turns a plain list
into a binary min-heap using module-level functions, no wrapper class
required.
import heapq
nums = [5, 1, 8, 3, 9, 2, 7]
heap = []
for x in nums:
heapq.heappush(heap, x) # O(log n) each
print("min (peek):", heap[0]) # O(1)
print("full pop order:", [heapq.heappop(heap) for _ in nums]) # O(log n) each
# heapify: turn an existing list into a heap IN PLACE, in O(n) -- not O(n log n)
data = [9, 4, 7, 1, 3]
heapq.heapify(data)
print("heapified array:", data)Max-heap via the negation trick
Section titled “Max-heap via the negation trick”heapq is always a min-heap. For a max-heap, negate values going in and out
— the smallest negative is the largest original number.
import heapq
nums = [4, 1, 7, 3, 8]
max_heap = [-x for x in nums]
heapq.heapify(max_heap)
heapq.heappush(max_heap, -10) # push -10 to represent the value 10
largest = -heapq.heappop(max_heap)
print("largest via negation:", largest)Top-k: a min-heap of size k
Section titled “Top-k: a min-heap of size k”The single most common heap pattern in interviews: keep a min-heap capped at
size . Any new value that beats the heap’s smallest replaces it —
heap[0] is always the current -th largest seen so far.
import heapq
nums = [7, 2, 9, 4, 1, 8, 5, 3]
k = 3
# Way 1: heapq.nlargest -- simplest, O(n log k) under the hood
top_k_builtin = heapq.nlargest(k, nums)
# Way 2: maintain a min-heap of size k by hand
min_heap = []
for x in nums:
if len(min_heap) < k:
heapq.heappush(min_heap, x)
elif x > min_heap[0]:
heapq.heapreplace(min_heap, x) # pop smallest, push x -- one O(log k) step
top_k_manual = sorted(min_heap, reverse=True)
print("builtin nlargest:", top_k_builtin)
print("manual k-heap: ", top_k_manual)The cue
Section titled “The cue”Visual intuition
Section titled “Visual intuition”Both faces at once — the tree you picture, and the array that is actually there. The index arithmetic linking them is shown under each cell:
heap is empty
Appending always keeps the tree complete, which is the property that lets an array stand in for a tree at all. The sift-up then repairs the one thing appending can break.
Filling the hole with the last leaf rather than with the smaller child is counter-intuitive but necessary: promoting a child would leave a gap in the middle of the array and break the index arithmetic everything else depends on.
Time and space complexity
Section titled “Time and space complexity”| Operation | Complexity |
|---|---|
heappush (insert) | |
heappop (extract-min) | |
heap[0] (peek min) | |
heapify (build from a list) | |
nlargest(k, it) / nsmallest(k, it) | |
| Search for an arbitrary value | |
| Space |
LeetCode problem set
Section titled “LeetCode problem set”Generated from the problem database, so each entry carries its sheet membership and reported companies. Tick them off as you go — progress is saved in this browser, and the Export button writes it to a file you can keep.
Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.
- 1046Last Stone Weighteasy
- 347Top K Frequent ElementsmediumCount with `Counter`, then heap on the counts
- 621Task Schedulermedium
- 215Kth Largest Element in an ArraymediumA size-$k$ min-heap, or `nlargest`
- 23Merge k Sorted ListshardA heap of the current head of each list, always popping the smallest
- 1851Minimum Interval to Include Each Queryhard
Practice — real LeetCode problems
Section titled “Practice — real LeetCode problems”Each exercise is the actual LeetCode problem with its real method signature and LeetCode’s own examples as the test. Write the body, press Run, and match the expected output.
LC 703 — Kth Largest Element in a Stream · Easy
Section titled “LC 703 — Kth Largest Element in a Stream · Easy”Problem. Design a class that is initialised with k and an initial list, and
whose add(val) returns the kth largest element among all values seen so far.
Constraints. 1 <= k <= 10^4, up to 10^4 calls, and there are always at
least k elements when add returns.
Examples. With k = 3 and [4,5,8,2]: add(3) gives 4, add(5) gives
5, add(10) gives 5, add(9) gives 8, add(4) gives 8
Editorial
The insight is inverted from what people expect: to track the kth largest you
keep a min-heap of size k. Everything in the heap is among the top k, and
its smallest member — the root — is exactly the kth largest.
Time per add, for the constructor via heapify.
Space .
heapify is where pushing n items individually is —
a free improvement worth taking.
Why not sort? Re-sorting on every add is per call. And why not a
max-heap of everything? That is space and you would have to pop k - 1
items to read the answer, then push them back.
Follow-ups: “kth smallest instead?” — a max-heap via negation. “kth largest in a static array (LC 215)?” — quickselect gives average. “Running median?” — two heaps. “Support removal?” — heaps cannot delete arbitrary elements; use lazy deletion.
LC 1046 — Last Stone Weight · Easy
Section titled “LC 1046 — Last Stone Weight · Easy”Problem. Repeatedly take the two heaviest stones and smash them: if equal both
are destroyed, otherwise the heavier one is replaced by the difference. Return the
weight of the last remaining stone, or 0 if none remain.
Constraints. 1 <= len(stones) <= 30, 1 <= stones[i] <= 1000.
Examples. [2,7,4,1,8,1] gives 1 · [1] gives 1 · [2,2] gives 0
Editorial
Every step needs the two current maxima, and the result re-enters the collection — which is exactly a priority queue’s job.
Time . Space .
heapq is a min-heap only, so negating on push and on pop is the standard
max-heap idiom. Forgetting to negate on the way out is the classic bug.
Two details: push back only when a != b (equal stones destroy each other, and
pushing a 0 would leave a phantom stone), and handle the empty heap at the end —
[2,2] must return 0.
Note this is genuinely a simulation, unlike LC 1049 Last Stone Weight II, which sounds similar but collapses to a subset-sum partition. Same setup, entirely different problem — worth being able to distinguish.
Follow-ups: “Minimise the final weight by choosing which stones to smash (LC 1049)?” — that is the DP one. “Return the number of smashes?” — count loop iterations. “Very large inputs?” — still ; the heap is optimal here.
LC 621 — Task Scheduler · Medium
Section titled “LC 621 — Task Scheduler · Medium”Problem. Given task labels and a cooldown n, identical tasks must be
separated by at least n intervals. Idle intervals are allowed. Return the minimum
number of intervals needed to finish all tasks.
Constraints. 1 <= len(tasks) <= 10^4, uppercase letters, 0 <= n <= 100.
Examples. ["A","A","A","B","B","B"], n = 2 gives 8 ·
["A","C","A","B","D","B"], n = 1 gives 6 ·
["A","A","A","B","B","B"], n = 0 gives 6
Editorial
Picture the most frequent task laid out first: with frequency m it forms m - 1
gaps, each needing n other intervals, giving a skeleton of (m - 1) * (n + 1)
slots. Then add one interval for each task that ties at that maximum frequency,
since each fills the final row.
For ["A","A","A","B","B","B"], n = 2: m = 3, two tasks tie, so
(3-1) * 3 + 2 = 8. The schedule is A B _ A B _ A B.
But if there are many distinct tasks, every gap gets filled with real work and
there is no idling — then the answer is just len(tasks). Taking the maximum of
the two covers both regimes.
Time to count. Space .
["A","C","A","B","D","B"], n = 1 gives 6 — exactly len(tasks), the dense
case. Omitting the max returns 5, which is impossible. And n = 0 also reduces
to len(tasks).
A heap-based simulation also works and is the more obvious “priority queue” answer:
repeatedly take the n + 1 most frequent available tasks. It is and
worth describing, especially if the interviewer wants the actual schedule rather
than just its length — which the formula cannot give you.
Follow-ups: “Output the schedule itself?” — use the heap simulation. “Tasks with
different durations?” — much harder, a scheduling problem. “Why does the formula
work?” — the two-regime argument above; be ready to justify the max.
Dry run
Section titled “Dry run”Top k with a size-limited heap — nums = [3, 2, 1, 5, 6, 4], k = 2,
using a min-heap to find the largest values.
| push | heap (sorted view) | over k? | evicted | heap after |
|---|---|---|---|---|
| 3 | [3] | no | — | [3] |
| 2 | [2,3] | no | — | [2,3] |
| 1 | [1,2,3] | yes | 1 | [2,3] |
| 5 | [2,3,5] | yes | 2 | [3,5] |
| 6 | [3,5,6] | yes | 3 | [5,6] |
| 4 | [4,5,6] | yes | 4 | [5,6] |
Root is 5, the 2nd largest. The counter-intuitive part is using a min-heap
for a largest query, and the reason is that the root is then exactly the
element to throw away when something better arrives — and exactly the answer at
the end.
Python only has a min-heap. For a max-heap, push negated values and negate
on the way out. For tuples, use (-priority, item). Say this out loud; it is a
small Python-specific detail interviewers listen for.
The variant map
Section titled “The variant map”| Need | Approach | Complexity |
|---|---|---|
| k largest | min-heap of size k | time, space |
| k smallest | max-heap of size k (negate) | |
| k-th largest | same size-k min-heap, return the root | |
| Running median | two heaps, max-heap for the low half, min-heap for the high | per insert |
| Merge k sorted lists | heap of one head per list | |
| Scheduling by priority | heap of (priority, task) | per operation |
| Build from an existing list | heapq.heapify |
Interview follow-ups
Section titled “Interview follow-ups”| They ask | What they’re checking | The answer |
|---|---|---|
| “Why a min-heap to find the k largest?” | Whether you understand or memorised | Its root is the smallest of the k best so far — exactly the element to evict when something better arrives, and exactly the k-th largest at the end |
| “Why not just sort?” | Complexity judgement | Sorting is and space. A size-k heap is and space, which matters when or the data is a stream |
| “Python has no max-heap” | Language fluency | Negate the values, or push (-priority, item) tuples |
“Is heapify or ?” | Depth | — sift down from the middle backwards. Most nodes are near the leaves and travel a short distance |
| “Running median of a stream” | Whether you know the two-heap trick | Max-heap for the low half, min-heap for the high half, rebalanced to differ by at most one |
| “Find the k-th largest in average” | Breadth | Quickselect. average, worst, and it mutates the input — trade-offs worth naming |
Self-check
Section titled “Self-check”-
To find the k LARGEST elements, which heap do you use and why?
The inversion is the insight. A max-heap of size k would put the wrong element within reach, so you could not cheaply decide what to discard.
pch.quizShowAnswer
B — A min-heap of size k — its root is the smallest of the k best, making it both the eviction candidate and the final answer — The inversion is the insight. A max-heap of size k would put the wrong element within reach, so you could not cheaply decide what to discard.
-
Is heapq.heapify O(n) or O(n log n)?
A common follow-up. heapify(existing_list) is asymptotically better than a loop of heappush, and the reason is the distribution of node depths.
pch.quizShowAnswer
B — O(n) — sifting down from the middle backwards, since most nodes are near the leaves and travel only a short distance — A common follow-up. heapify(existing_list) is asymptotically better than a loop of heappush, and the reason is the distribution of node depths.
-
Python's heapq is a min-heap only. How do you get max-heap behaviour?
A small language-specific detail that interviewers listen for, and worth saying before you are asked.
pch.quizShowAnswer
B — Negate the values on push and negate again on pop, or push (-priority, item) tuples — A small language-specific detail that interviewers listen for, and worth saying before you are asked.
-
You need the running median of a stream. What structure?
The median is then a root, or the mean of the two roots. The step people omit is rebalancing after every insert to preserve the size invariant.
pch.quizShowAnswer
B — Two heaps — a max-heap for the lower half and a min-heap for the upper half, sizes differing by at most one — The median is then a root, or the mean of the two roots. The step people omit is rebalancing after every insert to preserve the size invariant.
Recall card
Section titled “Recall card”- Use when — repeated smallest/largest, top k, k-th, running median, or priority-ordered processing.
- Versus sorting — sorting buys all the order for ; a heap buys the extremes on demand for each and never pays for unused order.
- Costs — push and pop ; peek ;
heapify; search (a heap cannot find arbitrary elements). - Top k — a size-
kmin-heap for the k largest. time, space. - Python — min-heap only. Negate for a max-heap.
(-priority, item)tuples. - Two heaps for a running median, rebalanced to differ by at most one.
- A binary heap is a complete tree stored in a flat array; index math (, , ) replaces pointers entirely.
- Insert sifts up, pop sifts down — both walk at most the tree height, .
heapqis always a min-heap; negate values for a max-heap.heapifybuilds a heap from an existing list in , not .- Top-k pattern: a size- min-heap keeps the running top- in total.
Next: Tries — a tree specialized for prefix search over strings.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading