Sorting & Order Statistics/Sorting in Linear Time

Lesson 3.33,000 words

Sorting in Linear Time

The Ω(nlogn)\Omega(n\log n) barrier only binds algorithms that compare. By instead using keys as array indices we slip past it: counting sort runs in Θ(n+k)\Theta(n+k) and is stable, radix sort layers it digit by digit, and bucket sort averages Θ(n)\Theta(n) on uniform data.

╌╌╌╌

The previous lesson proved that any sort that learns only by comparing elements needs comparisons. That proof assumes the algorithm extracts information one comparison at a time. If instead we treat keys as data we can read, using a key directly as an array index or splitting it into digits, the decision-tree argument no longer applies, and we can sort in linear time.1 The tradeoff is generality: these algorithms need keys drawn from a small or structured universe, not arbitrary comparables.

How the lower bound is escaped

The bound counts the branchings of a decision tree whose only moves are comparisons: with possible orderings and two outcomes per comparison, at least comparisons are needed to distinguish them. A key used as an index makes a move the tree cannot: it routes an element to one of slots in a single operation, a -way branch that no binary comparison tree models. The moment an algorithm reads a key's value directly — rather than only its order relative to another key — the decision-tree argument stops applying, and the floor it imposes is no longer binding.

This works only when the key universe is small or structured enough to index into: integers in a bounded range (counting sort), integers split into a bounded number of digits (radix sort), or reals whose distribution is known (bucket sort). On arbitrary comparable objects there is nothing to index on; the only remaining move is to compare, and the bound applies again.

Counting sort

Suppose every key is an integer in the range . Counting sort never compares two elements. Instead it counts, for each value , how many keys are ; that count gives the final position of the last key equal to . Reading the input back-to-front and decrementing as we place, we drop each element straight into its sorted slot.

Algorithm 1:Counting-Sort(A,B,k)\textsc{Counting-Sort}(A, B, k) — sort A[1..n]A[1..n] with keys in [0,k][0, k] into BB
  1. 1
    let C[0..k]C[0..k] be a new array
  2. 2
    for v0v \gets 0 to kk do
  3. 3
    C[v]0C[v] \gets 0
  4. 4
    for j1j \gets 1 to A.lengthA.length do
  5. 5
    C[A[j]]C[A[j]]+1C[A[j]] \gets C[A[j]] + 1
    C[v] = count of keys = v
  6. 6
    for v1v \gets 1 to kk do
  7. 7
    C[v]C[v]+C[v1]C[v] \gets C[v] + C[v - 1]
    C[v] = count of keys v\le v
  8. 8
    for jA.lengthj \gets A.length downto 11 do
  9. 9
    B[C[A[j]]]A[j]B[C[A[j]]] \gets A[j]
  10. 10
    C[A[j]]C[A[j]]1C[A[j]] \gets C[A[j]] - 1
    next equal key goes before it

The first count loop tallies occurrences; the prefix-sum loop turns counts into ranks (how many keys land at or before each value); the final loop scatters each element into its slot in the output array . Walking the input from down to is what makes the sort stable. Equal keys are emitted in their original relative order, because the last such key claims the highest of the slots reserved for that value, and earlier ones fill in below it.

Counting sort on : tally counts, prefix-sum into ranks, then scatter into the output .

Trace it on . The count loop tallies each value: two s, no s, two s, three s, no s, one , giving . The prefix-sum loop replaces each entry with the running total, . Read this as ranks: says seven keys are , so the last belongs in output slot . Each value's rank now gives the exact output index of its largest copy, computed without a single comparison between two keys.

One step of the scatter loop shows both the mechanism and the stability. Reading from the back, the last key looks up its rank and drops straight into ; we then decrement to , so the next we meet (an earlier one in ) lands in , just before it, preserving input order.

One step of the scatter loop. The last key reads its rank , is written to , and then is decremented to so the previous falls just before it — the source of stability.

Analysis. The loops run , , , and times, so counting sort is in both time and space. As long as this is , genuinely linear, beating the comparison bound because no comparisons happen.2 The limitation is the space and time in . If the keys range over, say, -bit integers, then dwarfs any realistic , the count array is enormous, and the method is impractical. Counting sort works best when the key universe is small.

counting_sort.pypython
from collections.abc import Callable, Sequence
from typing import TypeVar

Item = TypeVar("Item")

def counting_sort(values: Sequence[int]) -> list[int]:
  """
    Sort non-negative integers in O(n + k) time, k the maximum key.\n
    Returns a new sorted list; the input is left untouched.\n
  """
  if not values:
    return []
  return counting_sort_by_key(values, key=lambda value: value)

