Module 14 · Sorting

Linear-Time Sorts

Concept~10 min

Breaking the rules on purpose

The Merge Sort lesson proved that no algorithm sorting by comparisons can beat Ω(n log n) in the worst case. That proof's power came entirely from its assumption — "the only thing the algorithm can do is ask is A < B?." Drop that assumption, and the proof simply doesn't apply. This lesson's sorts don't compare elements to each other at all; they exploit structural knowledge about the values themselves — and that's precisely what lets them run in O(n).

Counting sort: when values are small integers

If every value is a small integer in a known range [0, k], you don't need to compare anything — you can count directly:

Countfrequenciesa → 2n → 1g → 1
def counting_sort(arr: list[int], k: int) -> list[int]:
    counts = [0] * (k + 1)
    for x in arr:
        counts[x] += 1                # tally each value — Module 6's Count verb
    result = []
    for value, count in enumerate(counts):
        result.extend([value] * count)   # emit each value 'count' times, in order
    return result

This is Hash Tables' Count pattern from Module 6, applied to sorting: tally every value's frequency, then read the tallies out in order. No comparison ever happens — the ORDER comes from iterating the counts array 0, 1, 2, …, k, which is only possible because the values are small integers you can use as array indices.

Think of it like sorting mail into a wall of labeled pigeonholes, one hole per possible value: drop each letter straight into its hole (no comparing letters to each other), then walk the wall left to right and empty each hole in turn. The order was never "figured out" — it's just where the wall already put things.

Complexity

O(n + k)time

O(n + k)space

One pass to count (O(n)), one pass to emit (O(n) total elements + O(k) to walk the counts array). When k = O(n), this is O(n) — genuinely linear, beating the comparison-sort floor because it isn't a comparison sort.

The catch is exactly what makes it fast: k must be small relative to n. Counting-sorting values up to 10⁹ would allocate a billion-entry array to sort a handful of numbers — the technique needs a bounded, usefully small range, not just "integers."

Radix sort: sort digit by digit, when k is too large

When values are large integers but you still want to avoid comparisons, sort by one digit at a time — least significant digit first — using counting sort as the per-digit subroutine:

def radix_sort(arr: list[int]) -> list[int]:
    if not arr:
        return arr
    max_val = max(arr)
    exp = 1
    result = list(arr)
    while max_val // exp > 0:
        result = counting_sort_by_digit(result, exp)
        exp *= 10
    return result

def counting_sort_by_digit(arr: list[int], exp: int) -> list[int]:
    counts = [0] * 10
    for x in arr:
        counts[(x // exp) % 10] += 1
    for d in range(1, 10):
        counts[d] += counts[d - 1]        # prefix sum -> final positions
    result = [0] * len(arr)
    for x in reversed(arr):               # reversed: keeps it STABLE
        digit = (x // exp) % 10
        counts[digit] -= 1
        result[counts[digit]] = x
    return result

The per-digit pass is a prefix-sum-positioned counting sort (Module 12's prefix sums, placing each element directly into its final slot instead of just emitting counts in order) — and it must be stable, because sorting by the next digit only produces a correct overall order if ties from the previous digit stay in their relative order. Walking the array in reverse while placing (rather than forward) is what makes this particular implementation stable — trace two equal last-digits through it once to see why order survives: arr = [13, 23, 7], digit = ones place (exp = 1). Digits are 3, 3, 7; prefix-summed counts give slot 1 for the last "3" placed and slot 0 for the one placed before it. Iterating in reverse means 7 (index 2) is placed first at its slot, then 23 (index 1) claims the LAST available "3" slot, then 13 (index 0) claims the slot before it — landing 13 ahead of 23 in the result, preserving their original relative order even though both have the same ones digit. Iterating forward would have handed the later slot to whichever "3" was seen first — the wrong element for a stable sort.

Full trace, both passes, on arr = [29, 13, 22, 19, 5]: ones-digit pass (digits 9, 3, 2, 9, 5) produces [22, 13, 5, 29, 19] — the two 9s (29, 19) keep their original relative order. Tens-digit pass on that result (digits 2, 1, 0, 2, 1) produces [5, 13, 19, 22, 29] — fully sorted, in two linear passes over five elements, no comparisons made.

Think of it like sorting index cards into bins by their last digit first, then re-sorting the (already ones-sorted) stack into bins by their tens digit, always keeping each bin's incoming order intact as you place cards into it — by the time the most significant digit's pass finishes, every earlier digit's ordering is still baked in.

Complexity

O(d · (n + b))time

O(n + b)space

d = number of digits in the largest value, b = base (10 here). Each of the d passes is an O(n + b) counting sort. When d is a constant (bounded-size integers), this is O(n) — again, only because it isn't comparing elements.

Reading which sort a problem wants

SignalReach for
general comparable objects, no structure to exploitmerge sort or quicksort (or the language's built-in sort — see below)
values are small integers (or map to one), frequency matterscounting sort
values are large integers/strings, sortable digit-by-digitradix sort
need a real STABILITY guaranteemerge sort, or a stable counting/radix sort — never quicksort
need in-place, don't care about stability, want it fast in practicequicksort
need a worst-case guarantee, not just averagemerge sort or heapsort (Module 19)

In practice, you will almost always call the language's built-in sort — Python's Timsort and JS's engine sort are both highly-tuned hybrids (insertion sort for small runs, merge-sort-like merging of already-sorted runs, stable). Knowing the theory here isn't about reimplementing sorts daily; it's about knowing which GUARANTEE you're actually getting when you call sorted() or .sort() — stable, O(n log n) worst case, not in-place — and recognizing the rare problems (bounded small-integer keys, custom multi-key comparisons) where a hand-written sort is the actually-correct engineering choice.

Check yourself

3 questions

01

Counting sort runs in O(n + k). Why doesn't this contradict the Ω(n log n) lower bound proven in the previous lesson?

02

Why must radix sort's per-digit counting pass be stable?

03

A problem asks you to sort 10^5 integers, each in the range [0, 100]. What's the best-fit tool, and why?