datarekha

Quicksort & Partitioning

How quicksort picks a pivot, splits the array in a single pass, and recurses — plus why a bad pivot turns O(n log n) into O(n²), and how real libraries dodge it.

8 min read Intermediate Data Structures & Algorithms Lesson 10 of 32

What you'll learn

  • How one partition pass places the pivot in its final home and splits the rest around it
  • Why quicksort averages O(n log n) but collapses to O(n²) on sorted input with a naive pivot
  • The three practical pivot strategies — last, random, median-of-three — and when each matters
  • Why quicksort (as introsort) is the default in-memory sort despite that O(n²) worst case

Before you start

Quicksort rests on one disarmingly simple move. Pick an element to be the pivot, rearrange the array so that everything smaller sits to its left and everything larger to its right, and then do the same to each side. That rearrangement is called a partition, and the lovely thing about it is this: once it finishes, the pivot is sitting in its final, sorted position forever. You never touch it again.

The idea

Imagine your cards laid out, and you point at one — say the last — as the pivot. You slide every smaller card to its left and every larger card to its right, then drop the pivot into the gap between them. Now you have three groups: a left group (unsorted, but all smaller than the pivot), the pivot (done), and a right group (unsorted, but all larger). Run the very same procedure on the left group, then on the right, and the deck sorts itself through a cascade of partitions.

The key fact is that one partition costs O(n) and permanently places one element. If the pivot keeps landing near the middle, each round halves the work — and halving log n times, with O(n) per level, gives O(n log n) overall.

Watching one partition

The common scheme is Lomuto partition: take the last element as pivot, sweep left to right, and keep a boundary just past the “small” zone. Each time you meet something the pivot, you swap it into the small zone and push the boundary forward. At the end, you drop the pivot right after the small zone. Let us partition [6, 2, 8, 1, 4] with pivot 4:

[6, 2, 8, 1, 4]   pivot = 4
  6 > 4  → leave it
  2 ≤ 4  → swap into the small zone → [2, 6, 8, 1, 4]
  8 > 4  → leave it
  1 ≤ 4  → swap into the small zone → [2, 1, 8, 6, 4]
  drop the pivot after the small zone → [2, 1, 4, 6, 8]
before — pivot is the last element6281421468≤ pivothome> pivot
After one pass, 4 is in its final place. Now recurse on [2, 1] and on [6, 8] — each a smaller copy of the same task.

The code

def partition(arr, lo, hi):
    pivot = arr[hi]               # last element is the pivot
    i = lo - 1                    # boundary of the "small" zone
    for j in range(lo, hi):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[hi] = arr[hi], arr[i + 1]   # drop pivot after the small zone
    return i + 1                  # the pivot's final index

def quicksort(arr, lo=0, hi=None):
    if hi is None:
        hi = len(arr) - 1
    if lo < hi:
        p = partition(arr, lo, hi)
        quicksort(arr, lo, p - 1)   # sort the smaller-than-pivot side
        quicksort(arr, p + 1, hi)   # sort the larger-than-pivot side
    return arr

print(quicksort([7, 2, 9, 4, 1, 8, 3, 6, 5, 10]))
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

The cost, and the trap

CaseTimeSpace
Average / bestO(n log n)O(log n) stack
WorstO(n²)O(n) stack

Quicksort sorts in place — the rearranging all happens inside the original array, with only the recursion stack as overhead. That keeps the elements being compared close together in memory, which the CPU cache loves; it is the main reason quicksort outruns merge sort on random data. It is not stable, though — swaps can reorder equal elements.

The average case is forgiving: a pivot does not have to be the exact median, it just has to avoid the extremes, which a typical element does. But there is a trap.

Why it dominates anyway

Merge sort also guarantees O(n log n), but it pays O(n) extra memory for the merge buffer. Quicksort’s in-place partitioning means better cache locality, and that constant-factor edge is enough that quicksort — wrapped in introsort’s safeguards — wins most in-memory benchmarks on random data. You see the pattern across standard libraries:

  • C++ std::sort — introsort (quicksort + heapsort fallback + insertion sort for tiny slices).
  • Java Arrays.sort for primitives — dual-pivot quicksort.
  • Rust sort_unstable — pattern-defeating quicksort (pdqsort).
  • NumPy sort(kind="quicksort") — an introsort variant.

Pure quicksort is fast but brittle; production sorts keep its speed and bolt on the guarantees.

Practice

Quick check

0/4
Q1After one Lomuto partition of an array of size n, how many elements are guaranteed to be in their final sorted position?
Q2Quicksort runs on an already-sorted array [1, 2, …, n] using the last element as pivot every time. What is the time complexity?
Q3Why is quicksort often preferred over merge sort for in-memory sorting of primitives?
Q4You apply Lomuto partition to [5, 5, 5, 5, 5] (all equal) with the last element as pivot. What happens?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions

Related lessons

Explore further

Skip to content