python/volume-3/chapter-42ch 42 / 64 · 9 min

42Sorting, properly — and a beautiful impossibility

In Chapter 41 we let a function call itself and walk a whole tree of choices to find an answer. Here we ask a colder, sharper question about code you already own. Not how a sort works, but whether it can be beaten. You stepped the five classic sorts line by line in the trace walkthroughs and raced them in the Algorithms Lab. So we skip the how and go straight at the wall — the hard floor every one of them hits — and the one trick that walks clean through it. The whole way, we keep circling the question that turns a coder into a computer scientist: is n log n the best any sort can do, or just the best we've thought of so far? By the end you'll be able to prove, on the back of a napkin, that no sort built from comparisons can ever beat n log n. Then you'll sort a million items in a single linear pass, under that limit, by refusing to compare at all.

The five sorts share one hidden move The speed limit — and why nobody can break it Walk through the wall — counting sort Radix sort — counting, one digit at a time
scroll to begin

01The five sorts share one hidden move

Let's start by lining them all up. Bubble, selection, insertion — the three O(n²) sorts that crawl once the list gets big. Merge and quicksort — the two O(n log n) sorts that win at scale. Five different methods, and the Lab lets you watch each one shuffle the bars into place. But look closely at any of them and the same basic move keeps showing up: pick two items, ask which is bigger, act on the answer. Everything else — the swaps, the splits, the merges — is just bookkeeping wrapped around that one question.

That shared move is the key to the whole chapter. Suppose the only thing your algorithm can ever learn about the data is the outcome of a < b questions. Then the number of questions is the true cost, and there is a hard floor on how few you can get away with. Call any such algorithm a comparison sort. All five of yours are comparison sorts. So is almost every sort you have ever seen.

