skip to content

Why can quicksort's recursion depth reach O(n), and how do you cap it at O(log n)?

level: seniorimportance: should knowfreq 42%

answer

  1. In-place says nothing about the call stack
  2. Each pending call is live memory
  3. Depth equals the number of nested levels
  4. You only need to recurse on one side
  5. Push the smaller half, loop on the bigger

basics

~20 s

Quicksort allocates no proportional buffer, but call frames are space: degenerate splits nest one call per element, so depth reaches n and the stack can be exhausted. Recursing into the smaller side and looping on the larger caps depth at log2 n.

solid answer

~50 s

Quicksort is in-place in that it rearranges elements within the range and allocates no proportional buffer, but its **call stack is part of its space cost**. Depth equals the number of nested partition levels, so the same degenerate splits that cause `O(n^2)` time also produce `O(n)` frames — a worse failure, because exhausting the stack crashes the process rather than merely delaying it. The classic trigger is input that arrives already ordered against a fixed-position pivot. The fix is structural, not statistical: after partitioning, **recurse into the smaller side and loop on the larger** by reassigning the range bounds. Since the recursed side holds at most half the current range, depth is bounded by `log2 n` for any pivot quality whatsoever. Time is unaffected — a bad pivot still costs `O(n^2)` — but the crash becomes impossible.

code

pseudocode · 9 lines
pseudocode
quicksort(a, lo, hi):
    while lo < hi:
        p = partition(a, lo, hi)
        if p - lo < hi - p:
            quicksort(a, lo, p - 1)   // smaller side: at most half
            lo = p + 1                // larger side: loop, no frame
        else:
            quicksort(a, p + 1, hi)
            hi = p - 1

go deeper

for a junior

Know that each pending recursive call occupies stack memory, so a recursion that nests n deep uses memory proportional to n even when the algorithm copies no data.

for a middle

Explain why depth equals level count, why degenerate splits make it n rather than log n, and why the smaller side is at most half the range — that halving argument is the whole proof of the log2 n bound.

for a senior

Show the operational instinct: recognise a crash of thousands of identical frames as a depth failure, connect it to an upstream change in input ordering, and separate the space fix from the still-open time problem.

for a principal

Own the preference for unconditional guarantees over probabilistic ones in shared code paths whose inputs you cannot see, and weigh a cheap structural bound against hardening that only reduces the likelihood of the bad case.

