Loading content...
The partition operation is the heart of Quick Sort, and there are two classic ways to implement it: the Lomuto partition scheme and the Hoare partition scheme. Both achieve the same logical result — rearranging elements around a pivot — but they differ significantly in their mechanics, performance characteristics, and ease of implementation.
Nico Lomuto's scheme (popularized through textbooks like CLRS) is simpler to understand and implement correctly. It uses a single scan through the array, maintaining a clear invariant that's easy to verify.
Tony Hoare's original scheme is more efficient, making approximately half as many swaps on average, but its mechanics are subtler and the invariants more nuanced. It's the scheme Hoare invented alongside Quick Sort itself.
Understanding both schemes is essential: Lomuto for clarity and teaching, Hoare for performance-critical implementations. Many interview questions explore the differences, and production implementations often use Hoare's scheme or its variants for the performance benefit.
By the end of this page, you will (1) be able to implement both Lomuto and Hoare partition schemes correctly, (2) understand the invariants each maintains, (3) recognize the performance differences between them, and (4) know when to choose one over the other.
The Lomuto partition scheme is characterized by its simplicity and clarity. It uses the last element as the pivot and scans from left to right, maintaining a boundary between elements ≤ pivot and elements > pivot.
Key Characteristics:
The Invariant:
At any point during the scan, the array is divided into four regions:
A[low..j] : elements ≤ pivot (processed, "small")
A[j+1..i-1] : elements > pivot (processed, "large")
A[i..high-1] : unprocessed elements
A[high] : pivot
As we scan, each element is classified and moved to the appropriate region.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
/** * Lomuto Partition Scheme * * Algorithm: * 1. Select the last element as pivot * 2. Initialize boundary j = low - 1 (nothing in "small" region yet) * 3. Scan elements from low to high-1 * - If element ≤ pivot: expand "small" region and swap element in * - If element > pivot: "large" region naturally expands * 4. Place pivot between the two regions * 5. Return pivot's position * * @param arr - The array to partition * @param low - Starting index (inclusive) * @param high - Ending index (inclusive), arr[high] is the pivot * @returns The final index of the pivot */function lomutoPartition(arr: number[], low: number, high: number): number { const pivot = arr[high]; // Step 1: Last element is pivot let j = low - 1; // Step 2: j is the boundary of "small" region // A[low..j] will contain elements ≤ pivot // Initially empty (j < low) // Step 3: Scan through all elements except pivot for (let i = low; i < high; i++) { // INVARIANT at this point: // - A[low..j] has elements ≤ pivot // - A[j+1..i-1] has elements > pivot // - A[i..high-1] is unprocessed // - A[high] is pivot if (arr[i] <= pivot) { // This element belongs in the "small" region j++; // Expand the "small" region // Swap arr[i] into the "small" region // arr[j] was the first element of "large" region (or unprocessed) [arr[j], arr[i]] = [arr[i], arr[j]]; } // If arr[i] > pivot, no action needed // The "large" region naturally expands to include it } // Step 4: Place pivot in its final position // The pivot should go right after the "small" region // That position is j + 1 [arr[j + 1], arr[high]] = [arr[high], arr[j + 1]]; // Step 5: Return pivot's final position return j + 1;} /** * Complete Quick Sort using Lomuto Partition */function quickSortLomuto(arr: number[], low: number, high: number): void { if (low < high) { const pivotIndex = lomutoPartition(arr, low, high); quickSortLomuto(arr, low, pivotIndex - 1); quickSortLomuto(arr, pivotIndex + 1, high); }}Trace Through an Example:
Let's partition [8, 3, 7, 1, 5, 2, 6, 4] with pivot = 4 (last element).
| Step | i | j | A[i] | A[i] ≤ 4? | Action | Array State |
|---|---|---|---|---|---|---|
| Init | — | -1 | — | — | — | [8, 3, 7, 1, 5, 2, 6, 4] |
| 1 | 0 | -1 | 8 | No | Skip | [8, 3, 7, 1, 5, 2, 6, 4] |
| 2 | 1 | -1 | 3 | Yes | j++, swap(0,1) | [3, 8, 7, 1, 5, 2, 6, 4] |
| 3 | 2 | 0 | 7 | No | Skip | [3, 8, 7, 1, 5, 2, 6, 4] |
| 4 | 3 | 0 | 1 | Yes | j++, swap(1,3) | [3, 1, 7, 8, 5, 2, 6, 4] |
| 5 | 4 | 1 | 5 | No | Skip | [3, 1, 7, 8, 5, 2, 6, 4] |
| 6 | 5 | 1 | 2 | Yes | j++, swap(2,5) | [3, 1, 2, 8, 5, 7, 6, 4] |
| 7 | 6 | 2 | 6 | No | Skip | [3, 1, 2, 8, 5, 7, 6, 4] |
| Final | — | 2 | — | — | swap(3,7) | [3, 1, 2, 4, 5, 7, 6, 8] |
Final Result: [3, 1, 2, 4, 5, 7, 6, 8]
Analysis of Lomuto:
Lomuto partition places all elements equal to the pivot on one side (the left, since we use ≤). With many duplicates, this causes unbalanced partitions. For [3, 3, 3, 3, 3] with pivot 3, ALL elements go left, creating n-1 / 0 splits — worst-case O(n²) behavior.
The Hoare partition scheme is Tony Hoare's original algorithm, developed alongside Quick Sort itself in 1959. It uses two pointers that scan from opposite ends toward the center, swapping elements that are on the wrong side.
Key Characteristics:
Important Distinction: Unlike Lomuto, Hoare's scheme doesn't necessarily place the pivot in its final position. It guarantees that elements in A[low..p] are ≤ pivot and elements in A[p+1..high] are ≥ pivot, but the pivot itself might be in either partition.
This means the recursive calls in Quick Sort with Hoare partition are slightly different:
QuickSort(A, low, p) // NOT p-1
QuickSort(A, p+1, high)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
/** * Hoare Partition Scheme * * Algorithm: * 1. Select a pivot (often first or middle element) * 2. Initialize two pointers: i at low-1, j at high+1 * 3. Loop: * a. Move i right until A[i] >= pivot * b. Move j left until A[j] <= pivot * c. If i < j: swap A[i] and A[j] * d. If i >= j: return j as the partition point * * @param arr - The array to partition * @param low - Starting index (inclusive) * @param high - Ending index (inclusive) * @returns The partition point p where A[low..p] ≤ pivot, A[p+1..high] ≥ pivot */function hoarePartition(arr: number[], low: number, high: number): number { // Step 1: Select pivot (using first element) // Using middle element is also common and protects against sorted inputs const pivot = arr[low]; // Alternative: const pivot = arr[Math.floor((low + high) / 2)]; // Step 2: Initialize pointers outside the array bounds // They will be moved before first access let i = low - 1; let j = high + 1; // Step 3: Main loop while (true) { // Step 3a: Move i right, find element >= pivot do { i++; } while (arr[i] < pivot); // Step 3b: Move j left, find element <= pivot do { j--; } while (arr[j] > pivot); // Step 3c & 3d: Check if pointers crossed if (i >= j) { // Pointers have crossed or met // j is the partition point return j; } // Pointers haven't crossed: swap the elements // arr[i] >= pivot and arr[j] <= pivot, but they're on wrong sides [arr[i], arr[j]] = [arr[j], arr[i]]; }} /** * Quick Sort using Hoare Partition * * Note: The recursive calls are different from Lomuto! * We recurse on [low, p] and [p+1, high], not [low, p-1] and [p+1, high] * This is because Hoare doesn't guarantee pivot is at position p */function quickSortHoare(arr: number[], low: number, high: number): void { if (low < high) { const p = hoarePartition(arr, low, high); // IMPORTANT: Different from Lomuto! quickSortHoare(arr, low, p); // Include p, not p-1 quickSortHoare(arr, p + 1, high); }}Trace Through an Example:
Let's partition [8, 3, 7, 1, 5, 2, 6, 4] with pivot = 8 (first element).
| Step | i | j | Action | Array State |
|---|---|---|---|---|
| Init | -1 | 8 | Starting state | [8, 3, 7, 1, 5, 2, 6, 4] |
| 1a | 0 | 8 | i++ until A[i]>=8: stop at i=0 | [8, 3, 7, 1, 5, 2, 6, 4] |
| 1b | 0 | 7 | j-- until A[j]<=8: stop at j=7 | [8, 3, 7, 1, 5, 2, 6, 4] |
| 1c | 0 | 7 | i<j: swap(0,7) | [4, 3, 7, 1, 5, 2, 6, 8] |
| 2a | 6 | 7 | i++ until A[i]>=8: stop at i=7 | [4, 3, 7, 1, 5, 2, 6, 8] |
| 2b | 7 | 6 | j-- until A[j]<=8: stop at j=6 | [4, 3, 7, 1, 5, 2, 6, 8] |
| 2d | — | — | i>=j: return j=6 | [4, 3, 7, 1, 5, 2, 6, 8] |
Final Result: [4, 3, 7, 1, 5, 2, 6, 8] with partition point 6
Wait, elements aren't fully separated?
This is a key difference from Lomuto! Hoare's invariant is weaker:
The pivot value might appear in either partition. Both partitions still need sorting, and the algorithm is correct because:
Hoare partition is correct but subtle. The returned index j is NOT necessarily where the pivot ends up. It's simply a dividing line such that A[low..j] ≤ pivot and A[j+1..high] ≥ pivot. The pivot might be at any position in either partition. This is why you must recurse on [low, j] not [low, j-1].
Let's directly compare the two schemes across multiple dimensions to understand their tradeoffs.
| Aspect | Lomuto | Hoare |
|---|---|---|
| Inventor | Nico Lomuto | Tony Hoare (with Quick Sort, 1959) |
| Pointer movement | Single pointer, left to right | Two pointers, inward from both ends |
| Pivot position | Always at A[high] initially | Can be anywhere (typically A[low] or A[mid]) |
| Pivot final position | Guaranteed at returned index | NOT guaranteed at returned index |
| Recursive call bounds | quickSort(low, p-1) and (p+1, high) | quickSort(low, p) and (p+1, high) |
| Average swaps | ≈ n/2 on random data | ≈ n/6 on random data |
| Comparisons | n-1 exactly | n-1 to n+1 |
| Handling duplicates | All equals go to one side | Equals distributed between sides |
| Code complexity | Simpler, fewer edge cases | More complex, subtle termination |
| Common bugs | Off-by-one in final swap | Infinite loop with wrong pivot/bounds |
Why Hoare Uses Fewer Swaps:
Consider what happens when partitioning a random array:
Lomuto: Every element ≤ pivot triggers a swap into the "small" region. With random data, about half the elements are ≤ pivot, so we perform ≈ n/2 swaps.
Hoare: We only swap when BOTH pointers find an element on the wrong side:
With random data, this happens much less frequently. Analysis shows ≈ n/6 swaps on average — three times fewer than Lomuto.
This difference matters because swaps are expensive operations involving three memory accesses (read two elements, write two elements with temp storage or destructuring).
Both partition schemes have subtle correctness requirements. Let's examine the most common bugs and how to avoid them.
Lomuto Common Bugs:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// BUG 1: Wrong initialization of boundary variablefunction buggyLomuto1(arr: number[], low: number, high: number): number { const pivot = arr[high]; let j = low; // BUG! Should be low - 1 // This causes first element to be skipped or misclassified // ...} // BUG 2: Wrong loop boundsfunction buggyLomuto2(arr: number[], low: number, high: number): number { const pivot = arr[high]; let j = low - 1; for (let i = low; i <= high; i++) { // BUG! Should be i < high // This processes the pivot itself, corrupting partition // ... }} // BUG 3: Wrong comparison operatorfunction buggyLomuto3(arr: number[], low: number, high: number): number { const pivot = arr[high]; let j = low - 1; for (let i = low; i < high; i++) { if (arr[i] < pivot) { // BUG! Should be <= // Equal elements all go right, causing worst-case on duplicates j++; [arr[j], arr[i]] = [arr[i], arr[j]]; } } [arr[j + 1], arr[high]] = [arr[high], arr[j + 1]]; return j + 1;} // BUG 4: Wrong final swapfunction buggyLomuto4(arr: number[], low: number, high: number): number { const pivot = arr[high]; let j = low - 1; for (let i = low; i < high; i++) { if (arr[i] <= pivot) { j++; [arr[j], arr[i]] = [arr[i], arr[j]]; } } [arr[j], arr[high]] = [arr[high], arr[j]]; // BUG! Should be j+1 return j; // BUG! Should return j+1}Hoare Common Bugs:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// BUG 1: Using == instead of >= in termination checkfunction buggyHoare1(arr: number[], low: number, high: number): number { const pivot = arr[low]; let i = low - 1, j = high + 1; while (true) { do { i++; } while (arr[i] < pivot); do { j--; } while (arr[j] > pivot); if (i == j) return j; // BUG! Should be i >= j // When i and j cross without being equal, infinite loop or wrong result [arr[i], arr[j]] = [arr[j], arr[i]]; }} // BUG 2: Wrong initial pointer values function buggyHoare2(arr: number[], low: number, high: number): number { const pivot = arr[low]; let i = low, j = high; // BUG! Should be low-1 and high+1 // First iteration will skip first/last element while (true) { do { i++; } while (arr[i] < pivot); // May go out of bounds do { j--; } while (arr[j] > pivot); if (i >= j) return j; [arr[i], arr[j]] = [arr[j], arr[i]]; }} // BUG 3: Using wrong recursive call boundsfunction buggyQuickSortHoare(arr: number[], low: number, high: number): void { if (low < high) { const p = hoarePartition(arr, low, high); buggyQuickSortHoare(arr, low, p - 1); // BUG! Should be p, not p-1 buggyQuickSortHoare(arr, p + 1, high); }} // BUG 4: Infinite loop with wrong pivot selectionfunction buggyHoare4(arr: number[], low: number, high: number): number { const pivot = arr[high]; // Using high as pivot with this logic can cause issues let i = low - 1, j = high + 1; // If pivot is the minimum, j will never find element <= pivot before hitting low // Need careful handling or use low/mid as pivot with this pointer setup while (true) { do { i++; } while (arr[i] < pivot); do { j--; } while (arr[j] > pivot); if (i >= j) return j; [arr[i], arr[j]] = [arr[j], arr[i]]; }}Always test partitions with: (1) single element [5], (2) two elements [5,3] and [3,5], (3) three elements with duplicates [3,3,3], (4) already sorted [1,2,3,4,5], (5) reverse sorted [5,4,3,2,1], (6) all elements equal to pivot. These cases expose most off-by-one and boundary bugs.
Understanding loop invariants is the key to implementing partition correctly. Let's formalize the invariants for both schemes.
Lomuto Invariant:
At the start of each iteration of the for loop (before processing element i):
Initialization (before loop): j = low - 1
Maintenance (during loop): At iteration i:
Termination (after loop): i = high
Hoare Invariant:
The invariant is more nuanced because we maintain regions from both ends:
Before each swap:
After termination (i ≥ j):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
/** * Lomuto with detailed invariant annotations */function lomutoAnnotated(arr: number[], low: number, high: number): number { const pivot = arr[high]; let j = low - 1; // INVARIANT: A[low..j] has elements ≤ pivot // A[j+1..i-1] has elements > pivot // Initially: both regions are empty (j = low-1, i = low) for (let i = low; i < high; i++) { // Check invariant holds: // assert: forall k in [low..j]: A[k] <= pivot // assert: forall k in [j+1..i-1]: A[k] > pivot if (arr[i] <= pivot) { j++; [arr[j], arr[i]] = [arr[i], arr[j]]; // A[j] now contains element <= pivot // Invariant maintained: "small" region extended } // else: A[i] > pivot, "large" region extended (j+1..i) } // After loop: i = high // A[low..j] = all elements ≤ pivot // A[j+1..high-1] = all elements > pivot // A[high] = pivot [arr[j + 1], arr[high]] = [arr[high], arr[j + 1]]; // Final state: // A[low..j] ≤ A[j+1] (pivot) ≤ A[j+2..high] return j + 1;} /** * Hoare with detailed invariant annotations */function hoareAnnotated(arr: number[], low: number, high: number): number { const pivot = arr[low]; let i = low - 1; let j = high + 1; while (true) { // Find rightmost element in left region that's >= pivot do { i++; } while (arr[i] < pivot); // Post: arr[i] >= pivot, and all arr[low..i-1] < pivot // Find leftmost element in right region that's <= pivot do { j--; } while (arr[j] > pivot); // Post: arr[j] <= pivot, and all arr[j+1..high] > pivot if (i >= j) { // INVARIANT at termination: // arr[low..j] ≤ pivot (because arr[low..i-1] < pivot and arr[j] <= pivot) // arr[j+1..high] ≥ pivot (because arr[j+1..high] > pivot, with possible exception at crossing) return j; } // Both i and j are valid and haven't crossed // arr[i] >= pivot but belongs in left (should be < pivot) // arr[j] <= pivot but belongs in right (should be > pivot) // Swap them to restore invariant progress [arr[i], arr[j]] = [arr[j], arr[i]]; }}Let's analyze the performance of both partition schemes rigorously.
Comparison Count:
Both schemes perform Θ(n) comparisons per partition call:
The comparison counts are essentially identical in big-O terms.
Swap Count (the key difference):
This is where the schemes differ significantly.
Lomuto Swap Analysis:
Every element ≤ pivot triggers a swap. If k elements are ≤ pivot:
For random data where each element is equally likely to be ≤ pivot:
Worst case (all elements ≤ pivot): n swaps
Hoare Swap Analysis:
Swaps occur only when both pointers find an element on the "wrong" side. Analysis of random permutations shows:
This is 3x fewer swaps than Lomuto on average!
The intuition: Hoare swaps efficiently "fix" pairs of misplaced elements, while Lomuto swaps every "small" element into position one by one.
| Metric | Lomuto | Hoare | Winner |
|---|---|---|---|
| Comparisons | n-1 | n-1 to n+1 | Tie |
| Expected swaps (random) | n/2 | n/6 | Hoare (3x better) |
| Worst-case swaps | n | n/2 | Hoare (2x better) |
| Best-case swaps | 1 | 0 | Hoare |
| Cache behavior | Good | Excellent | Hoare (slightly) |
| Code simplicity | Simple | Complex | Lomuto |
| Correctness pitfalls | Few | Many | Lomuto |
Real-World Benchmark Considerations:
While Hoare performs fewer swaps, the actual performance difference depends on:
Element size: For small elements (integers), swap overhead is minimal; comparison may dominate. For large structs, swap reduction matters more.
Comparison cost: If comparison involves string comparison or custom comparators, comparisons dominate and swap savings matter less.
Cache effects: Hoare's bidirectional scan may have different cache behavior than Lomuto's unidirectional scan, though both are generally cache-friendly.
Branch prediction: Modern CPUs predict branches. Lomuto has a single forward scan that may predict better than Hoare's pattern.
Empirical results: On most systems with integer or pointer elements, Hoare is 10-20% faster than Lomuto. The gap widens for larger elements.
Production implementations often use hybrid approaches: Hoare partition with median-of-three pivot selection, switching to Insertion Sort for small subarrays, and using three-way partitioning when many duplicates are detected. The base algorithm matters less than these optimizations for achieving peak performance.
Given the tradeoffs, how should you choose between Lomuto and Hoare? Here's a decision framework:
Choose Lomuto When:
Learning or teaching Quick Sort — Lomuto's single-scan approach with clear regions is easier to understand and verify.
Code maintainability matters more than peak performance — When others will read and modify your code, simpler is better.
Interview settings — Many interviewers expect Lomuto (from CLRS familiarity). It's easier to write correctly under pressure.
One-off scripts or prototypes — The performance difference won't matter for small or infrequent use.
Choose Hoare When:
Performance is critical — The 3x swap reduction adds up on large datasets.
Standard library implementations — If you're writing a library sort, use Hoare (or its variants).
Large elements — Swapping large structs or objects benefits more from swap reduction.
You're confident in the implementation — Only use Hoare if you understand its subtleties.
The Best of Both Worlds:
Many production implementations combine ideas from both schemes:
1. Use median-of-three for pivot selection
2. Use Hoare's bidirectional scan for efficiency
3. Switch to Insertion Sort for small subarrays
4. Detect excessive recursion depth and switch to Heap Sort (Introsort)
5. Use three-way partitioning when duplicates are detected
This hybrid approach inherits Hoare's efficiency, protects against worst-case inputs, and handles edge cases gracefully.
We've thoroughly examined the two classic partition schemes. Here are the essential takeaways:
What's Next:
Now that we understand the mechanics of partitioning, we need to analyze Quick Sort's time complexity rigorously. Why is it O(n log n) on average but O(n²) in the worst case? How do different pivot strategies affect the constants? This analysis is the subject of our next page.
You can now implement both Lomuto and Hoare partition schemes, understand their invariants, and choose the appropriate one for your context. You understand why Hoare is more efficient (fewer swaps) but more subtle to implement correctly. This knowledge prepares you for implementing production-quality Quick Sort.