Sorting in Linear Time

By the end of this lesson you will be able to prove WHY no comparison-based sort (insertion sort, merge sort, heapsort, quicksort) can ever beat Ω(n lg n) in the worst case, and then trace three algorithms that legally beat that bound anyway — countingSort, radixSort, and bucketSort — by looking at the actual VALUES being sorted instead of only comparing pairs of elements. All arrays are 0-indexed, exactly like the Dart code.

1. Why comparison sorts have a floor: the Ω(n lg n) lower bound

Think of sorting by comparisons as playing a game of "Twenty Questions" where every question can only be of the form "is person A more senior than person B?" (a yes/no comparison). No matter how cleverly you choose your questions, if there are n! (n factorial) possible seniority orderings to distinguish between, and every yes/no answer can only cut the remaining possibilities roughly in half, you need at least lg(n!) questions in the worst case to be certain which exact ordering you're facing — you simply cannot shortcut past that with smarter questions alone.

Merge sort and heapsort guarantee O(n lg n) in the worst case; quicksort guarantees it only on average. All four sorts you've met so far (insertion, merge, heap, quick) are comparison sorts: the only thing they ever learn about two elements is the answer to "is a[i] ≤ a[j]?" — never the actual numeric value. So here is the question: is O(n lg n) actually the best ANY comparison sort could ever do, or are we just not clever enough yet?

The answer comes from the decision-tree model: imagine drawing every possible sequence of comparisons a sorting algorithm could make on n elements as one giant binary tree. Every internal node is one comparison "a_i : a_j?"; the left branch is taken if the answer is "≤", the right branch if ">". Follow one root-to-leaf path and you've traced exactly one full execution of the algorithm on one specific input ordering. Because a CORRECT sorting algorithm must end up at a DIFFERENT leaf for every one of the n! possible input orderings (otherwise it couldn't tell two different orderings apart), every decision tree for a correct comparison sort must have at least n! reachable leaves.

Insertion sort is a comparison sort, so it has its own decision tree. Here is that exact tree for n = 3 elements — watch it get built, one input ordering at a time, by literally running insertion sort on every one of the 3! = 6 possible orderings of (a₀, a₁, a₂):

In Dart: insertion sort run on every ordering of n distinct values; each comparison outcome string is one root-to-leaf path, so the number of distinct paths is the number of leaves (n! for every n) and the longest path is the tree height (n = 3: 6 leaves, height 3).

A binary tree of height h has at most 2^h leaves. So if the tree needs at least n! leaves, we need 2^h ≥ n!, i.e. h ≥ lg(n!). Using Stirling's approximation (lg(n!) = Θ(n lg n)), this gives:

The comparison-sort lower bound. Any comparison sort requires Ω(n lg n) comparisons in the worst case. Consequence: heapsort and merge sort are asymptotically optimal comparison sorts — their O(n lg n) upper bound exactly matches this lower bound, so no comparison sort can ever do asymptotically better.

In Dart: merge sort’s worst-case comparison count W(n) = W(⌈n/2⌉) + W(⌊n/2⌋) + n − 1: it stays above ⌈lg n!⌉ for every n ≤ 200 and for n = 220 it is within 3% of lg(n!), which is what “asymptotically optimal” means.

Watch how fast n! — and therefore the minimum possible tree height ⌈lg(n!)⌉ — outgrows anything close to linear, even for tiny n:

In Dart: ⌈lg(n!)⌉ as a sum of logarithms (it reproduces the table above: 0, 1, 3, 5, 7, 10, 13, 16, 19, 22), together with the bounds n lg n − n lg e ≤ lg(n!) ≤ n lg n.

Input size → what is feasible. n = 10: no comparison sort can use fewer than ⌈lg 10!⌉ = 22 comparisons. n = 106: lg(n!) ≈ n lg n − 1.44n ≈ 1.85·107, so every comparison sort needs at least about 1.85·107 comparisons, whatever its cleverness.
A common misreading: the lower bound does NOT say "no algorithm can sort in less than n lg n time." It says no comparison-based algorithm can. The rest of this lesson is entirely about algorithms that sidestep the bound by never comparing two elements at all — they use the elements' actual values as array indices instead.
The decision-tree argument is an information-theoretic bound: it counts how many yes/no answers are needed to pin down one of n! possibilities, completely independent of how "smart" the algorithm is. This is why it applies to EVERY possible comparison sort, including ones nobody has invented yet — it isn't a statement about insertion sort or merge sort specifically, it's a statement about the comparison model itself.

2. Counting sort

