58The heap — a tree that lives in an array
In Chapter 57 we spent two pointers per node to keep a tree searchable. We also fought that tree on every insert to keep it balanced. Here we strike the opposite bargain: a tree that spends no pointers at all. A heap is a binary tree with a rule so tight that its shape no longer needs storing. It folds flat into one contiguous array, and pure arithmetic walks it. Here's the plan. We'll fold the tree into an array, then navigate it by doubling and halving a single index. After that we add and remove items by letting one value climb or sink along a single path. And the whole way through we keep asking the one thing that matters — if the tree isn't stored anywhere, what is actually holding its shape? By the end you'll see why the smallest of a million items always sits at index 0. You'll see why pulling it out costs only ~20 hops. And you'll see why every priority queue, scheduler, and shortest-path frontier you've ever used is this one flat array underneath.
01A whole tree, and not one pointer
Let's start with two words, each defined the moment it lands. A complete binary tree is filled level by level, top to bottom, and each level runs left to right. Every level is full except possibly the last, and the last is packed to the left with no gaps. That "no gaps" rule is the whole secret, so hold onto it. The second word is the heap property. In a min-heap, every parent is less than or equal to both of its children. That is not sorted. It is just parent-below-children, everywhere. Two consequences fall straight out. The smallest value in the whole tree has nobody smaller above it, so it must sit at the root. And the arrangement is only a partial order, so the root's two children can sit in either order relative to each other.
Let's make that concrete before it stays abstract. Take [1, 3, 6, 5, 9, 8, 7], the heap we'll use all chapter. The root is 1, the smallest value, exactly where the rule forces it. Its children are 3 and 6, both larger — good. Node 3's children are 5 and 9, both larger than 3 — good again. Notice what is not required. The 6 sits to the right of 3, yet 6 is bigger than 3. Siblings never have to be ordered, and whole subtrees never have to be ordered against each other. Only the parent-child edge is ever checked. Flip the rule to parent ≥ children and you get a max-heap, with the largest value at the root. Everything in this chapter works the same way, just mirrored.
Now watch the memory picture, this volume's obsession. Back in the binary-tree chapter, a plain binary tree stored two pointers in every node just to remember where its children live. It had to, because the nodes sat at unrelated heap addresses. A complete tree has no gaps. So number its nodes in reading order: root 0, then 1, 2, then 3, 4, 5, 6. Those numbers are dense — 0, 1, 2, 3, … with nothing missing. Dense integers starting at zero are exactly what an array index is. So drop the values into a flat array in that order, and the tree is gone as a scatter and present as an array. The shape isn't stored anywhere — it's implied by position. That is the human insight this whole chapter rests on.
heappop — take the root, promote the last name, sift it down, and the new minimum has risen to index 0 in O(log n)(urgency, arrival, patient) — Python compares tuples left to right, so the clock breaks the tie and triage stays fair2i+1 / 2i+2 all the way down — an address computed, never stored, exactly like seat K14 (ch 55)It's worth seeing why the no-gaps rule can't bend. Imagine the last level had a hole — say index 4 was empty but index 5 was filled. Then the value we're calling "node 5" isn't really the sixth node in reading order anymore, and its index no longer tells us where its parent sits. The position-to-shape link snaps, and every formula in the next section returns the wrong slot. So completeness isn't a nicety we'd merely like to keep. It is the load-bearing wall: the single promise that lets a position stand in for a stored pointer. That is why every heap operation is built to preserve it.
[1, 3, 6, 5, 9, 8, 7] is a complete tree and a flat array at once. Number the tree in reading order and the numbers are the array indices. No node stores a pointer — position carries the shape.list — a contiguous array of references (Volume 1) — that you have agreed to read as a tree. The tree exists only in how you index it. That agreement is the entire data structure: same bytes as a list, a completely different set of things you can do fast.So how heavy is it? Weightless, by tree standards — but measure, don't guess. A heap of n items is one list of n references. That costs about 8 bytes per element of structure, plus one small header. The pointer-based tree node from the binary-tree chapter spent 56 bytes per node just to hold a header and two child pointers. The heap pays none of that.
import sys
heap = [1, 3, 6, 5, 9, 8, 7] # a heap is just a list
print(sys.getsizeof(heap)) # 120 → header + 7 eight-byte references
big = list(range(100_000)) # 100k-element heap
print(sys.getsizeof(big)) # 800056 → 8.00 bytes/element of structure
print(sys.getsizeof(big) / 100_000) # 8.00056Line 3, on CPython 3.12.7: the seven-element heap weighs 120 bytes. That is a list header plus seven 8-byte slots. Line 6: a 100,000-element heap is 800,056 bytes, which is line 7's 8.00 bytes of structure per element. The pointer tree needed 56 bytes per node for the same 100,000 nodes. That is 5,600,000 bytes, measured, and 7× heavier. The heap is the lightest tree there is, because it is the only tree that stores zero shape. (As always, the values themselves are separate objects on the heap. The array holds references, exactly like any Python list.)
h[-1], with the values in between roughly in order. Settling it borrows one word and two functions from later in this chapter, and all three are pinned down here so that nothing is taken on trust. The word is leaf — a node with no children, which in an array-heap means a node whose first child index 2i+1 has already run off the end, so every index from n//2 onward is one; check it against the figure above, where n = 7 and the leaves are indices 3, 4, 5, 6 (section 05 derives this properly and puts it to work). The functions are heapq.heapify(lst), which rearranges an ordinary list into a valid heap, in place (section 05 shows how, and why it is cheap), and heapq.heappop(lst), which removes and returns the current minimum (section 04). That is the whole borrowing.import heapq
h = [8, 4, 6, 2, 9, 3, 7, 1]
heapq.heapify(h) # a real, valid min-heap
print(h)
print("index 0:", h[0], " last slot:", h[-1], " actual max:", max(h), "at index", h.index(max(h)))
s = sorted(h) # a sorted array is ALSO a valid heap
before = list(s)
heapq.heapify(s) # ...so heapify finds nothing to fix
print(s, " heapify moved anything?", s != before)
a = [heapq.heappop(h) for _ in range(len(h))]
b = [heapq.heappop(s) for _ in range(len(s))]
print("two different arrays, one pop order:", a == b, a)[1, 2, 3, 4, 9, 6, 7, 8] index 0: 1 last slot: 8 actual max: 9 at index 4 [1, 2, 3, 4, 6, 7, 8, 9] heapify moved anything? False two different arrays, one pop order: True [1, 2, 3, 4, 6, 7, 8, 9]
[1, 2, 3, 4, 9, 6, 7, 8] is a genuinely valid min-heap — check every parent against its children and not one pair fails. It also looks almost sorted, which is the trap doing its work in the open. Line 2 is the bill. The last slot holds 8; the actual maximum is 9, sitting at index 4, four slots from the end and squarely in the middle of the picture. Which slot it landed in is luck; that it landed in a leaf is not. With n = 8 the leaves are indices 4 through 7, and here is the guarantee you actually have, which is worth owning precisely because it is weaker than the one you wanted. Take any node holding the maximum: if it had a child, that child would have to be at least as large, so the child holds the maximum too. Walk down and you always land on a leaf. So the largest value always turns up somewhere in indices n//2 … n-1, and which of those it is, nothing records — max(h) has to look. Lines 3 and 4 are the twist that makes the belief forgivable. A sorted array satisfies parent ≤ children everywhere by construction, so it is already a valid heap: heapify ran on [1, 2, 3, 4, 6, 7, 8, 9] and moved nothing at all. Sorted implies heap; heap does not imply sorted. And draining both arrays handed back the same eight values in the same order from two different layouts — which is the whole deal in one line: a heap is not an ordering, it is a promise about the front, and a sorted array is simply the one heap where the belief you started with happens to be true.It turned into a calculation. When there are no gaps, the address of a child isn't stored — it's computed. →
02The address of a child is a formula
Here is why "no gaps" buys everything. Number the tree in reading order and look at any node at index i. Its two children are the next two nodes to be laid down after all of i's left-neighbours filled their children — and because the tree is complete, "next two" lands at fixed offsets. The result is three formulas that never change:
- left child of
iis at2*i + 1 - right child of
iis at2*i + 2 - parent of
iis at(i - 1) // 2(integer division — the two child formulas run backwards)
Check it against the figure. Node [1] holds 3, so its children should be at 2·1+1=3 and 2·1+2=4. Indices 3 and 4 hold 5 and 9, the two nodes drawn beneath it. Correct. Node [5] holds 8, so its parent is at (5-1)//2 = 2. Index 2 holds 6, drawn directly above it. Correct. There is no search and no pointer dereference. Navigating the tree is just doubling and halving an integer. That is O(1) per hop, and it is the cheapest kind of O(1) — a shift and an add, with no memory touched to find the address.
Where does 2i+1 actually come from? Count the tree level by level. Level 0 holds 1 node, level 1 holds 2, and level 2 holds 4 — each level doubles, because every node above spawns two below with no gaps between them. So the nodes sitting before i, plus i itself, each contribute two children packed tightly to the left. Do that bookkeeping and the left child of i always lands at 2i+1, with the right child one slot later at 2i+2. Test the smallest case. The root is i=0, so its children are at 1 and 2, the first two slots after it. The doubling in the tree is the doubling in the formula.
def left(i): return 2*i + 1
def right(i): return 2*i + 2
def parent(i): return (i - 1) // 2
h = [1, 3, 6, 5, 9, 8, 7]
print(h[left(1)], h[right(1)]) # 5 9 → the children of node 3
print(h[parent(5)]) # 6 → the parent of node 8Those three one-liners are the complete navigation system of a heap. There is no Node class and no .left/.right fields — there is nothing on the heap but the array. Lines 6–7 ran on CPython 3.12.7 and printed exactly 5 9 and 6, matching the diagram.
We can climb and descend for free. Now use it: to add a value and keep the heap a heap, you only ever walk one path — straight up. →
03Insert = drop it at the end, then let it climb
Adding to a heap has to preserve two things at once: completeness (no gaps) and the heap property (parent ≤ children). Completeness pins down where the new value goes. The only spot that keeps the tree gapless is the very next array slot, so we append it to the end. That is O(1) amortized, the list's own trick from Volume 1. But the newcomer may be smaller than its parent, which breaks the heap property on that one edge. So we repair it by sift-up, also called bubble-up or percolate-up. Compare the new value with its parent. If it's smaller, swap the two. Then repeat from the new position, until it's no longer smaller than its parent, or it reaches the root.
Why is that correct, and why O(log n)? It is correct because each swap fixes the only broken edge, and can only create a new broken edge one level up. The disorder travels upward and nowhere else. It is cheap because it walks a single root-ward path, and the tree's height is ⌊log₂n⌋. A complete tree is as short as a tree can be. So a heap of a million items is 19 levels tall, which means an insert touches at most 19 slots. Watch it happen with a real value:
Insert 2 into [1, 3, 6, 5, 9, 8, 7], and this is exactly the trace CPython 3.12.7 produced. 2 is appended at index 7. Its parent at (7-1)//2=3 is 5, and 2 < 5, so they swap. Its new parent at index 1 is 3, and 2 < 3, so they swap again. Its next parent at index 0 is 1, and 2 ≥ 1, so it stops. Two swaps, and the final array is [1, 2, 6, 3, 9, 8, 7, 5]. In real code you never hand-roll this. Python's heapq is the heap, operating in place on an ordinary list.
import heapq
h = [1, 3, 6, 5, 9, 8, 7] # already a valid min-heap
heapq.heappush(h, 2) # insert 2, sift-up runs internally
print(h) # [1, 2, 6, 3, 9, 8, 7, 5]
print(h[0]) # 1 → the minimum, O(1), always at index 0heapq.heappush (line 3) does the append-then-sift-up you just watched, in C, in place on the list h. Line 4 printed [1, 2, 6, 3, 9, 8, 7, 5], the same array the trace produced. And line 5 is the quiet superpower. The minimum is always at h[0], so peeking at it is a single array read. That is O(1), no matter how many items the heap holds.
One caveat that trips everyone the first time: Python's heapq is a min-heap only. There is no max flag to set. When you need the largest value out each time, the standard trick is to negate on the way in: push -x instead of x, and negate again when you pop. The smallest of the negatives is the largest of the originals, so -heappop(h) hands back the true maximum. Push -5, -1, -8 and the heap's root is -8, so the first pop returns 8. It feels like a hack, but it's the idiom you'll meet in real code everywhere.
heapq is a min-heap only — for a max-heap, negatemaxheap flag. To get the largest-on-top behaviour, push -value and negate again on the way out (or wrap items in a key). It's a wart, but a cheap one — and the two-heap median trick later leans on exactly this.Peeking at the minimum is free. But a priority queue has to remove it and hand you the next-smallest — and the root is the one slot you can't just delete without tearing a hole in the tree. →
04Extract-min = promote the last, then let it sink
To remove the minimum, you can't simply erase index 0. That would orphan its two subtrees. The trick mirrors insert. Take the root's value so you can return it. Now you need to fill the hole at the top and keep the tree complete. Exactly one value can be removed without leaving a gap: the last one in the array. So move that last element up to the root, shrinking the array by one. It almost certainly violates the heap property now, because a leaf value sits at the top. So repair it downward with sift-down, also called bubble-down. Compare the value with its smaller child. If it's larger, swap with that child. Repeat until it's ≤ both children, or it reaches a leaf. Comparing against the smaller child is the subtle, essential detail. Swap with the larger one, and you'd just recreate the violation.
Here is the smaller-child rule made concrete, because it's the single most common heap bug. Suppose 7 has just been promoted to the top, over children 3 and 6. Swap 7 with the larger child, 6, and now 6 sits above 3 — but 6 is bigger than 3, so the parent-child edge is broken all over again. Swap 7 with the smaller child, 3, instead, and 3 rises above 6, which it's already smaller than. The smaller child is the only one guaranteed to be ≤ its new sibling, so it's the only safe value to lift. Pick it, and the violation moves strictly downward until it runs out of tree.
[1,3,6,5,9,8,7]: return 1, promote the last value 7 to the root, and sink it past its smaller child twice — landing at [3,5,6,7,9,8]. One root-to-leaf path, O(log n).That figure is the exact run from CPython 3.12.7. Pop 1, then move last value 7 to the root. Now 7 > 3, the smaller of children 3 and 6, so swap. Then 7 > 5, its new smaller child, so swap again. It is a leaf now, so stop. The final heap is [3, 5, 6, 7, 9, 8], and the new minimum 3 has surfaced at index 0. The whole operation walked one path down, so it is O(log n) — 19 steps for a million items.
import heapq
h = [1, 3, 6, 5, 9, 8, 7]
heapq.heapify(h) # make sure it obeys the heap property
smallest = heapq.heappop(h) # remove & return the min; sift-down runs internally
print(smallest) # 1
print(h) # [3, 5, 6, 7, 9, 8]heappop (line 4) performs the promote-last-then-sift-down you just traced, and hands back the minimum. Line 5 printed 1, and line 6 printed [3, 5, 6, 7, 9, 8], matching the figure exactly. Push and pop together give you a priority queue. It is a bag you throw items into in any order, and always draw the smallest or highest-priority one out. Each operation is O(log n), and the peek is free.
That one shape — throw items in, always pull the most urgent out — is quietly everywhere. Dijkstra's shortest-path algorithm keeps a heap of frontier nodes and repeatedly pops the closest one, and that pop is the heart of the routing in your maps app. An operating-system scheduler keeps runnable threads in a priority structure and pops the highest-priority one to run next. A discrete-event simulator keeps future events keyed by timestamp and pops whichever happens soonest. None of these ever needs the whole set sorted. They need the next extreme, now, over and over — which is precisely the one thing a heap does in O(log n).
h[1] is the second-smallest item. Both wrong. A heap enforces only a partial order — parent ≤ children — and says nothing about siblings or cousins. In [1, 2, 6, 3, 9, 8, 7, 5] the second-smallest is 2, which happens to be at index 1 here, but in general the runner-up can be at index 1 or index 2 and nowhere else — you'd have to compare them. The only guarantee a heap gives is the item at the top. That is the deal: a heap is not "sorted for cheap," it's "the single most extreme item, always, for cheap." Ask it for order and it has none to give.Myth
"A heap keeps my data sorted, so I can read it out in order by scanning the array."Reality
Scanning a heap's array gives you level-order garbage, not sorted order. To get sorted output you mustheappop repeatedly — n pops at O(log n) each, which is O(n log n). That algorithm has a name: heapsort.Push, pop, peek — the whole priority queue. But there's a faster way in than pushing one at a time. Handing a heap a whole array at once costs less than you'd ever guess. →
05Building a heap in O(n) — the pleasant surprise
You have an array of a million values in random order, and you want a heap. The obvious way is to start empty and heappush each item. That is n inserts at O(log n) apiece, so O(n log n) total. But there's a better way, and its cost is a genuine surprise. Heapify works bottom-up, in place. Skip the leaves — the second half of the array, which are already valid one-element heaps. Then run sift-down on every internal node, from the last one back to the root. When you fix a node, its two subtrees are already heaps, so one sift-down settles it.
Why is "the second half" exactly the leaves? A node is a leaf when it has no children, and node i's first child would sit at 2i+1. So i is a leaf the moment 2i+1 runs past the end of the array. Work that out and every index from n//2 onward is a leaf. For our seven-element heap, 7//2 = 3, so indices 3, 4, 5, 6 — the last four — are leaves, and only 0, 1, 2 are internal nodes worth sifting. Half the array does zero work, which is the first hint that heapify is cheaper than it looks.
Now the surprise, and it's worth deriving, because one intuition is exactly the trap. That intuition says "n nodes times log n each = O(n log n)". It assumes every node sinks the full height, and it doesn't. Half the nodes are leaves and do zero work. A quarter sit one level up and sink at most one step. An eighth sink at most two. In general, the many nodes near the bottom have almost no distance to fall. Only the lone root can fall the full ⌊log₂n⌋. Sum the real work — n·(1/2·0 + 1/4·1 + 1/8·2 + 1/16·3 + …) — and the series in parentheses converges to a constant, under 1. So total work is proportional to n. Heapify is O(n): you can turn a raw array into a heap in linear time.
import heapq, random
data = [random.randint(0, 10**9) for _ in range(1_000_000)]
# way 1: push one at a time — O(n log n)
h1 = []
for x in data: heapq.heappush(h1, x)
# way 2: heapify the whole array in place — O(n)
h2 = list(data)
heapq.heapify(h2) # ~2.3x faster here, and the gap widens with nTimed on this machine (CPython 3.12.7, and timings are machine-dependent): building the million-item heap by repeated heappush took about 65 ms. heapify on the same data took about 28 ms, roughly 2.3× faster, and both produced a valid heap with the same minimum. The measured gap here is modest, because Python's per-operation overhead dominates. But the asymptotic difference — O(n) versus O(n log n) — only grows as the data does. When you have all the items up front, always reach for heapify.
heapq — the triage nurse, written in Cno Heap class, no max flag: a plain list, six functions, and one tuple conventionheapify is O(n) and heappush×n is O(n log n). When the whole room is already in front of you — a file read, a query result, a batch — heapify it. Push only for what arrives afterwards.peek(), because there does not need to be. The minimum is at index 0 by definition, so a peek is one array read, no matter how large the heap. lst[0] above answered “who is next” without touching the other five patients.nsmallest(3, waiting) returned the three most urgent without ordering the rest. For k=1 use min/max, and for k near n use sorted — the module's own docs say so.[7, 2, 9, 4]: heappop of the heap gave 2, while heapifying the negatives and negating the pop gave 9. For tuples, negate only the priority field: (-urgency, arrival, patient).INPUTimport heapq
# (urgency, minutes past 14:00, patient) - 1 = resuscitate now, 5 = can wait
waiting = [
(3, 0, "you - sprained wrist"),
(4, 15, "sore throat"),
(1, 120, "chest pain"),
(2, 35, "deep cut, bleeding"),
(3, 8, "twisted ankle"),
(5, 5, "repeat prescription"),
]
room = list(waiting)
heapq.heapify(room) # O(n) - the whole room in one pass
print("the array after heapify:")
for slot, p in enumerate(room):
print(f" [{slot}] {p}")
print("who is next:", room[0], " <- O(1) peek, no scan")
print("\ntreatment order:")
order = list(room)
while order:
urgency, arrived, who = heapq.heappop(order)
print(f" urgency {urgency} arrived +{arrived:3} min {who}")
heapq.heappush(room, (1, 140, "stroke symptoms")) # a walk-in, mid-shift
print("\nafter one push, next is:", room[0])
print("three most urgent :", [p[2] for p in heapq.nsmallest(3, waiting)])
print("least urgent :", [p[2] for p in heapq.nlargest(1, waiting)])
print("heappushpop (burn) :", heapq.heappushpop(room, (2, 200, "burn"))[2])OUTPUTthe array after heapify:
[0] (1, 120, 'chest pain')
[1] (2, 35, 'deep cut, bleeding')
[2] (3, 0, 'you - sprained wrist')
[3] (4, 15, 'sore throat')
[4] (3, 8, 'twisted ankle')
[5] (5, 5, 'repeat prescription')
who is next: (1, 120, 'chest pain') <- O(1) peek, no scan
treatment order:
urgency 1 arrived +120 min chest pain
urgency 2 arrived + 35 min deep cut, bleeding
urgency 3 arrived + 0 min you - sprained wrist
urgency 3 arrived + 8 min twisted ankle
urgency 4 arrived + 15 min sore throat
urgency 5 arrived + 5 min repeat prescription
after one push, next is: (1, 120, 'chest pain')
three most urgent : ['chest pain', 'deep cut, bleeding', 'you - sprained wrist']
least urgent : ['repeat prescription']
heappushpop (burn) : chest pain- Printing a heap does not print it sorted, and index 1 is not the runner-up. Look at the ER array above: slot 3 holds urgency 4 while slot 4 holds urgency 3, and it is still a perfectly valid heap — because the only promise is parent ≤ children. Heapifying
[9, 8, 7, 6, 5, 4, 3, 2, 1]gave[1, 2, 3, 6, 5, 4, 7, 8, 9], and[5, 1, 9, 3, 7, 2, 8, 6, 4]gave[1, 3, 2, 4, 7, 9, 8, 6, 5]. Both popped in perfect order. Reading the list tells you nothing; only popping does. - A tie falls through to the next tuple field — and if that field is your object, the push raises. Pushing
(3, Patient("you"))then(3, Patient("ankle"))gaveTypeError: '<' not supported between instances of 'Patient' and 'Patient'. The fix is a never-tying middle field:seq = itertools.count(), then push(urgency, next(seq), patient). That popped['chest pain', 'you', 'ankle']— ties broken in arrival order, which is usually exactly what you wanted anyway. - Editing a priority in place silently breaks the heap. Starting from
[[2, 'deploy'], [5, 'backup'], [8, 'cleanup']]we settasks[2][0] = 1— “make it urgent” — giving[[2, 'deploy'], [5, 'backup'], [1, 'cleanup']], where a 1 now sits below a 2.heappopreturned[2, 'deploy']: not the minimum, no error. There is nodecrease_keyinheapq. Eitherheapifyagain (O(n)) or do what real Dijkstra code does — push a fresh entry and skip the stale one when it surfaces.
heapq has no Heap class — the heap is your listHeap object; heapq is a set of functions that treat any plain list as a heap, mutating it in place. That's why heapify costs no extra array and heapsort runs in O(1) spare space — and why you must never .append() or .sort() the list yourself between heap calls, or you'll silently break the property the functions rely on.The deeper cut — heapsort, and the heap that isn't binary
Chain heapify with repeated extract-min and you have heapsort: heapq.heapify(a), then pop n times. Heapify is O(n), and each of the n pops is O(log n), so the whole sort is O(n log n). That matches the sorting lower bound from Volume 3, with the heap doing all the bookkeeping. Run on [5,2,9,1,7,3,8,4,6,0], it returned [0,1,2,…,9]. Heapsort's quiet virtue is space. Because the heap lives in the array you're sorting, it needs only O(1) extra memory, with no second array like mergesort. Its vice is the cache story from the binary-tree chapter, inverted. The 2i+1 index jumps stride across the array and misses cache, which is why quicksort usually wins the wall-clock race despite the same O(n log n). And the "2" in 2i+1 isn't sacred. A d-ary heap gives each node d children, at d·i+1 … d·i+d, making the tree shallower. That means faster sift-up and insert, but slower sift-down. Timer wheels and some priority queues tune d to the workload.
One practical note for when your items aren't bare numbers. To queue real work — say a task with a priority — you push a tuple like (priority, task), because Python compares tuples left to right and the heap only ever needs the first field. But there's a trap. If two priorities tie, Python falls through to comparing the second field, and if task is an object with no natural order, that raises TypeError. The fix is a throwaway tie-breaker: push (priority, count, task) with an ever-increasing count, so no two tuples ever reach the task field to compare. It also makes ties break in insertion order, which is usually exactly what you want.
Peek in O(1), push and pop in O(log n), build in O(n), sort in O(n log n) — all from one flat array. Now the only question left is the one that matters most: when, on sight, is a heap the right container? →
06When to reach for a heap
The heap answers one access pattern, and it answers that pattern better than anything else: "give me the most extreme item, over and over, while items keep arriving." Not "sort everything" — that's a one-shot. Not "look things up by key" — that's what a hash map is for. The heap's home is the running extreme. It is a set that changes under you, from which you repeatedly pluck the min or max. The instant a problem sounds like "always process the smallest, largest, nearest, or highest-priority next," the heap is the answer. Choosing it on sight is most of what "good at data structures" means.
The clean way to feel the win is top-k. To find the 10 largest of a million numbers, you do not sort the million, which is O(n log n). You keep a heap of just 10, and let each new number bump the smallest survivor. That is O(n log k).
That "heap of just 10" hides the whole trick. Sorting forces you to hold every number at once so it can rank them. The heap holds only the k survivors. Each new value either bumps the smallest of them, or falls away forever. Drag the pile from a thousand up to a billion, and watch what the two approaches must keep in memory:
import heapq, random
scores = [random.randint(0, 10**6) for _ in range(1_000_000)]
top10 = heapq.nlargest(10, scores) # a size-10 heap sweeps the stream once
Measured on this machine: heapq.nlargest(10, scores) over a million values took about 14 ms. That is against about 149 ms for sorted(scores, reverse=True)[:10]. Same ten numbers, roughly 10× faster, because the heap never pays to order the 999,990 items you were going to throw away. That is the whole art. The heap sorts nothing. It just keeps the boundary.
sorted(itertools.chain(*streams)) is a single line, it is obviously correct, and it earns a second job below as the referee that any cleverer answer has to match line for line and sum for sum. It is not even slow. Python's sort spots the already-ordered runs inside the concatenation and merges them rather than re-sorting from scratch, so on two million lines it finished the entire job in 0.27 s. If the instinct were simply wrong this would be a short story. It is not wrong. It is differently shaped from the problem, and the shape is what you pay for.sorted cannot return until it has every line, so building that one list peaked at 89.1 MB for two million integers, and the first line of output was not available until 0.184 s in — which is to say, not until after the last line had already arrived. Both of those numbers scale with the total, not with the number of streams. Point it at eight files ten times bigger and the memory goes with them. Point it at a socket that is still receiving and the sort never returns at all, because there is no last line for it to wait for. That is the real failure mode and it is not a gradual one: the machine gives out before the algorithm does. There is a quieter tell underneath, though, and it is the one that leads somewhere. Every one of those two million lines was compared into position — and they arrived already ordered within their own stream. We paid, at length, to rediscover order we had been handed for free.frontier[0] is the winner, for free. Emit it, pull that stream's next line, and put the replacement back — and because that is a pop and a push against the same heap, heapreplace does both in one sift instead of two, which is the toolbelt line that finally earns its keep. Nothing is ever sorted here. The heap is a window of size k, and the orderedness of the output is inherited from the streams rather than manufactured. Push each head inside a tuple, (value, tag, iterator), so that a tie on value falls through to the counter and never reaches the iterator — the same tuple discipline the triage queue runs on.Two honest edges, both visible in the run below. First, the heap merge is not faster. End to end it took 0.52 s against sort-it-all's 0.27 s, because a generator suspend-and-resume plus a sift per line costs more than one C-level merge pass across a list that is already sitting in RAM. What it bought is the other three rows of the table: the first line came out in 0.00003 s instead of 0.184, peak memory was 2.9 KB instead of 89.1 MB, and the number of lines held at once was 8 instead of two million. You are not buying speed. You are buying a cost that has stopped depending on the size of the data — roughly 2× the wall clock for roughly 30,000× the memory. Take that trade when the data will not fit or will not stop, and refuse it when the whole pile is already in front of you. Second, you do not have to write this. The standard library ships this exact loop as
heapq.merge — a heap of [value, order, next] entries, peeked at index 0 and restored with heapreplace — which is why it clocked the same 0.52 s on the same data and produced the same 2,000,000 lines. Write it once so you own it, then import it forever.import heapq, itertools, time, tracemalloc
K, M = 8, 250_000 # 8 already-sorted files, 250k lines each = 2,000,000 lines
def log_file(offset): # stands in for one sorted file, read one line at a time
for i in range(M):
yield i * K + offset
def merge_sorted(streams):
"""k ordered streams in, one ordered stream out - k items held, ever."""
frontier = []
for tag, it in enumerate(streams): # one head per stream
head = next(it, None)
if head is not None:
heapq.heappush(frontier, (head, tag, it)) # tag = the never-tying middle field
while frontier:
value, tag, it = frontier[0] # O(1) peek: the winner is on top
yield value
nxt = next(it, None)
if nxt is None:
heapq.heappop(frontier) # that stream is spent
else:
heapq.heapreplace(frontier, (nxt, tag, it)) # pop + push in ONE sift
def files(): return [log_file(o) for o in range(K)]
def sort_it_all(): return sorted(itertools.chain(*files()))
def drain(src):
n = total = 0
for v in src:
n += 1; total += v
return n, total
print("three tiny streams :", list(merge_sorted([iter([1,4,9]), iter([2,3,10]), iter([5,6,7,8])])))
t = time.perf_counter(); next(iter(sort_it_all())); f_sort = time.perf_counter() - t
t = time.perf_counter(); next(merge_sorted(files())); f_heap = time.perf_counter() - t
t = time.perf_counter(); a = drain(sort_it_all()); w_sort = time.perf_counter() - t
t = time.perf_counter(); b = drain(merge_sorted(files())); w_heap = time.perf_counter() - t
t = time.perf_counter(); c = drain(heapq.merge(*files())); w_std = time.perf_counter() - t
tracemalloc.start(); drain(sort_it_all()); p_sort = tracemalloc.get_traced_memory()[1]; tracemalloc.stop()
tracemalloc.start(); drain(merge_sorted(files())); p_heap = tracemalloc.get_traced_memory()[1]; tracemalloc.stop()
print("same lines, same sum, all three :", a == b == c, a)
print()
print(" sort-it-all heap merge")
print("first line available %10.3f s %10.5f s" % (f_sort, f_heap))
print("all 2,000,000 lines %10.2f s %10.2f s" % (w_sort, w_heap))
print("peak memory %10.1f MB %10.1f KB" % (p_sort/1e6, p_heap/1e3))
print("lines held at once %10d %10d" % (a[0], K))
print()
print("stdlib heapq.merge %10.2f s (the same loop, shipped)" % w_std)three tiny streams : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
same lines, same sum, all three : True (2000000, 1999999000000)
sort-it-all heap merge
first line available 0.184 s 0.00003 s
all 2,000,000 lines 0.27 s 0.52 s
peak memory 89.1 MB 2.9 KB
lines held at once 2000000 8
stdlib heapq.merge 0.52 s (the same loop, shipped)
heapq never updates an entry in place — it pushes a fresh, smaller one and simply skips the stale copy when it pops it (lazy deletion). Reach for a heap only when the item you want is always the extreme; the moment you need to find middle items, you've chosen the wrong container.Finally, the boundary that trips people: heap versus the binary search tree of the last chapter. Both are binary trees, but they are opposites in purpose. A BST enforces a total order (left < node < right), so it can find any value and walk everything in sorted order. The cost is that it's a pointer structure that must fight to stay balanced. A heap enforces only a partial order (parent ≤ children), so it can find one value, the extreme. In return it lives pointerless in an array, always perfectly balanced by construction. Ask "is 42 in here?" and the heap is useless — O(n), because it must scan. Ask "what's the smallest?" and the BST is merely fine, while the heap is instant.
That is the 1% move in one line: match the question to the layout. "Any key, in order" points to a BST or a hash map. "The extreme, repeatedly, as data streams" points to a heap. Recognising which of those two questions a problem is really asking, before you type, is the whole skill. import heapq is the easy part that follows.
(minute, urgency, patient): you at t=0 with a sprained wrist (urgency 3), a repeat prescription at 5 (urgency 5), a twisted ankle at 8 (3), a sore throat at 15 (4), a bleeding cut at 35 (2), and chest pain at 120 (1). Write treatment_order(events) that walks the clock forward, admits everyone who has arrived by now, and calls the most urgent waiting patient every twenty minutes. Print who is called, and when.Two things will bite you. First, the ties: two patients at urgency 3 must come out oldest-wait first, and if your tuple ends at the patient's name Python will happily compare strings and give you alphabetical triage. Second, the room can be empty while events remain — at t=100 nobody is waiting and chest pain has not walked in yet, so your loop must jump the clock forward rather than pop from an empty heap.
Now the twist that makes it real. At t=40 the sore throat turns out to be an airway problem: urgency 4 becomes urgency 1. There is no way to reach into a heap and change a priority — that is the danger callout in this chapter, not a gap in your code. So do what production schedulers do: push a fresh entry at the new urgency, keep a
live dict of the urgency you currently trust for each patient, and when a popped entry disagrees with it, drop that entry on the floor and pop again. That is lazy deletion, and once you have written it once you will recognise it in every priority queue you ever read.show the solution
import heapq, itertools
# (minute the event happens, urgency, patient) - one night, one triage nurse
events = [
( 0, 3, "you - sprained wrist"),
( 5, 5, "repeat prescription"),
( 8, 3, "twisted ankle"),
( 15, 4, "sore throat"),
( 35, 2, "deep cut, bleeding"),
( 40, 1, "sore throat"), # RE-TRIAGE: the throat turns out to be an airway
(120, 1, "chest pain"),
]
def treatment_order(events, minutes_each=20):
room, seq = [], itertools.count()
live = {} # patient -> the urgency we trust
log, clock, i = [], 0, 0
events = sorted(events)
while i < len(events) or room:
while i < len(events) and events[i][0] <= clock: # everyone in by now
minute, urgency, who = events[i]; i += 1
live[who] = urgency
heapq.heappush(room, (urgency, minute, next(seq), who))
if not room: # empty room: wait for the door
clock = events[i][0]; continue
urgency, arrived, _, who = heapq.heappop(room)
if live.get(who) != urgency: # a stale copy - skip it
continue
log.append((clock, urgency, arrived, who))
del live[who]
clock += minutes_each
return log
print("treatment order:")
for called, urgency, arrived, who in treatment_order(events):
print(f" called at t={called:3} urgency {urgency} logged +{arrived:3} {who}")
print("\nthe tie, on purpose - two patients at urgency 3:")
tie = []
for urgency, arrived, who in [(3, 8, "twisted ankle"), (3, 0, "you - sprained wrist")]:
heapq.heappush(tie, (urgency, arrived, who))
print(" pop order:", [heapq.heappop(tie)[2] for _ in range(2)])
# ---------------- OUTPUT, verbatim (CPython 3.12.7) ----------------
# treatment order:
# called at t= 0 urgency 3 logged + 0 you - sprained wrist
# called at t= 20 urgency 3 logged + 8 twisted ankle
# called at t= 40 urgency 1 logged + 40 sore throat
# called at t= 60 urgency 2 logged + 35 deep cut, bleeding
# called at t= 80 urgency 5 logged + 5 repeat prescription
# called at t=120 urgency 1 logged +120 chest pain
#
# the tie, on purpose - two patients at urgency 3:
# pop order: ['you - sprained wrist', 'twisted ankle']Next (chapter 59): we've keyed data by number and by hash, and ordered it by comparison. But some data is made of sequences of symbols — words, IP addresses, DNA — and there's a tree that branches not on "bigger or smaller" but on the next character, turning "find every word starting with str" into a walk of three steps. That's the trie, the structure behind autocomplete, spell-check, and how a router decides where your packet goes next. →
- A heap is a complete binary tree that has stopped storing its own shape — every level full and the last packed to the left with no gaps, which makes the node numbers dense, and dense numbers starting at zero are array indices — so the tree folds into one flat list where position carries the structure that chapter 56's tree paid 56 bytes a node to keep in pointers, and the whole thing weighs 8.00 bytes per element measured.
- Because there are no gaps, a child's address is a calculation rather than a fetch: left at
2i+1, right at2i+2, parent at(i-1)//2— you descend by doubling and climb by halving, which is O(1) per hop with no memory touched to find the address, and it is why the heap streams through cache where a pointer tree stalls on a random jump at every level. - Both mutations repair a single path and nothing else: push appends to the end — the only slot that keeps the tree gapless — then sifts up while it is smaller than its parent, and pop takes the root, promotes the last value into the hole and sifts down past its smaller child, which is the essential detail, since lifting the larger one simply recreates the violation you were fixing; a complete tree is as short as a tree gets, so both walks are bounded by height — 19 slots for a million items.
- Handed the whole array at once, building the heap costs O(n), not O(n log n) — sift-down every internal node from
n//2 - 1back to the root, and the arithmetic that makes it linear is that the crowded bottom of the tree has almost nowhere to fall: half the nodes are leaves and move zero levels, a quarter move at most one, and only the lone root can fall the full height, so the weighted sum converges to a constant — measured, 28 ms against 65 for a million pushes. - The only thing a heap promises is the front:
h[0]is the minimum in O(1), and everything else — the maximum, the runner-up beyond “one of the root's two children”, membership, reaching an item to change its priority — is a scan, which is exactly the trade that lets top-k over a million values run in 14 ms against a full sort's 149, because the heap sorts nothing and only keeps the boundary.
sorted() is the answer and a heap is a slower road to it. “Still moving” rules out the frozen list — if nothing will ever be inserted again, sort once and bisect, and you get ordered answers a heap could never give. In between, the shapes are worth recognising by sight, because each is this chapter wearing different clothes: a scheduler or simulator popping the next event by time; a frontier in Dijkstra or A* popping the nearest unvisited node; top-k, where a heap of size k sweeps a stream in O(n log k) and never holds the n you are discarding; Huffman, fusing the two least-frequent symbols over and over; a running median, held between a max-heap of the low half and a min-heap of the high; and a k-way merge, where the heap holds one head per already-sorted stream and the output's order is inherited rather than computed — 2.9 KB of working set against 89.1 MB to merge the same two million lines. And know the two refusals by name — no membership test cheaper than O(n), no decrease_key — which is why the lazy deletion you just wrote in the night-shift drill is not a workaround but the standard move.2i+1 is that same idea aimed at a tree, and it is what buys a whole binary tree for zero pointers. Chapter 56 gave you the binary tree and the bill that comes with it — 56 bytes a node, and every hop a jump to an unrelated address — and chapter 57 gave you the invariant that turns a tree into a search engine, plus the fight to stay balanced that a complete tree simply never has: a BST buys a total order and must be given balance, a heap buys a partial order and gets balance free, and choosing between them is choosing which question must be cheap. Volume 1 supplied the ground twice: the list as a contiguous block of 8-byte references, which is the array a heap secretly is, and amortized append, which is why pushing costs O(1) before the sift even starts. And Volume 3 paid you back here as well — the cache line that makes a flat layout beat a scattered one at identical O(log n), and Dijkstra's frontier, which turns out to have been a heap the entire time you were reading about shortest paths.Twelve tiny heaps you can run — we fold a binary tree into one flat array, navigate it by doubling and halving a single index, and watch one value climb or sink along one path to keep the smallest always sitting at index 0.