def counting_sort_by_key(
  items: Sequence[Item],
  key: Callable[[Item], int],
) -> list[Item]:
  """
    Stable counting sort of `items` by an integer `key` in [0, k].\n
    Equal keys keep their input order, so this is the stable subsort that\n
    radix sort layers digit by digit.\n
  """
  if not items:
    return []

  # project each item to its integer key once.
  keys: list[int] = [key(item) for item in items]
  max_key: int = max(keys)

  # counts[value] = how many items carry that key.
  counts: list[int] = [0 for _ in range(max_key + 1)]
  for current_key in keys:
    counts[current_key] += 1

  # prefix-sum turns counts into ranks: counts[value] = number of keys <= value.
  for value in range(1, max_key + 1):
    counts[value] += counts[value - 1]

  # scatter back-to-front so equal keys keep input order (stable).
  output: list[Item] = [items[0]] * len(items)
  for position in range(len(items) - 1, -1, -1):
    current_key = keys[position]
    counts[current_key] -= 1
    output[counts[current_key]] = items[position]
  return output

Radix sort

What if the keys are larger, say -digit numbers, so that a single counting pass is infeasible? Radix sort decomposes each key into digits and sorts one digit at a time. The counterintuitive rule, known since the days of punched-card machines, is to sort by the least significant digit first (LSD), working up to the most significant.

Algorithm 2:Radix-Sort(A,d)\textsc{Radix-Sort}(A, d) — sort dd-digit keys, least significant digit first
  1. 1
    for i1i \gets 1 to dd do
  2. 2
    use a stable sort to sort AA on digit ii
    digit 1 = least sig.

The correctness rests entirely on stability.

Using an unstable per-digit sort would destroy the work of every earlier pass. This is why the inner sort must be stable, and counting sort is the natural choice.

LSD radix sort over three digits; each pass stably sorts on one digit, and ties keep the previous pass's order until the array is sorted.

A single pass shows why stability is required. Suppose the array is already ordered on the low digit, and we now sort on the next one. Keys that tie on the new digit must keep their incoming order, since that order already encodes the lower digit; only keys that differ on the new digit may be reordered.

Why stability is essential. Sorting on the tens digit, keys that tie there (both ) keep their incoming order, preserving the units sort; only keys that differ on the tens digit cross.

Analysis. With counting sort on each of digits, each drawn from a range of size , every pass costs , for a total of

When is a constant and , for example fixed-width integers split into a constant number of digits in a base of size , radix sort runs in . Choosing the digit size is an engineering tradeoff: larger digits mean fewer passes ( shrinks) but a larger per pass. For -bit keys, the best choice is typically digits of about bits, so and .

Consider -bit keys with elements. Splitting into -bit digits gives passes over a count array of size ; each pass is , for total. Splitting into -bit digits gives passes but a count array of size , comparable to itself; the total is again, but the larger strains the cache. Halving the digit size the other way — -bit digits — doubles to passes with a tiny . The product is what to minimize, and the sweet spot keeps near .

The radix digit-size tradeoff for -bit keys. Wider digits cut the pass count but enlarge the per-pass count array ; total work is minimized when sits near (the middle rung).
radix_sort.pypython
from collections.abc import Sequence

def _digit_count(largest: int, radix: int) -> int:
  """
    Number of base-`radix` digits needed to represent `largest`.\n
  """
  if largest == 0:
    return 1

  # strip one digit per division until nothing is left.
  digits: int = 0
  while largest > 0:
    largest //= radix
    digits += 1
  return digits

