A heapsort loop swaps the root to the last unsorted slot, then sifts down over the whole array — what breaks?
answer
- two regions, one boundary
- what is sitting at the last index now?
- the sift can see the finished tail
- the biggest element gets pulled back up
- pass the shrunken size, not the length
basics
~20 sSifting down over the whole array pulls already-placed maximums back into the heap: the sift finds the largest element sitting in the sorted tail and drags it up to the root again. The output is wrong, not merely slow.
solid answer
~40 sThe extract loop depends on a two-region invariant: `a[0..end]` is a max-heap and `a[end+1..n-1]` is finished output that the heap must never touch again. Passing the full length `n` as the heap size erases that boundary. Right after the first swap, the global maximum is parked at `a[n-1]`; a sift-down that can see index `n-1` compares it as a child, finds it larger, and swaps it back up toward the root. The sorted tail gets re-mixed into the heap, the same elements get re-extracted, and the final array is simply wrong — not merely slower. The fix is one argument: sift down over `end` (the new, shrunken heap size) rather than over `length(a)`. This class of off-by-one is nasty precisely because the code still terminates and still looks plausible; only the output is wrong.
code
pseudocode · 8 linesn = length(a)
build_max_heap(a) // a[0] is now the global maximum
end = n - 1
while end > 0
swap(a[0], a[end]) // maximum moves to its final slot
sift_down(a, 0, n) // <-- heap size passed as n
end = end - 1
...go deeper
Recall that the heap shrinks by one element every round and that the finished tail must never be touched again. Passing the full array length ignores that.
Trace the first two iterations aloud on a four-element array and show exactly how the placed maximum climbs back to the root when the sift can see it.
Talk about detection: invariant assertions in debug builds, randomized property tests over a permutation check, and why a passing suite of tiny examples proves nothing here.
Argue the API-design point — pass the boundary explicitly so the repair routine cannot reach finished output, and decide when a debug-only invariant check earns its keep in hot code.
## The invariant this bug violates Heapsort's extract phase is governed by a single invariant, and every line of the loop exists to preserve it. At the top of each iteration: ``` [ 0 .............. end ][ end+1 ......... n-1 ] a max-heap sorted output, all >= everything in the heap region ``` Two clauses matter. First, the prefix satisfies the heap property. Second — and this is the clause the bug destroys — the suffix is **frozen**: those slots hold their final values and no operation may read or write them again. ## What the buggy call actually does Suppose the array is a valid max-heap of size `n`, so `a[0]` is the global maximum. The loop swaps `a[0]` with `a[end]` where `end = n-1`. Now the global maximum is at `a[n-1]` — correct and final — and some small element is sitting at the root, violating the heap property inside the prefix. Sift-down is supposed to repair the prefix. Call it with size `n` instead of `end`, and sift-down treats index `n-1` as an ordinary heap node. Walking down from the root it eventually reaches that slot's subtree, sees the largest value in the whole array, and swaps it upward. Within a few iterations the maximum is back at or near the root, gets swapped to the tail again, and the loop churns. Concretely with `[9, 7, 5, 3]`: swap gives `[3, 7, 5, 9]`; a full-length sift-down from the root compares 3 against children 7 and 5, swaps with 7, then compares 7 against child 9 and swaps — leaving `[7, 9, 5, 3]`, with the finished 9 dragged back inside and the 3 now buried. The loop still terminates after n-1 rounds, but the output is unsorted. ## Why reviewers miss it Three things make this a genuinely hard review catch: 1. **It compiles and terminates.** The loop counter is independent of the bug, so there is no hang and no out-of-bounds access — every index touched is legal. 2. **Small inputs can accidentally pass.** With one or two elements, or with input where the tail happens not to outrank the heap, the output can look right. A test suite that sorts three-element arrays may go green. 3. **The two sizes are both "obviously" correct-looking.** `n` is the array length, and every other part of the algorithm does think in terms of the whole array. Only the extract loop needs the shrunken view. The defensive habits that catch it: assert the invariant in a debug build ("the sorted suffix is non-decreasing and its minimum is >= the heap's maximum"), give sift-down an explicit `size` parameter rather than letting it read a length from anywhere ambient, and property-test against a reference ordering with randomized inputs of a few hundred elements rather than hand-written tiny cases. ## The sibling mistakes in the same loop While you are reviewing this loop, three neighbouring off-by-ones live in the same three lines: - **Shrinking after the sift instead of before.** If `end` is decremented after sift-down runs with the old size, the freshly placed maximum is still visible to the sift — the same corruption, one round later. - **Looping while `end >= 0` instead of `end > 0`.** Harmless but pointless: a heap of one element is already a heap, and sifting down from a root with no children does nothing. - **Sifting from the wrong index.** Sift-down must start at the root, index 0, because that is the only position the swap could have violated. Starting anywhere else leaves the heap broken. ## The general lesson Any algorithm that partitions one buffer into a live region and a finished region has this failure mode: every operation on the live region must be handed the boundary explicitly, and the finished region must be unreachable by construction. When you can, make the boundary a parameter rather than a shared variable — a function that cannot see the whole array cannot corrupt the part it should not touch.
- The buggy version still terminates and never reads out of bounds — how would you catch it in tests?Property-test rather than example-test: sort a few hundred randomized arrays of a few hundred elements each and assert the result is non-decreasing and a permutation of the input. Tiny hand-written cases can pass by luck. In debug builds, assert the loop invariant directly — the sorted suffix is non-decreasing and its smallest element is at least the heap region's maximum.
- What happens if the boundary is decremented after the sift-down instead of before it?The same corruption, delayed by one line. Sift-down would still be handed the old, larger size, so the maximum just placed at the tail remains visible to it and gets pulled back into the heap. Order matters: swap, shrink, then repair over the shrunken region.
saying these in an interview costs you the question
- Says it only costs performance, output still sorted
- Claims the sorted tail is safe because it is larger
- Expects an out-of-bounds crash to reveal it
- Cannot state the loop's two-region invariant
- Believes small hand-written test cases would catch it