- Trace merge sort and its merge step and show that it is Θ(n log n)
- Perform quicksort's partition and explain its worst case
- Justify the Ω(n log n) bound with a decision tree and apply counting sort
Bubble sort needs about 5 · 10¹¹ comparisons to sort a million records — at 10⁸ comparisons per second that is well over an hour. Merge sort does the same job with about 2 · 10⁷ comparisons, in a fraction of a second. The difference is not the computer but the idea: divide and conquer. In this lesson we learn the two main n log n algorithms, the proof that comparison sorting cannot do better, and how to “cheat” that limit.
Merge sort
- Divide: split the array into two halves.
- Conquer: sort each half recursively (a one-element array is already sorted).
- Combine: merge the two sorted halves by repeatedly moving the smaller front element to the output — linear time O(n).
Sort [38, 27, 43, 3, 9, 82, 10] with merge sort. How many comparisons does the final merge make?
Show solutionHide solution
Small merges: [27, 43]; [38] + [27, 43] → [27, 38, 43]; [3, 9]; [10, 82]; [3, 9] + [10, 82] → [3, 9, 10, 82].
Final merge [27, 38, 43] + [3, 9, 10, 82]:
27 ↔ 3 → 3; 27 ↔ 9 → 9; 27 ↔ 10 → 10; 27 ↔ 82 → 27; 38 ↔ 82 → 38; 43 ↔ 82 → 43; left is empty → append 82.
Result: [3, 9, 10, 27, 38, 43, 82], with 6 comparisons in the final merge (at most n − 1 = 6).
- 2T(n/2)2T(n/2)sorting the two halves recursively
- cnthe merge (at most n − 1 comparisons)
On each of the log₂ n levels the merges total ≤ n comparisons → ≤ n log₂ n. This holds in the worst, average and best case alike. Extra memory is O(n); the algorithm is stable (equal elements keep their order).
comparisons = 0
def merge(left, right):
global comparisons
out, i, j = [], 0, 0
while i < len(left) and j < len(right):
comparisons += 1
if left[i] <= right[j]:
out.append(left[i])
i += 1
else:
out.append(right[j])
j += 1
return out + left[i:] + right[j:]
def merge_sort(a):
if len(a) <= 1:
return a
mid = len(a) // 2
return merge(merge_sort(a[:mid]), merge_sort(a[mid:]))
print(merge_sort([38, 27, 43, 3, 9, 82, 10]))
comparisons = 0
merge_sort([(i * 37) % 1024 for i in range(1024)])
print('n = 1024:', comparisons, 'comparisons; n log2 n =', 1024 * 10)▸ Expected output
[3, 9, 10, 27, 38, 43, 82] n = 1024: 8161 comparisons; n log2 n = 10240
<= test gives stability: on a tie the element from the left half goes first. For a mixed array of 1024 elements, 8161 comparisons stay below the n log₂ n = 10,240 bound.Quicksort
Quicksort does the work in the opposite order: first a pivot is chosen and the array is partitioned — elements less than or equal to the pivot go left, larger ones go right. The pivot lands in its final place, then both parts are sorted recursively; no merge is needed. It works in place (no extra array) and is very fast in practice, but it is not stable.
Partition [7, 2, 9, 4, 3, 8, 5] using the last element (5) as the pivot. i marks the end of the “≤ pivot” part; initially i = −1.
Show solutionHide solution
j = 1: 2 ≤ 5 → i = 0, a[0] ↔ a[1] → [2, 7, 9, 4, 3, 8, 5]
j = 2: 9 > 5 → skip.
j = 3: 4 ≤ 5 → i = 1, a[1] ↔ a[3] → [2, 4, 9, 7, 3, 8, 5]
j = 4: 3 ≤ 5 → i = 2, a[2] ↔ a[4] → [2, 4, 3, 7, 9, 8, 5]
j = 5: 8 > 5 → skip.
Finally swap the pivot with a[i + 1] = a[3] → [2, 4, 3, 5, 9, 8, 7].
The pivot 5 sits at index 3, its final place; {2, 4, 3} on the left, {9, 8, 7} on the right. 6 = n − 1 comparisons.
- T(n − 1)the remaining part when the pivot is always the minimum or maximum
- n − 1comparisons of one partition
With balanced splits quicksort behaves like merge sort, T(n) = 2T(n/2) + n → Θ(n log n); with the worst split every time it is Θ(n²).
comparisons = 0
def partition(a, lo, hi):
global comparisons
pivot, i = a[hi], lo - 1
for j in range(lo, hi):
comparisons += 1
if a[j] <= pivot:
i += 1
a[i], a[j] = a[j], a[i]
a[i + 1], a[hi] = a[hi], a[i + 1]
return i + 1
def quicksort(a, lo, hi):
if lo < hi:
p = partition(a, lo, hi)
quicksort(a, lo, p - 1)
quicksort(a, p + 1, hi)
a = [7, 2, 9, 4, 3, 8, 5]
print(partition(a, 0, 6), a)
for name, data in [('mixed', [(i * 37) % 200 for i in range(200)]), ('sorted', list(range(200)))]:
comparisons = 0
quicksort(data, 0, len(data) - 1)
print(name, comparisons, data == sorted(data))▸ Expected output
3 [2, 4, 3, 5, 9, 8, 7] mixed 1542 True sorted 19900 True
The lower bound: Ω(n log n)
Picture any sorting algorithm that only asks “is a ≤ b?” as a decision tree: each internal node is one comparison and each leaf is one answer — an ordering of the elements. n distinct elements can be ordered in n! ways, and each must lead to a different leaf. A binary tree of height h has at most 2ʰ leaves, so 2ʰ ≥ n!.
- hworst-case number of comparisons (height of the tree)
- n!number of possible orderings of n elements
The n/2 factors of n! that exceed n/2 are each ≥ n/2, so n! ≥ (n/2)^(n/2). More precisely, by Stirling's formula, log₂(n!) ≈ n log₂ n − 1.44n.
In the worst case, at least how many comparisons must any comparison sort make for 3, 4 and 10 elements?
Show solutionHide solution
n = 3: 3! = 6, 2² = 4 < 6 ≤ 8 = 2³ → 3 comparisons.
n = 4: 4! = 24, 2⁴ = 16 < 24 ≤ 32 = 2⁵ → 5 comparisons.
n = 10: 10! = 3,628,800, 2²¹ = 2,097,152 < 10! ≤ 2²² = 4,194,304 → 22 comparisons.
For comparison: bubble sort makes 45 comparisons in the worst case for n = 10.
Counting sort: beating the bound
The Ω(n log n) bound applies only to comparison-based algorithms. If the keys are integers in the range 0 … k, counting sort makes no comparisons at all: it counts how often each value occurs, then writes the values out in order as many times as counted. Time and memory are O(n + k). Repeating a stable counting sort digit by digit is radix sort: O(d · (n + k)) for d-digit numbers.
import math
def counting_sort(a, k):
count = [0] * (k + 1)
for x in a:
count[x] += 1
out = []
for value, c in enumerate(count):
out.extend([value] * c)
return count, out
print(counting_sort([2, 5, 3, 0, 2, 3, 0, 3], 5))
for n in [3, 4, 5, 10, 1000]:
print(n, math.ceil(math.log2(math.factorial(n))))▸ Expected output
([2, 0, 2, 3, 0, 1], [0, 0, 2, 2, 3, 3, 3, 5]) 3 3 4 5 5 7 10 22 1000 8530
| Algorithm | Best | Average | Worst | Extra memory | Stable |
|---|---|---|---|---|---|
| Insertion | n | n² | n² | 1 | yes |
| Merge sort | n log n | n log n | n log n | n | yes |
| Quicksort | n log n | n log n | n² | log n | no |
| Heap sort | n log n | n log n | n log n | 1 | no |
| Timsort (Python) | n | n log n | n log n | n | yes |
| Counting sort | n + k | n + k | n + k | n + k | yes |
Key points
- Merge sort: split, sort recursively, merge in O(n); T(n) = 2T(n/2) + n → Θ(n log n), stable, O(n) memory.
- Quicksort partitions around a pivot; average Θ(n log n), worst Θ(n²) — a random pivot makes the worst case unlikely in practice.
- Decision tree: 2ʰ ≥ n! → every comparison sort needs Ω(n log n) comparisons in the worst case.
- Counting sort makes no comparisons and runs in O(n + k); it beats the bound when keys are small integers.
Check yourself
10 questions. Every correct answer earns XP.