Sorting Is the Cheapest Structure: The Comparison Lower Bound, Counting Sort, and Binary Search

· tech

#algorithms#cphb#sorting#binary-search#book-notes

📑 Contents

Part one was about reading nn and knowing which complexity to write. This part covers CPHB chapter 3: sorting. It’s a short chapter, but it hides an idea I find more important than any sorting algorithm — sorting isn’t the goal, it manufactures structure for the problems that come after. A lot of problems are hard only because the elements are in no particular order; once sorted, duplicates sit next to each other, things that belong together become neighbours, and whatever you’re looking for can be closed in on with binary search. You pay O(nlog⁡n)O(n \log n), and every step after that gets cheaper.

The post follows the book’s three sections: the theory (why nlog⁡nn \log n, and when you can do better), using it in code (the book does C++; here it’s Python), and binary search.

Why n log n

Start with the dumbest one: bubble sort, one inversion per swap

The book opens with bubble sort — nn rounds, each sweeping left to right and swapping any adjacent pair that’s out of order:

for i in range(n):
    for j in range(n - 1):
        if array[j] > array[j + 1]:
            array[j], array[j + 1] = array[j + 1], array[j]

Two nested loops, O(n2)O(n^2). But the book follows it with a lovely argument for why every sort that only swaps adjacent elements is at least O(n2)O(n^2). The tool is the inversion: a pair of positions a<ba < b with array[a]>array[b]\text{array}[a] > \text{array}[b] — an earlier element that’s larger than a later one. The number of inversions measures how far the array is from sorted: sorted means 0, fully reversed means n(n−1)/2n(n-1)/2.

The key sentence: swapping two adjacent elements removes exactly one inversion. No more, no less. So if adjacent swaps are all you have, the worst case needs n(n−1)/2n(n-1)/2 of them — and that’s where O(n2)O(n^2) comes from.

The meter in the animation shows “inversions left”. Step through a few: it only drops on a swap step, and it always drops by exactly one.

