Sorting Algorithms

Quick Sort

● Intermediate ⏱ 9 min read sorting

Quick sort picks a pivot element, partitions the array so all smaller elements go left and all larger go right, then recursively sorts each partition. It is in-place and O(n log n) on average — often faster than merge sort in practice due to better cache locality.

How It Works

Text
Array: [3, 6, 8, 10, 1, 2, 1]  pivot = 3 (last element)

After partition:
  [1, 2, 1, 3, 6, 8, 10]
   ←smaller→  ↑  ←larger→
              pivot is now in its final sorted position

Recurse:
  left  [1, 2, 1] → sort → [1, 1, 2]
  right [6, 8, 10] → sort → [6, 8, 10]

Result: [1, 1, 2, 3, 6, 8, 10]

Lomuto Partition Scheme

Lomuto is the simpler scheme. It uses the last element as the pivot and tracks a boundary index i that grows as smaller elements are found.

Python
def partition(arr, lo, hi):
    pivot = arr[hi]        # last element is pivot
    i = lo - 1             # i tracks the right edge of the "smaller" region
    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]  # place pivot in correct spot
    return i + 1           # return pivot's final index

def quick_sort(arr, lo=0, hi=None):
    if hi is None:
        hi = len(arr) - 1
    if lo < hi:
        p = partition(arr, lo, hi)
        quick_sort(arr, lo, p - 1)    # sort left of pivot
        quick_sort(arr, p + 1, hi)    # sort right of pivot
    return arr

nums = [3, 6, 8, 10, 1, 2, 1]
print(quick_sort(nums))   # [1, 1, 2, 3, 6, 8, 10]

Random Pivot

Choosing the last element as pivot is O(n²) on already-sorted input because every partition creates one empty side. Randomising the pivot makes this pathological case astronomically unlikely.

Python
import random

def partition_random(arr, lo, hi):
    # Swap a random element to the end, then use Lomuto
    rand_idx = random.randint(lo, hi)
    arr[rand_idx], arr[hi] = arr[hi], arr[rand_idx]
    return partition(arr, lo, hi)

def quick_sort_random(arr, lo=0, hi=None):
    if hi is None:
        hi = len(arr) - 1
    if lo < hi:
        p = partition_random(arr, lo, hi)
        quick_sort_random(arr, lo, p - 1)
        quick_sort_random(arr, p + 1, hi)
    return arr

# Sorted input no longer causes O(n²)
sorted_input = list(range(1000))
print(quick_sort_random(sorted_input)[:5])   # [0, 1, 2, 3, 4]

Naive Python Version (for Clarity)

Not in-place, but shows the logic without index arithmetic.

Python
def quick_sort_naive(arr):
    if len(arr) <= 1:
        return arr
    pivot = arr[len(arr) // 2]
    left   = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right  = [x for x in arr if x > pivot]
    return quick_sort_naive(left) + middle + quick_sort_naive(right)

print(quick_sort_naive([3, 6, 8, 10, 1, 2, 1]))
# [1, 1, 2, 3, 6, 8, 10]

Call Stack Depth

💡
Stack depth: O(log n) average, O(n) worst

With random pivot, the expected recursion depth is O(log n). In the pathological case (always picking min or max), depth is O(n), hitting Python’s default recursion limit of 1000 for n > 1000. For large production use, prefer Python’s built-in sorted() (Timsort).

Complexity Table

CaseTimeSpace (stack)Note
Best caseO(n log n)O(log n)Pivot always splits evenly
Average caseO(n log n)O(log n)Expected with random pivot
Worst caseO(n²)O(n)Always min/max pivot; avoid with randomisation
StableNoSwaps can change equal element order
In-placeYesNo auxiliary array (call stack only)