Heapsort & Priority Queues
By the end of this lesson you will be able to explain exactly how a binary heap is stored as a plain array with no pointers at all, trace maxHeapify, buildMaxHeap and heapSort line by line, prove buildMaxHeap runs in O(n) (not the "obvious" O(n lg n)), and implement a working priority queue (insert, find-max, extract-max, increase-key) — the same data structure that powers job schedulers, Dijkstra's algorithm, and "top-k" interview questions.
1. The binary heap as an array
A heap has two attributes on top of the raw array a: its length (the physical size of the array) and heapSize (how many of those slots currently belong to the heap — always ≤ length). Array positions start at 0, exactly like a Dart List, so the root of the tree always lives at a[0]. For any node stored at index i:
| Relationship | Formula (0-indexed) | Why it works |
|---|---|---|
Parent of i | parent(i) = ⌊(i − 1) / 2⌋ | Subtract the root slot, then integer division throws away the remainder, landing on the row above. |
Left child of i | left(i) = 2i + 1 | Doubling always lands in the next row down. |
Right child of i | right(i) = 2i + 2 | One slot to the right of the left child. |
A max-heap obeys the max-heap property: for every node i other than the root, a[parent(i)] ≥ a[i] — every parent is at least as large as its children. This says nothing about left vs. right, or about siblings, or about any two nodes that aren't in a parent/child relationship — only "parent ≥ child" all the way up. The consequence: the maximum element of the whole heap always sits at the root, a[0], because every other element has a chain of ancestors leading up to it. A min-heap is the mirror image (a[parent(i)] ≤ a[i], minimum at the root) — heapSort uses a max-heap; later lessons use min-heaps for scheduling and shortest paths.
The same arithmetic as code — the 0-indexed Dart versions (the root is index 0, so index 10 has its parent at (10 − 1) ~/ 2 = 4):
Because a nearly-complete binary tree with n nodes has height ⌊lg n⌋ (each row roughly doubles the node count, so the number of rows grows only as the logarithm of n), and every heap operation you'll meet in this chapter walks up or down one root-to-leaf path, every basic heap operation costs O(lg n) — except building a whole heap from scratch, which is the pleasant surprise in Section 3.
Height ⌊lg n⌋ and the leaf boundary, as code (the leaves are exactly the indices n ~/ 2 up to n − 1):
Not every array of numbers is a max-heap. Try your own array below and watch the check run exactly the property definition, index by index:
The property check behind that animation, as code (the maximum then sits at index 0):
Input size → what’s feasible. parent/left/right are O(1), so even n = 109 is fine arithmetically (2n + 2 ≤ 2·109 + 2 fits a 64-bit int); the real limit is memory: n = 107 ints ≈ 80 MB, height ⌊lg 107⌋ = 23. Checking the property is O(n).
a[1] and a[2] (two children of the root) can be in either order relative to each other, and neither has any required relationship to a[3] or a[4] (which live one level further down, under a different parent). A sorted-in-increasing-order array happens to also be a valid min-heap but is generally not a max-heap at all.parent(i) = (i - 1) ~/ 2, left(i) = 2i + 1, right(i) = 2i + 2. Some courses start counting at 1 to get the tidier ⌊i/2⌋, 2i, 2i+1; if you ever read code or notes written that way, shift every index by one. Every algorithm in this lesson uses the 0-indexed formulas, so what you read here is exactly what you type in Dart.Θ(lg n), which is why almost every heap operation is O(lg n).2. Maintaining the heap property: maxHeapify
maxHeapify assumes the two subtrees rooted at left(i) and right(i) are already valid max-heaps, but a[i] itself might be smaller than one of its children — the single node that's out of place. The pseudocode appears in the player below (0-indexed, like the Dart code); line 3 starts with largest ← i and lines 4–7 let each child challenge it:
Dart implementation. First the recursion line for line (left(i) = 2i+1, right(i) = 2i+2, and l < heapSize is the bounds check), then the loop version used everywhere else:
The two-thirds bound and the recurrence T(n) ≤ T(2n/3) + Θ(1), with an exchange counter (each exchange moves the value one level down):
minHeapify is the mirror image — flip every comparison:
Input size → what’s feasible. maxHeapify is O(lg n): at most ⌊lg 106⌋ = 19 exchanges for n = 106, so 106 calls cost about 2·107 steps (< 1 s). The recursive form uses O(lg n) stack, the loop form O(1).
Running time. In the worst case the element at the root of an n-node subtree sinks all the way to a leaf, following one root-to-leaf path. Because a heap's bottom row can be up to half-empty, a node's two subtrees can each hold up to 2n/3 of the elements in the worst case (the worst case is a bottom row that is exactly half full), giving a recurrence T(n) ≤ T(2n/3) + Θ(1). Here a = 1 and b = 3/2, so nlogb a = n0 = 1 = Θ(f(n)): master-theorem case 2, where every level of the recursion does the same Θ(1) work, so the total is Θ(1) × (number of levels) and T(n) = O(lg n). Equivalently: maxHeapify called on a node of height h does O(h) work, since it follows exactly one path down to a leaf.
i — it assumes both children's subtrees are already valid heaps. Calling it on an array where some deeper node also violates the property will silently produce garbage; you must call it bottom-up (exactly what buildMaxHeap does next) to make an arbitrary array into a heap.largest ≠ i is also maxHeapify's own base case: as soon as a[i] already outranks both children, the recursive calls stop — the function does not keep recursing all the way to a leaf every single time, only as far as it needs to.3. Building a heap in linear time: buildMaxHeap
About half the nodes of any heap (exactly ⌈n/2⌉) are leaves — indices ⌊n/2⌋ through n − 1 — and a single node is trivially already a max-heap, so buildMaxHeap only needs to call maxHeapify on the remaining internal nodes, from ⌊n/2⌋ − 1 down to 0.
Loop invariant: at the start of each iteration of the for loop, every node i+1, i+2, …, n−1 is the root of a valid max-heap.
- Initialization: before the first iteration,
i = ⌊n/2⌋ − 1, and every node⌊n/2⌋, …, n−1is a leaf — trivially the root of a (1-node) max-heap. - Maintenance: both children of node
ihave indices larger thani, so by the invariant they are already roots of max-heaps — exactly what maxHeapify(a, heapSize, i) requires — and after it runs, nodeiis a max-heap root too. Decrementingire-establishes the invariant for the next iteration. - Termination: the loop stops when
i = −1, so the invariant says nodes0, …, n−1— the whole array — are max-heap roots, i.e. the array is now a valid max-heap. ∎
Dart implementation (the pseudocode’s for i from ⌊n/2⌋ − 1 down to 0 is for (i = n ~/ 2 - 1; i >= 0; i--)), and the same loop with the loop invariant checked at the top of every iteration:
Running time — the pleasant surprise. A loose bound of "n calls to an O(lg n) procedure" gives O(n lg n) — correct, but not tight. The tight bound uses two facts: (1) a heap of n nodes has at most ⌈n / 2^{h+1}⌉ nodes of height h, and (2) maxHeapify on a node of height h costs O(h). The visual below adds up cost × count over every height level:
Heights and counts as code: the number of nodes of each height, the ⌈n/2h+1⌉ bound, and the summed cost bound:
The series Σ h/2h numerically (the closed form of the partial sum is 2 − (t+2)/2t, so the limit is exactly 2):
Summing Σ (n / 2^{h+1}) · O(h) over h = 0 to ⌊lg n⌋ pulls out the constant n and leaves O(n · Σ h/2^h). The infinite sum Σ_{h=0}^{∞} h/2^h converges to exactly 2 (a standard identity: Σ h·xh = x/(1−x)² at x = 1/2), so the whole sum is O(n · 2) = O(n). buildMaxHeap runs in linear time — the same total work as just scanning the array a couple of times, even though it calls a "logarithmic" subroutine n times.
And the measured comparison counts (buildMaxHeap stays under 3n; heapSort is counted the same way):
Input size → what’s feasible. buildMaxHeap is Θ(n): n = 107 costs at most 3n = 3·107 comparisons, versus n lg n ≈ 2.3·108 for n separate inserts.
O(n).4. The Heapsort algorithm
Loop invariant: at the start of each iteration of the for end from heapSize − 1 down to 1 loop, the subarray a[0..end] is a max-heap containing the end + 1 smallest elements of the original array, and a[end+1..n−1] holds the n − 1 − end largest elements, already in their final sorted position.
Dart implementation (for end from heapSize − 1 down to 1 is for (end = n - 1; end >= 1; end--); heapSize is a plain variable), and the same loop with its invariant checked:
Input size → what’s feasible. heapSort is Θ(n lg n): n = 106 → about 4·107 comparisons (fits a second), n = 107 → about 5·108 (borderline); for n ≤ 104 even an O(n2) sort (108 steps) would do.
Running time. buildMaxHeap costs O(n), then the loop runs n - 1 times, each iteration doing O(1) work for the exchange plus one O(lg n) call to maxHeapify. Total: O(n) + (n-1)·O(lg n) = O(n lg n). Unlike insertion sort, heapsort has no better best-case: even a best-case, all-distinct input still forces Ω(n lg n) work overall, because the heap-repair step doesn't get meaningfully cheaper just because the final order happens to favor it.
O(1) extra memory beyond the input array) but it is not stable: two equal elements can easily end up swapped relative to their original order, because maxHeapify's comparisons only look at value, never at original position. If your problem needs equal keys to keep their relative order, heapsort is the wrong tool — use merge sort or a stable variant of insertion/counting sort instead.The “not stable” claim, demonstrated on (key, label) pairs — three equal keys come out as b, c, a:
O(n lg n) in every case (no O(n²) worst case the way quicksort has); like insertion sort, it sorts in-place with O(1) extra space (merge sort needs Θ(n) extra space to merge). In practice, well-tuned quicksort usually beats heapsort due to better cache behavior, which is why heapsort is more often seen as a fallback (e.g. introsort, used by many standard library sorts, switches to heapsort only when quicksort's recursion gets suspiciously deep).5. Priority queues: heapMaximum, heapExtractMax, heapIncreaseKey, maxHeapInsert
A max-priority queue supports four operations, all backed directly by the array-plus-heapSize structure from Section 1:
- heapMaximum — just
return a[0].Θ(1), no search needed at all, because the max-heap property guarantees the maximum is always at the root. - heapExtractMax — removes and returns the maximum.
- heapIncreaseKey(i, key) — raises the key of an existing element at index
i(it is an error to try to decrease it with this operation). - maxHeapInsert(key) — adds a brand-new element.
These four live as methods of the Heap class shown in §7 (fields a and heapSize; 0-indexed). heapMaximum:
heapExtractMax: after saving the root's value, the problem is "the root slot is now empty, but every other slot must stay full for the array to represent a valid, gapless nearly-complete tree." The fix: move the very last element (the one that's easiest to remove without leaving a hole anywhere else) up to the root, shrink the heap, and let maxHeapify sink it into its correct place.
heapExtractMax in Dart (the last element is a[heapSize - 1]):
heapIncreaseKey: raising a value can only ever break the property upward (the node might now outrank its own parent) — it can never break anything below, since the node only got bigger. So instead of a full maxHeapify sink, heapIncreaseKey does the opposite motion: it repeatedly compares the node to its parent and swaps upward ("bubbles up") until it finds a parent at least as large, or reaches the root.
heapIncreaseKey in Dart (the loop stops at the root, so the test is i > 0):
maxHeapInsert: to add a new element without breaking the "nearly-complete" shape, the trick is to first extend the heap by one slot and fill that brand-new leaf with a sentinel value guaranteed smaller than every real key (conceptually -∞). This makes it perfectly safe to hand off to heapIncreaseKey, since "increasing" from -∞ up to the real key can never trigger that procedure's underflow-style error check.
maxHeapInsert in Dart (a 64-bit int has no true -∞, so a very small stand-in is used):
heapDelete(h, i), removing an arbitrary element, built from those pieces:
Input size → what’s feasible. each operation is O(lg n): 105 operations on a 105-element heap are about 1.7·106 steps, while a plain unsorted array makes every extractMax Θ(n), i.e. 1010 steps in total.
-∞ sentinel trick. This is a general lesson in algorithm design — express a new operation in terms of one you've already proven correct, rather than writing new (and newly-buggy) logic from scratch.-∞" for a fixed-width integer type. Our Dart implementation below uses a sentinel value guaranteed smaller than any realistic key (or, when keys can be arbitrarily large, a nullable "empty slot" marker checked before every comparison) — a small, deliberate simplification you should always call out explicitly when translating pseudocode into production code.6. Going further: d-ary heaps & Young tableaus
d children instead of just 2 — like reorganizing the family tree so each parent can have, say, 4 children instead of 2. More children per row means a shorter tree (fewer rows for the same headcount), but now maxHeapify must compare against d children instead of 2 at every level it visits.For an n-element d-ary heap: parent(i) = ⌊(i−1)/d⌋, and the children of i occupy a contiguous block of d slots. Height is Θ(log_d n) = Θ(lg n / lg d) — shorter as d grows. Insert and increaseKey only ever compare a node against its one parent while bubbling up, so they cost O(log_d n) — genuinely faster for large d. But extractMax must find the largest of up to d children at every level while sinking down, costing O(d · log_d n) — which actually gets worse once d is large enough that the extra comparisons outweigh the shorter tree. Explore the trade-off:
d-ary heap in Dart: the index formulas, the height, and the class (children of i sit at d·i + 1 … d·i + d):
Input size → what’s feasible. n = 106 keys: d = 2 gives height 19 (about 38 comparisons per extractMax), d = 4 gives height 10 (about 40), d = 16 gives height 5 (about 80); insert does one comparison per level, so 19 / 10 / 5. Pick d = 2 to 4 unless inserts and increase-keys far outnumber extractions (Dijkstra-style workloads).
A Young tableau is an m × n grid where every row is sorted left-to-right and every column is sorted top-to-bottom (using +∞ to mark unused cells). It generalizes a heap into two dimensions: the smallest finite value is always at the top-left corner, and both insert and extractMin take O(m + n) by "bubbling" along whichever direction (row or column) currently violates the order — and because searching can eliminate an entire row or column at every step (start at a corner, and every comparison rules out a whole line), searching for a target value also only takes O(m + n).
Young tableau in Dart (inf marks an empty cell); the steps counter shows the O(m + n) bound, and sorting n² numbers uses it:
Input size → what’s feasible. a tableau of 1000 × 1000 = 106 cells does each insert / extractMin / search in at most m + n = 2000 steps; sorting those 106 numbers with it costs about n3 = 109 moves, too slow — use heapSort (about 4·107 comparisons) instead.
Search is the other half of the story. Try any target and watch each comparison throw away a whole row or a whole column:
7. From pseudocode to working Dart
The pseudocode and Dart are both 0-indexed, so the translation is almost mechanical: parent(i) = (i - 1) ~/ 2, left(i) = 2*i + 1, right(i) = 2*i + 2, and "is there a parent?" is the test "i > 0" (index 0 is the root). Every comparison, every swap, every loop bound carries over line for line. Here is the whole thing packaged as a small Heap class (full source, tested against 200 random arrays and every edge case below, lives in verify/c06.dart):
Input size → what’s feasible. the same loop-based maxHeapify/buildMaxHeap as above, so n = 106 elements build in about 3·106 comparisons; BinaryHeap<T> (below, used by the interview questions) does push/pop in O(lg n).
Notice maxHeapify is written as a while loop instead of recursion — an iterative rewrite of the exact same logic. It produces the identical sequence of swaps as the recursive version, but avoids pushing a new stack frame on every sink step; Dart has no tail-call optimization (see D07), so for a very deep heap the iterative form is the safer production choice.
i > 0, while code written for a 1-indexed root says i > 1. Copy-pasting comparison operators from a 1-indexed source without re-deriving them for the new origin is one of the most common heap bugs.Quiz
Interview questions
Cheat sheet
| Operation | Time | Space | Notes |
|---|---|---|---|
| parent / left / right | Θ(1) | Θ(1) | Pure arithmetic, no pointers stored |
| maxHeapify(a, heapSize, i) | O(lg n) worst (O(h) for a node of height h) | O(1) iterative / O(lg n) recursion stack | Assumes both children already valid heaps; sinks one node down |
| buildMaxHeap(a) | Θ(n) — tight bound, not the loose O(n lg n) | O(1) extra (in-place) | Most of the ⌊n/2⌋ calls are cheap (near the bottom) |
| heapSort(a) | Θ(n lg n) best, average, AND worst case (distinct keys; all-equal keys is Θ(n)) | O(1) extra (in-place) | NOT stable; comparison sort so no lower bound below n lg n |
| heapMaximum() | Θ(1) | Θ(1) | Max always at the root by the heap property |
| heapExtractMax() | O(lg n) | O(1) | Move last element to root, shrink, maxHeapify |
| heapIncreaseKey(i, key) | O(lg n) | O(1) | Bubble up; errors if key < current value |
| maxHeapInsert(key) | O(lg n) | O(1) | Insert -∞ sentinel leaf, then heapIncreaseKey |
| d-ary heap insert / increaseKey | O(log_d n) | — | Shorter tree than binary (d=2) |
| d-ary heap extractMax | O(d · log_d n) | — | Must scan up to d children per level while sinking |
| Young tableau insert / extractMin / search | O(m + n) | Θ(mn) grid | 2-D generalization: sorted rows AND columns |