3.6.5 · D4Sorting & Searching

Exercises — Heap sort — O(n log n), in-place, not stable

2,926 words13 min readBack to topic

Throughout, arrays are 0-indexed and we build a max-heap (every parent its children). Recall the three index formulas:


Level 1 — Recognition

L1.1 — Is it a heap?

Consider the array . Draw it as a tree and decide: is it a valid max-heap?

Recall Solution

Place the values level by level, left to right. Index 0 is the root, indices 1–2 are the next level, indices 3–5 the last.

Figure — Heap sort — O(n log n), in-place, not stable
Figure s01: the six values drawn as a tree. Each node is labelled with its array index (yellow) so you can see how the flat array maps onto parent/child links. The green edges are the parent→child comparisons you must check; trace each edge and confirm the upper number is the lower one — the visual makes the "check vertically" rule concrete in a way the array of numbers alone does not.

Check every parent against its children:

  • Node 0 (): children are index 1 () and index 2 (). and . ✅
  • Node 1 (): children are index 3 () and index 4 (). and . ✅
  • Node 2 (): children are index 5 () and index 6 (does not exist). . ✅

Every parent dominates its children, so yes, it is a valid max-heap.

L1.2 — Find the family

In , what is the parent, left child, and right child of the node at index (value )?

Recall Solution

Plug into the formulas.

  • parent → value .
  • left child → value .
  • right child → value .

So 's parent is , and its children are and . (Sanity: and , so the heap property holds here.)


Level 2 — Application

L2.1 — One sift-down

Given the array (heap size ), run sift_down(A, 0, 5) on the root. Show the array after it finishes.

Recall Solution

The root () is too small; sift-down pushes it down until both its children are it.

Figure — Heap sort — O(n log n), in-place, not stable
Figure s02: the same tree with the sinking value and its downward path drawn in red. The two red arrows show the exact swaps sift-down performs — first (idx 0↔1), then (idx 1↔4). The blue nodes are untouched. This picture answers "how far does actually fall?" — you can literally count the two arrow hops rather than re-simulating the loop in your head.

Iteration 1: , children (), (). Largest of is index 1. Swap indices 0 and 1: Now follow the demoted : .

Iteration 2: , children (), (). Largest of is index 4. Swap indices 1 and 4: Now .

Iteration 3: . Its left child would be , so it has no children. largest == i, loop breaks.

Final array: — a valid max-heap.

L2.2 — Build-max-heap by hand

Turn () into a max-heap. The build loop starts at and counts down to . Show the array after each sift_down.

Recall Solution

We only sift the internal (non-leaf) nodes, indices . Leaves (indices 3,4,5) are already trivially heaps.

(value ): children (), (none). Largest of is index 2 itself. No swap.

(value ): children (), (). Largest of is index 3. Swap 1↔3: Follow to : child , no children. Stop.

(value ): children (), (). Largest of is index 2. Swap 0↔2: Follow to : children (), (none). Largest of is index 5. Swap 2↔5: Follow to : leaf. Stop.

Final heap: . Verify root is the maximum — good.


Level 3 — Analysis

L3.1 — Why to build, not

The naive guess: build calls sift-down on nodes, each costing , so build is . But the true bound is . Explain the gap.

Recall Solution

The naive bound is not wrong, just loose. It over-charges by assuming every node can fall the full height . But most nodes are near the bottom and can barely fall at all.

Count by height (leaves have ). A tree of nodes has at most nodes at height , and each can fall at most levels. Total work: Why is exactly ? Write it out and use a shift trick. Let Halve every term (this shifts the sequence one slot right): Subtract the second line from the first. Each coefficient drops by exactly , leaving a plain geometric series: So , giving . (The geometric series sums to because it is .) Therefore

Picture: the many cheap leaves (half the nodes, cost 0) dwarf the few expensive nodes near the root. The geometric decay of beats the linear growth of .

L3.2 — Best case is still

Merge sort and heap sort are both . But quicksort has a bad case and insertion sort has a lovely best case. What is heap sort's best case, and why can't it be faster?

Recall Solution

Even on an already-sorted or all-equal array, the sort-down phase must do its full work: times it swaps the root to the back and calls sift-down on a heap of shrinking size . Each such sift-down on the root of a size- heap costs in the worst path, and — crucially — the value swapped up from the back is a small leaf value, so it almost always sinks the full height.

