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

⚠ MOST BEGINNERS THINK…the smallest is at index 0, so the largest is at the end
Section 01 has just handed you two facts that fit together beautifully, and the fit is the problem. The array is the tree, and the smallest value in the whole heap is pinned at index 0 by the rule itself. So the array must run small-to-large, near enough — and whatever sits at the far end must be the largest thing in there. Everything you have seen quietly agrees. The figure above starts at its minimum and the first three boxes climb — 1, 3, 6 — which is all a belief needs before it stops looking. The belief is not lazy, either: it is the same symmetry that makes a sorted array work, applied to a structure that really does pin one extreme at one end — and, as you are about to see, it is exactly right for one special heap, which is precisely why it survives so long. So the claim is that a min-heap's largest item sits in the last slot, 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.
TYPE THIS — 10 SECONDS
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]
A min-heap pins exactly one slot — index 0 — and promises nothing whatsoever about any other; the largest value is only ever guaranteed to be somewhere among the leaves, the back half of the array, so finding it still costs a scan. Read the four answers in order, because each one takes away something different. Line 1 is the heap itself. [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.
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.

WATCH AN EXPERT THINKeight already-sorted files, two million lines, one ordered stream out
You have k streams and every one of them is already in order. Eight hourly log files sorted by timestamp; eight shards of a query result sorted by key; eight runs a too-big sort already spilled to disk. You need one ordered stream out, and whatever is downstream will take it a line at a time — writing it to a file, feeding a reducer, pushing it to a client. Two labels before we start, so that nothing here reads as more than it is. The streams below are generators, handing over one value at a time, standing in for file handles; that is the entire point of the exercise, because if they were lists you could already hold everything and there would be no problem to have. And they are already sorted — that is the asset in the room, and the first instinct is about to throw it away without noticing. (You met this shape once in chapter 32, when the pieces were lists and the machine that merges k ordered piles properly was still a promise — this chapter has just built it, and this time the pieces will not fit in memory.)
FIRST INSTINCT
Pour them all into one pile and sort the pile. 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.
WHY IT STALLS
Measure what it had to hold in order to get there. 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.
THE TELL
Stop and ask where the next line of output could possibly come from. Not from anywhere among two million: at any instant, everything still unread in a stream sits behind that stream's current head, so the next line overall must be the head of one of the k streams. Eight candidates, not two million. And “the smallest of a changing set of k, over and over” is the sentence this entire chapter has been about. So hold the k heads in a heap. 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)
trigger: reach for a heap of k candidates the moment the data is already ordered in pieces and you need it ordered as a whole — that is a merge, not a sort, and the heap holds one representative per piece rather than the whole pile. Timings are machine-dependent (CPython 3.12.7); the memory column is not, and it is the column that decides.
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. →

SAY IT BACKthe chapter in five breaths
  1. 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.
  2. Because there are no gaps, a child's address is a calculation rather than a fetch: left at 2i+1, right at 2i+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.
  3. 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.
  4. 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 - 1 back 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.
  5. 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.
reach for this when… you need the most extreme item repeatedly while the data is still moving. Both halves carry weight. “Most extreme, repeatedly” rules out the one-shot — if you want the whole thing in order once and then never again, 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.
You already owned the pieces: chapter 52 gave you the queue — throw work in, take work out, never look at the middle — and a heap is that same discipline with the door order decided by urgency instead of arrival, which is the one word that separates the till line from the triage desk. Chapter 55 gave you the array whose address you compute rather than store, seat K14 landing at one multiply and one add; 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.
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