Imagine grading a stack of exam papers, each already marked with a score from 0 to 100. Instead of comparing papers pairwise, you set up 101 labeled trays, one per possible score, and walk once through the stack dropping each paper straight into its tray by score — no comparisons at all. Then you walk past the trays in order (0, 1, 2, …, 100) and the papers come out perfectly sorted. countingSort is exactly this idea, done with array indices instead of physical trays.

Counting sort assumes every input element is an integer in a known range 0 to k. It is not a comparison sort — it never asks "is this bigger than that?"; it only ever asks "how many elements equal exactly this value?", then converts those counts directly into final array positions. Here is the idea as pseudocode (a is the input, b the sorted output, counts the tally array, k the largest possible value):

Now trace it on a small example, one step at a time — watch a (input), counts (the tally array, indexed by VALUE) and b (the output being filled in) all at once:

Now an array with many repeated values — the case where STABILITY (preserving the original relative order of equal elements) actually matters and is visible. The little subscript next to each number is its original 0-indexed position in a, so you can watch exactly where each copy of a repeated value ends up in b:

Try your own array (non-negative whole numbers, comma-separated):

The single most important detail in the whole procedure is the placement loop on rows 8–10: for j from n − 1 down to 0 — scanning the INPUT backwards during placement. If you instead scanned forwards (j = 0 up to n − 1), the algorithm would still produce a correctly sorted array — but it would no longer be stable: two equal elements would come out in REVERSED relative order, because whichever one is seen last claims the higher slot first (question c08-q8 below asks you to spot exactly this bug).

Why stability matters here at all: counting sort on its own doesn't care about stability — the numbers alone can't tell you if it stayed stable or not. Stability matters because counting sort is about to become the inner subroutine of radix sort in the next section, and radix sort's correctness proof depends entirely on every one of its digit-by-digit passes being stable.

Running time. Count the cost of every row of the pseudocode:

StepRowsCost
Allocate and zero counts[0..k]1Θ(k)
Tally: one pass over a2–4Θ(n)
Prefix sums over counts5–7Θ(k)
Place into b: one pass over a8–10Θ(n)
Total—Θ(n + k) — Θ(n) whenever k = O(n)

In Dart: counting every step of the four phases (counts zeroed: k + 1, tally: n, prefix sums: k, placement: n) gives exactly 2n + 2k + 1 = Θ(n + k): n = 10, k = 10 → 41 steps; n = 1000, k = 10 → 2021; n = 10, k = 106 → 2 000 021, so a huge k dominates.

Correctness idea (loop invariant for the placement loop, rows 8–10): at the start of each iteration with index j, for every value v, counts[v] = (the number of elements of a smaller than v) + (the number of elements equal to v inside a[0..j]), i.e. counts[v] − 1 is the slot of the right-most copy of v that is not placed yet; and every slot of b already filled holds its final, correct, stably-ordered value. Before the first iteration: j = n − 1, so a[0..j] is the whole array and counts[v] straight out of the prefix-sum step is the number of elements ≤ v = (smaller than v) + (equal to v). Each iteration keeps it true: placing a[j] at b[counts[a[j]] − 1] is correct because that is exactly the right-most still-open slot for that value — and decrementing counts[a[j]] keeps the invariant true for j − 1. When the loop ends: at j = −1 every element has been placed, so b holds the fully sorted, stable output. ∎

In Dart: the invariant checked by brute force before every placement on the page example and on random arrays (slot c[v] − 1 is the 0-indexed output index).

The Dart translation below uses exactly the same indices as the pseudocode: a[0..n-1], and counts[0..k] is indexed by VALUE, not position, so it starts at 0 either way. The downward loop is a plain for (var j = n - 1; j >= 0; j--):

List<int> countingSort(List<int> a) {
  if (a.isEmpty) return [];
  final k = a.reduce(max);              // largest value in a
  final c = List<int>.filled(k + 1, 0);
  for (final v in a) {
    c[v] = c[v] + 1;                     // tally
  }
  for (var i = 1; i <= k; i++) {
    c[i] = c[i] + c[i - 1];              // prefix sums: count of elements <= i
  }
  final b = List<int>.filled(a.length, 0);
  for (var j = a.length - 1; j >= 0; j--) {   // downto -> STABLE
    b[c[a[j]] - 1] = a[j];
    c[a[j]] = c[a[j]] - 1;
  }
  return b;
}

In Dart: the (key, payload) version of counting sort that radix sort calls for each digit; MapEntry.key is the digit and .value is the whole number carried along.

Input size → what is feasible. Counting sort costs 2n + 2k + 1 steps. n = 106 keys with k = 106: 4·106 steps, instant. n = 10 keys with k = 109: the counter array alone is 109 slots (8 GB of Dart ints), so use radix sort instead.
Notice k here is not a constant — it's part of the algorithm's cost. Counting sort is only genuinely linear when k = O(n) (e.g. sorting exam scores 0–100 among thousands of students). Sorting the single value 10,000,000 alongside nine small numbers would allocate a 10-million-slot array to sort ten elements — technically Θ(n+k) still, but a terrible idea in practice. This is exactly why radix sort exists: it reuses counting sort on small DIGITS instead of huge raw values.
Counting sort assumes integer keys in a known range 0..k, never compares two elements, runs in Θ(n + k), uses Θ(n + k) extra space (not in-place), and — crucially, if you scan the placement loop backwards — is stable.

3. Radix sort

Picture the old mechanical card-sorting machines banks and census bureaus used before computers: a stack of punch cards, each with several columns of holes representing digits. The historical (and correct!) trick was to run the ENTIRE stack through a machine that sorts by the rightmost column first, gather the output, run that whole stack through again sorting by the next column to the left, and so on — never needing to look at all the digits of a number at once. That machine, run column by column from least-significant to most-significant, is exactly radixSort.

Radix sort sorts multi-digit numbers by repeatedly counting-sorting on ONE digit position at a time — crucially, starting from the least-significant digit (LSD), not the most-significant one (which is the "obvious" but wrong-headed first instinct). Its pseudocode is almost embarrassingly short (digit position 0 is the ones place):

Cost of radix sort. Given n numbers of d digits each, where each digit takes up to k possible values, radix sort correctly sorts them in Θ(d(n + k)) time, provided the stable sort used for each digit takes Θ(n + k) — exactly what counting sort gives us. Trace it on a list of seven 3-digit numbers, one full digit-pass at a time:

Numbers with different numbers of digits are handled automatically: a "missing" high digit is simply digit value 0 (an implicit leading zero) once the digit-extraction place value exceeds the number:

Try your own list of non-negative whole numbers:

Why least-significant digit FIRST is not optional. If you sorted by the MOST-significant digit first instead, you'd need to recursively re-sort every "tied" group by the next digit — extra record-keeping and, worse, no simple way to reuse a single flat stable sort per pass. Sorting LSD-first with a STABLE per-digit sort means every earlier pass's hard work is automatically preserved for ties in the current pass — that's the entire trick, and it's why the stability requirement is non-negotiable, not a nice-to-have.

Correctness (by induction on the digit position). Base case: after sorting by digit 0 alone, the array is correctly sorted by digit 0. Inductive step: assume after passes 0..i-1 the array is correctly sorted by its low-order i digits (as a number). Pass i stably sorts by digit i. Two elements with different digit-i values end up correctly ordered by that pass. Two elements with the same digit-i value are left in whatever relative order they already had — and by the inductive hypothesis, that order is already correct on the lower i digits, which (since digit i is tied) is exactly the correct final order for their low-order i+1 digits too. This is precisely where the stability of each pass gets used. ∎

In Dart: the induction of the correctness proof, checked: after pass i the list is sorted by its low i digits (value mod 10i).

Choosing the digit size. For n numbers of b bits each, viewed as d = ⌈b/r⌉ digits of r bits (so k = 2^r - 1): total time is Θ((b/r)(n + 2^r)). If b < ⌊lg n⌋, pick r = b — a single pass, Θ(n). Otherwise pick r = ⌊lg n⌋, giving Θ(bn / lg n) — for b = O(lg n) (e.g. sorting n numbers in the range [0, n^c) for constant c), that's Θ(n), beating any comparison sort's Ω(n lg n) floor. The trade-off: radix sort (via counting sort) is not in-place, and its per-pass overhead often loses to a well-tuned quicksort in practice despite the better asymptotic bound, because quicksort has better cache locality.

In Dart: the digit-size rule in code: b-bit keys cut into r-bit digits need d = ⌈b/r⌉ passes of counting sort with k = 2r − 1; radixCost is (b/r)(n + 2r) and bestR finds the best r by brute force (n = 216, b = 32: r = 11 costs 202 752, r = ⌊lg n⌋ = 16 costs 262 144; b = 8 < lg n: r = b, one pass).

Dart, using countingSortByKey to stably sort by one digit at a time (the digit becomes the "key", the whole original number is the "payload" carried through each pass):

