python/volume-4/chapter-58ch 58 / 64 · 12 min

58The heap — a tree that lives in an array

In Chapter 57 we spent two pointers per node to keep a tree searchable, and fought it on every insert to stay balanced. Here we make 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 has to be stored. It folds flat into one contiguous array, and plain arithmetic walks it. Here's the plan. We fold the tree into an array, navigate it by doubling and halving a single index, then add and remove items by letting one value climb or sink along one path. The whole way through, we keep asking the one question 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, why pulling it out costs only ~20 hops, and why every priority queue, scheduler, and shortest-path frontier you've ever used is this one flat array underneath.

A whole tree, and not one pointer The address of a child is a formula Insert = drop it at the end, then let it climb Extract-min = promote the last, then let it sink
scroll to begin

01A whole tree, and not one pointer

Let's start with two terms, each defined the moment it appears. A complete binary tree is filled level by level, top to bottom, and each level 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 term is the heap property: in a min-heap, every parent is less than or equal to both of its children. That is not the same as sorted — it just means parent-below-children, everywhere. Two consequences follow right away. First, the smallest value in the whole tree has nobody smaller above it, so it must sit at the root. Second, the arrangement is only a partial order, so the root's two children can sit in any order relative to each other.

Now look at the memory picture, which is this volume's obsession. Back in the binary-tree chapter, a plain binary tree had to store two pointers in every node just to remember where its children live, 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 — and 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.

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 let's measure rather than guess. A heap of n items is one list of n references: 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 — a list header plus seven 8-byte slots. Line 6: a 100,000-element heap is 800,056 bytes, which works out to line 7's 8.00 bytes of structure per element. The pointer tree from the binary-tree chapter needed 56 bytes per node, so the same 100,000 nodes cost 5,600,000 bytes — measured, 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 laid down after all of i's left-neighbours have filled their own children. Because the tree is complete, that "next two" always lands at fixed offsets. The result is three formulas that never change:

Check it against the figure. Node [1] holds 3; its children should be at 2·1+1=3 and 2·1+2=4, and indices 3 and 4 do hold 5 and 9 — the two nodes drawn beneath it. Correct. Node [5] holds 8; its parent is at (5-1)//2 = 2, and index 2 holds 6, drawn directly above it. Correct. No search, 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.

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 — no Node class, no .left/.right fields, 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 settles 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, breaking the heap property on that one edge. So we repair it with sift-up (also called bubble-up or percolate-up): compare the new value with its parent; if it's smaller, swap them; 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)? 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. 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 — 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; its next parent at index 0 is 1, and 2 ≥ 1, so it stops. Two swaps, final array [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, O(1), no matter how many items the heap holds.

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 to return it. Now you need to fill the hole at the top and keep the tree complete, and there's exactly one value whose removal keeps it gapless: the last one in the array. So move the last element up to the root, shrinking the array by one. That almost certainly violates the heap property — a leaf value now sits at the top — so repair it downward with sift-down (bubble-down): compare the value with its smaller child; if it's larger, swap with that child; repeat, until it's ≤ both children or reaches a leaf. Comparing against the smaller child is the subtle, essential detail — swap with the larger and you'd just recreate the violation.

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; move last value 7 to the root; 7 > 3 (the smaller of children 3 and 6) so swap; 7 > 5 (its new smaller child) so swap; now a leaf — stop. Final heap [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; line 6 printed [3, 5, 6, 7, 9, 8] — matching the figure exactly. Push and pop together give you a priority queue: a bag you throw items into in any order and always draw the smallest (or highest-priority) out, each operation O(log n), the peek free.

↺ 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: start empty and heappush each item — n inserts, 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 — they're already valid one-element heaps), and 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.

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 the intuition "n nodes times log n each = O(n log n)" is exactly the trap. That estimate assumes every node sinks the full height. 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, and 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 (it's under 1). 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 — 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, both producing a valid heap with the same minimum. The measured gap here is modest because Python's per-operation overhead dominates, but the asymptotic difference — O(n) versus O(n log n) — only grows as the data does. When you have all the items up front, always reach for heapify.

heapq 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), each of the n pops is O(log n), so the whole sort is O(n log n) — the sorting lower bound from Volume 3, matched, with the heap doing all the bookkeeping. Ran 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 — no second array like mergesort. Its vice is the same cache story from the binary-tree chapter inverted: the 2i+1 index jumps stride across the array and miss 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 (children at d·i+1 … d·i+d), making the tree shallower — faster sift-up/insert, slower sift-down. Timer wheels and some priority queues tune d to the workload.

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 it 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: 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 / highest-priority next," the heap is the answer, and 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 (O(n log n)) — you keep a heap of just 10 and let each new number bump the smallest survivor (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 and lets each new value either bump the smallest of them or fall 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, against about 149 ms for sorted(scores, reverse=True)[:10] — the 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; 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 — but 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 — and it lives pointerless in an array, always perfectly balanced by construction. Ask "is 42 in here?" and the heap is useless (O(n) — 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" → a BST or a hash map. "The extreme, repeatedly, as data streams" → 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.

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.com
Written by Ajai Raj