Skip to content
Educora
University25 min52 / 59

Efficient sorting algorithms

Learn merge sort and quicksort step by step, prove the Ω(n log n) lower bound for comparison sorting, and meet counting sort, which beats that bound.

Check yourself
In this lesson you will learn
  • 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

  1. Divide: split the array into two halves.
  2. Conquer: sort each half recursively (a one-element array is already sorted).
  3. Combine: merge the two sorted halves by repeatedly moving the smaller front element to the output — linear time O(n).
Example 1: tracing merge sort

Sort [38, 27, 43, 3, 9, 82, 10] with merge sort. How many comparisons does the final merge make?

Show solution
Split: [38, 27, 43] | [3, 9, 82, 10] → [38] | [27, 43] and [3, 9] | [82, 10].
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).
T(n) = 2T(n/2) + cn = Θ(n log n)T(n) = 2T(n/2) + cn = Θ(n log n)
where:
  • 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).

Python
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
The <= 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.

Example 2: Lomuto partition

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 solution
j = 0: 7 > 5 → skip.
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.
worst case: T(n) = T(n − 1) + (n − 1) = n(n − 1)/2; average: ≈ 2n ln n ≈ 1.39 n log₂ nworst case: T(n) = T(n − 1) + (n − 1) = n(n − 1)/2; average: ≈ 2n ln n ≈ 1.39 n log₂ n
where:
  • 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²).

Python
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
1542 comparisons for 200 mixed elements (n log₂ n ≈ 1529), but exactly 200 · 199 / 2 = 19,900 for 200 already sorted elements — with the last element as pivot, sorted input is the worst case.
Interactive
Loading simulation…
Insertion sort is very fast on small or nearly sorted arrays. That is why Python's Timsort first cuts the array into short runs and sorts them with insertion sort, then merges them pairwise like merge sort. Watch one such small run being sorted in the animation.

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!.

h ≥ log₂(n!) ≥ log₂((n/2)^(n/2)) = (n/2) · log₂(n/2) = Ω(n log n)h ≥ log₂(n!) ≥ log₂((n/2)^(n/2)) = (n/2) · log₂(n/2) = Ω(n log n)
where:
  • 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.

Example 3: how many comparisons at least?

In the worst case, at least how many comparisons must any comparison sort make for 3, 4 and 10 elements?

Show solution
h ≥ ⌈log₂(n!)⌉:
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.

Python
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
The count array: 0 twice, 1 never, 2 twice, 3 three times, 4 never, 5 once. The lines below compute the lower bound ⌈log₂(n!)⌉: no comparison sort can sort 1000 elements with fewer than 8530 comparisons in the worst case.
AlgorithmBestAverageWorstExtra memoryStable
Insertionnn²n²1yes
Merge sortn log nn log nn log nnyes
Quicksortn log nn log nn²log nno
Heap sortn log nn log nn log n1no
Timsort (Python)nn log nn log nnyes
Counting sortn + kn + kn + kn + kyes
All entries are inside O(…). For quicksort, log n is the average depth of the recursion stack.

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.

1 / 10
How many levels of merging does merge sort go through on an array of 1024 elements?