Quicksort

By the end of this lesson you will be able to trace partitionLomuto and quicksort by hand, explain exactly why quicksort is Θ(n²) in the worst case but Θ(n lg n) on average, implement the randomized version and three classic variants (Hoare's partition, three-way partitioning, bounded stack depth), and answer interview questions that build on the same partitioning idea (Dutch-flag sort, quickselect, and more). Every array in this lesson is 0-indexed, exactly like the Dart code, and lo..hi always means an inclusive range of positions.

1. The idea: divide, partition, conquer

Imagine sorting a shelf of crates by weight: pick ONE crate (the pivot), walk the whole shelf once, and move every crate that is "lighter or equal" to the pivot's LEFT of it and every "heavier" crate to its RIGHT. You never fully sorted anything yet — but now the pivot sits in its exact final position, and you've split the job into two smaller shelves that can each be sorted the same way, completely independently. Quicksort is exactly this idea, applied recursively.

Quicksort is a divide-and-conquer algorithm, like merge sort — but where merge sort does the hard work in the combine step (merging two sorted halves), quicksort does all its hard work in the divide step, and the combine step is free. To sort the range a[lo..hi]:

Quicksort sorts in place (only a constant number of values are ever stored outside the array, apart from the call stack) and, despite a Θ(n²) worst case, is usually the fastest general-purpose comparison sort in practice — small constants and good cache behavior, because it scans memory in order.

Quicksort's entire cleverness lives in one subroutine: partitioning. Everything else is "partition, then recurse on both sides." Learn partitioning cold and the rest follows.

2. Lomuto partition — the workhorse subroutine

Picture four labelled bins lined up left to right as you walk down the shelf once, left to right: "≤ pivot", "> pivot", "not looked at yet", and the pivot itself (parked at the far right end the whole time). Every crate you pick up goes either into the "≤ pivot" bin (if it's small enough) or stays where it is, silently becoming part of the "> pivot" bin. At the very end, you swap the pivot into the gap right after the "≤ pivot" bin — and now it's home.

The version below is named after Nico Lomuto. It always takes the last element of the range, a[hi], as the pivot, and keeps one index i marking the end of the "≤ pivot" region:

The loop invariant — a fact that is true every time the for loop (row 3) is about to start an iteration — is what makes the procedure provably correct. For the current loop index j:

Before the first iteration: i = lo-1 and j = lo, so the first two regions are empty — trivially true. Each iteration keeps it true: either (a) a[j] > pivot, so the "> pivot" region simply absorbs a[j] (nothing to move), or (b) a[j] ≤ pivot, so we increment i and swap a[i] with a[j] — the OLD a[i] (the first element of the "> pivot" region) goes to the end of that region, while a[j] becomes the new last element of the "≤ pivot" region. When the loop ends: j = hi, so the unrestricted region is empty and every element except the pivot has been classified — exactly what we need before swapping the pivot into place.

In Dart: the loop invariant checked at the top of every iteration on the first example (lo = 0, hi = 7).

A very common mistake: if every element of a[lo..hi-1] is equal to the pivot, the test a[j] ≤ pivot is true every single time, so i marches all the way up and the procedure always returns q = hi — the WORST possible split (sizes n-1 and 0), every single time. Plain Lomuto partitioning degrades to its Θ(n²) worst case on arrays full of duplicates, which is exactly the problem the three-way partition (section 8 below) fixes.
Partitioning runs in Θ(n) time on a range of n = hi - lo + 1 elements: the for loop body does O(1) work per iteration and runs exactly hi - lo times, plus O(1) work before and after the loop.

The Dart implementation

The pseudocode above and this code use the same indices: the range is lo..hi (inclusive) and the pivot is simply a[hi].

In Dart: partitioning n = hi − lo + 1 elements makes exactly n − 1 comparisons, i.e. Θ(n): 9, 99 and 999 comparisons for n = 10, 100 and 1000.

Input size → what is feasible. One partition pass is Θ(n): n = 107 elements cost about 107 comparisons (~0.1 s), so it is never the bottleneck on its own; what matters is how many times it is called and on how big a piece.
Lomuto partition does ONE pass (Θ(n)), classifies every element into "≤ pivot" or "> pivot" using only ONE comparison per element, and finishes with the pivot in its final sorted position. That's the entire algorithm's engine.

3. Quicksort and the loop invariant

quicksort(a, 0, a.length − 1) sorts the whole array. If lo ≥ hi the range has 0 or 1 elements — already sorted, nothing to do (the base case).

Under the hood, every recursive call to quicksort pushes a stack frame holding its own lo and hi — exactly like the call-stack behavior from D07. A call with lo ≥ hi returns immediately without pushing any further frames, which is why the recursion always terminates.

The Dart implementation

Input size → what is feasible. n ≤ 104: even the Θ(n²) worst case (n²/2 = 5·107 steps) finishes in about half a second; n = 105 or more with unknown input order: n²/2 = 5·109 steps on sorted input is far too slow and the plain recursion can reach n − 1 frames, so use the randomized version (section 5) and the bounded-stack version (section 9).

4. Performance: worst case, best case, balanced splits

Think of the recursion tree: each node is one call to quicksort, labelled with the size of the range it's sorting, and its two children are the two recursive calls it makes. The total work quicksort does is the sum of the Θ(size) partitioning cost at EVERY node in that tree. A tall, skinny tree (many levels, most nodes tiny) costs much more to add up than a short, bushy one.

Worst case — Θ(n²): partitioning returns the most unbalanced split possible, sizes 0 and n-1, at every single call. This happens whenever the input is already sorted (ascending OR descending) and the pivot is always the largest or smallest remaining element. The recurrence is T(n) = T(n-1) + T(0) + Θ(n) = T(n-1) + Θ(n), which unrolls to Θ(n) + Θ(n-1) + … + Θ(1) = Θ(n²) — an arithmetic series, exactly like the worst case of insertion sort.

In Dart: the worst-case recurrence T(n) = T(n−1) + n as a recursive function, its closed form n(n+1)/2, and the real comparison count n(n−1)/2 of quicksort on a sorted array.

Best case — Θ(n lg n): partitioning always splits as evenly as possible, sizes ⌊(n−1)/2⌋ and ⌈(n−1)/2⌉. The recurrence is T(n) = 2T(n/2) + Θ(n) — master theorem case 2, giving Θ(n lg n), matching merge sort.

In Dart: the best-case recurrence T(n) = 2T(n/2) + n for n = 2k against its closed form n lg n + n.

The recursion tree has depth Θ(n) in the worst case (one element peeled off per level) but only Θ(lg n) in the best case (the range size halves every level). Total cost = (cost per level) × (number of levels); in the worst case every level costs less but there are Θ(n) of them, in the best case every level costs Θ(n) but there are only Θ(lg n) of them.

Balanced-split intuition (the key insight that makes randomization work): you don't need an EXACTLY even split to get Θ(n lg n) — any constant proportion split works. Even a lopsided 9-to-1 split gives T(n) = T(9n/10) + T(n/10) + cn. The recursion tree for this still only has depth Θ(lg n) (each branch shrinks by a constant factor every level, so it takes log_{10/9} n levels down the "9/10" side to hit size 1 — still Θ(lg n), just with a bigger constant), and every level costs at most cn, so the total is still O(n lg n). Even a 99-to-1 split is Θ(n lg n) — only the constant factor gets worse, never the asymptotic order. This is exactly why randomized quicksort (next section), which makes no promise of an even split but does avoid always picking the worst one, still runs in expected Θ(n lg n).

In Dart: the 9-to-1 recurrence T(n) = T(9n/10) + T(n/10) + n with a memo table, and the number of levels on the deep side (about log10/9 n, still Θ(lg n)).

Input size → what is feasible. n = 105: best case n lg n ≈ 1.7·106 steps (instant), even a 9-to-1 split only about 2 times that (3.4·106 by the recurrence), but the worst case n²/2 = 5·109 steps takes tens of seconds: the difference between instant and unusable.
Average-case intuition (before the formal analysis in section 6): imagine a bad split (sizes n-1, 0, cost Θ(n)) immediately followed by a good split of that n-1-sized piece (sizes roughly n/2, n/2). Two levels combined cost Θ(n) + Θ(n) = Θ(n) — the SAME order as one single good split alone! One bad split's damage is absorbed by a good split right after it. Since (as the next section proves) a "reasonably good" split happens often when the pivot is random, alternating good/bad splits throughout the whole recursion still totals Θ(n lg n), just with a larger hidden constant than the pure best case.

5. Randomized quicksort

Deterministic quicksort (always pivoting on a[hi]) is like a card dealer who always deals from the top of a deck that a cheater got to arrange beforehand — feed it an already-sorted array and it hits its Θ(n²) worst case EVERY time, deterministically. Randomization is like shuffling the deck first: for the exact same "sorted array" input, the ALGORITHM now makes different, unpredictable choices each run, so no single fixed input can reliably trigger the worst case.

Instead of always using a[hi] as the pivot, first swap a uniformly random element of a[lo..hi] into position hi, then run the ordinary Lomuto partition.

This changes the running time from a property of the input to a property of the random choices made — no particular array (not even a fully sorted one) can force worst-case behavior on every run, and the expected running time over the algorithm's own coin flips is Θ(n lg n) for ANY input, proven in the next section.

For a RANDOMIZED algorithm, it makes no sense to talk about its "worst-case input" the way we do for deterministic algorithms — the SAME input can run fast on one execution and slow on another, purely because of which random pivots got drawn. What we CAN analyze is the expected running time, taken over the algorithm's own randomness, for a worst-case-chosen (adversarial, but fixed in advance) input. That's exactly the Θ(n lg n) guarantee below.

The Dart implementation

Input size → what is feasible. n = 106 distinct keys: expected 2(n+1)Hn − 4n ≈ 2.5·107 comparisons (~0.3 s) for ANY input order; with many equal keys randomization does not help, use the three-way partition (section 8).

6. Analysis: why the expected time is Θ(n lg n)

Worst case is still Θ(n²): even randomized, SOME sequence of coin flips could always draw the worst pivot; substituting T(n) ≤ cn² into the max-recurrence T(n) = max0≤q≤n-1[T(q) + T(n-1-q)] + Θ(n) and noting q² + (n-1-q)² is maximized at the endpoints (q=0 or q=n-1) confirms O(n²), matching the Ω(n²) lower bound from the deterministic worst-case example — so the worst case is exactly Θ(n²), it's just astronomically unlikely.

In Dart: the worst-case recurrence T(n) = maxq[T(q) + T(n−1−q)] + n computed by brute force over every split q: the maximum is always at an endpoint, so it equals n(n+1)/2.

Expected case is Θ(n lg n) — the heart of the lesson. Rename the elements in sorted order z1 < z2 < … < zn (the subscript is the element's rank, counting from 1), and let X be the TOTAL number of times the test a[j] ≤ pivot is ever evaluated across the entire run. Cost fact: the running time of quicksort is O(n + X) (at most n calls do any partitioning, each doing O(1) work plus exactly one comparison per loop iteration).

In Dart: the cost fact: counting the calls that reach the partition (at most n − 1) and X, the total number of comparisons.

The key trick is an indicator random variable for every PAIR (zi, zj) with i < j: define Xij = I{zi and zj are ever compared}. Then X = Σi<j Xij, and by linearity of expectation, E[X] = Σi<j Pr{zi and zj are compared} — no independence needed, linearity always holds.

The crucial observation: once a pivot is chosen that lies STRICTLY BETWEEN zi and zj in value, they end up in different ranges forever and are NEVER compared to each other again. So zi and zj are compared if and only if one of them is the very FIRST pivot ever chosen from the set Zij = {zi, zi+1, …, zj} (which has j-i+1 elements). Since every element of Zij is equally likely to be the first one picked as a pivot from that set, Pr{zi compared to zj} = 2/(j-i+1) (the "pair rule").

In Dart: estimating Pr{zi and zj are compared} by running randomized quicksort 20 000 times on the sorted values 0..7 (value k−1 is the element of rank k); the code also throws if any pair is ever compared twice.

Substituting and summing (change of variable k = j - i) turns the double sum into a sum of harmonic series, giving E[X] = Σk=1..n−1 (n−k)·2/(k+1) = 2(n+1)Hn − 4n = O(n lg n) — combined with the Ω(n lg n) best-case lower bound, the expected running time of randomized quicksort is Θ(n lg n), for ANY input.

In Dart: the pair sum, the gap sum and the closed form 2(n+1)Hn − 4n, plus the 2n ln n leading term from the table below (n = 200: 1563.0 vs 2119.3; n = 1000: 10985.9 vs 13815.5).

Input size → what is feasible. n = 1000: exactly 10 985.9 expected comparisons; n = 106: about 2.5·107. The leading term 2n ln n overshoots by 26–36% at these sizes.
nexact E[X] (closed-form double sum)measured average (300/100 seeded trials, verify/c07.dart)crude asymptotic 2n·ln n
2001563.01560.12119.3 (ratio to exact: 1.36×)
100010985.910925.513815.5 (ratio to exact: 1.26×)

The measured column comes from running randomized quicksort (with a fixed seed for reproducibility) many times and counting every comparison the partition loop makes, then averaging — it lines up almost exactly with the exact combinatorial formula E[X] = Σi<j 2/(j-i+1). The simpler "2n ln n" figure is only the leading term of that exact sum as n → ∞ — at these finite sizes it's a real overestimate (26–36% high here), though the ratio does shrink toward 1 as n grows, since the lower-order corrections become relatively smaller.

The whole proof rests on ONE idea: two elements are compared at most once, and only if the first pivot drawn from the range between them (inclusive) happens to be one of the two endpoints. Everything else is arithmetic on that one probability.

7. Hoare's original partition

Lomuto's partition is like ONE person walking down the shelf left to right. Hoare's original scheme (published by Tony Hoare in 1961) is like TWO people starting at opposite ENDS of the shelf and walking toward each other, each looking for a crate on the "wrong side" of the pivot — and whenever they both find one, they swap the two crates and keep walking inward.

hoarePartition picks the FIRST element, a[lo], as the pivot (not the last), and uses two indices that scan inward from both ends. Each one always takes at least one step before it starts testing:

hoarePartition does not return the pivot's final index the way Lomuto's does — it returns some index j with the property a[lo..j] ≤ a[j+1..hi] elementwise, but the pivot value itself can end up ANYWHERE inside a[lo..j], not necessarily at position j. So a quicksort built on Hoare's partition MUST recurse on (lo, j) and (j+1, hi) — recursing on (lo, j-1) and (j+1, hi) (copying Lomuto's pattern) would silently DROP element a[j] from ever being sorted, a genuine and easy-to-miss bug.

The Dart implementation

In Dart: checking the Hoare postcondition a[lo..j] ≤ a[j+1..hi] (with lo ≤ j < hi) on the page's 10-element array, which is exactly what makes the recursion on (lo, j) and (j+1, hi) correct.

Input size → what is feasible. n ≤ 106: Hoare typically makes noticeably fewer swaps than Lomuto (about half as many in a seeded test on random permutations of n = 1000; the often-quoted ‘up to 3×’ depends on the pivot rule), so it is usually the faster partition at large n. But with the first element as pivot, a sorted array of n = 105 costs n²/2 = 5·109 steps (split (1, n−1) every time): use a random or middle pivot in practice.

8. Three-way partitioning (many duplicates)

This is the classic Dutch national flag problem (Edsger Dijkstra): sort marbles into three colored bins — red (less than the pivot), white (equal to the pivot), blue (greater than the pivot) — using only ONE pass down the row, with three indices instead of Lomuto's one.

Here is the standard one-pass solution. It keeps three markers lt, i and gt that split the range into four zones: < pivot, == pivot, not yet examined, and > pivot:

Once partitioned this way, the sort recurses only on a[lo..q-1] and a[t+1..hi] (where (q, t) is the returned pair) — it skips the entire "equal to pivot" block a[q..t], since those elements are already in their final sorted position no matter how many duplicates there are. On an array of n equal elements, plain Lomuto quicksort is Θ(n²) (section 2's pitfall) — three-way quicksort sorts it in Θ(n), a single partitioning pass with zero further recursion.

The Dart implementation

In Dart: counting the work on an all-equal array: Lomuto quicksort does n(n−1)/2 comparisons, the three-way version makes one pass of n steps (n = 10, 100, 1000).

Input size → what is feasible. n = 107 keys with only 5 distinct values: three-way quicksort needs a handful of passes (about 108 steps), whereas 2-way Lomuto does about (n/5)²/2 · 5 ≈ 1013 steps on the equal blocks.

9. Bounding the stack depth

Recall from D07: every recursive call pushes a real stack frame using real memory. A naive quicksort that ALWAYS makes two genuine recursive calls (never a loop) is like insisting on stacking a fresh tray for both halves of the shelf every single time — even when one half is empty. On an adversarial (already-sorted) input, that means Θ(n) trays stacked up at once, risking a StackOverflowError exactly like D07's unbounded-recursion example.

A first improvement turns one of the two recursive calls into a while-loop update — a genuine tail call (the very last thing the function does), which reuses the CURRENT stack frame instead of pushing a new one. But by itself, always recursing on the left piece and looping on the right (without choosing) can STILL hit Θ(n) stack depth on adversarial input — the fix is to always make the genuine recursive call on the SMALLER piece and loop (tail-call) on the LARGER one. The panel below shows both: rows 1–5 are leftRecursiveQuicksort (the naive loop version), rows 7–15 are boundedStackQuicksort (the fix):

Because the recursive call only ever happens on the SMALLER side, that side is at most half the current size — so the recursion depth can double the array size at most lg n times before hitting a base case. This guarantees O(lg n) worst-case stack depth, while the LARGER side is still fully processed (via the loop, in the same frame) — so the overall O(n lg n) expected running time is completely unchanged. This costs literally nothing except choosing which side to recurse on.

The Dart implementation

In Dart: leftRecursiveQuicksort (rows 1–5), measuring its recursion depth: on a sorted array of n = 64 it reaches n − 1 = 63.

In Dart: the fix (rows 8–15):

In Dart: recursing only on the smaller side and looping on the larger: the maximum depth stays ≤ ⌊lg n⌋ even on a sorted array, where the left-recursive version reaches n − 1.

Input size → what is feasible. n = 106: smaller-first caps the stack at ⌊lg n⌋ = 19 frames, whereas the left-recursive version (or plain quicksort) on a sorted array needs 999 999 frames and overflows the default stack.

10. Median-of-three pivots

Instead of blindly trusting ONE random element to be a decent pivot, take three random samples and use their MEDIAN — like asking three random strangers to guess a stranger's age and trusting the middle guess over any single one, because it's much less likely that the middle of three independent guesses is wildly wrong.

Sample 3 elements uniformly at random (without replacement) from the n elements and use their median as the pivot. This is a real, runnable algorithm — not just theory — so here it is in action, using the exact same Lomuto machinery from section 2 once the median-of-three pivot has been chosen and swapped into place:

The Dart implementation

Let s1 < … < sn be the elements in sorted order (subscript = rank, counting from 1), and let pi = Pr{the pivot turns out to be si}. For the pivot to land EXACTLY on rank i, the sample of 3 needs exactly one element ranked below i, one ranked above, and one AT rank i itself:

pi = C(i-1,1)·C(n-i,1) / C(n,3) = 6(i-1)(n-i) / (n(n-1)(n-2)), for 2 ≤ i ≤ n-1 (and p1 = pn = 0 — the extreme ranks can never be the median of 3 distinct samples).

rank i (n = 9)123456789
pi (formula, verified by brute force over all C(9,3)=84 triples)00.0830.1430.1790.1900.1790.1430.0830

In Dart: the formula pi = 6(i−1)(n−i) / (n(n−1)(n−2)) (rank i counts from 1, so the list index is i−1) next to a brute-force count over all 84 triples for n = 9, which reproduces the table above.

Exactly as the formula predicts: probability rises smoothly toward the true middle rank and is symmetric — and the extreme ranks 1 and 9 are impossible, unlike ordinary random-pivot selection where every rank (including the extremes) is equally likely at probability 1/9 ≈ 0.111.

The limiting ratio: as n → ∞, compare pi at the exact middle rank against the ordinary single-random-pivot probability 1/n. For n = 99, exact middle rank i = 50: p50 = 0.015308 versus ordinary 1/99 = 0.010101 — a ratio of 1.515, already very close to the limiting ratio of 3/2 at n = 99 (both numbers verified in verify/c07.dart).

In Dart: the ratio p(n+1)/2 / (1/n): 1.515 at n = 99 and 1.5000015 at n = 1 000 001.

"Good split" probability: define a split as "good" if the pivot's rank lands in the middle third, [n/3, 2n/3]. Integrating pi over that range (as n → ∞) shows median-of-3 lands in the good range with probability 13/27 ≈ 0.4815, noticeably more often than ordinary random sampling's flat 1/3 chance.

In Dart: summing pi over the middle third for n = 3000 gives ≈ 0.4815 (= 13/27), against ≈ 1/3 for a uniform pivot.

Input size → what is feasible. n ≥ 106: median-of-3 only changes the constant hidden in Θ(n lg n); it costs an extra sort of 3 samples per call, so it pays off on large ranges, and small ones are best left to insertion sort (q26).
Despite all of this, here is the sobering punch line: median-of-3 (and its more extreme cousins, sampling more than 3 candidates) only ever improves the constant factor hidden inside the Θ(n lg n) expected running time — it can NEVER change the asymptotic order. It's a real, measurable practical speedup (used in production sort implementations), not an asymptotic one.

Quiz

Interview questions

Cheat sheet

AlgorithmBestAverageWorstSpaceStable?In place?When to use
partitionLomutoΘ(n) alwaysO(1) extra—YesBuilding block, not a full sort
quicksort (deterministic)Θ(n lg n)Θ(n lg n)Θ(n²) (sorted input!)O(lg n) stack (avg), O(n) worstNoYesAvoid on data that may already be sorted
randomizedQuicksortΘ(n lg n)Θ(n lg n) expected, any inputΘ(n²) (astronomically unlikely)O(lg n) expected stackNoYesDefault general-purpose in-place sort
hoarePartitionΘ(n) always, usually fewer swaps than Lomuto (about 2× fewer in a seeded random test)O(1) extra—YesFaster constant factor; recursion bounds differ from Lomuto
Three-way (Dutch-flag) quicksortΘ(n) (all equal)Θ(n lg n)Θ(n²) (all distinct, unlucky pivots)O(1) extraNoYesMany duplicate keys (e.g. sorting by a low-cardinality field)
boundedStackQuicksort (smaller side first + loop)Same time bounds as plain quicksortO(lg n) worst-case stack, guaranteedNoYesProduction code, to make StackOverflow impossible
Median-of-3 pivot selectionSame asymptotic bounds — improves the constant factor onlyO(1) extraNoYesSqueeze more real-world speed out of any of the above