skip to content

A hand-rolled introsort passes its depth counter down unchanged on one branch — what breaks?

level: seniorimportance: nice to knowfreq 26%

answer

  1. Where does the guarantee actually live?
  2. Think about one root-to-leaf path
  3. Which branch does degenerate input take?
  4. Would any random-input test notice?
  5. An unbounded right spine of recursion

basics

~20 s

The depth budget is per root-to-leaf path, so a branch that forwards it undecremented is unbounded. Degenerate input that keeps pushing work down that branch never trips the fallback, and the sort silently keeps quicksort's O(n^2) worst case.

solid answer

~50 s

The whole guarantee rests on one property: every root-to-leaf path in the recursion tree spends budget. Forward `depth` unchanged on the right branch and the right spine is free — an input that repeatedly sends almost everything to that side recurses to depth proportional to `n`, runs in `O(n^2)`, and may exhaust the call stack before it finishes. The bug is invisible in testing: on random data neither branch approaches the limit, so the sort is correct and fast, and the only inputs that reveal it are precisely the adversarial or already-ordered ones the fallback existed to survive. The fix is one token — decrement on every recursive call — but the lesson is where to look during review: the guarantee lives in the recursion plumbing, not in the partition routine, so verify it with a test that forces the fallback rather than by reading the partition.

code

pseudocode · 12 lines
pseudocode
// initial call: sort(a, 0, length(a) - 1, 2 * floor(log2(length(a))))
sort(a, lo, hi, depth):
    if hi - lo < CUTOFF:
        insertion_sort(a, lo, hi)
        return
    if depth == 0:
        heapsort(a, lo, hi)
        return
    p = partition(a, lo, hi)
    sort(a, lo, p - 1, depth - 1)
    sort(a, p + 1, hi, depth)        // <-- budget forwarded unchanged
...

go deeper

for a junior

Recall that introsort's guarantee comes from a depth budget spent on every recursive call, and that skipping the decrement anywhere means some path can recurse without limit.

for a middle

Trace the recursion tree and explain why a single undecremented branch leaves a whole spine unbounded, and why the degenerate input that motivated the fallback is exactly the input that travels that spine.

for a senior

Show the review instinct: verify the mechanism that carries a guarantee, not just the routine that does the work, and design a test that forces the fallback and asserts on measured depth rather than on sorted output.

for a principal

Own the class of defect — safety mechanisms that are unobservable until needed — and the standard you set for it: guarantees ship with tests that exercise them, or they are documentation, not behaviour.

## The invariant that the guarantee rests on Introsort's worst-case bound is an argument about the recursion tree. Start with a budget of about `2 * floor(log2 n)`; require that **every** recursive call receives a strictly smaller budget than its parent; and when the budget reaches zero, finish that subrange with an in-place, worst-case-`O(n log n)` sort. From those three facts it follows that no path can be longer than the budget, that the quicksort phase does at most `O(n)` work on each of `O(log n)` levels, and that everything below a zeroed budget is bounded by the fallback. Total: `O(n log n)`. Break the middle requirement on even one branch and the argument collapses. Consider this shape: ``` sort(a, lo, hi, depth): if hi - lo < CUTOFF: insertion_sort(a, lo, hi); return if depth == 0: heapsort(a, lo, hi); return p = partition(a, lo, hi) sort(a, lo, p - 1, depth - 1) sort(a, p + 1, hi, depth) // budget forwarded unchanged ``` Every call reachable by going right forever holds the same budget its ancestor held. The right spine of the recursion tree has no depth bound at all. ## Why this is worse than it looks The failure is not symmetric with the input. The recursion goes right when the split point lands near the low end — which is exactly the pattern degenerate input produces. So the branch left unprotected is the branch that pathological input travels. The sort will handle an adversarially or pathologically ordered array by recursing `Θ(n)` levels deep, doing `Θ(n^2)` comparison work, and quite possibly overflowing the call stack first — a crash rather than a slow response, which on a server is the more severe outcome. Meanwhile the fallback still *exists*, still passes code review by eye, and still appears in the code coverage report if any test happens to construct a small enough limit. The guarantee is entirely notional. ## Why tests miss it On random input, quicksort's recursion depth is tightly concentrated around a small multiple of `log2 n` — well under a budget of `2 log2 n`. A test suite made of random arrays, sorted arrays of a few hundred elements, and hand-written edge cases will never reach the limit on either branch. The sort produces correct output in every case, at normal speed. Nothing is red. That is the general shape of this defect class: **a safety mechanism whose absence is invisible until the day it is needed**. Correctness tests cannot find it, because the code is correct; performance tests cannot find it, because the common case is unaffected. ## How to actually verify it Three techniques, in increasing order of strength: 1. **Instrument and assert.** Expose a counter of fallback invocations (or maximum depth reached) in test builds, and assert on an input engineered to be degenerate for that implementation's split rule that the fallback fired and the observed depth stayed within the budget. 2. **Property-test the bound.** For many generated inputs, including deliberately hostile ones, assert that measured recursion depth never exceeds the initial budget. This catches the one-branch bug directly, because the bug's signature is depth exceeding the budget. 3. **Make it structurally impossible.** Compute the child budget once — `child = depth - 1` — and pass that variable to both calls, so a future editor cannot decrement one side and not the other. Better still, keep a single depth parameter and derive the fallback test from it in one place. ## Where to look when reviewing sort code The habit worth taking away: for any algorithm whose headline property is a guarantee, review **the plumbing that carries the guarantee**, not the routine that does the visible work. Reviewers' eyes go to the partition function, because that is where the clever code is and where an off-by-one would corrupt output. But partition bugs are loud — they produce wrong answers and tests catch them within minutes. The bug that survives to production is in the parameter passed down the recursion, the initial budget computed from the wrong `n` (the whole array's length versus the current subrange's), the fallback branch placed after the recursion instead of before it, or the base-case check that shadows the depth check so the limit is never consulted on short ranges. Each of those keeps the sort correct and quietly deletes its worst-case bound.

  • What is the observable symptom in production, and why is it hard to diagnose?
    A request whose input happens to be ordered or adversarially arranged takes quadratic time or overflows the stack, while every other request is fast. It looks like an input-dependent hang or crash rather than a sort bug, and it cannot be reproduced from a sample of ordinary traffic — you need the specific pathological ordering to see it at all.
  • How would you write a test that actually proves the depth limit holds?
    Expose maximum recursion depth or a fallback-invocation counter in test builds, then assert two things across many generated inputs, including deliberately degenerate ones: depth never exceeds the initial budget, and the fallback does fire on the hostile case. Asserting only on sorted output cannot distinguish a working guarantee from an absent one.
  • Besides forwarding the budget unchanged, what other plumbing mistakes silently void the guarantee?
    Computing the initial budget from the wrong length, placing the depth check after the recursive calls so it is never consulted in time, letting the small-range base case shadow the depth check, or resetting the counter inside the recursion. All of them leave the sort correct on every test and remove the worst-case bound entirely.

saying these in an interview costs you the question

  • The output is still sorted, so the bug is cosmetic
  • Random-input tests would have caught it
  • The bug must be in the partition routine
  • One unbounded branch only doubles the recursion depth
  • The fallback still exists, so the guarantee still holds
  • Asserting on sorted output is enough to test the depth limit

context