Summing the sort-down cost: Why is ? Bound it from both sides.

  • Upper bound: every factor in is , so , hence . That gives the side.
  • Lower bound: the top half of the factors, from up to (there are of them), are each . So , hence .

Squeezed between and , we get . (This is the elementary version of Stirling's approximation .) There is no input that lets sift-down "get lucky and stop early" across all rounds. Hence heap sort is in best, average, and worst cases — remarkably consistent, unlike quicksort.


Level 4 — Synthesis

L4.1 — Full trace, first two crownings

Take the heap from L2.2, . Run the sort-down phase: swap(A[0], A[end]); sift_down(A,0,end) for end = 5 then end = 4. Show the array after each of these two crownings. Which values are now locked in the sorted tail?

Recall Solution

end = 5. Swap root and index 5: . The is now locked in place at the end. Heap shrinks to indices ; sift-down index 0 over :

  • : children (), (). Largest index 1. Swap 0↔1: .
  • : children (), (). Largest of is index 1 itself. Stop.
  • Array: .

end = 4. Swap index 0 and index 4: . The is locked. Heap shrinks to indices ; sift-down index 0 over :

  • : children (), (). Largest index 2. Swap 0↔2: .
  • : child , no children. Stop.
  • Array: .

After two crownings the array is and the sorted tail holds .

L4.2 — Complete the sort

Continue L4.1 from (active heap = first 4 slots) to the fully sorted array. Show every crowning.

Recall Solution

end = 3. Swap 0↔3: . Sift-down 0 over :

  • : children (), (). Largest index 1. Swap: .
  • : child , no children. Stop → .

end = 2. Swap 0↔2: . Sift-down 0 over :

  • : child (). Largest is index 0. Stop → .

end = 1. Swap 0↔1: . Sift-down 0 over : no children. Loop end ends at 1.

Final: — fully sorted. ✅


Level 5 — Mastery

L5.1 — Stability counterexample (find the minimum size)

Heap sort is not stable. Find the smallest array of records with a key and a tag that proves it — i.e. two equal keys end up in swapped original order after sorting. Justify why your size is minimal.

Recall Solution

"Stable" means equal keys keep their original left-to-right order. We need two equal keys that heap sort reorders. Write records as , sorting ascending by key.

The minimum is . (You cannot do it with : with or record there is no pair of equal keys to reorder at all.) Take (two equal keys, before ):

  • Build: the only internal node is index . sift-down(0) over : child index 1 has key , which is not strictly greater than the root's key , so no swap. Heap stays .
  • Sort-down end=1: swap root and index 1: , then lock index 1. sift-down(0) over the shrunk : no children, nothing happens.
  • Final: .

The single final swap flips the equal keys from to — original order broken. So the smallest witness is just two equal elements, and that lone crowning swap is the whole reason heap sort is unstable.

(Aside: a -element array like also fails, but it is not minimal — the is irrelevant to the instability, which lives entirely in the equal-key swap.)

L5.2 — Degenerate inputs

State precisely what heap sort does on: (a) an empty array , (b) a single element , (c) all-equal . No swaps happen in some — say which and why.

Recall Solution

(a) , . Build loop starts at and counts down to 0 — it never runs (start already below 0). Sort-down loop runs end from down to 1 — never runs. Zero operations, output . Correctly sorted (vacuously).

(b) , . Build starts at down to 0 → doesn't run. Sort-down runs end from down to → doesn't run. Zero swaps, output .

(c) , . Build sifts . Every comparison uses strict >; since all values are equal, no child is ever strictly greater, so largest never moves — no swaps in build. Sort-down still runs times, each swapping root with the back (swapping equal values — harmless) then sifting with no strict-greater child, so no further movement. Output , sorted. Note: the swaps here are "wasted work" but the strict > guard keeps sift-down from looping forever.


Recall Self-check (how to use these)

The lines below are active recall prompts. For each ==highlighted== term, cover the sentence and try to say the highlighted word before revealing it. For each Question ::: Answer line, read only the part before :::, answer aloud, then check yourself against the part after :::. In Obsidian these render as click-to-reveal.

A heap sort's build phase costs == while the sort-down phase costs . sift-down assumes the node's subtrees are already valid heaps. Heap sort is not stable because the build/crown swaps move equal keys across each other==. Best case for heap sort ::: — no input lets it finish faster. Why start build at index ::: it is the last internal (non-leaf) node; everything after is a leaf and already a heap.