Comparison sorting and the n log n bound
Derive merge sort and quicksort, and prove the comparison-sort lower bound.
Sorting is the canonical algorithmic problem: the workhorse for search, statistics, and most data processing. Merge sort and quicksort both achieve time by splitting and recombining, but with different tradeoffs in memory and worst-case behavior[1]. A short information-theoretic argument shows that is the floor for any comparison-based sort[2, 3].
Merge sort
Merge sort is divide-and-conquer:
- Split the array in half.
- Recursively sort each half.
- Merge the two sorted halves into one sorted array.
The merge step walks two sorted arrays with a pointer in each, copying the smaller front element into the output until both are exhausted. This is for arrays of total length .
The recursion has depth and does work at each level (every element participates in exactly one merge at each level), giving total work . Merge sort is stable (equal elements keep their relative order) and has a worst-case guarantee, but it needs auxiliary space.
Quicksort
Quicksort is also divide-and-conquer, but it splits by value rather than by position:
- Pick a pivot element.
- Partition the array into elements pivot, elements pivot, and elements pivot.
- Recursively sort the two outer partitions.
Partitioning can be done in place in time using two pointers. The merging step is trivial: the partitions are already in the right relative order.
Average vs worst case. If the pivot splits the array roughly in half each time, the recursion is deep with work per level: total. If the pivot is always the minimum or maximum (worst case, achieved on a sorted array with a poor pivot rule), the recursion is deep with work per level: . Randomizing the pivot choice makes the expected behavior regardless of input. Production implementations use median-of-three or other robust pivots and fall back to heapsort if recursion gets too deep (introsort).
Quicksort is in-place ( stack space) and typically faster in practice than merge sort because of better cache locality, at the price of the worst-case quadratic risk.
The lower bound
Any comparison-based sort can be modeled as a decision tree: every internal node asks "is ?" and branches on the answer; every leaf labels a permutation of the input. For the algorithm to correctly sort every possible input, the tree must have at least leaves, one per possible permutation.
A binary tree with leaves has height at least . So any comparison-based sort, in the worst case, performs at least
comparisons (using Stirling's approximation ). Merge sort and heapsort achieve this bound; quicksort matches it on average. No algorithm that only compares pairs of elements can do better.
(Sorts that look at the bits of the values, like counting sort and radix sort, can beat at the cost of restrictions on the input domain.)
Check it on arrays
import random
def merge_sort(a):
if len(a) <= 1: return a
m = len(a) // 2
L, R = merge_sort(a[:m]), merge_sort(a[m:])
out, i, j = [], 0, 0
while i < len(L) and j < len(R):
if L[i] <= R[j]: out.append(L[i]); i += 1
else: out.append(R[j]); j += 1
out.extend(L[i:]); out.extend(R[j:])
return out
def quicksort(a):
if len(a) <= 1: return a
pivot = a[random.randrange(len(a))] # randomized pivot
less = [x for x in a if x < pivot]
equal = [x for x in a if x == pivot]
greater = [x for x in a if x > pivot]
return quicksort(less) + equal + quicksort(greater)
rng = random.Random(0)
data = [rng.randint(0, 1000) for _ in range(500)]
random.seed(1)
print("merge sort matches sorted():", merge_sort(data) == sorted(data))
print("quicksort matches sorted():", quicksort(data) == sorted(data))
# Stability test: equal keys keep original order in merge sort
pairs = [(1, 'a'), (2, 'b'), (1, 'c'), (2, 'd')]
stable = merge_sort(pairs)
print("merge sort is stable:", stable == [(1, 'a'), (1, 'c'), (2, 'b'), (2, 'd')])
# Worst-case quicksort with a deterministic-bad pivot (first element) on sorted input
# would be O(n^2); randomized pivot avoids it.
import math
print(f"log2(n!) for n=500 ~ {math.lgamma(501)/math.log(2):.0f} comparisons (n log n lower bound)")Both algorithms reproduce the library sort, merge sort preserves the relative order of equal keys, and the decision-tree bound predicts the comparison count.
Exercises
Exercise 1. Trace the merge step on the two sorted halves and . Show the output after each comparison, and count the comparisons performed.
Solution
Step 1. Merge keeps a pointer in each half and copies the smaller front element each time. Start with pointers at and .
- Compare and : copy . Output .
- Compare and : copy . Output .
- Compare and : copy . Output .
- Compare and : copy . Output .
- Compare and : copy . Output .
- The left half is exhausted; copy the rest of the right half, . Output .
Step 2. Five comparisons were made before one half emptied. In general, merging halves of total length costs at most comparisons, here , matching the count.
Exercise 2. The comparison lower bound uses . Prove the upper half of that bound, , directly from the definition of .
Solution
Step 1. Write as a product and bound each factor: every term in is at most , and there are of them, so
Step 2. Take of both sides. The logarithm is increasing, so the inequality is preserved:
This gives the side. A matching lower bound (keeping only the top half of the factors, each at least ) yields , so .
Exercise 3 (code). Counting sort beats the bound on integers in a small range by reading values instead of comparing them. Implement it for nonnegative integers in in time, and assert it matches the library sort on random data.
Solution
The lower bound applies only to comparison sorts; counting sort sidesteps it by using each value as an array index, so its cost is linear in the input size plus the range .
import random
def counting_sort(a, K):
counts = [0] * K
for x in a: # tally each value: O(n)
counts[x] += 1
out = []
for v in range(K): # emit values in order: O(K + n)
out.extend([v] * counts[v])
return out
rng = random.Random(0)
K = 100
data = [rng.randrange(K) for _ in range(2000)]
assert counting_sort(data, K) == sorted(data)
print("counting sort matches sorted()")The next lesson abstracts the divide-and-conquer pattern itself and the master theorem for its recurrences.
References
- [1]Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Merge sort, quicksort, and their analysis (Ch. 2, 7).↩
- [2]Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. The decision-tree lower bound for comparison sorts (§8.1).↩
- [3]Knuth, D. E. (1998). The Art of Computer Programming, Vol. 3: Sorting and Searching (2nd ed.). Addison-Wesley. The definitive treatment of sorting and the comparison lower bound (§5.3).↩