## The incident A nightly job sorts a few million rows in memory and has done so for a year. One morning it dies immediately with a stack exhaustion, not a timeout. Nothing in the sorting code changed. What changed is upstream: another team added an ordering step to the stage that feeds it, so rows now arrive already sorted by the very key this job sorts on. The sort uses a fixed first-element pivot. Every partition picks the minimum of its range, every split is `1 : m-1`, and the recursion nests once per row. That is the double failure worth internalising: degenerate splits cost `O(n^2)` **time** and `O(n)` **stack**, and the stack limit is usually hit long before anyone notices the time. The job did not get slow — it stopped existing. ## Recursion depth is space "In-place" is a statement about **auxiliary data structures**: quicksort rearranges elements within the range and needs no buffer proportional to `n`, unlike schemes that merge into a second array. It is not a statement about total memory. Every pending recursive call holds a frame — the range bounds, the pivot index, a return address — and those frames are live memory that grows with depth. So quicksort's space complexity is `O(depth)`: - Balanced splits: depth `~ log2 n`, so `O(log n)` space. For a few million elements that is around 20 frames — nothing. - Degenerate splits: depth `~ n`, so `O(n)` space. For a few million elements that is millions of frames, and the stack is a fixed, comparatively small region. It runs out. This is the general rule, not a quicksort quirk: **for any recursive algorithm, the stack is part of its space complexity**, and a candidate who reports space without counting depth has reported the wrong number. ## The structural fix After partitioning, you hold two subranges. The insight is that you do not need a recursive call for both. Recursion in the *tail* position can be turned into a loop by reassigning the range bounds and going round again, which consumes no frame at all. And you get to choose **which** side is the loop. ``` while lo < hi: p = partition(a, lo, hi) if the left side is smaller: recurse on the left; then set lo = p + 1 // continue with the right else: recurse on the right; then set hi = p - 1 // continue with the left ``` Always recurse into the **smaller** side. Now every frame that is actually pushed covers a range of at most half its parent's size, because the smaller of two pieces of an `m`-element range is at most `m/2`. Halving from `n` can happen at most `log2 n` times, so **depth <= log2 n regardless of how badly the pivot behaves**. The pathological `1 : m-1` split now pushes a frame for a one-element range and loops on the rest — the deep chain of frames simply never forms. ## What the fix does and does not buy - **It bounds space, not time.** A first-element pivot on ordered input still does `O(n^2)` comparisons. You converted a crash into a slowdown; you did not make the sort fast. Anyone who offers this as the cure for the quadratic worst case has confused the two axes. - **It costs nothing.** One size comparison per partition, and fewer calls than before on every input, balanced or not. - **It is unconditional.** It does not rely on the pivot being good, on the data being unordered, or on any distributional assumption. That property — a hard guarantee rather than a probabilistic one — is what makes it the right first move on a shared sorting routine, where you cannot see who will feed it what. - **The order matters, and only in one direction.** Recursing into the *larger* side and looping on the smaller inverts the argument: the pushed frame can cover `m-1` elements, and the depth is back to `O(n)`. Writing the comparison backwards silently removes the entire guarantee, which makes this a detail worth a review comment. ## How the failure presents in production A time blowup shows up as a latency graph bending upward, a task overrunning its window, a queue growing — you get warning, and you can profile a running process. A depth blowup shows up as an immediate crash with a stack trace thousands of frames deep, all of them the same function. That trace is the tell: an enormous run of identical frames means unbounded recursion depth, and for a sort it means the partitions degenerated. The two questions worth asking next are what the input's ordering looks like now versus before, and whether the pivot rule reads a fixed position. ## Framing the answer Say four things and you have covered it: recursion depth is space; degenerate partitions make it `O(n)`; recursing into the smaller half and looping on the other caps it at `log2 n` for any pivot; and that bound is on space only, so the time worst case still needs a separate answer.

  • Does recursing into the smaller side first fix quicksort's O(n^2) worst-case time?
    No. It bounds the depth of the recursion, which is a space guarantee. The comparison count is unchanged: a fixed-position pivot on ordered input still does about n^2/2 comparisons, now inside a loop instead of a deep call chain. You have converted a crash into a slowdown, which is progress, but the time worst case needs a separate answer about pivot choice.
  • What happens if the comparison is written backwards, so the larger side is recursed into?
    The guarantee disappears entirely. The pushed frame can then cover m-1 elements, so degenerate splits rebuild the O(n) chain exactly as before, while the code still looks like it has the optimisation. It is a silent regression: correct output, correct performance on balanced input, and a crash on the one input class the change was meant to survive.
  • How would you recognise this failure from a crash report alone?
    By the shape of the stack trace: thousands of identical frames for the same partition function, rather than a varied call chain. That signature means recursion depth, not memory volume, was exhausted. From there you compare the input's ordering against previous runs and check whether the pivot rule reads a fixed position such as the first or last element.

You can only hold so many half-finished tasks on your desk. If you always delegate the small piece and keep working the big one yourself, the pile of half-finished tasks never grows past a handful, however lopsided the work turns out to be.

saying these in an interview costs you the question

  • Says quicksort uses O(1) space because it is in-place
  • Reports space complexity without counting recursion depth
  • Claims smaller-side-first also fixes the quadratic time bound
  • Thinks a deep recursion only makes the sort slower
  • Recurses on the larger side and still claims a log n depth bound
  • Assumes balanced-input depth of log n holds for every input

context