Lecture W4M1: Counting Sort, Radix Sort, and Sorting Without Comparing
September 14, 2026
find fast and keeps the keys in order.Last Wednesday we beat the \(\log n\) speed limit on find by computing where to look instead of comparing. Today we play the same trick on sorting, and beat \(n \log n\).
insert, delete, find) has a speed limit if you only compare keys: \(\Omega(\log n)\) per find. Binary search is already optimal.find is expected \(\Theta(1 + \alpha)\).Nine keys, eight slots, \(h(k) = k \bmod 8\):

Same three numbers every time: \(n\) stored keys, \(m\) slots, \(\alpha = n/m\) keys per slot on average. Read \(n\) and \(m\) off the table and \(\alpha\) tells you the expected cost.
Two different words for “average” showed up last week, and they are not interchangeable:
find might be slow if the keys happened to pile up.insert uses both at once: expected for the chain, amortized for the occasional table doubling.Amortized is the one that trips people up, so let us rebuild it from scratch with numbers.
Amortized analysis exists for one specific situation:
Doubling a table when it fills is the textbook example. Watch sixteen inserts into a table that starts with room for one key.

Do the arithmetic, line by line:
Suppose \(n = 1{,}000{,}000\) inserts. The copies happen at sizes \(1, 2, 4, \ldots, 524{,}288\): twenty of them, totaling \(1{,}048{,}575\). Together with the million writes, that is about \(2.05\) million units of work for a million inserts.
The single biggest insert cost half a million by itself. And the average is still about \(2\). That is the whole idea: rare and expensive averages out against frequent and cheap. ✔︎
An operation runs in amortized time \(T(n)\) if any sequence of \(k\) of these operations costs at most \(k \cdot T(n)\) in total.
find is amortized \(O(1)\). False. It is expected \(O(1)\). A bad spread of keys makes every find slow, and averaging over your calls does not rescue you.insert is “amortized expected” \(O(1)\). True. Expected because of the chain walk; amortized because of the occasional doubling. Two different averages, each covering a different part of the cost.| Kind | Averaged over | The promise | Seen with |
|---|---|---|---|
| Worst case | nothing | every single call is this fast | binary search, merge sort |
| Amortized | a sequence of your calls | the sequence’s total, guaranteed | dynamic array append, table doubling |
| Expected | the randomness | fast on average; any one call might be slow | hashing |
When an exam asks “what is the running time,” it will say which kind it wants. When it does not, worst case is the default.
Amortized means “the total over any sequence is bounded, guaranteed.” Expected means “the average over the randomness is bounded, but you might get unlucky.” Both can be \(O(1)\); only one of them is a promise about your particular run.
Last week’s scorecard, plus one column we have not filled in:
| Structure | build |
find(k) |
insert |
find_min |
|---|---|---|---|---|
| Unsorted array | \(n\) | \(n\) | \(n\) | \(n\) |
| Sorted array | \(n \log n\) | \(\log n\) | \(n\) | \(1\) |
| Hash table | \(n\) (expected) | \(1\) (expected) | \(1\) (amortized expected) | \(n\) |
build for a sorted array is sorting, and merge sort’s \(\Theta(n \log n)\) is the best we have. Is \(n \log n\) a law of nature, or just a limit on comparing?
Suppose a sorting algorithm learns about the keys only by comparing pairs. Each comparison has at most \(3\) outcomes.
Check with \(n = 8\): \(8! = 40{,}320\) and \((8/2)^{8/2} = 256\). Comfortably true.
Among algorithms that only compare keys, merge sort’s \(\Theta(n \log n)\) cannot be beaten. Any faster sort must be doing something other than comparing: the same escape hatch that made hashing fast.
Suppose the keys are distinct integers in \(\{0, 1, \ldots, u - 1\}\). Put each item in slot key of an array with \(u\) slots, then read the slots in order:

D[x.key]: \(n\) writes, \(O(1)\) each.Two catches, though. Keys had to be distinct (two items cannot share a slot), and \(u\) had to be small (a key range of \(n^2\) would cost \(\Theta(n^2)\) just to walk). Counting sort fixes the first; radix sort fixes the second.
Store a chain at each slot, exactly like hashing, but with a rule about order:



def counting_sort(A: list) -> None:
"Sort A by x.key, assuming keys are integers in 0..u-1"
u = 1 + max(x.key for x in A) # O(n) find the key range
D = [[] for _ in range(u)] # O(u) one chain per possible key
for x in A: # O(n) append to the END of chain x.key
D[x.key].append(x)
i = 0
for chain in D: # O(n+u) read chains back in slot order
for x in chain:
A[i] = x
i += 1Cost: \(\Theta(n + u)\), same as before, and now repeated keys are fine. Appending to the end of each chain is what makes it stable.
A sort is stable if items with equal keys come out in the same relative order they went in.
CLRS writes counting sort without chains. Instead of building lists, it works out where each key’s block will sit in the sorted output, then drops every item straight into its final position.

Walk the input backwards. Each item goes to position \(C[\text{key}]\), and then \(C[\text{key}]\) drops by one:



Chains or running totals, counting sort costs \(\Theta(n + u)\) and is stable either way. The running-total version just replaces “append to a list” with “know in advance where the list would have ended.” Either version is fine on an exam; say which one you are using.
Counting sort is \(\Theta(n + u)\). That is only linear while \(u = O(n)\).
In symbols, with \(a\) for the quotient and \(b\) for the remainder:
a, b = divmod(k, n)Then sort by digits, one at a time. The order of the passes is the whole trick.
\(n = 5\) keys, \(23, 6, 19, 11, 14\), all below \(n^2 = 25\):



The passes go from the least significant digit to the most significant, and every pass must be stable:
Try it the other way round (high digit first) and the second pass scrambles the first. Sort a spreadsheet by state and then by last name, and the states are gone.
The cast of characters, in plain words:
| Symbol | Meaning |
|---|---|
| \(n\) | how many keys |
| \(u\) | how big the keys can get: every key is below \(u\) |
| \(c\) | how many base-\(n\) digits it takes to write a key |
The question to ask every time: how big can the key get, compared with \(n\)? The answer picks the sort.
Take one worksheet per pair.
Side 1: counting-sort eight tagged items by hand, chains and all, then read the sorted order and check stability.
Side 2: radix-sort seven 3-digit numbers, one pass per digit, then answer three “which sort?” questions from the size of the keys.
10 minutes. Worksheets come in at the end; one per pair, both names on it.
Items \(3_A, 1_B, 4_C, 1_D, 0_E, 3_F, 1_G, 2_H\) with keys in \(\{0, \ldots, 4\}\):
| Slot | Chain (front to back) |
|---|---|
| 0 | \(0_E\) |
| 1 | \(1_B \to 1_D \to 1_G\) |
| 2 | \(2_H\) |
| 3 | \(3_A \to 3_F\) |
| 4 | \(4_C\) |
Output: \(0_E, 1_B, 1_D, 1_G, 2_H, 3_A, 3_F, 4_C\). The three 1s and the two 3s are in input order: stable. Cost \(\Theta(n + u) = \Theta(8 + 5)\).
Keys \(512, 348, 152, 890, 341, 705, 148\), one stable pass per digit:
Watch \(348\) and \(148\): they tie on the tens pass (\(4\)), and stability kept \(348\) ahead because the ones pass had put it there. Then the hundreds pass separated them for good.
| Algorithm | Time | In place? | Stable? | When to reach for it |
|---|---|---|---|---|
| Insertion sort | \(O(n^2)\) | yes | yes | tiny or nearly sorted inputs |
| Merge sort | \(O(n \log n)\) | no | yes | any keys you can compare; optimal among comparison sorts |
| Counting sort | \(O(n + u)\) | no | yes | integer keys with \(u = O(n)\) |
| Radix sort | \(O(n \log_n u)\) | no | yes | integer keys with \(u = O(n^c)\), \(c\) constant |
Python’s built-in sort is a comparison sort (a tuned merge sort called Timsort), because it has to work on anything. When your keys are small integers and \(n\) is large, you can beat it.
Any sort that only compares keys needs \(\Omega(n \log n)\) comparisons, because \(n!\) answers need \(\log(n!)\) three-way questions. To go faster, stop comparing and use the key as an address: counting sort drops items into chains indexed by key and reads them back in order, \(\Theta(n + u)\), stable. When keys are too big, radix sort splits each key into base-\(n\) digits and counting-sorts one digit at a time, least significant first, relying on stability to keep earlier passes intact: \(\Theta(n \log_n u)\), linear whenever \(u \leq n^c\). The question that picks the sort is always the same: how big are the keys, compared with \(n\)?
Topics: Binary trees, part 1. A structure that keeps keys in order and makes find, insert, delete, and the order operations (find_min, next-larger, range queries) all logarithmic at once. The hash table’s missing column.
Supplemental reading (highly recommended): CLRS Chapter 8 for today; Chapter 12 for Wednesday.
Upcoming deliverables:
