Integer Sorting
The question
A log holds a million 32-bit integer keys, and you want them in order. Module 02 closed one door: every comparison sort needs at least comparisons on some input. This module sorts the same million keys in about 4.3 million steps. That does not contradict module 02, and the reason why is the first thing to understand.
Here are the twelve keys used on paper throughout the lesson, all below 1000:
Comparing is the naive approach
Merge sort puts in order with comparisons, and module 02 proved that nothing that only compares can do much better. Look at what one comparison tells the algorithm: one bit, "left is smaller" or not. A comparison sort treats 503 as an opaque object. It never looks at the digits 5, 0, 3.
That is where it wastes work. A 32-bit key is a small integer, and a machine can use a small integer directly as a memory address. If you split a key into two 16-bit halves, one array lookup on a half sends the key to one of 65,536 slots in a single step. A single comparison, by contrast, only picks between two outcomes. The algorithms in this module use keys that way, as addresses.
The model: the word-RAM
Lower bounds are always relative to a model, so the model has to be stated. The word-RAM has a memory of -bit words, where so that an index fits in a word. In one step it can add, subtract, multiply, divide with remainder, compare two words, or read or write the memory word whose address is held in a word. Cost is the number of such word operations. Keys are integers in , and a key fits in one word when .
Module 02's theorem charged one step per two-way question "is ?", so a deterministic
algorithm was a binary tree with at least leaves. Reading count[x] is not a two-way
question. It jumps to one of cells in one step, a -way branch. The theorem covers every
algorithm that learns about keys only by comparing them, and the algorithms below are not in
that class. Nothing is contradicted: the two results are about different models.
Predict: so is the lower bound wrong on real computers?
No. It is still true of every comparison sort, including CPython's sorted, which compares
items only with <. What changes is the class of algorithms. When the keys are integers you
may use as addresses, you are allowed to leave that class, and the bound no longer applies.
Counting sort: keys as addresses
Suppose the keys are in for a small . Sort by the ones digit of , so . The algorithm has three steps:
- Count. For each digit value , count the keys whose digit is .
- Prefix sums. Turn the counts into starting positions.
start[c]is the number of keys whose digit is smaller than , which is exactly where the first key with digit belongs in the output. - Place. Scan the input from left to right. Put each key at
start[c]for its digit , then add 1 tostart[c].
import math
C = [503, 87, 612, 245, 87, 930, 318, 764, 59, 441, 276, 805]
counts = [sum(1 for x in C if x % 10 == c) for c in range(10)]
starts = [sum(counts[:c]) for c in range(10)]
assert counts == [1, 1, 1, 1, 1, 2, 1, 2, 1, 1]
assert starts == [0, 1, 2, 3, 4, 5, 7, 8, 10, 11]
Theorem (counting sort). Counting sort is correct and stable: records with equal keys leave in the order they arrived. It uses word operations and extra space in the worst case.
Proof. After the prefix sums, start[c] counts the records with key below , so the records
with key are given the block of positions that follows all smaller keys. That makes the output
sorted. Within one key, the placement scan meets the records in input order and hands them the
positions start[c], start[c] + 1, … in increasing order. So equal keys keep their input order,
which is stability. For the cost, clearing the counts takes steps, counting takes , the
prefix sums take and placing takes . That is word operations, and this course's
labs charge exactly that.
There is one step not to skip. The scan goes forwards and hands out increasing positions. If it went backwards it would have to hand out decreasing positions. Mixing the two, a backward scan with increasing positions, reverses every group of ties. That sort is still correct on its own, but it is useless for what comes next.
The catch is . Sorting 32-bit keys directly needs a count array of cells, four thousand times larger than a million keys. To get around that, sort by pieces of the key.
One digit at a time, least significant first
Write each key in base as digits. For , and , and 87 is written 087.
Then run a stable counting pass on the ones digit, then on the tens, and last on the
hundreds. This is LSD radix sort (least significant digit first).
Look at pass 2's bracket for tens digit 0: 503 before 805. The tens pass sees two keys with the
same digit and keeps them in the order pass 1 left them, and pass 1 had put them in order of their
ones digit, 3 before 5. That is the whole mechanism.
The lesson checks the three rows below. Python's sorted is guaranteed stable, so here it stands
in for a stable pass. The lab builds the real pass from counts.
def stable_pass(a, digit):
return sorted(a, key=digit) # Python's sort is stable: ties keep their order
def lsd_rows(a, b, d, order=None):
rows = []
for j in order or range(d):
a = stable_pass(a, lambda x, j=j: (x // b**j) % b)
rows.append(a)
return rows
rows = lsd_rows(C, 10, 3)
assert rows == [[930, 441, 612, 503, 764, 245, 805, 276, 87, 87, 318, 59],
[503, 805, 612, 318, 930, 441, 245, 59, 764, 276, 87, 87],
[59, 87, 87, 245, 276, 318, 441, 503, 612, 764, 805, 930]]
# Tag each key with its input position: after pass 1 the two 87s (tags 1 and 4) are in input order.
tagged = sorted([(x, t) for t, x in enumerate(C)], key=lambda r: r[0] % 10)
assert [t for _, t in tagged] == [5, 9, 2, 0, 7, 3, 11, 10, 1, 4, 6, 8]
Invariant
After the stable passes on the last digits, the keys are in increasing order of those digits (that is, of ), and keys whose last digits are equal are in input order.
Proof, by induction on . Before any pass () there is nothing to order, and the keys are in input order. Suppose the invariant holds after passes, and run the pass on digit . Keys with different digit are put in order of that digit, because a counting pass sorts by its key. Keys with the same digit stay in the order they had, because the pass is stable, and by the induction hypothesis that is the order of their last digits. So the keys are ordered by digit first and then by the last digits, which is exactly the order of the last digits. Ties on all digits never moved, so they are still in input order. After passes the "last digits" are the whole key.
Stability is used exactly once, in the sentence "stay in the order they had", and without it the theorem is false. On the keys , a pass that reverses ties gives on the ones digit (2 before 5). Then, since both tens digits are 1, it reverses them again to .
The same proof sorts tuples. To order records by (field 1, field 2, …, field ), make stable passes on field first and field 1 last. The fields do not even need the same range: each pass is a counting sort with its own .
Predict: run the same three stable passes on C, but hundreds first and ones last. What comes out?
930 441 612 503 764 805 245 276 87 87 318 59, which is ordered by the ones digit. The
last pass decides the primary order, so it must be the most significant digit. Each pass was
correct and the result is still wrong.
assert lsd_rows(C, 10, 3, order=(2, 1, 0))[-1] == [930, 441, 612, 503, 764, 805, 245, 276, 87, 87, 318, 59]
Complexity, and choosing the base
Theorem (radix sort). LSD radix sort with base sorts keys in in counting passes (at least one), so word operations in the worst case, and exactly in the labs' accounting. Proof: the invariant gives correctness after passes, and the counting-sort theorem gives the cost of each pass.
The base is a trade-off. A small base means cheap passes but many of them. A large base means few passes, but each one pays for its count array. On :
| base | passes | cost |
|---|---|---|
| 10 | 3 | 132 |
| 32 | 2 | 176 |
| 1000 | 1 | 2,024 |
With there is a single pass, but the count array is 83 times the size of the input.
Take . Then each pass costs and the number of passes is , so radix sort runs in . When the keys are polynomially bounded, , that is : linear time. And no algorithm can sort without reading all keys, so for constant this is optimal.
is right up to a constant factor, but it is not exactly the cheapest. For random keys below , the powers of two compare like this:
| base | 2 | 16 | 256 | 512 | 1024 | 4096 () | 16384 | |
|---|---|---|---|---|---|---|---|---|
| passes | 36 | 9 | 5 | 4 | 4 | 3 | 3 | 2 |
| cost | 295,056 | 74,016 | 43,520 | 36,864 | 40,960 | 49,152 | 122,880 | 1,064,960 |
The cheapest is . costs 1.33 times as much. The exact best base minimizes , and it sits somewhat below . That part is sketched here, not proved.
def digits(x, b):
"""x in base b, least significant digit first (at least one digit)."""
out = [x % b]
while x >= b:
x //= b
out.append(x % b)
return out
def cost(n, U, b):
d = len(digits(U - 1, b)) if U > 1 else 1
return d, d * (2 * n + 2 * b)
assert [cost(12, 1000, b) for b in (10, 32, 1000)] == [(3, 132), (2, 176), (1, 2024)]
table = {b: cost(4096, 2**36, b) for b in (2, 16, 256, 512, 1024, 4096, 16384, 2**18)}
assert table[512] == (4, 36864) and table[4096] == (3, 49152) and table[2] == (36, 295056)
assert min(t for _, t in table.values()) == 36864 and round(49152 / 36864, 2) == 1.33
Predict: int(math.log(1000, 10)) counts the base-10 digits of keys below 1000. How many passes does it give?
2, one pass short: floating point computes as 2.9999999999999996.
Similarly, math.ceil(math.log(125, 5)) is 4 when 3 passes suffice. Count digits with
integer arithmetic.
assert int(math.log(1000, 10)) == 2 and math.ceil(math.log(125, 5)) == 4
assert len(digits(999, 10)) == 3 and len(digits(124, 5)) == 3
When the passes stop paying
Return to the million keys. Every comparison sort needs 18,488,885 comparisons on some input. Radix sort with needs 2 passes on 32-bit keys, steps (a comparison and a word operation are both one step). On 64-bit keys it needs 4 passes, 8,524,288 steps. Both are far below the comparison bound.
Now make the keys 128 bits wide, two words each. In base there are 8 passes costing 17,048,576 steps. Base needs 7 passes, but the count arrays make that more expensive: 28,680,064 steps. The advantage is gone. As a rule of thumb, radix sort with makes about passes of work each, while a comparison sort pays about per key. The passes win while is small compared with , which for keys that fit in one word and large is nearly always.
lg_fact = math.lgamma(10**6 + 1) / math.log(2)
assert math.ceil(lg_fact) == 18488885
assert cost(10**6, 2**32, 2**16) == (2, 4262144) and cost(10**6, 2**64, 2**16) == (4, 8524288)
assert cost(10**6, 2**128, 2**16) == (8, 17048576) and cost(10**6, 2**128, 2**20) == (7, 28680064)
Measure the claim
A linear bound predicts that the cost per key stays flat as grows. Take seeded random keys below , run the passes, charge each pass its word operations, and divide the total by :
| base | base 10 | |
|---|---|---|
| 1,000 | 8.00 | 12.12 |
| 10,000 | 8.00 | 16.02 |
| 100,000 | 8.00 | 20.00 |
With the cost is exactly : two passes whatever is. With a fixed base 10 the cost per key grows, because the number of passes grows (6, 8, 10). "Radix sort is linear" is only true when the base grows with .
import random
def measured(n, U, b, seed=451):
rng = random.Random(seed)
a = [rng.randrange(U) for _ in range(n)]
d, _ = cost(n, U, b)
ops = 0
for j in range(d):
ops += 2 * b # clear the counts, then prefix sums
a = stable_pass(a, lambda x, j=j: (x // b**j) % b)
ops += 2 * n # count each key, then place it
assert a == sorted(a)
return ops / n
assert [measured(n, n * n, n) for n in (1000, 10000, 100000)] == [8.0, 8.0, 8.0]
assert [round(measured(n, n * n, 10), 2) for n in (1000, 10000, 100000)] == [12.12, 16.02, 20.0]
A problem that looks different
A dictionary publisher must put 50,000 headwords of up to eight lowercase letters in alphabetical order, and the words have different lengths: "cab" must come before "cable", which comes before "cabled". Could anything from this lesson help, and what would you do about the short words? It is not solved here, and the lab's last problem is a different one.
Practise
In the lab you build a counting pass step by step and watch every placement keep ties in order. You predict the passes of LSD radix sort on new keys before running them, write radix sort with an exact pass count, and measure its cost per key as grows. The last problem does not say what it is.
Recap
- You can now: say which hypothesis of module 02's lower bound counting sort breaks; prove counting sort stable with word operations; prove by induction that stable passes, least significant first, sort keys and tuples; and choose a base to predict passes and cost.
- Invariant: after stable passes on the last digits (or fields), the keys are ordered by those digits, with ties in input order.
- Complexity achieved: word operations with , so with : linear for keys below . For a million 32-bit keys that is 4,262,144 steps, against 18,488,885 comparisons for any comparison sort on some input.
- Failure mode: an unstable pass, or passes in most-significant-first order. Each pass is correct on its own, and the result is still wrong.
- In real software: NumPy's
np.sort(a, kind="stable")uses radix sort for integer types of 16 bits or less. NVIDIA CUB'scub::DeviceRadixSortis a stable GPU radix sort that processes digits from least to most significant. - Next: module 04 uses keys as addresses when the universe is far too large for an array: hashing.
Check yourself
- Why must every pass of LSD radix sort be stable, and which pass decides the final order?
- With , the keys grow from below to below . What happens to radix sort with , and does it still beat comparison sorting?
- Compare radix sort in base with merge sort on a million keys below , and say what changes if you use base instead.
Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.