List<int> radixSort(List<int> a) {
  if (a.isEmpty) return [];
  final maxV = a.reduce(max);
  var cur = List<int>.from(a);
  var place = 1;
  while (maxV ~/ place > 0) {                 // one pass per digit, LSD first
    final items = [for (final v in cur) MapEntry((v ~/ place) % 10, v)];
    final sorted = countingSortByKey(items, 9);  // stable! digits are 0..9
    cur = sorted.map((e) => e.value).toList();
    place *= 10;
  }
  return cur;
}
Input size → what is feasible. n = 106 keys of 32 bits: r = 16 gives d = 2 passes of (n + 65 536), about 2.1·106 steps against n lg n ≈ 2·107 for a comparison sort. n = 100 keys of 64 bits: the best radix still costs 1716 steps against 664, so a comparison sort wins at small n.
Radix sort for NEGATIVE integers needs one extra trick (interview bank c08-q22): split the array into non-negatives and negatives, radix-sort the ABSOLUTE VALUES of the negatives, reverse that result and negate it back (since a larger magnitude negative number is actually smaller), then place negatives before non-negatives. The core LSD digit-pass logic never has to touch a sign bit directly.
radixSort sorts d-digit numbers with d passes of a STABLE digit-sort, least-significant digit first. Time: Θ(d(n+k)) where k is the number of distinct digit values. Beats comparison sorts asymptotically for bounded-range integers, but isn't in-place and often loses in practice to cache-friendlier comparison sorts.

4. Bucket sort

Imagine sorting a huge pile of mail by house number on a street with exactly as many houses as letters, where the letters are spread out roughly evenly along the street. Instead of one giant sorted pile, you set up one small bin per house, drop each letter straight into its bin by address, and now each bin holds only a handful of letters (maybe one, maybe zero, rarely more than a couple) — cheap to tidy up individually, and then you just walk the bins in order. That's bucketSort: divide, don't compare-everything.

Bucket sort assumes the input is n real numbers drawn roughly uniformly from [0, 1). It divides [0, 1) into n equal-width buckets, drops each element straight into its bucket by value, sorts each (usually tiny) bucket with insertion sort, then concatenates. Here it is on ten values spread fairly evenly across [0,1):

Now a smaller example that is easier to follow slowly — five values:

Worst case: if the values are NOT spread evenly — say they're all clustered near the same point — almost every element lands in the SAME bucket, and that one bucket's insertion sort degrades to Θ(n²):

In Dart: all n keys in one bucket: insertion sort shifts n(n−1)/2 times (n = 5: 10; n = 1000: 499 500), whereas uniform keys need only a few hundred shifts. The same panel checks the correctness step: ⌊n·a[i]⌋ never decreases along sorted input.

Try your own values in [0, 1):

Correctness. If a[i] ≤ a[j], then ⌊n·a[i]⌋ ≤ ⌊n·a[j]⌋ (the floor function is non-decreasing), so a[i]'s bucket index is never greater than a[j]'s. Elements in the SAME bucket are put in order by that bucket's own insertion sort. Elements in DIFFERENT buckets are already in the right relative order simply because the buckets are concatenated in increasing index order. Both cases covered ⇒ the final array is sorted. ∎

Average-case analysis. Let n_i be the (random) number of elements landing in bucket i. Total time is Θ(n) (the distribute + concatenate passes) plus Σᵢ O(n_i²) (the per-bucket insertion sorts). Using indicator random variables for "element j lands in bucket i", you get E[n_i²] = 2 - 1/n for every bucket, so:

In Dart: E[ni²] = 2 − 1/n three ways: the indicator-variable formula n(1/n) + n(n−1)(1/n²), the exact Binomial(n, 1/n) sum, and a seeded simulation of the total work Σ ni² (n = 1000: about 2n − 1 = 1999).

Summing that constant E[n_i²] bound over all n buckets gives E[T(n)] = Θ(n) + n · O(2 - 1/n) = Θ(n) — linear on average, exactly because a roughly-even spread keeps every bucket tiny.

Bucket sort's Θ(n) promise is an AVERAGE-case guarantee that depends entirely on the "roughly uniform" input assumption. Feed it clustered, skewed, or adversarial data and it silently degrades to Θ(n²) — unlike counting sort and radix sort, whose Θ(n+k) / Θ(d(n+k)) bounds hold unconditionally for any input in range. Interview bank c08-q28 shows the standard fix: replace each bucket's insertion sort with an O(n lg n) comparison sort, trading a little average-case speed for a guaranteed Θ(n lg n) worst case.

Dart (buckets as List<List<double>>, clamped so floating-point edge values near 1.0 never index out of bounds):

List<double> bucketSort(List<double> a) {
  final n = a.length;
  if (n == 0) return [];
  final buckets = List.generate(n, (_) => <double>[]);
  for (final v in a) {
    var idx = (n * v).floor();
    if (idx < 0) idx = 0;
    if (idx > n - 1) idx = n - 1;
    buckets[idx].add(v);
  }
  for (final b in buckets) {
    insertionSortDoubles(b);
  }
  return buckets.expand((b) => b).toList();
}