InteractiveSlide n — watch the n² sort fall off a cliff the n log n sort strolls past
same list · same machine · comparisons to sort n items O(n²) — the crawl · bubble / insertion / selection · n(n−1)/2 compares 45 compares 45 ns O(n log n) — the win · merge / quick / Timsort · n·log₂n compares 33 compares 33 ns 1.4× more comparisons for the n² sort — the growth-curve gap tiny n: both finish before you blink log-scaled bars · timed on a machine doing 1 billion (10⁹) compares / second
1,000,000
Both bars do the same job — sort the list. On tiny inputs nobody notices; the bars sit almost level. But n² grows with the square while n log n grows a hair above linear, so the gap widens without limit. At a million items the "slow" sort isn't a bit slower — it's ~25,000× slower, minutes against milliseconds. This is why which algorithm outweighs which language or which CPU: no faster chip closes a 25,000× hole. (Honest numbers: n(n−1)/2 is the exact worst-case compare count of bubble/insertion/selection; n·log₂n is merge sort's; wall-clock assumes 10⁹ comparisons/second.)
the one operation underneath all five sorts Levels 200 Titanium 245 a < b ? one bit of information: yes / no bubble · selection · insertion · merge · quick → all ask the same question
Fig — every comparison sort learns about the data one yes/no answer at a time. Count the questions and you have counted the real cost.
Wait —
if a comparison is just one bit — yes or no — how many bits do you need before you know the whole order of the list? Answer that and you have found the speed limit of sorting. →

02The speed limit — and why nobody can break it

Here is the most beautiful argument in this volume, and it is pure counting. Lay out every decision your sort could make as a tree. At the top sits the first comparison it does. Two branches — yes and no — lead to the next comparison, and which one you reach depends on the answer. Keep going. This is a decision tree: internal nodes are comparisons, each edge is an answer, and every path from the top down is one possible run of the algorithm on some input.

Where does a path end? At a leaf — the moment the sort stops asking and commits to an output order. Now the punch line. A list of n distinct items has n! possible starting arrangements (n choices for first, n−1 for second, and so on), and each one needs a different reshuffle to come out sorted. So the tree must have at least n! leaves — one distinct verdict per arrangement. Miss even one, and there is an input your sort gets wrong.

decision tree for sorting 3 items — every path is a run of the sort a < b? yes no b < c? b < c? a b c a < c? a < c? c b a a c b c a b b a c b c a 6 leaves = 3! orderings · deepest leaf is 3 comparisons down = the worst case tree height h ≥ log₂(3!) = log₂ 6 ≈ 2.58 → at least 3 comparisons
Fig — the sort's whole strategy, drawn out. To tell all 6 arrangements apart it needs 6 leaves; to hold 6 leaves a yes/no tree must be at least 3 levels deep — so some input always costs 3 comparisons.

Now weigh the tree. Each comparison drops you one level. So the height of the tree — the longest root-to-leaf path — is the worst-case number of comparisons. And a binary (yes/no) tree of height h can hold at most leaves: 2 at depth 1, 4 at depth 2, doubling each level. Put the two facts together:

max leaves the tree can hold n! leaves it must have to be correct h ≥ log₂(n!) ≈ n log₂ n take log₂ of both sides — the worst case can never be smaller than this
Fig — three symbols and it is done. 2ʰ ≥ n!, so h ≥ log₂(n!), and Stirling's formula turns that into ≈ n log n. A genuine impossibility, proven by counting leaves.

Read what just happened. We never named a specific algorithm. We just said this: whatever your comparison sort is — one you know, one you will invent, one no human has thought of yet — it is some decision tree. And every decision tree that sorts n items is at least log₂(n!) levels tall. That is why n log n is not "the best we have managed." It is the best that can exist. Merge sort already sits on that floor, and you cannot pay less.

The numbers are concrete and I checked each one in Python. To sort 3 items you need at least ⌈log₂ 6⌉ = 3 comparisons; 5 items, at least 7; 12 items, at least 29; 100 items, at least 525. And log₂(n!) hugs n log₂ n so tightly that by a million items the floor is already 93% of it — same curve, same Θ(n log n) band, forever.

InteractiveSlide n — watch the floor rise with n log n
orderings to tell apart (leaves the tree needs): 6 floor — ⌈log₂(n!)⌉ comparisons, unbeatable 3 n · log₂ n — what a good comparison sort spends 4.8 floor is 63% of n log₂ n
3
The green floor and the blue target never separate — both live in the Θ(n log n) band. The number of orderings (purple) explodes, but the number of questions to resolve them grows only like n log n.
↺ The thing people get backwards
The lower bound is not a challenge waiting for a smarter engineer. People hear "you can't sort faster than n log n" and assume it means "nobody has managed it yet." No — it is a proof of impossibility, the same species as "you can't draw a triangle with two right angles." The catch is hidden in one word: comparison. Break that assumption and the floor vanishes. That is the next section.
The deeper cut — Stirling, and the one place the floor is a hair too low

Where does log₂(n!) ≈ n log₂ n come from? Stirling's approximation: log₂(n!) = n log₂ n − n log₂ e + O(log n). The leading term is n log₂ n; the correction subtracts about 1.44n. I checked it in Python — at n = 10000, exact log₂(n!) = 118458.14 versus the two-term estimate 118450.17, a gap under 8 in a hundred thousand.

The bound counts information — the minimum questions needed to pin down one of n! answers — so it is a floor, not always a reachable target. Usually it is reachable. But not always. Sorting 12 items needs at least ⌈log₂ 12!⌉ = 29 comparisons, yet the best method anyone has — the Ford–Johnson "merge insertion" algorithm — needs 30. That is a one-comparison gap between what information theory permits and what any real algorithm achieves, and 12 is the smallest n where it appears. The floor is exact, and sometimes it cannot be reached from the other side either.

Where you meet this — the shape of "20 questions"
This is the same argument that powers the game of twenty questions (2²⁰ ≈ a million things you can pin down with 20 yes/no answers) and, in a later chapter, the reason you cannot losslessly compress below a file's entropy: a yes/no channel carries one bit, and k bits can name only 2ᵏ distinct things. Sorting, guessing, compressing — one counting argument underneath all three. Learn to count the distinct outcomes and take the log, and you can find the floor of problems you have never seen.

The floor only holds if you insist on comparing. What if the values could tell you where they belong — without a single comparison? →

03Walk through the wall — counting sort

Suppose you are sorting a million song ratings, each an integer from 0 to 5. The comparison floor says n log₂ n ≈ 20 million questions. But you never need a question. A rating of 5 doesn't need to be compared with anything — it just is a 5, and you already know exactly where all the 5s go: after the 4s, before nothing. The value is its own address.

That is counting sort. Make one tally box per possible value. Walk the data once, dropping each item into its box. Then walk the boxes in order, pouring them back out. No item is ever weighed against another. Here it is, and it really runs:

counting.pypython
def counting_sort(a, k):        # every value is an int in range 0..k-1
    count = [0] * k             # one tally box per possible value
    for x in a:                 # PASS 1 - n steps: tally each item
        count[x] += 1           #   the value IS the index. no comparison.
    out = []
    for v in range(k):          # PASS 2 - k steps: walk boxes in order
        out.extend([v] * count[v])   # pour box v back out, count[v] times
    return out

print(counting_sort([3,1,4,1,5,9,2,6,5,3,5,0,7,2,6], 10))
# [0, 1, 1, 2, 2, 3, 3, 4, 5, 5, 5, 6, 6, 7, 9]   <- matches sorted()

Line by line: count = [0]*k builds k empty boxes. The first loop runs n times — count[x] += 1 uses the value x directly as an array index (Volume 1: indexing a list is O(1), a straight jump to a memory address). The second loop runs k times, emitting each value as many times as it was tallied. Total work: n + k. I ran it against Python's sorted() on the same data — identical output.

input: 3 1 4 1 5 2 0 … PASS 1 — each value jumps to its own box (no comparing) 01 12 21 31 41 51 PASS 2 — read the boxes left → right 0 1 1 2 3 4 5 … ← sorted, in n + k steps k boxes, one per value
Fig — the value is the address. Tallying is n steps, reading the boxes is k steps — total O(n + k), and not one comparison in sight.

When k is small next to n, this is linear — it slips under the comparison floor as if the floor isn't there. I measured the op counts: a million items with values in 0–255 costs about 1,000,256 steps, where a comparison sort would spend ~20 million — roughly 20× fewer. There is no contradiction with the last section. The floor was proven for sorts that only compare. Counting sort never compares. Different game, different rules.

The price is written in the method. You need k boxes, so the values must be bounded integers (or things you can map to a small integer range) — you cannot counting-sort arbitrary floats or strings this way — and you pay O(k) memory for the boxes. Push k up toward "any 64-bit integer" and you would need billions of boxes; the trick collapses. Counting sort wins precisely when the range of values is small, whatever the count.

InteractiveSlide the key range k — find where counting stops winning
n = 1000 items, values in 0 … k−1 counting sort — n + k steps 1010 comparison sort — n · log₂ n ≈ 9966 steps (fixed) 9966 counting wins by 9.9×
10
Small range → counting sort is a linear stroll. Push k past ~9000 and the boxes outnumber the useful work — the comparison sort wins again. The crossover is exactly where n + k meets n log₂ n.

Myth

Counting sort is "the fast one" — always beats n log n, so use it when speed matters.

Reality

It beats the floor only while k stays near n. With a million-wide value range it does a million pointless box-reads. Its speed is a trade — cheap time bought with O(k) space and a bounded-integer key.

Bounded to 0–5 is easy. But a million distinct 9-digit ID numbers? That is k = a billion boxes. There is a way to keep the linear speed anyway — sort them one digit at a time. →

04Radix sort — counting, one digit at a time

Big numbers break counting sort because their range is huge. But each individual digit has a tiny range — 0 to 9. So sort by the last digit, then the next, up to the first, using a stable counting sort on each pass. After the final digit, the whole list is sorted. This is radix sort, and it is exactly how the old punched-card machines physically sorted — a fact we'll come back to.

radix.pypython
def radix_sort(a):
    m = max(a)
    exp = 1
    while m // exp > 0:              # one pass per digit of the biggest number
        a = counting_by_digit(a, exp)   # stable counting sort on this digit
        exp *= 10                    # move to the next digit up
    return a

print(radix_sort([170, 45, 75, 90, 802, 24, 2, 66]))
# [2, 24, 45, 66, 75, 90, 170, 802]   <- matches sorted()

The loop runs once per digit — call that d passes — and each pass is a counting sort over ten boxes, costing O(n + 10). Total: O(d·(n + 10)), which for fixed-width keys is just O(n). I ran it against sorted(), and the outputs match. The whole thing works only because each pass is stable: it never disturbs the order the earlier passes established. That word, stable, is carrying a lot of weight here, so it gets its own section next.

pass 1 · ones 170 90 802 02 024 045 075 066 pass 2 · tens 802 02 024 045 066 170 075 090 pass 3 · hundreds 002 024 045 066 075 090 170 802 done ✓ 2 24 45 66 75 90 170 802 each pass = one stable counting sort over 10 boxes · d passes · O(d·(n+10)) = O(n) for fixed-width keys
Fig — sort by the least significant digit first, then work leftward. Because every pass keeps earlier ties in place, the final pass over the top digit lands everything home.
Where you meet this — machines that never compared
In the 1880s Herman Hollerith built electric tabulating machines that sorted the 1890 U.S. census by dropping punched cards into bins one column at a time — physical radix sort, decades before "algorithm" was a word programmers used. Today the same idea sorts billions of keys on GPUs (radix sort is the workhorse of CUDA sorting libraries) and builds the suffix arrays behind DNA aligners like BWA and Bowtie and the bzip2 compressor. Wherever keys are fixed-width integers and there are a lot of them, radix quietly beats every comparison sort on Earth.

Everything in radix hangs on one promise: equal-keyed items keep their order across passes. Break that promise and the whole scheme scrambles. Time to make it precise. →

05Stability — when equal keys must keep their order

A sort is stable if, when two items compare equal on the sort key, they come out in the same order they went in. Unstable sorts are free to shuffle ties. It sounds like a technicality. It is the difference between a spreadsheet that does what you meant and one that quietly corrupts your data.

Why it matters: it is how you sort by two keys. Say you want your playlist grouped by artist, and within each artist, ordered by length. Sort by the secondary key first (length), then by the primary key (artist) with a stable sort — the stable pass keeps the length-order intact inside each artist group. I ran exactly this:

stability.pypython
songs = [("Levels","Avicii",200), ("Wake Me Up","Avicii",247),
         ("Titanium","Guetta",245), ("Without You","Guetta",210),
         ("Hey Brother","Avicii",254)]

step1 = sorted(songs, key=lambda s: s[2])   # first by seconds (secondary key)
step2 = sorted(step1, key=lambda s: s[1])   # then by artist (primary), STABLE

# ('Levels','Avicii',200) ('Wake Me Up','Avicii',247) ('Hey Brother','Avicii',254)
# ('Without You','Guetta',210) ('Titanium','Guetta',245)

Read the result: the Avicii block is internally sorted by seconds (200, 247, 254) and the Guetta block too (210, 245) — even though the second sort only looked at artist. The stable pass never touched the ties, so the first sort's work survived underneath it. Two stable sorts in a row give you a two-level ordering, for free. An unstable sort would have jumbled the seconds within each artist, and you'd have to sort with a compound key instead.

input, already ordered by seconds within each pair: Levels · Avicii · 200 Wake Me Up · Avicii · 247 stable sort by ARTIST: ties (both Avicii) keep input order ✓ Levels · Avicii · 200 Wake Me Up · Avicii · 247 an UNSTABLE sort is free to swap these — order lost
Fig — stability is a promise about ties: equal keys keep their entry order. That promise is what lets you layer sorts to sort by artist, then length, then title.
Where you meet this — every spreadsheet
Click a column header in Excel, Google Sheets, or a pandas DataFrame, then click another — the second sort keeps the first as a tiebreak, precisely because these sorts are stable. Leaderboards, file managers sorting by name-then-date, SQL ORDER BY a, b — all lean on stability. It is the feature you never notice until an unstable sort scrambles it.

Python's sorted() is stable, and I have been leaning on that all chapter. But what is sorted()? It isn't plain merge sort. It is something cleverer — and it gets suspiciously fast on data that is already a bit sorted. →

06What sorted() actually runs — Timsort

Real data is rarely random. Logs arrive almost in time order; a list you append to and re-sort is mostly sorted already; sensor readings drift. Timsort — the algorithm behind Python's list.sort() and sorted() — is built around that observation. It is a merge sort / insertion sort hybrid that first scans for runs: stretches already in order. Every run it finds is work it doesn't have to do. Then it merges the runs, using insertion sort to tidy short pieces and clever "galloping" to skip through long ones.

The consequence is dramatic, and I timed it on this machine (best of 5 runs, a million floats — treat the milliseconds as indicative, not exact):

timsort.pypython
n = 1_000_000                       # sorted() on different input orders
# random order    :  146.1 ms       <- the full n log n price
# already sorted  :    9.6 ms       <- ~15x faster: one giant run, near-linear
# reverse sorted  :   10.9 ms       <- ~13x faster: spots the descending run, flips it
# 20 sorted runs  :   10.6 ms       <- ~14x faster: merges 20 pre-made runs

On already-sorted data Timsort makes essentially one pass, confirms the whole list is one long run, and stops — O(n), not O(n log n). Reverse-sorted is nearly as fast: it spots a strictly descending run and reverses it in place. That is why re-sorting a list you just tweaked is almost free. The general bound is O(n log r), where r is the number of runs. When the data is one big run (r = 1), that collapses to O(n). When it's fully scrambled (r ≈ n), you pay the full n log n. It never beats the comparison floor on random data — the last section forbids that — but it collects every scrap of existing order.

1 · scan for natural runs (ascending stretches already in order) 3 7 9 12 · run A 1 4 8 · run B 2 5 6 10 11 · run C 2 · merge runs pairwise (galloping through long agreeing stretches) 1 3 4 7 8 9 12 · merged A+B run C waits cost O(n log r), r = number of runs · one run → O(n) · scrambled → n log n
Fig — Timsort's edge: it never re-sorts order that already exists. Fewer runs in the input means fewer merges and a cost that slides all the way down to linear.
InteractiveHow ordered is your data? Slide the run count
n = 1,000,000 items · r natural runs Timsort ≈ n + n·log₂ r (its O(n log r)) n log₂ n — the random-data ceiling already sorted (r=1) → ~n, linear
1
One run (sorted input) sits at the far left — linear. Push r up to n and the bar walks right to meet the n log n ceiling. Timsort's whole gift is charging you only for the disorder that's actually there.
Where you meet this — the sort in your pocket
Tim Peters wrote Timsort for CPython in 2002. It was so good at real-world data that it spread: Java uses it to sort objects (Arrays.sort), Android runs it, and since 2018 V8 — the engine in Chrome and Node.js — makes Array.prototype.sort a Timsort, so every time a web page sorts a table you are running this algorithm. Rust's standard stable sort is a Timsort descendant too. The line sorted(data) you type without thinking is one of the most-run algorithms in the history of computing.
↺ The 1% move this chapter installs
When you hit a wall, ask which assumption built the wall — then check whether your real problem actually needs it. The n log n floor is unbreakable given comparisons; counting sort wins by dropping comparisons; radix wins by shrinking the key range; Timsort wins by assuming the input isn't random. None of them beat the theorem — each one steps outside its premise. That is the engineer's reflex the whole volume is training: don't fight a proven limit, find the hidden "if" it rests on and ask whether it's yours to keep.

Sorting arranges data so you can find things fast — the next chapter is about the finding itself: searching & selection. How binary search pinpoints any item in a sorted array in O(log n) by halving the haystack instead of scanning it — and how the same halving idea plucks the median out of a billion numbers without ever sorting them. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 42, in working code

Every sort you've met plays the same one-note game: ask which of two items is bigger. So let's run the real algorithms, count the questions, and then watch two clever sorts walk straight through the n log n wall.

The five comparison sorts
Bubble, selection, insertion — the O(n²) crawlers — plus merge and quicksort, the O(n log n) winners. Squint at any of them and the same atomic move flashes past: pick two items, ask a < b, act on the answer. These run and sort real data.
The speed limit — a beautiful impossibility
Every comparison is one bit: yes or no. Lay out all the sort's decisions as a binary tree — the height is the worst-case comparison count. To tell n! arrangements apart the tree needs n! leaves, and a height-h binary tree holds at most 2^h. So h ≥ log2(n!) ≈ n log n. No comparison sort can ever beat that floor.
Walking through the wall — non-comparison sorts
The floor only binds algorithms that learn by comparing. Stop asking a < b, and the wall isn't there. Counting sort tallies values into boxes; radix sort does it one digit at a time. Neither ever weighs two items against each other — so both run in linear time.
Stability & Timsort
A sort is stable if equal keys come out in their input order. That quiet property is what lets you sort by two keys — and it's the promise radix sort leans on every pass. Python's own sorted() is stable and cleverer still: Timsort finds runs of order already present and only pays for the disorder that's actually there.
end of chapter 42 · six sections
iolinked.com
Written by Ajai Raj