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:
- 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 testA[j] <= vtriggers 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 aresort(lo, j)andsort(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!
Step through Bentley-McIlroy 3-way partition QuickSort, Bottom-Up MergeSort inversion counting, and non-comparison LSD RadixSort with live comparison and swap counters.
Invariant & State
| 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) |
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.
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;
}
}
}
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).