In Dart: the per-bucket insertion sort used by bucketSort above.

Input size → what is feasible. n = 106 uniform keys in [0, 1): about 2n = 2·106 insertion-sort steps in total (E[Σ ni²] = 2n − 1). n = 106 clustered keys: n²/2 = 5·1011 shifts, unusable; the q28 variant keeps n lg n ≈ 2·107.
Bucket sort assumes roughly-uniform input in [0,1), distributes into n buckets, sorts each with insertion sort, concatenates. Θ(n) average case, Θ(n²) worst case (all elements in one bucket). Not stable as normally implemented (bucket-internal insertion sort can be made stable, but nothing preserves cross-bucket tie order beyond value order, which is fine since bucket sort compares real numbers, not records with hidden ties).

5. Beyond the basics: the 0-1 sorting lemma & columnsort

An oblivious sorting algorithm is one that decides, in advance, EXACTLY which pairs of array positions it will compare-and-swap — the sequence of index pairs never depends on the actual data, only on the array's size. Think of it like a fixed wiring diagram of swap-stations a set of balls roll through, where the wiring never changes no matter what's written on the balls. The question: is there a shortcut to PROVING such a fixed wiring diagram sorts correctly, without checking all n! possible input orderings?

Remarkably, yes. The 0-1 sorting lemma: if an oblivious compare-exchange algorithm correctly sorts every possible array made ONLY of 0s and 1s, then it correctly sorts EVERY array of arbitrary values. This shrinks the space of inputs you must check from n! arbitrary orderings down to just 2^n binary strings — a much smaller (and highly structured) set to verify.

ConceptWhat it says
Oblivious algorithmIts sequence of compare-exchange index pairs is FIXED for a given n, independent of the data (e.g. insertion sort's nested loops, expressed as compare-exchange operations on fixed index pairs)
0-1 sorting lemmaCorrect on all 2^n binary inputs ⟺ correct on ALL inputs of arbitrary values
Proof idea (contrapositive)If some general array is sorted wrongly, build a 0-1 array by thresholding at the misplaced value; the same wiring must also mis-sort that 0-1 array
Columnsort (Leighton)An application: sorts n = r·s elements arranged as an r×s grid using 8 fixed steps (sort columns / permute / sort columns, repeated), proved correct entirely via the 0-1 lemma instead of a direct argument

In Dart: insertion sort written as a fixed list of compare-exchange pairs; the lemma says testing all 2n binary inputs is enough, and the code checks both that and all n! orderings for n = 1..8 (a network with one comparator removed fails both).

In Dart: the eight columnsort steps on a flat column-major list (r = 18, s = 3 satisfies s | r, r even, r ≥ 2(s−1)² = 8); steps 2 and 4 are index maps between column-major and row-major order.

Input size → what is feasible. n ≤ 20: checking all 220 = 1 048 576 binary inputs is feasible, while all 20! ≈ 2.4·1018 orderings is not. Columnsort needs r ≥ 2(s−1)²: an 18 × 3 grid sorts 54 values with only column sorts and fixed rearrangements.

This is a genuinely different FLAVOR of correctness argument than anything else in this lesson — instead of a loop invariant or an inductive argument over passes, it's a reduction: "if this special restricted family of inputs (0s and 1s) always works, then EVERYTHING works." The same "restrict to a smaller but sufficient test set" idea shows up again when proving sorting networks and parallel merging algorithms correct.

The 0-1 sorting lemma reduces "does this oblivious sorter work on all inputs?" to "does it work on all 0-1 inputs?" — a finite, structured check instead of an argument over arbitrary values. Columnsort is a concrete oblivious sorting network whose correctness proof leans entirely on this lemma.

Quiz

Interview questions

Cheat sheet

AlgorithmTimeSpaceStable?In-place?When to use
Any comparison sortΩ(n lg n) worst case (the lower bound) — a hard floor—dependsdependsGeneral-purpose, no assumptions on the values
countingSortΘ(n + k)Θ(n + k)Yes (if placement loop scans downto)NoSmall integer range k = O(n) (grades, ages, digits)
radixSortΘ(d(n + k))Θ(n + k)Yes (relies on each pass being stable)NoFixed-width integers/strings; k = O(n) after choosing digit size
bucketSortΘ(n) average, Θ(n²) worstΘ(n)Not guaranteed cross-bucketNoRoughly-uniform real-valued input in a known range