Introduction
In the previous post, we learned Merge Sort — a guaranteed $O(n \log n)$ algorithm that needs $O(n)$ extra space. Quick Sort takes a different approach: it achieves $O(n \log n)$ on average while sorting in-place (only $O(\log n)$ extra space for the recursion stack).
Quick Sort is the default sorting algorithm in many standard libraries (Java’s Arrays.sort() for primitives uses a variant of Quick Sort). Let’s understand why.
Think of this analogy: you’re organising books on a shelf. You pick one book at random — call it the pivot. You place all books that come before the pivot alphabetically to its left, and all books that come after it to its right. Now the pivot book is in its final correct position. You then repeat the same process for the left group and the right group separately.
How Quick Sort Works
- Choose a pivot element from the array.
- Partition the array: rearrange elements so that everything less than the pivot goes to the left, and everything greater goes to the right.
- After partitioning, the pivot is in its final sorted position.
- Recursively apply the same process to the left sub-array and the right sub-array.
Step-by-Step Walkthrough
Let’s sort [33, 15, 10, 45, 27, 8, 42] using the last element as pivot (Lomuto partition scheme).
Round 1 — Pivot = 42
We need to rearrange so that everything ≤ 42 goes left and everything > 42 goes right.
Walk through the array with a pointer i that tracks where the “small side” ends:
| j | arr[j] | arr[j] ≤ 42? | Action | Array State |
|---|---|---|---|---|
| 0 | 33 | Yes | Swap arr[0] with arr[0], i→1 | [33, 15, 10, 45, 27, 8, 42] |
| 1 | 15 | Yes | Swap arr[1] with arr[1], i→2 | [33, 15, 10, 45, 27, 8, 42] |
| 2 | 10 | Yes | Swap arr[2] with arr[2], i→3 | [33, 15, 10, 45, 27, 8, 42] |
| 3 | 45 | No | Skip | [33, 15, 10, 45, 27, 8, 42] |
| 4 | 27 | Yes | Swap arr[3] with arr[4], i→4 | [33, 15, 10, 27, 45, 8, 42] |
| 5 | 8 | Yes | Swap arr[4] with arr[5], i→5 | [33, 15, 10, 27, 8, 45, 42] |
Now swap the pivot (42) into position i (index 5):
After partition: [33, 15, 10, 27, 8, **42**, 45] — 42 is in its final position ✅
- Left sub-array:
[33, 15, 10, 27, 8] - Right sub-array:
[45](single element, already sorted ✅)
Round 2 — Sort [33, 15, 10, 27, 8], Pivot = 8
| j | arr[j] | arr[j] ≤ 8? | Action | Result |
|---|---|---|---|---|
| 0 | 33 | No | Skip | |
| 1 | 15 | No | Skip | |
| 2 | 10 | No | Skip | |
| 3 | 27 | No | Skip |
Swap pivot (8) into position 0:
After partition: [**8**, 33, 15, 10, 27, 42, 45] — 8 is in place ✅
- Left sub-array:
[](empty) - Right sub-array:
[33, 15, 10, 27]
Round 3 — Sort [33, 15, 10, 27], Pivot = 27
| j | arr[j] | arr[j] ≤ 27? | Action | Result |
|---|---|---|---|---|
| 0 | 33 | No | Skip | |
| 1 | 15 | Yes | Swap, i→1 | [15, 33, 10, 27] |
| 2 | 10 | Yes | Swap, i→2 | [15, 10, 33, 27] |
Swap pivot into position 2:
After partition: [15, 10, **27**, 33] — 27 is in place ✅
Remaining rounds sort [15, 10] and [33]:
- Pivot = 10 →
[**10**, 15]✅ -
[33]is a single element ✅
Final sorted array: [8, 10, 15, 27, 33, 42, 45] ✅
Java Implementation
public class QuickSort {
/**
* Entry point for Quick Sort.
*/
public static void quickSort(int[] arr, int low, int high) {
if (low < high) {
// Partition the array and get the pivot's final index
int pivotIndex = partition(arr, low, high);
// Recursively sort elements before and after the pivot
quickSort(arr, low, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, high);
}
}
/**
* Lomuto partition scheme: uses the last element as pivot.
* Returns the final index of the pivot after partitioning.
*/
private static int partition(int[] arr, int low, int high) {
int pivot = arr[high]; // choose the last element as pivot
int i = low; // i tracks the boundary of the "small" side
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
// Swap arr[i] and arr[j], then advance i
swap(arr, i, j);
i++;
}
}
// Place the pivot in its correct position
swap(arr, i, high);
return i;
}
private static void swap(int[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
public static void main(String[] args) {
int[] data = {33, 15, 10, 45, 27, 8, 42};
quickSort(data, 0, data.length - 1);
// Output: [8, 10, 15, 27, 33, 42, 45]
for (int num : data) System.out.print(num + " ");
}
}
Key Lines Explained
| Line | Purpose |
|---|---|
int pivot = arr[high]; | Pick the last element as pivot. Simple but can lead to worst-case on sorted input (see below). |
int i = low; | i is the boundary: everything to the left of i is ≤ pivot. |
if (arr[j] <= pivot) | If the current element is small enough, swap it into the “small” side and advance i. |
swap(arr, i, high); | After the loop, position i is the correct final position for the pivot. |
Pivot Strategies
The pivot choice dramatically affects performance:
| Strategy | How | Pros | Cons |
|---|---|---|---|
| Last element | pivot = arr[high] | Simplest to implement | $O(n^2)$ on already-sorted arrays |
| First element | pivot = arr[low] | Simple | Same worst-case problem |
| Random element | pivot = arr[random(low, high)] | Avoids worst-case in practice | Adds a random call |
| Median-of-three | Median of first, middle, last | Excellent balance | Slightly more code |
Java’s Arrays.sort() for primitives uses dual-pivot Quick Sort — a variant that picks two pivots and partitions into three sections, offering even better real-world performance.
Time & Space Complexity
| Case | Time | Explanation |
|---|---|---|
| Best | $O(n \log n)$ | Pivot always lands in the middle, splitting evenly. |
| Average | $O(n \log n)$ | Random input leads to reasonably balanced partitions. |
| Worst | $O(n^2)$ | Pivot is always the smallest or largest element (sorted array + last-element pivot). Each partition only removes one element. |
| Space | $O(\log n)$ — for the recursion stack | |:—:|:—:|
Tip: To avoid the $O(n^2)$ worst case in practice, use randomised pivot selection or the median-of-three strategy.
Merge Sort vs Quick Sort
| Feature | Merge Sort | Quick Sort |
|---|---|---|
| Worst-case time | $O(n \log n)$ ✅ | $O(n^2)$ ❌ |
| Average-case time | $O(n \log n)$ | $O(n \log n)$ |
| Space | $O(n)$ ❌ | $O(\log n)$ ✅ |
| In-place? | No | Yes ✅ |
| Stable? | Yes ✅ | No (Lomuto/Hoare) |
| Practical speed | Good | Usually faster (better cache locality) ✅ |
| Best for | Linked lists, guaranteed worst-case | Arrays in practice |
Quick Sort wins in practice because it operates on contiguous memory (great for CPU cache), makes fewer data copies, and has a smaller constant factor.
When to Use / When NOT to Use
| ✅ Use When | ❌ Avoid When |
|---|---|
| You need fast in-place sorting (most common scenario) | Worst-case $O(n^2)$ is unacceptable (e.g., real-time systems) — use Merge Sort |
| Memory is limited — only $O(\log n)$ extra space | Stability is required — use Merge Sort instead |
| General-purpose sorting of arrays with randomised pivot | The array is already sorted and you are using naive pivot selection |
Practice Problems
- Sort an Array – LeetCode #912 — Implement Quick Sort with random pivot. Compare its runtime against your Merge Sort solution.
- Kth Largest Element – LeetCode #215 — Use the partition step (Quick Select) to find the k-th element in $O(n)$ average time without fully sorting.
- Sort Colors – LeetCode #75 — A three-way partition problem inspired by Quick Sort (Dutch National Flag).
What’s Next?
We have now seen comparison-based sorts that top out at $O(n \log n)$. But can we do even better? Yes — when the data has special properties (integers in a known range), we can sort in linear time $O(n)$. Next up: Counting Sort, Radix Sort & Bucket Sort.