def _counting_sort_on_digit(
  values: Sequence[int],
  place: int,
  radix: int,
) -> list[int]:
  """
    One stable counting-sort pass keyed on the digit at `place`\n
    (`place` is a power of `radix`: 1, radix, radix**2, ...).\n
  """
  # tally how many keys carry each digit at this place.
  counts: list[int] = [0 for _ in range(radix)]
  for value in values:
    digit: int = (value // place) % radix
    counts[digit] += 1

  # prefix-sum into ranks within this digit.
  for digit in range(1, radix):
    counts[digit] += counts[digit - 1]

  # scatter back-to-front to keep the pass stable.
  output: list[int] = [0 for _ in range(len(values))]
  for position in range(len(values) - 1, -1, -1):
    digit = (values[position] // place) % radix
    counts[digit] -= 1
    output[counts[digit]] = values[position]
  return output

def radix_sort(values: Sequence[int], radix: int = 10) -> list[int]:
  """
    Sort non-negative integers least significant digit first.\n
    `radix` sets the digit base; larger bases mean fewer passes but a\n
    larger count array per pass. Returns a new sorted list.\n
  """
  if radix < 2:
    raise ValueError("radix must be at least 2")
  if not values:
    return []

  if any(value < 0 for value in values):
    raise ValueError("radix_sort handles non-negative integers only")

  # one pass per digit of the largest key.
  result: list[int] = list(values)
  passes: int = _digit_count(max(result), radix)

  # sort on each digit position, least significant first.
  place: int = 1
  for _ in range(passes):
    result = _counting_sort_on_digit(result, place, radix)
    place *= radix
  return result

Bucket sort

Counting and radix sort exploit integer keys. Bucket sort instead exploits a distributional assumption: that the keys are drawn (roughly) uniformly at random from an interval, say . It scatters the keys into equal sub-intervals, the buckets, sorts each bucket with a simple sort like insertion sort, then concatenates the buckets in order.

Bucket sort on keys in . Key drops into bucket ; each bucket is sorted and the buckets concatenated left to right. Uniform keys give per bucket.
Algorithm 3:Bucket-Sort(A)\textsc{Bucket-Sort}(A) — sort nn keys drawn uniformly from [0,1)[0, 1)
  1. 1
    nA.lengthn \gets A.length
  2. 2
    let B[0..n1]B[0..n-1] be an array of empty lists
  3. 3
    for i1i \gets 1 to nn do
  4. 4
    insert A[i]A[i] into list B[nA[i]]B[\,\floor{n \cdot A[i]}\,]
    bucket by value
  5. 5
    for i0i \gets 0 to n1n - 1 do
  6. 6
    sort list B[i]B[i] with insertion sort
  7. 7
    concatenate B[0],B[1],,B[n1]B[0], B[1], \dots, B[n-1] in order

Scattering is , and concatenation is . The only variable cost is sorting the buckets. If the input is spread uniformly, each bucket holds about one element on average, so the insertion sorts cost each in expectation.4

Analysis. Let . Insertion sort on bucket costs , so the expected total bucket-sorting cost is . Each key lands in bucket independently with probability , so is Binomial, which has

Summing over the buckets gives , so the total expected running time is

This is an average-case result: it assumes the inputs are uniformly distributed. Adversarial input, with every key landing in the same bucket, degrades bucket sort to the of a single insertion sort. Bucket sort is the right tool when you know your data is spread evenly (or can cheaply map it so), as with fractional parts of well-mixed values.

A worked bucket sort

Take the keys , uniform-looking values in . Each key lands in bucket , so , , , and so on. Scattering costs one pass:

bucket keys placed (in arrival order)

Buckets , , , and stay empty. Insertion sort now orders each bucket's short list — bucket becomes , bucket becomes , bucket becomes — and reading the buckets left to right concatenates them into the sorted output. No bucket held more than three keys, so every insertion sort was work, and the whole sort touched each key a constant number of times.

Bucket sort on the keys. Each key scatters to bucket , buckets are insertion-sorted in place (most hold or key), then read left to right into the sorted run.
bucket_sort.pypython
from collections.abc import Sequence

def insertion_sort(values: list[float]) -> None:
  """
    In-place insertion sort — the cheap per-bucket subsort.\n
    Stable, and O(length) on the near-sorted, near-singleton buckets that\n
    uniform input produces.\n
  """
  for position in range(1, len(values)):
    current: float = values[position]

    # shift larger neighbors right until current's slot opens up.
    scan: int = position - 1
    while scan >= 0 and values[scan] > current:
      values[scan + 1] = values[scan]
      scan -= 1
    values[scan + 1] = current

def bucket_sort(values: Sequence[float]) -> list[float]:
  """
    Sort reals drawn (roughly) uniformly from [0, 1) in expected O(n).\n
    Returns a new sorted list; raises if any key falls outside [0, 1).\n
  """
  count: int = len(values)
  if count == 0:
    return []

  # reject keys outside the half-open interval the scatter step assumes.
  if any(value < 0.0 or value >= 1.0 for value in values):
    raise ValueError("bucket_sort expects keys in the half-open interval [0, 1)")

  # scatter each key into bucket floor(count * value) in [0, count).
  buckets: list[list[float]] = [[] for _ in range(count)]
  for value in values:
    index: int = int(count * value)
    buckets[index].append(value)

  # subsort each bucket and concatenate in bucket order.
  result: list[float] = []
  for bucket in buckets:
    insertion_sort(bucket)
    result.extend(bucket)
  return result

Choosing among them

None of these linear-time sorts is a drop-in replacement for a comparison sort like mergesort or heapsort. Each rests on a structural assumption about the keys, so the choice comes down to matching the algorithm to what you know about your data.5

AlgorithmAssumption on keysTimeStable?Extra space
Counting sortintegers in a small range yes
Radix sort digits, each in a small rangeyes
Bucket sortreals spread uniformly over an interval expectedyes

Practical guidance:

  • Use counting sort when keys are integers over a range comparable to (grades, small ages, byte values). It is also the standard stable subsort inside radix sort.
  • Use radix sort for fixed-width keys with a larger range, such as - or -bit integers or fixed-length strings, where a single counting pass would need an impossibly large count array.
  • Use bucket sort when keys are real numbers believed to be uniformly (or near-uniformly) distributed, and linear expected time suffices.

These methods beat precisely because they are not comparison sorts: they compute with the keys rather than comparing them. On arbitrary comparable objects with no exploitable integer or distributional structure, the linear-time guarantee is gone, and a comparison sort with its bound is the only option.

Radix sort in practice

The textbook radix sort scatters into separate output lists per pass, paying auxiliary space. In production that copying and the poor cache behavior of scattered writes are the bottleneck, and two refinements address them.

MSD radix, in place: American flag sort. Sorting most-significant digit first lets a radix sort partition the array in place, the way quicksort does, rather than into external buckets. American flag sort (McIlroy, Bostic, and McIlroy, 1993) makes two passes over the array per digit: the first counts how many keys fall in each of the digit values, turning the counts into bucket boundaries; the second permutes elements into place by following a cycle of swaps, so each key is moved directly to its bucket with no auxiliary array. It then recurses on each bucket for the next digit. The in-place permutation trades counting sort's scratch space for a swap-heavy inner loop, and because it is MSD it can stop early on distinguishing prefixes — the standard choice for sorting large string sets where keys share long common prefixes.

Adaptive bucketing: spreadsort. Bucket sort's fragility is its fixed uniform partition; real data is rarely uniform. Spreadsort (Ross, 2002; shipped in the Boost C++ libraries) is a hybrid that inspects the actual range of the keys, sizes its buckets to that range rather than assuming , and recursively spreads or falls back to a comparison sort when a bucket is small enough that partitioning no longer pays. It interpolates between radix sort's digit-splitting and quicksort's divide-and-conquer, achieving close to linear time on real numeric data without bucket sort's uniform-distribution assumption or radix sort's fixed digit width.

Where linear sorts actually run. Radix sort is the standard high-throughput sort on GPUs: a GPU has thousands of lanes but suffers from the branch divergence of a comparison sort's data-dependent control flow, whereas a radix pass is a fixed sequence of counts and scatters that maps cleanly onto parallel prefix-sums (Merrill and Grimshaw, 2011). Column-store databases likewise radix-sort fixed-width integer and date columns, and MapReduce-style systems partition keys by a radix-like hash to route them to reducers. The common pattern: when the keys have exploitable structure, computing with them beats comparing them, and the advantage is largest on wide parallel hardware and data too large to shuffle randomly.5

Takeaways

  • The bound binds only comparison sorts; using keys as array indices or digit sequences sidesteps it entirely.
  • Counting sort ranks keys by prefix-summing their counts: , stable, linear when but impractical when is large.
  • Radix sort stably sorts digit by digit, least significant first; stability is what preserves earlier passes, giving .
  • Bucket sort scatters uniform keys into buckets and sorts each; expected , but if the distribution is adversarial.
  • Each linear sort trades generality for a structural assumption on the keys, so choose by what you actually know about your data.

Footnotes

  1. Erickson, Algorithms, Ch. — Sorting Beyond Comparisons — treating keys as readable data sidesteps the decision-tree argument and permits linear-time sorting.
  2. CLRS, §8.2 — Counting Sort — counting sort runs in , is stable, and is linear when .
  3. CLRS, §8.3 — Radix Sort — sorting least-significant digit first with a stable subsort yields a correct sort in .
  4. CLRS, §8.4 — Bucket Sort — scattering uniformly distributed keys into buckets gives expected running time.
  5. Skiena, The Algorithm Design Manual, §4 — Sorting and Searching — choosing the right sort by matching the algorithm to the structure of the keys. 2
Practice

╌╌ END ╌╌