◈ python mapVol 4 · Ch 58/62
Volume 4 Python, from the metal up · chapter 58

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.

iolinked · chapter 58 — the checkpoints6 steps
$ sections covered in The heap — a tree that lives in an array
01A whole tree, and not one pointer
02The address of a child is a formula
03Insert = drop it at the end, then let it climb
04Extract-min = promote the last, then let it sink
05Building a heap in O(n) — the pleasant surprise
06When to reach for a heap

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.

★ YOU ALREADY RUN THIS · the-emergency-waiting-roomyou got there first and you are still sitting down — and you know exactly why
Two o'clock, sprained wrist, and the triage nurse writes your name on the board. You settle in with a two-year-old magazine. At four, a man walks in grey-faced holding his chest, and he is through the double doors before he has finished giving his name. Nothing has gone wrong; you have just watched the rule work. Now notice what the nurse never does. She never sorts the room. She never ranks all twenty-three of you from worst to mildest, because she is only ever asked one question — who goes in next. And when the doors swing shut behind him she does not start again from scratch. The next-most-urgent name has simply surfaced to the top of her list.
you take a seat behind everyone already waiting, and it changes nothingchapter 52's till line served the longest wait; a heap serves the worst injury — same two moves, a different reason to be next
she never ranks the whole room, only ever the top of ita partial order: parent ≤ children, and nothing else is promised. Ask a heap for the third-most-urgent and it has no answer to give you
the doors shut, and the next-most-urgent surfaces on its ownheappop — take the root, promote the last name, sift it down, and the new minimum has risen to index 0 in O(log n)
two sprained wrists, same urgency? the one who has waited longer goes first(urgency, arrival, patient) — Python compares tuples left to right, so the clock breaks the tie and triage stays fair
the board behind the desk is one numbered row of names, and she reads it as a treethe tree lives in the array: row 0 sits above rows 1 and 2, then 2i+1 / 2i+2 all the way down — an address computed, never stored, exactly like seat K14 (ch 55)
pin it: an emergency room is not a queue — it is a heap, and a heap answers exactly one question, the only one the triage nurse ever asks: who is next?

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.

the tree (a picture in your head) — each node tagged with its position number 1[0] 3[1] 6[2] 5[3] 9[4] 8[5] 7[6] the array (what is actually in RAM) — seven values, contiguous, no pointer cells 10 31 62 53 94 85 76 reading the tree top-to-bottom, left-to-right = reading the array 0→6. They are the same seven boxes.
Fig — the heap [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.
The array is the tree
There is no separate "heap object" in memory. A heap in Python is a plain 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.

memory.pypython
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.00056

Line 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.)

Wait —
if nothing in the array records which node is whose parent, how does the machine ever walk the tree? There are no arrows to follow. Where did the "go to my child" step go?

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:

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.

arithmetic.pypython
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 8

Those 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.

i L R parent = (i − 1) // 2 left = 2i + 1 right = 2i + 2 halve to go up double to go down
Fig — the entire "which node connects to which" of a heap, as arithmetic. Double-and-add to descend, subtract-and-halve to climb. The links a pointer tree stored in RAM, a heap recomputes for free.
InteractiveClick a node — watch the formula pick its family out of the array
1 3 6 5 9 8 7 10 31 62 53 94 85 76 click a node → parent (blue) & children (green) light up by formula
blue = parent at (i−1)//2 · green = children at 2i+1 and 2i+2. Same highlight in the tree and the array, because they are one object.
Why the metal loves this tree
A pointer tree (the binary-tree chapter) chases a reference to a random RAM address at every hop, and the CPU's prefetcher stalls on the cache miss. A heap never leaves one contiguous array, and its next index is computed, not fetched — so a walk down the heap streams through memory the prefetcher already pulled in (Volume 3's cache lines). Same O(log n) hop count as a balanced pointer tree; far friendlier constant, because the layout is flat. This is the payoff the binary-tree chapter promised: put the tree in an array and you get the tree's shape with an array's speed.

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:

InteractiveInsert a value — watch it bubble up, one swap per level
1 3 6 5 9 8 7 ? 1 3 6 5 9 8 7 ? pick a value, then press insert
2
Small values climb far; large values barely move. The number of swaps is never more than the height — ⌊log₂ n⌋. That is O(log n).

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.

append 2 at index 7, then sift up — one swap per level until the parent is no longer larger 1 3 6 5 9 8 7 2 ① 2<5 ② 2<3 2 ≥ 1 → stop result: [1, 2, 6, 3, 9, 8, 7, 5] — 2 rose two levels, the root 1 never moved
Fig — the exact insert-2 trace as a picture: the newcomer climbs its single root-ward path, swapping past 5 then 3, and halts under the root. Disorder only ever travels up, so the walk is bounded by the height — O(log n).
ex2_rst.pypython
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 0

heapq.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, negate
Python ships one heap discipline: smallest on top. There is no maxheap 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.

start [1,3,6,5,9,8,7] → return 1, move last (7) to root → sift 7 down past its smaller child 7 3 6 5 9 8 7 > smaller child 3 → swap; then 7 > smaller child 5 → swap 3 5 6 7 9 8 valid heap again: [3,5,6,7,9,8] · min 3 now on top
Fig — extract-min from [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.

extract.pypython
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).

↺ The thing people get backwards
People assume a heap is sorted, or that 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 must heappop 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.

Wait —
n nodes, and each one might sink up to log n levels — so building the heap must be O(n log n), the same as pushing them one by one. Right? Count the falls more carefully before you agree.

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.

why heapify is O(n): the many are shallow, the deep are few 1 node · can sink up to log₂n n/8 nodes · sink ≤ 2 n/4 nodes · sink ≤ 1 n/2 leaves · sink 0 Σ n·(¼·1 + ⅛·2 + …) → a constant × n ⇒ O(n), not O(n log n)
Fig — heapify's cost lives in the picture: the crowded bottom levels barely move, only the sparse top can fall far. The weighted sum collapses to a constant times n — linear.
heapify.pypython
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 n

Timed 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.

THE STDLIB TOOLBELT · heapq — the triage nurse, written in Cno Heap class, no max flag: a plain list, six functions, and one tuple convention
import heapq # no Heap class - your list IS the heap heapq.heapify(lst) # the whole list, in place O(n) heapq.heappush(lst, item) # append, then sift UP O(log n) heapq.heappop(lst) # the minimum, removed O(log n) lst[0] # the minimum, PEEKED O(1) heapq.heappushpop(lst, item) # push then pop - ONE sift, not two heapq.heapreplace(lst, item) # pop then push - ONE sift, not two heapq.nsmallest(k, iterable) # a size-k heap sweeps the stream O(n log k) heapq.nlargest(k, iterable) (priority, tiebreak, payload) # the triage tuple - compared left to right heapq.heappush(lst, (-score, item)) # max-heap: negate in, negate out
heapify vs pushing one at a timeheapify 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.
lst[0]There is no 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.
(priority, tiebreak, payload)The heap only ever needs to compare, and Python compares tuples field by field until one differs. So the first field decides, the second breaks ties, and the third is just cargo. Here arrival time is the tiebreak — which is precisely what makes the triage stable and fair.
nsmallest / nlargestThe top-k sweep, with the size-k heap already written for you: 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.
max-heap by negationThere is no flag; you invert the key. Measured on [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
TRIPWIRES
  • 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")) gave TypeError: '<' 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 set tasks[2][0] = 1 — “make it urgent” — giving [[2, 'deploy'], [5, 'backup'], [1, 'cleanup']], where a 1 now sits below a 2. heappop returned [2, 'deploy']: not the minimum, no error. There is no decrease_key in heapq. Either heapify again (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 list
Unlike most languages, Python doesn't hand you a Heap 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.

🌍 Where you meet this — every day, mostly unseen
Dijkstra and A* (Volume 3, ch 13) keep their frontier in a heap — "expand the nearest unvisited node next" is a repeated extract-min, which is how your maps app finds a route and your game pathfinds. Your OS scheduler and every event-driven simulation pull the next event by time from a heap (a "timer wheel" or priority queue). Top-k — the 10 nearest restaurants, the 100 highest-scoring results, "trending now" — is a heap of size k, never a full sort. A streaming median is kept live by two heaps (a max-heap of the low half, a min-heap of the high half). Huffman coding — inside every ZIP and JPEG — repeatedly fuses the two least-frequent symbols via a min-heap. And heapsort sorts in place with O(1) spare memory. You lean on a heap dozens of times a day and never see it.

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:

InteractiveTop-k — the search you can hold in one hand
you want the 10 largest numbers in the pile. what must each method keep in memory? TO FIND THE TOP 10, EACH APPROACH MUST HOLD: sort() the whole pile — every item at once 1,048,576 items in RAM heapq.nlargest(10) — a heap of just 10 10 items — one streaming pass ITEMS YOU NEVER HAD TO HOLD 104,857× fewer you kept 10 numbers; the other 1,048,566 fell straight through — never sorted, never stored.
1.05 million
Sorting a billion numbers to keep 10 is like reading every book in a library to find the newest ten — then throwing the library away. Sorting is O(n log n) and must hold all n; a heap of k is O(n log k) in one pass and holds only k. The bars are log-scaled — the amber fills the width while the green never moves.
meta.pypython
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.

A heap can't answer "is X in here?" — or reach X — cheaply
The partial order buys the instant minimum by giving up everything else. Searching a heap for an arbitrary value is O(n) — you must scan the array, because a value could be anywhere below its ancestors. There's also no cheap decrease-key (lower an item already inside), since you'd first have to find it. This is why real Dijkstra code (Volume 3) built on 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.

BST (ch 9) — pointers · total order HEAP (ch 10) — array · partial order 5 3 8 1 9 find ANY key in O(log n) · in-order = sorted cost: 2 pointers/node · must stay balanced 1 3 5 8 9 min min in O(1) at index 0 · push/pop O(log n) 0 pointers · always balanced · can't find 8 fast
Fig — same family, opposite deals. The BST spends pointers to make every key findable; the heap spends nothing to make one key — the extreme — instant. Pick by the question you'll ask most.

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.

NOW RUN THE NIGHT SHIFTsix patients, one re-triage, and a heap that has no way to change its mind
The drill. One night, one nurse, one heap. The desk logs seven events as (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. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 58, in working code

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.

The array IS the tree
A heap stores no pointers — number a complete tree in reading order and those numbers ARE the array indices. Navigation is pure arithmetic, and the smallest item is always at index 0.
Insert — append, then let it climb
Adding keeps the tree gapless by dropping the newcomer at the end, then repairs the one broken edge with sift-up: swap with the parent while smaller, walking a single root-ward path. That is O(log n).
Extract-min — promote the last, then sink
You can't just erase the root. Return it, move the last value up to fill the hole, then sift-down past the SMALLER child until the property holds. n pops in a row give sorted order — that is heapsort.
Build it fast, then use it
Handed a whole array, heapify costs O(n), not O(n log n). heapq is min-only — negate for a max-heap, and nlargest/nsmallest keep only the boundary. The finale: two heaps holding a live median.
end of chapter 58 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked