Sorting Algorithms II

Two faster comparison-based sorts, and two algorithms that skip comparisons entirely

Introduction

Lesson 14 covered O(n²) sorts and merge sort's guaranteed O(n log n). This lesson adds two more comparison-based sorts that reach that same O(n log n) territory without merge sort's O(n) extra space: quick sort, which partitions around a pivot (Lesson 5's divide-and-conquer again), and heap sort, which builds directly on Lesson 9's heap. Both sort in place. Then it steps outside the comparison model entirely with counting sort and radix sort, which never ask "is this bigger than that?" and can beat the O(n log n) lower bound that applies to every comparison-based sort, as long as the keys being sorted are integers with a known, reasonably small range.

Quick Sort

Pick a pivot value, then partition the rest of the list into everything smaller and everything larger. Recursively sort each side (the same recursive shape as Lesson 5 and Lesson 14's merge sort), and the whole list ends up sorted with no merge step needed, the pivot is already exactly where it belongs.

def quick_sort(items: list[int]) -> list[int]:
    if len(items) <= 1:
        return items
    pivot = items[len(items) // 2]
    left = [x for x in items if x < pivot]
    middle = [x for x in items if x == pivot]
    right = [x for x in items if x > pivot]
    return quick_sort(left) + middle + quick_sort(right)
print(quick_sort([5, 3, 8, 1, 9, 2]))
Expected Output:
[1, 2, 3, 5, 8, 9]

That version is easy to follow, but building three new lists at every call costs O(n) extra space per level. The classic in-place version instead partitions the list around itself using a partition helper, so only recursion-stack space is used:

def quick_sort_in_place(items: list[int], low: int = 0, high: int | None = None) -> list[int]:
    if high is None:
        high = len(items) - 1
    if low < high:
        pivot_index = partition(items, low, high)
        quick_sort_in_place(items, low, pivot_index - 1)
        quick_sort_in_place(items, pivot_index + 1, high)
    return items

def partition(items: list[int], low: int, high: int) -> int:
    pivot = items[high]
    i = low - 1
    for j in range(low, high):
        if items[j] <= pivot:
            i += 1
            items[i], items[j] = items[j], items[i]
    items[i + 1], items[high] = items[high], items[i + 1]
    return i + 1
print(quick_sort_in_place([5, 3, 8, 1, 9, 2]))
Expected Output:
[1, 2, 3, 5, 8, 9]
Pivot Choice Decides Everything

When the pivot lands near the middle of the values each time, the split is balanced and quick sort runs in O(n log n). But this implementation always picks the last element as the pivot, so an already-sorted (or reverse-sorted) list produces the worst possible split every time, one side empty, the other with everything else, degrading to O(n²). Production quick sorts (like C's qsort) avoid this by picking the pivot randomly or as the median of a few sampled elements, making the worst case extremely unlikely in practice.

Heap Sort

Lesson 9's heap always keeps its largest (or smallest) value at the root in O(1) time. Heap sort exploits that directly: turn the whole array into a max-heap, then repeatedly swap the root (the current maximum) with the last unsorted slot and sift the new root back into place. Each swap drops one more value into its final sorted position.

Max-Heap Built From [5, 3, 8, 1, 9, 2]

958132

Figure 1: The array [9, 5, 8, 1, 3, 2] read as a complete binary tree, every parent ≥ its children

def heapify(items: list[int], n: int, root: int) -> None:
    while True:
        largest = root
        left = 2 * root + 1
        right = 2 * root + 2
        if left < n and items[left] > items[largest]:
            largest = left
        if right < n and items[right] > items[largest]:
            largest = right
        if largest == root:
            break                     # heap property restored, no need to go further
        items[root], items[largest] = items[largest], items[root]
        root = largest                # keep sifting down from the swapped-into position

def heap_sort(items: list[int]) -> list[int]:
    n = len(items)
    for root in range(n // 2 - 1, -1, -1):   # build a max-heap from the bottom up
        heapify(items, n, root)
    for end in range(n - 1, 0, -1):
        items[0], items[end] = items[end], items[0]   # move current max to its final slot
        heapify(items, end, 0)                          # restore heap order in the shrunken heap
    return items
print(heap_sort([5, 3, 8, 1, 9, 2]))
Expected Output:
[1, 2, 3, 5, 8, 9]

Building the heap costs O(n), and each of the n extraction steps costs O(log n) to sift down, giving O(n log n) in the best, average, and worst case, unlike quick sort, heap sort's runtime doesn't depend on the input's initial order. It also sorts entirely in place, using only O(1) extra space, better than merge sort's O(n).

Counting Sort

Every algorithm so far decides order by comparing pairs of elements, and no comparison-based sort can beat O(n log n) in the worst case. Counting sort sidesteps the comparison entirely: if the values are integers within a known range, just count how many times each value appears, then write each value back out that many times, in order.

def counting_sort(items: list[int]) -> list[int]:
    if not items:
        return items
    minimum, maximum = min(items), max(items)
    counts = [0] * (maximum - minimum + 1)
    for value in items:
        counts[value - minimum] += 1     # tally how many times each value appears
    result = []
    for offset, count in enumerate(counts):
        result.extend([offset + minimum] * count)   # write each value back "count" times
    return result
print(counting_sort([4, 2, 2, 8, 3, 3, 1]))
Expected Output:
[1, 2, 2, 3, 3, 4, 8]

This runs in O(n + k) time, where k is the range of values (maximum - minimum). That's faster than O(n log n) when k is small relative to n, but it falls apart for a huge range: sorting six numbers spread across a million possible values would allocate a million-slot counts array to save almost nothing.

Radix Sort

Radix sort handles that huge-range problem by never counting the whole value at once. Instead, it runs a counting sort on just one digit at a time, ones place first, then tens, then hundreds, and so on, using each pass's results as the input to the next.

def counting_sort_by_digit(items: list[int], exp: int) -> list[int]:
    n = len(items)
    output = [0] * n
    counts = [0] * 10
    for value in items:
        digit = (value // exp) % 10
        counts[digit] += 1
    for d in range(1, 10):
        counts[d] += counts[d - 1]       # running totals give each digit its final block
    for i in range(n - 1, -1, -1):       # walk backward so equal digits keep their order
        digit = (items[i] // exp) % 10
        counts[digit] -= 1
        output[counts[digit]] = items[i]
    return output

def radix_sort(items: list[int]) -> list[int]:
    if not items:
        return items
    maximum = max(items)
    exp = 1
    while maximum // exp > 0:
        items = counting_sort_by_digit(items, exp)
        exp *= 10
    return items
print(radix_sort([170, 45, 75, 90, 802, 24, 2, 66]))
Expected Output:
[2, 24, 45, 66, 75, 90, 170, 802]

One stable counting sort runs per digit, starting from the ones place. Each pass reorders the whole array by just that digit, and because every pass is stable, the ordering from earlier (less significant) digits survives as a tiebreaker:

Radix Sort on [170, 45, 75, 90, 802, 24, 2, 66]
170
45
75
90
802
24
2
66
Start: unsorted
170
90
802
2
24
45
75
66
Pass 1: stably sorted by the ones digit (0, 0, 2, 2, 4, 5, 5, 6)
802
2
24
45
66
170
75
90
Pass 2: stably sorted by the tens digit
2
24
45
66
75
90
170
802
Pass 3: stably sorted by the hundreds digit, now fully ordered
Figure 2: One stable pass per digit, least significant first. After the hundreds pass the array is fully sorted.
Why Sort by the Least Significant Digit First?

Counting sort is stable, so equal digits keep their relative order from the previous pass. Sorting ones first, then tens, then hundreds means that by the time the most significant digit is sorted, every less-significant digit is already correctly ordered within each group, so the final pass leaves the whole number properly sorted. This implementation works on non-negative integers; negative values need an offset first, the same trick counting sort uses.

With d digits in the largest number, radix sort runs in O(d · (n + k)) where k = 10 for each digit pass, effectively O(d · n). For fixed-width keys like 32-bit integers, d is a constant, making this linear in n, genuinely faster than any comparison-based sort can ever be.

Complexity at a Glance

AlgorithmBestAverageWorstExtra SpaceStable?
Quick SortO(n log n)O(n log n)O(n²)O(log n)-O(n)No
Heap SortO(n log n)O(n log n)O(n log n)O(1)No
Counting SortO(n + k)O(n + k)O(n + k)O(n + k)Yes
Radix SortO(d(n + k))O(d(n + k))O(d(n + k))O(n + k)Yes

k is the key range (counting sort) or the digit base of 10 (radix sort); d is the number of digits in the largest value. Quick sort's recursion-stack usage tracks its own partition balance: O(log n) when splits stay roughly even, but the exact same worst-case input that pushes its runtime to O(n²) (an already-sorted list with this last-element pivot) also pushes its stack depth to O(n), one recursive call per element. The in-place partition-swap version of quick sort shown here isn't stable, swapping elements across the pivot can reorder equal keys, but heap sort's sift-down swaps break stability too, both trade stability for speed and space that merge sort doesn't have to give up.

Key Takeaways

  • Quick sort partitions around a pivot and reaches O(n log n) on average, but a poor pivot choice on already-ordered input degrades it to O(n²)
  • Heap sort guarantees O(n log n) in every case by repeatedly extracting the max from a heap built in place, using only O(1) extra space
  • No comparison-based sort can beat O(n log n) in the worst case - that's a hard lower bound, not a limitation of any one algorithm
  • Counting sort and radix sort break that bound by never comparing elements, counting occurrences instead, but only work for integers (or integer-like keys) with a bounded range
  • Speed, space, and stability trade off against each other - the fastest sort for a given job depends on the data's size, range, and whether relative order of equal keys needs to be preserved