(This is an interactive animation and needs JavaScript. The book's example [1, 3, 8, 2, 9, 2, 5, 6] starts with 9 inversions, and bubble sort makes exactly 9 swaps.)

To go faster, remove many inversions at once: merge sort

An O(nlog⁡n)O(n \log n) sort isn’t bound by “adjacent swaps only”. The book’s representative is merge sort, and its shape is recursion:

  1. A single element needs no work.
  2. Split in the middle.
  3. Sort each half recursively.
  4. Merge the two sorted halves — since both are in order, merging takes O(n)O(n) (two pointers walking right, always taking the smaller).

Each level of recursion halves the range, so there are O(log⁡n)O(\log n) levels; the merging on each level adds up to O(n)O(n). Multiply and you get O(nlog⁡n)O(n \log n). One merge can carry an element past many others at once — that’s why it kills inversions faster than bubble sort.

The lower bound: if you compare, you can’t escape n log n

Can anything beat nlog⁡nn \log n? The book gives a compact proof: not as long as your sort works by comparing elements.

Each comparison can only split the remaining permutations in two a < b ? b < c ? a < c ? < > a b c a < c ? b a c b < c ? < > < > a c b c a b b c a c b a < > < > n! leaves → height ≥ log₂(n!) ≈ n log n → at least n log n comparisons in the worst case
Three elements have 3! = 6 orderings, and each comparison halves the candidates at best, so telling them apart takes at least log₂6 ≈ 2.6, i.e. 3 comparisons. Swap 3 for n and you have the lower bound for comparison sorts — if your sort works by asking “which is bigger”, there is no way around it.

Think of sorting as a decision tree: each node asks ”x<yx < y?” and goes left or right; at a leaf, you know how these nn elements are ordered. nn elements have n!n! orderings, so the tree needs at least n!n! leaves, which makes its height at least log⁡2(n!)\log_2(n!). And log⁡2(n!)≥(n/2)log⁡2(n/2)\log_2(n!) \ge (n/2)\log_2(n/2), which is roughly nlog⁡nn \log n. The height of the tree is the worst-case number of comparisons — lower bound proved.

What’s nice about this proof is that it’s algorithm-agnostic. It doesn’t say “merge sort can’t get faster”; it says “nothing that compares can get faster”.

Around the bound: counting sort, no comparisons, no limit

The bound assumes comparisons. Skip them? Counting sort uses each element’s value as an index: allocate a count array, count how many times each value appears in one pass, then write them back out in index order in a second pass. O(n)O(n), provided the elements are integers in 0…c0 \ldots c with c=O(n)c = O(n) — the count array has to fit.

(This is an interactive animation and needs JavaScript. The book's example [1, 3, 6, 9, 9, 3, 5, 9] is sorted in 8 + 10 = 18 steps without comparing any two elements.)

Look at the state right after the first pass: the count array is already a compressed form of the sorted array — how many of each value. The second pass just unpacks it. It’s fast because it uses a piece of information comparison sorts never touch: the value itself is the position. The price is that the information has to exist in the first place (integers, small range); without it there’s nothing to use.

Sorting in Python

The book is blunt: you almost never write your own sort in a contest. The standard library’s sort is fast and correct, and yours won’t be better. The book talks about C++‘s sort; in Python that’s sorted and list.sort — Timsort underneath, stable, O(nlog⁡n)O(n \log n), and implemented in C, which makes it far faster than any sort you’d write in a Python loop.

v = [4, 2, 5, 3, 5, 8, 3]
sorted(v)                        # [2, 3, 3, 4, 5, 5, 8], returns a new list
v.sort()                         # in place
v.sort(reverse=True)             # descending
"".join(sorted("monkey"))        # strings sort too: "ekmnoy"

Sorting your own things works differently from C++: C++ hands sort a function that compares two elements, Python hands sorted a key — what to sort each element by. Three routes:

ApproachWhen
Sort directly; tuples compare element by element (first field, then second on ties)Most cases. For “by A, then by B”, pack the data into tuples: sorted(pairs)
key=lambda x: (len(x), x)Same type, different order — e.g. strings by length first, then lexicographically. A key returning a tuple is a multi-level sort key
Define __lt__ on the classYour own type, with exactly one natural order
words = ["banana", "kiwi", "apple", "fig"]
sorted(words, key=lambda w: (len(w), w))     # ['fig', 'kiwi', 'apple', 'banana']
sorted(words, key=len, reverse=True)         # longest first

The key design sidesteps the classic C++ comparator trap: a comparator must be a strict weak ordering, a < a has to return false, and writing it with <= produces garbage on certain inputs. A key only maps each element to another value and compares with the built-in <, so an inconsistent order is impossible to write. If you really need the old “compare two elements” form, functools.cmp_to_key converts it — but you almost never will.

Finding x in a sorted array

In an unsorted array, finding a value means checking cells one at a time, O(n)O(n) — nothing tells you where to look. Once sorted, the order itself is the navigation. Binary search checks the middle cell each step: equal means found; larger than xx means the whole right half can be skipped; smaller means the whole left half can.

a, b = 0, n - 1
while a <= b:
    k = (a + b) // 2
    if array[k] == x:
        return k          # found
    if array[k] > x:
        b = k - 1
    else:
        a = k + 1

The “still possible” region a…ba \ldots b halves every step, hence O(log⁡n)O(\log n). Below it runs side by side with a linear scan, one comparison per step on each side. Watch the blue region on the right shrink — and the faded cells on the left, which are what the linear scan has “ruled out”, growing by one cell per step.

(This is an interactive animation and needs JavaScript. Looking for 18: the linear scan makes 8 comparisons, binary search makes 3.)

At n=9n = 9 it’s 8 against 3. At n=106n = 10^6 it’s up to 10610^6 against at most 20.

The book also gives a second form: start from the left and jump, with a jump length that starts at n/2n/2 and halves each round — jump if you can, shrink the stride if you can’t. Still O(log⁡n)O(\log n), but with no notion of “the middle”, and it comes back when we do binary lifting on trees (part 16).

k = 0
b = n // 2
while b >= 1:
    while k + b < n and array[k + b] <= x:
        k += b
    b //= 2
if array[k] == x:
    pass                  # found, at k

The standard library has it too, in the bisect module: bisect_left (the first position with value ≥x\ge x, C++‘s lower_bound) and bisect_right (the first position with value >x> x, C++‘s upper_bound). Their difference is how many times xx occurs:

from bisect import bisect_left, bisect_right
count = bisect_right(array, x) - bisect_left(array, x)

The real binary search: binary search on the answer

This section is the most valuable part of the chapter.

What binary search actually needs is not a sorted array but a monotone predicate ok(x)\text{ok}(x): over the answer space, a run of false followed by all true. As long as that shape holds, the same halving finds the first true — which is usually “the smallest feasible answer”.

Find x in a sorted array the possible region halves every step 16 cells 8 cells 4 cells 2 cells 1 cells 16 → 8 → 4 → 2 → 1: 4 steps = log₂16 yellow = the middle cell checked this step Binary search on the answer any ok(x) that goes false… then true… will do F F F F F F T T T T x = 0 x = 9 first true = the answer Same method, different ok(x): “is array[x] ≥ target?” → array search “can k machines finish?” → fewest machines “is this commit broken?” → git bisect “does this capacity hold?” → load-test ceiling each call to ok halves the range: O(log range) calls
What binary search really needs is not a sorted array but a monotone predicate: somewhere in the answer space there is a line with all false on one side and all true on the other. A sorted array is just the most familiar shape of that line.

The book’s version uses the jumping form:

x = -1
b = z
while b >= 1:
    while not ok(x + b):
        x += b
    b //= 2
k = x + 1        # the smallest k with ok(k) true

zz has to be large enough that ok(z)\text{ok}(z) is known to be true. Every call to ok\text{ok} halves the range, so there are O(log⁡z)O(\log z) calls in total; the overall complexity depends on what ok\text{ok} itself costs — an O(n)O(n) ok\text{ok} makes the whole thing O(nlog⁡z)O(n \log z).

This idea turns many “find the smallest / largest X” problems into “decide whether X works”: deciding is usually much easier than solving, and binary search is what turns the decision into the solution. The same section notes that a function that rises and then falls can be binary-searched too (find the last xx with f(x)<f(x+1)f(x) < f(x+1)), but adjacent values must not be equal, or you can’t tell which way to go.

What got wasted

  • bubble sort → merge sort: a swap moves two adjacent elements and removes one inversion; a merge carries an element past a whole run at once.
  • comparing → counting: a comparison sort only uses “which is bigger”; counting sort uses “the value is the position”, which is how it gets under the nlog⁡nn \log n bound — provided that information exists.
  • linear scan → binary search: a linear scan rules out one cell per step; once sorted, one comparison rules out half.

All three are the same move: find an order the brute-force solution never used. This post’s structure tags are [order] — sort things first, or notice the answer space is already ordered — and [monotone]: every step of a binary search closes in from one direction only.

Where this shows up at work

  • A database index is “sort, then binary search”. B-tree leaves are ordered, so an equality lookup is a binary search closing in, and range queries, ORDER BY and prefix LIKE all benefit — the post on indexes is really this chapter. An LSM tree’s SSTables are sorted before they’re written, and the compaction merge is merge sort’s merge.
  • Binary search on the answer is everywhere in engineering. git bisect is binary search over commit history with the predicate “is this version broken?”; a load test looking for a system’s ceiling is binary search over QPS with the predicate “does it hold at this rate?”. As long as the predicate is monotone, you don’t try values one by one.
  • Sorting first often beats a set. The book’s next chapter makes the point: for many “find duplicates, find pairs, count frequencies” problems, sorting the array and scanning it once is faster and leaner than throwing it into a hash structure — smaller constants, cache-friendly. The next post expands on it.

Reflections

Sorting pays on behalf of everyone who comes after

Last time I said the thing I use most at work is estimation — reading the size of the data to decide whether a computation is viable. Sorting is the item that shows up most often in that estimate: on a table of a few million rows, “sort first, then process” versus “look each one up” is routinely the difference between seconds and hours. An index is, at bottom, the database doing that sort for you ahead of time — and keeping it maintained.

This chapter let me say it more cleanly: sorting costs nlog⁡nn \log n, and what it buys is that every query after it becomes log⁡n\log n. Query once and it’s not worth it; query many times and sorting is the cheapest investment there is. It’s also why, when I see code doing a linear search over the same data inside a loop, my first reaction isn’t “pick a different data structure” — it’s “sort it first”.