Skip to content

Quick Sort

Quicksort is the sort most language standard libraries reach for first (with a merge-sort or insertion-sort fallback for edge cases). It sorts in place, has tiny constant overhead, and averages O(nlogn)O(n \log n) — but unlike merge sort, its worst case is a real O(n2)O(n^2) that shows up on ordinary-looking input if you’re not careful about pivot choice.

What you’ll learn

  • Partitioning — the one operation quicksort is built from — via the Lomuto scheme, plus a look at the alternative Hoare scheme.
  • Recursive, in-place quicksort.
  • Why picking the last element as pivot turns an already-sorted array into the O(n2)O(n^2) worst case.
  • The randomized pivot fix, and why it makes the worst case vanishingly unlikely in practice.
  • Why quicksort is not stable, and when that matters.

Partitioning: the Lomuto scheme

Partitioning rearranges a subarray around a chosen pivot value so that everything \le the pivot ends up to its left, everything greater ends up to its right, and the pivot lands in its final, correctly-sorted position. Everything else in quicksort is just “partition, then recurse on both halves.”

The Lomuto scheme picks the last element as the pivot and walks the array with two indices: jj scans left to right, ii tracks the boundary of the “elements seen so far that are \le pivot” region.

lomuto_partition.py
def partition(arr, low, high):
    pivot = arr[high]          # last element as pivot
    i = low - 1                # boundary of the "<= pivot" region
 
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
 
    arr[i + 1], arr[high] = arr[high], arr[i + 1]   # drop pivot into place
    return i + 1                                     # pivot's final index
 
 
arr = [8, 3, 1, 7, 0, 2]
p = partition(arr, 0, len(arr) - 1)
print("after one partition:", arr)
print("pivot landed at index:", p)
lomuto_partition.py
def partition(arr, low, high):
    pivot = arr[high]          # last element as pivot
    i = low - 1                # boundary of the "<= pivot" region
 
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
 
    arr[i + 1], arr[high] = arr[high], arr[i + 1]   # drop pivot into place
    return i + 1                                     # pivot's final index
 
 
arr = [8, 3, 1, 7, 0, 2]
p = partition(arr, 0, len(arr) - 1)
print("after one partition:", arr)
print("pivot landed at index:", p)

Run it: everything left of the returned index is 2\le 2, everything right of it is >2> 2 — the pivot itself sits between them, already in its correct sorted spot. That’s the whole trick, repeated recursively.

Recursive quicksort, in place

Quicksort partitions the whole array, then recursively quicksorts the left and right sides of the pivot. There’s no merge step (unlike merge sort) — once both sides are sorted, the whole array is sorted, because partitioning already guaranteed the pivot’s position relative to everything else.

quicksort_inplace.py
def partition(arr, low, high):
    pivot = arr[high]
    i = low - 1
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1
 
 
def quicksort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    if low < high:
        p = partition(arr, low, high)
        quicksort(arr, low, p - 1)      # everything <= pivot
        quicksort(arr, p + 1, high)     # everything > pivot
    return arr
 
 
nums = [8, 3, 1, 7, 0, 10, 2, 5]
print("sorted:", quicksort(nums))
quicksort_inplace.py
def partition(arr, low, high):
    pivot = arr[high]
    i = low - 1
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1
 
 
def quicksort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    if low < high:
        p = partition(arr, low, high)
        quicksort(arr, low, p - 1)      # everything <= pivot
        quicksort(arr, p + 1, high)     # everything > pivot
    return arr
 
 
nums = [8, 3, 1, 7, 0, 10, 2, 5]
print("sorted:", quicksort(nums))

No extra array is allocated — every swap happens directly inside arrarr. Only the recursion’s call stack uses extra space.

Watch a single partition step by step

sketch Lomuto partition around a pivot p5.js
j scans left to right. Any element <= the pivot (last element, shown in amber) gets swapped into the growing 'smaller' region tracked by i. The final swap drops the pivot into its correct sorted position.

Why pivot choice matters: the O(n^2) trap

Lomuto’s partition always picks the last element as pivot. If the input is already sorted (or reverse-sorted), that pivot is always the largest (or smallest) remaining value — partitioning splits the array into a piece of size 00 and a piece of size n - 1n - 1, every single time.

worst_case_demo.py
def partition(arr, low, high, counter):
    pivot = arr[high]
    i = low - 1
    for j in range(low, high):
        counter[0] += 1   # count comparisons
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1
 
 
def quicksort(arr, low, high, counter):
    if low < high:
        p = partition(arr, low, high, counter)
        quicksort(arr, low, p - 1, counter)
        quicksort(arr, p + 1, high, counter)
 
 
sorted_input = list(range(200))       # already sorted -- the worst case
counter = [0]
quicksort(sorted_input, 0, len(sorted_input) - 1, counter)
print(f"n=200, already sorted: {counter[0]} comparisons")
print(f"n^2 would be {200 * 200}, n*log2(n) would be about {int(200 * 7.6)}")
worst_case_demo.py
def partition(arr, low, high, counter):
    pivot = arr[high]
    i = low - 1
    for j in range(low, high):
        counter[0] += 1   # count comparisons
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1
 
 
def quicksort(arr, low, high, counter):
    if low < high:
        p = partition(arr, low, high, counter)
        quicksort(arr, low, p - 1, counter)
        quicksort(arr, p + 1, high, counter)
 
 
sorted_input = list(range(200))       # already sorted -- the worst case
counter = [0]
quicksort(sorted_input, 0, len(sorted_input) - 1, counter)
print(f"n=200, already sorted: {counter[0]} comparisons")
print(f"n^2 would be {200 * 200}, n*log2(n) would be about {int(200 * 7.6)}")

That comparison count lands right on n(n1)/2n(n-1)/2 — quadratic, not log-linear. Recursing into a 00-and-(n-1)(n-1) split at every level also means the recursion goes nn levels deep instead of logn\log n, which on a truly pathological input can blow the call stack too.

The fix: pick the pivot randomly

The failure mode above only happens because the pivot is picked by a fixed rule (always “last element”) that an input can be crafted to defeat. Picking the pivot uniformly at random before partitioning breaks that: no fixed input can reliably trigger the bad split anymore, because the “unlucky” pivot is a different, unpredictable index every run.

randomized_quicksort.py
import random
 
 
def partition(arr, low, high):
    # swap a random element into the pivot slot before partitioning as usual
    rand_idx = random.randint(low, high)
    arr[rand_idx], arr[high] = arr[high], arr[rand_idx]
 
    pivot = arr[high]
    i = low - 1
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1
 
 
def quicksort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    if low < high:
        p = partition(arr, low, high)
        quicksort(arr, low, p - 1)
        quicksort(arr, p + 1, high)
    return arr
 
 
already_sorted = list(range(15))
print("random-pivot quicksort:", quicksort(already_sorted))
randomized_quicksort.py
import random
 
 
def partition(arr, low, high):
    # swap a random element into the pivot slot before partitioning as usual
    rand_idx = random.randint(low, high)
    arr[rand_idx], arr[high] = arr[high], arr[rand_idx]
 
    pivot = arr[high]
    i = low - 1
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1
 
 
def quicksort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    if low < high:
        p = partition(arr, low, high)
        quicksort(arr, low, p - 1)
        quicksort(arr, p + 1, high)
    return arr
 
 
already_sorted = list(range(15))
print("random-pivot quicksort:", quicksort(already_sorted))

The worst case is still technically O(n2)O(n^2) — an adversary who can see your random seed could still construct a bad case — but for any fixed input, the expected running time is O(nlogn)O(n \log n). This is the standard production fix; some libraries instead pick the median of three (first, middle, last elements) as a cheaper, deterministic-ish alternative.

Time and space complexity

CaseComplexityWhy
BestO(nlogn)O(n \log n)Pivot splits the array roughly in half each time
AverageO(nlogn)O(n \log n)True for random pivots and random or randomized input
WorstO(n2)O(n^2)Pivot is always the min/max — one side of the split is empty
SpaceO(logn)O(\log n) average, O(n)O(n) worstRecursion call stack depth
Stable?NoPartitioning can reorder equal elements

Practice — real LeetCode problems

Each exercise is the actual LeetCode problem with its real method signature and LeetCode’s own examples as the test. Write the body, press Run, and match the expected output.

LC 2161 — Partition Array According to Given Pivot · Medium

Problem. Rearrange numsnums so that every value less than pivotpivot comes first, then every value equal to pivotpivot, then every value greater — and the relative order within the less-than and greater-than groups must be preserved.

Constraints. 1 <= len(nums) <= 10^51 <= len(nums) <= 10^5, -10^6 <= nums[i] <= 10^6-10^6 <= nums[i] <= 10^6, and pivotpivot occurs in numsnums.

Examples. nums = [9,12,5,10,14,3,10], pivot = 10nums = [9,12,5,10,14,3,10], pivot = 10 gives [9,5,3,10,10,12,14][9,5,3,10,10,12,14] · nums = [-3,4,3,2], pivot = 2nums = [-3,4,3,2], pivot = 2 gives [-3,2,4,3][-3,2,4,3]

Editorial

The requirement that relative order be preserved is what makes this interesting. Quicksort’s in-place partition works by swapping distant elements, which destroys order — it is not stable. So the Dutch national flag approach from LC 75, which is the obvious reach, gives a wrong answer here.

Time O(n)O(n) — three linear passes. Space O(n)O(n).

The three-comprehension solution is O(n)O(n) and obviously stable. A single pass with two write pointers (one from each end) is also possible but only if you fill the lessless group forward and the greatergreater group backward and then reverse the greater part — more code for the same complexity.

[-3,4,3,2][-3,4,3,2] with pivot = 2pivot = 2 shows the stability requirement clearly: 44 appears before 33 in the input, and must still do so in the output even though 3 < 43 < 4.

Follow-ups: “Do it in place?” — not while preserving order without extra work; say so explicitly, since it is the crux. “Why can’t you use the Dutch flag partition?” — it is unstable; this is the expected question. “What if order did not matter?” — then LC 75’s in-place three-way partition is ideal at O(1)O(1) space.

LC 462 — Minimum Moves to Equal Array Elements II · Medium

Problem. In one move you may increment or decrement any element by 1. Return the minimum number of moves to make all elements equal.

Constraints. 1 <= len(nums) <= 10^51 <= len(nums) <= 10^5, -10^9 <= nums[i] <= 10^9-10^9 <= nums[i] <= 10^9.

Examples. [1,2,3][1,2,3] gives 22 · [1,10,2,9][1,10,2,9] gives 1616

Editorial

The cost of choosing target tt is nit\sum |n_i - t|, and that sum is minimised at the median — not the mean, which minimises squared deviation instead. Confusing the two is the standard mistake.

Time O(nlogn)O(n \log n) for the sort. Space O(1)O(1) beyond the sort.

Why the median? Imagine moving tt slightly upward. Every element below tt costs one more, every element above costs one less, so the net change is (count below) - (count above)(count below) - (count above). That is zero exactly when the counts balance — i.e. at the median. For an even-length array any value between the two middle elements is optimal, which is why nums[n // 2]nums[n // 2] works without averaging.

[1,10,2,9][1,10,2,9] gives 16 with either middle value (9 or 2 as the target both give 16), confirming the plateau.

Since only the median is needed, quickselect finds it in O(n)O(n) average without a full sort — the direct connection to this page, and the answer if asked to beat O(nlogn)O(n \log n).

Follow-ups: “Prove the median is optimal” — the balance argument above; the most likely follow-up. ”O(n)O(n)?” — quickselect. “Only increments allowed (LC 453)?” — different: the answer is sum(nums) - n * min(nums)sum(nums) - n * min(nums). “Minimise squared distance?” — then it is the mean.

LC 969 — Pancake Sorting · Medium

Problem. You may only reverse a prefix of the array (a “flip”). Return any sequence of flip sizes that sorts the array, using at most 10 * len(arr)10 * len(arr) flips.

Constraints. 1 <= len(arr) <= 1001 <= len(arr) <= 100, arrarr is a permutation of 1..len(arr)1..len(arr).

Examples. [3,2,4,1][3,2,4,1] gives a valid flip sequence such as [4,2,4,3][4,2,4,3] · [1,2,3][1,2,3] gives [][]

Editorial

The only permitted operation is a prefix reversal, which rules out ordinary swapping. But selection sort adapts neatly: bring the largest unplaced value to the front with one flip, then flip the whole unsorted prefix to send it to the back of that prefix — its final home.

Time O(n2)O(n^2) — each round scans for a maximum. Flips at most 2n2n, well inside the 10n10n budget. Space O(1)O(1) beyond the output.

Three cases save flips and are worth handling:

  • Already at the end (idx == size - 1idx == size - 1) — no flips needed at all.
  • Already at the front (idx == 0idx == 0) — skip the first flip.
  • Sorted input — returns [][], which is valid.

A flip of size 1 is a no-op, which is why the idx != 0idx != 0 guard avoids emitting one.

[1,2,3][1,2,3] returning an empty list is a good check that you are not flipping unconditionally.

Follow-ups: “Minimise the flip count?” — that is the pancake number problem, NP-hard in general and unsolved for large nn; the greedy 2n bound is what is expected. “Why does two flips suffice per element?” — one to the front, one to the target. “Burnt pancake variant?” — each flip also inverts the pieces, making it harder.

Recap

  • Quicksort = partition around a pivot, then recurse on both sides — no merge step needed.
  • The Lomuto scheme picks the last element as pivot and tracks a growing ”\le pivot” region with index ii; Hoare’s two-pointer scheme does fewer swaps but returns a split point, not the pivot’s final index.
  • Fixed pivot choice (always last element) degrades to O(n2)O(n^2) on sorted or reverse-sorted input — the exact input you’d expect to see often.
  • A randomized (or median-of-three) pivot makes that worst case practically unreachable, restoring the expected O(nlogn)O(n \log n).
  • In-place, O(logn)O(\log n) average extra space, not stable.

Next: Heap Sort — a comparison sort with a guaranteed O(nlogn)O(n \log n) worst case, no randomization required, built directly on the binary heap from Phase 3.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did