DSA DSA In Depth
0%
lesson quicksortpartitioninghoare

Quicksort: Lomuto vs Hoare vs Bentley-McIlroy 3-Way Partitioning

7 min read Module 2.2: Sorting Mechanics, Hybrid Sorts & Linear-Time Order Statistics Updated Oct 23, 2026
📄 Cheat Sheet
3-Way Quicksort Partitioning (Dutch National Flag & Hoare Partition Schemes)
Interview Kickstart 3-Way Quicksort Partitioning (Dutch National Flag & Hoare Partition Schemes) Watch on YouTube ↗

Robert Sedgewick traces Hoare's two-pointer crossing scheme, demonstrates why duplicate keys trigger quadratic degradation without 3-way partitioning, and presents Dijkstra's Dutch National Flag and Bentley-McIlroy partitioning.

0:00 / --:--

Anatomy of the Three Partition Schemes

Given a subarray $A[lo \dots hi]$ and a chosen pivot value $v$, partitioning rearranges elements in-place so keys smaller than $v$ precede keys larger than $v$. Yet the choice of pointer invariant changes swap counts, branch predictability, and duplicate-key behavior dramatically:

Loop Invariants of Lomuto, Hoare, and Dijkstra 3-Way Partitioning
  • 1. Lomuto Partition (1-Way Scan): Pivot at $v = A[hi]$. Maintains index $i$ such that $A[lo \dots i-1] \le v$, $A[i \dots j-1] > v$, and $A[j \dots hi-1]$ is unexamined.
    Fatal Flaw: On random permutations, Lomuto executes $\frac{n}{2}$ swaps on average ($3\times$ more than Hoare's $\frac{n}{6}$). Worse, if all elements equal $v$, the test A[j] <= v triggers a swap on every single element and leaves the pivot at index $hi$, recurring on $n - 1$ elements in $\Theta(n^2)$ time!
  • 2. Hoare Partition (2-Way Crossing Pointers, 1961): Pivot $v = A[lo]$. Left pointer $i$ scans right stopping on $A[i] \ge v$; right pointer $j$ scans left stopping on $A[j] \le v$. Notice both pointers stop on elements equal to $v$!
    Why stopping on $== v$ is essential: If all keys equal $v$, every step performs one redundant swap, but $i$ and $j$ meet at the exact midpoint $\lfloor(lo+hi)/2\rfloor$, splitting the array into two equal halves in $\Theta(n \log n)$ time! Note also that Hoare's scheme does not place the pivot at its final index; it returns a split index $j$ guaranteeing $\max(A[lo \dots j]) \le \min(A[j+1 \dots hi])$, so recursive calls are sort(lo, j) and sort(j + 1, hi).
  • 3. Dijkstra 3-Way (Dutch National Flag) & Bentley-McIlroy: Maintains three pointers $lt, i, gt$ partitioning $A[lo \dots hi]$ into four regions: $$\underbrace{A[lo \dots lt-1]}_{< v} \;\Big|\; \underbrace{A[lt \dots i-1]}_{= v} \;\Big|\; \underbrace{A[i \dots gt]}_{\text{unexamined}} \;\Big|\; \underbrace{A[gt+1 \dots hi]}_{> v}$$ When $i > gt$, all duplicates $A[lt \dots gt] = v$ are permanently in their final sorted positions and excluded from both recursive calls!
Sorting Arena: 3-Way QuickSort, MergeSort & LSD RadixSort
O(N log N) vs O(d·N)

Step through Bentley-McIlroy 3-way partition QuickSort, Bottom-Up MergeSort inversion counting, and non-comparison LSD RadixSort with live comparison and swap counters.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

3-Way QuickSort (Dijkstra Dutch National Flag): partitions subarray into < pivot, == pivot, and > pivot.
🌳 Live 2D Hierarchical Tree Topology — Sorting Arena: 3-Way QuickSort, MergeSort & LSD RadixSort Visited/Active Pivot/Root Resolved
38 [0] 27 [1] 43 [2] 3 [3] 9 [4] 82 [5] 10 [6] 27 [7]
[0]38
[1]27
[2]43
[3]3
[4]9
[5]82
[6]10
[7]27
Comparisons
0
Swaps
0
Locked Keys
0 / 8
Array Size
8
[Step 1] Starting 3-Way QuickSort on [38, 27, 43, 3, 9, 82, 10, 27].
Partition Scheme Avg Swaps (Random) All-Equal Keys Time $k$ Distinct Keys Time Pivot Final Position?
Lomuto (CLRS) $\frac{1}{2}n$ $\Theta(n^2)$ (catastrophic) $\Theta(n^2)$ Yes ($A[p] = v$, recurse $[lo, p-1], [p+1, hi]$)
Hoare (Stop on $== v$) $\frac{1}{6}n$ ($3\times$ fewer) $\Theta(n \log n)$ ($n/2$ swaps/pass) $\Theta(n \log n)$ No (recurse $[lo, j]$ and $[j+1, hi]$)
Dijkstra 3-Way (DNF) $\approx \frac{2}{3}n$ $\Theta(n)$ (0 swaps!) $\Theta(n \log k)$ Yes (band $[lt \dots gt] = v$ excluded)
Bentley-McIlroy Split-End 3-Way $\frac{1}{6}n$ (zero overhead on distinct) $\Theta(n)$ $O(n H + n)$ (entropy-optimal) Yes (swaps ends $= v$ to center at end)
1. Dijkstra 3-Way (Dutch National Flag) Invariant During Scan < v [lo .. lt-1] == v [lt .. i-1] ? [i .. gt] > v [gt+1 .. hi] 2. Bentley-McIlroy Split-End 3-Way (C qsort, 1993): Parks == v at Ends, Swaps to Center Only at Crossing == v [lo..p-1] < v [p..i-1] unscanned [i..j] > v [j+1..q] == v [q+1..hi] Why Bentley-McIlroy wins when keys are distinct: it does Hoare's exact 1/6 N swaps during the scan, paying 0 extra swaps unless equal keys exist!

Entropy-Optimal Sorting & Pivot Selection

Suppose an array of $n$ elements has $k$ distinct values occurring with frequencies $f_1, f_2, \dots, f_k$ and empirical probabilities $p_i = f_i / n$. The Shannon entropy of the multiset is $H = -\sum_{i=1}^k p_i \log_2 p_i \le \log_2 k$. Sedgewick and Bentley proved that 3-way Quicksort uses $O(n H + n)$ comparisons—matching the information-theoretic lower bound $\log_2\!\left(\frac{n!}{f_1! f_2! \cdots f_k!}\right) = n H - O(k \log n)$ up to a constant factor!

To prevent adversarial inputs (sorted, reverse-sorted, or "organ-pipe" arrays) from picking extreme pivots, production implementations use:

  • Median-of-Three ($n \le 128$): Median of $(A[lo], A[mid], A[hi])$, which also places sentinels at the ends.
  • Tukey's Ninther ($n > 128$): Median of three medians of three ($9$ samples), costing only $12$ comparisons while concentrating the pivot tightly around the true median and defeating killer inputs like McIlroy's "AntiQsort" adversary when combined with pattern randomization.
flowchart TD A["Choose Pivot v via Median-of-3 or Tukey's Ninther"] --> B{"Many Equal Keys? (e.g., Pivot == Neighbor)"} B -- "Distinct Keys Common Case" --> C["Hoare / BlockQuicksort 2-Way Partition 1/6 N swaps, branch-free offsets"] B -- "Low Cardinality / Equal Keys" --> D["3-Way Dutch National Flag Partition Exclude [lt..gt] == v in O(N)"] C --> E["Tail-Call Elimination: Recurse on SMALLER side first, loop on LARGER side Guarantees ≤ O(log N) Stack Space!"] D --> E
Hardware Reality: Branch Mispredictions & BlockQuicksort (Edelkamp & Weiß, 2016)

On a random array, the comparison A[i] < pivot inside Lomuto or Hoare partitioning has a $50\%$ probability of being true—the exact worst-case entropy for a CPU branch predictor! Every mispredicted branch flushes the 14–20 stage out-of-order pipeline, costing ~15 cycles (often $5\times$ slower than the comparison and L1 load combined). BlockQuicksort (used in Rust's pdqsort and modern C++ stdlibs) eliminates branches from partitioning by evaluating A[i] < pivot across a block of 64 elements, storing the matching offsets into two small 64-byte index buffers using branchless pointer increments (offsets[num++] = i where num += (A[i] < pivot)), and then performing a tight cyclic permutation of the mismatched pairs!

Production C++20 Implementation: Hoare, 3-Way & Tail-Call Bounded Quicksort

#include <algorithm>
#include <concepts>
#include <cstddef>
#include <utility>

// Median-of-3 pivot selection that also sorts A[lo] <= A[mid] <= A[hi] as sentinels
template <std::totally_ordered T>
void sort_median_of_three(T* a, std::size_t lo, std::size_t mid, std::size_t hi) noexcept {
    if (a[mid] < a[lo]) std::swap(a[lo], a[mid]);
    if (a[hi]  < a[lo]) std::swap(a[lo], a[hi]);
    if (a[hi]  < a[mid]) std::swap(a[mid], a[hi]);
}

// Hoare 2-Way Partition: stops on keys == pivot to prevent O(N^2) on equal elements
template <std::totally_ordered T>
std::size_t hoare_partition(T* a, std::size_t lo, std::size_t hi) noexcept {
    const std::size_t mid = lo + (hi - lo) / 2;
    sort_median_of_three(a, lo, mid, hi);
    T pivot = a[mid];
    std::size_t i = lo;
    std::size_t j = hi;
    while (true) {
        while (a[i] < pivot) ++i;
        while (pivot < a[j]) --j;
        if (i >= j) return j;
        std::swap(a[i], a[j]);
        ++i;
        --j;
    }
}

// Dijkstra 3-Way Partition + Sedgewick Tail-Call Elimination (Guarantees O(log N) stack depth)
template <std::totally_ordered T>
void quicksort_3way(T* a, std::size_t lo, std::size_t hi) noexcept {
    while (lo < hi) {
        const std::size_t mid = lo + (hi - lo) / 2;
        sort_median_of_three(a, lo, mid, hi);
        const T pivot = a[mid];

        // Invariant: a[lo..lt-1] < pivot, a[lt..i-1] == pivot, a[gt+1..hi] > pivot
        std::size_t lt = lo, i = lo, gt = hi;
        while (i <= gt) {
            if (a[i] < pivot) {
                std::swap(a[lt++], a[i++]);
            } else if (pivot < a[i]) {
                std::swap(a[i], a[gt--]);
            } else {
                ++i;
            }
        }

        // Tail-Call Elimination: recurse ONLY on the smaller partition (<= N/2),
        // and iterate the while-loop on the larger partition!
        const std::size_t left_len  = (lt > lo) ? (lt - lo) : 0;
        const std::size_t right_len = (hi > gt) ? (hi - gt) : 0;

        if (left_len < right_len) {
            if (lt > lo + 1) quicksort_3way(a, lo, lt - 1);
            lo = gt + 1;
        } else {
            if (hi > gt + 1) quicksort_3way(a, gt + 1, hi);
            if (lt == 0) break; // Prevent unsigned underflow when lt == 0
            hi = lt - 1;
        }
    }
}
Two Classic Quicksort Bugs: Stack Overflow &Hoare Off-by-One Infinite Loops

1. Unbounded $O(N)$ Stack Overflow: Without recursing on the smaller partition first and tail-looping on the larger partition, an adversarial input producing $1 : (n-1)$ splits will push $n$ stack frames and crash the thread stack (~1–8 MB) at $n \approx 10^5$. Reordering so the recursive call is always on the side of size $\le n/2$ strictly bounds stack depth to $\le \log_2 n$ frames even in the $\Theta(n^2)$ time worst case!
2. Hoare Partition Infinite Recursion: In Hoare partitioning with pivot $A[lo]$, if you pick $A[hi]$ as the pivot or recurse on (lo, j - 1) and (j, hi) when $j$ can return $lo$, one partition can have size $n$ and the other size $0$, causing infinite recursion. Always pair pivot choice at $A[lo]$ (or median-of-three placed at $A[lo]$) with split (lo, j) and (j + 1, hi).

Check yourself Question 1 of 4
Concept Check Why Hoare Partitioning Stops on Elements Equal to the Pivot recall

In Hoare's two-pointer partition scheme with pivot $v$, the inner loops are written `while (A[i] < v) ++i;` and `while (v < A[j]) --j;`—meaning both pointers **stop** and swap whenever $A[i] == v$ and $A[j] == v$.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

How did Bentley and McIlroy (1993) eliminate this extra swap overhead when keys are distinct while keeping $O(n)$ performance when duplicates are abundant?

Key Takeaways

Lomuto partitioning performs 3x more swaps than Hoare on random inputs and degrades to Θ(N^2) when all elements are equal.
Hoare partitioning stops both pointers on elements equal to the pivot, trading minor redundant swaps on duplicates for balanced O(N log N) splits.
Dijkstra 3-way (Dutch National Flag) and Bentley-McIlroy partitioning isolate all keys equal to the pivot into a center band, achieving O(N) time on low-cardinality inputs and entropy-optimal O(N H + N) comparisons.
Always recurse on the smaller partition first and loop on the larger partition (Sedgewick tail-call elimination) to guarantee O(log N) worst-case stack space.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue