CMPUT-204 · Lesson 4 of 12 · 26 min

Sorting I: elementary sorts and merge sort

Sorting is the most studied problem in algorithms, for good reasons. It is useful on its own, it is a first step in many other algorithms, such as the duplicate check in lesson 1 and Kruskal's algorithm in lesson 10, and it shows almost every design technique in a small space. This lesson studies two simple quadratic sorts, selection sort and insertion sort, and then merge sort, the first divide-and-conquer algorithm in the course, which sorts in \(\Theta(n \log n)\) time. Along the way it introduces the vocabulary used to compare sorting algorithms: comparisons, inversions, stability and extra space.

The sorting problem and how we measure sorts

The input is a sequence of \(n\) items, each with a key drawn from a totally ordered set, and the output is a permutation of the input with keys in non-decreasing order. The algorithms in this lesson and the next only learn about keys by comparing two of them, so they are called comparison sorts, and we usually count key comparisons as the basic operation. Two other properties matter in practice. A sort is in place if it uses only \(O(1)\) extra memory beyond the input array, or \(O(\log n)\) for a recursion stack. A sort is stable if items with equal keys keep their original relative order, which matters when you sort records by one field after another.

Sorting algorithms in lessons 4 and 5 compared
AlgorithmWorst caseBest caseExtra spaceStable
Selection sortΘ(n²)Θ(n²)Θ(1)No (usual array version)
Insertion sortΘ(n²)Θ(n)Θ(1)Yes
Merge sortΘ(n log n)Θ(n log n)Θ(n)Yes
Quicksort (lesson 5)Θ(n²)Θ(n log n)Θ(log n) expectedNo
Heapsort (lesson 5)Θ(n log n)Θ(n log n)Θ(1)No

Selection sort

Selection sort repeatedly finds the smallest remaining item and swaps it into place. After pass \(i\), positions 0 to \(i\) hold the \(i+1\) smallest items in order, which is the loop invariant. Finding the minimum of the \(n - i\) remaining items takes \(n - i - 1\) comparisons, so the total is \((n-1) + (n-2) + \cdots + 1 = n(n-1)/2\), whatever the input. Selection sort is therefore \(\Theta(n^2)\) in every case. Its one virtue is that it makes at most \(n - 1\) swaps, which matters only when moving items is far more expensive than comparing them.

Insertion sort

Insertion sort builds a sorted prefix one item at a time. It takes the next item and shifts larger items in the prefix one position right until it finds where the new item belongs, the way many people sort a hand of cards. Its invariant is that before processing position \(i\), the prefix \(a[0..i-1]\) contains the original first \(i\) items in sorted order. Insertion sort is stable, in place, simple, and very fast on small or nearly sorted inputs, which is why production sorting routines use it for short runs.

Insertion sort on sorted, reversed and random input
def insertion_sort(a):
    a = list(a)
    comps = shifts = 0
    for i in range(1, len(a)):
        key = a[i]
        j = i - 1
        while j >= 0:
            comps += 1
            if a[j] <= key:          # <= keeps equal keys in order: stable
                break
            a[j + 1] = a[j]          # shift right
            shifts += 1
            j -= 1
        a[j + 1] = key
    return a, comps, shifts

def inversions_brute(a):
    return sum(1 for i in range(len(a)) for j in range(i + 1, len(a)) if a[i] > a[j])

import random
random.seed(204)
n = 200
cases = {'sorted': list(range(n)), 'reversed': list(range(n, 0, -1)),
         'random': random.sample(range(n), n)}
for name, data in cases.items():
    out, c, s = insertion_sort(data)
    assert out == sorted(data)
    print(f'{name:>8}: comparisons {c:>6}, shifts {s:>6}, inversions {inversions_brute(data):>6}')
Output
  sorted: comparisons    199, shifts      0, inversions      0
reversed: comparisons  19900, shifts  19900, inversions  19900
  random: comparisons   9753, shifts   9561, inversions   9561

The output reveals the exact cost of insertion sort. The number of shifts always equals the number of inversions in the input, where an inversion is a pair of positions \(i < j\) with \(a[i] > a[j]\). Each shift swaps one adjacent out-of-order pair and so removes exactly one inversion. A sorted list has no inversions and needs \(n - 1\) comparisons; a reversed list has the maximum \(n(n-1)/2\) inversions. A random permutation has, on average, half of all pairs inverted, \(n(n-1)/4\), so the average case is also \(\Theta(n^2)\). That argument proves something stronger: any sort that only swaps adjacent items, including bubble sort, needs \(\Omega(n^2)\) swaps on average.

Worked problem

Counting inversions by hand

How many inversions does 3, 1, 4, 2 have, and how many shifts will insertion sort make?

Show solution
  1. List the out-of-order pairs: (3, 1), (3, 2), (4, 2).
  2. The pair (1, 4) and (1, 2) and (3, 4) are in order.
  3. So there are 3 inversions.
  4. Insertion sort will shift exactly 3 times: 1 past 3, then 2 past 4 and past 3.

Answer: 3 inversions and 3 shifts. The number of inversions measures how far a list is from sorted.

Merge sort: divide and conquer

Merge sort follows the divide-and-conquer pattern from lesson 3. Divide the list into two halves. Conquer by sorting each half recursively. Combine by merging the two sorted halves into one sorted list. A list of length 0 or 1 is already sorted, which is the base case. All the real work happens in the merge step.

To merge two sorted lists \(L\) and \(R\), keep a pointer at the front of each. Repeatedly compare the two front items, move the smaller one to the output and advance its pointer. When one list runs out, append the rest of the other. Each comparison outputs one item, and the last item is output without a comparison, so merging lists of total length \(n\) takes at most \(n - 1\) comparisons. Taking from the left list when the keys are equal makes merge sort stable.

The list 38 27 43 3 9 82 10 is split repeatedly into halves down to single items, then merged back up level by level into 3 9 10 27 38 43 82.
Merge sort splits down to single items, then merges back up
Merge sort with a comparison counter
def merge(left, right, counter):
    out = []
    i = j = 0
    while i < len(left) and j < len(right):
        counter[0] += 1
        if left[i] <= right[j]:          # take from the left on ties: stable
            out.append(left[i]); i += 1
        else:
            out.append(right[j]); j += 1
    out.extend(left[i:])
    out.extend(right[j:])
    return out

def merge_sort(a, counter):
    if len(a) <= 1:
        return list(a)
    mid = len(a) // 2
    return merge(merge_sort(a[:mid], counter), merge_sort(a[mid:], counter), counter)

import math, random
random.seed(4)
print(merge_sort([38, 27, 43, 3, 9, 82, 10], [0]))
for n in (2**10, 2**14, 2**17):
    data = [random.random() for _ in range(n)]
    c = [0]
    assert merge_sort(data, c) == sorted(data)
    print(n, c[0], round(n * math.log2(n)), round(c[0] / (n * math.log2(n)), 3))
Output
[3, 9, 10, 27, 38, 43, 82]
1024 8929 10240 0.872
16384 208469 229376 0.909
131072 2062401 2228224 0.926

On random inputs merge sort uses about \(n \log_2 n - 1.25n\) comparisons, which is 87 to 93 percent of \(n \log_2 n\) for these sizes and creeps toward 100 percent as \(n\) grows. The saving comes from merges that end with one list exhausted early. The worst case is at most \(n \lceil \log_2 n \rceil - n + 1\) comparisons, and even the best case, an already sorted list, needs about half of \(n \log_2 n\), because each merge still compares until one side runs out. Unlike insertion sort, merge sort does not adapt much to sorted input.

Why merge sort is correct

Correctness follows by strong induction on the length \(n\). Lists of length 0 or 1 are sorted, so the base case returns a correct result. For \(n \ge 2\), both halves are shorter than \(n\), so by the inductive hypothesis the recursive calls return them sorted. The merge then produces a sorted list containing exactly the items of both halves, by its own loop invariant: the output holds the smallest items seen so far in sorted order, and the front items of \(L\) and \(R\) are the smallest remaining ones in their lists. So merge sort returns a sorted permutation of its input.

Analysis and space

The running time satisfies \(T(n) = 2T(n/2) + \Theta(n)\), which lesson 3 solved with a recursion tree and the master theorem: \(T(n) \in \Theta(n \log n)\). This holds in every case, because the splitting does not depend on the data. The price is memory. The straightforward version creates new lists at every level, but even a careful array implementation needs a temporary buffer of \(n\) items for merging, so merge sort uses \(\Theta(n)\) extra space. In-place merging is possible but complicated and slow, which is one reason quicksort and heapsort, studied next, remain important.

Worked problem

Counting comparisons in a small merge

Merge L = [2, 5, 9] and R = [3, 4, 10, 12]. How many comparisons are made?

Show solution
  1. Compare 2 and 3: output 2. Compare 5 and 3: output 3. Compare 5 and 4: output 4.
  2. Compare 5 and 10: output 5. Compare 9 and 10: output 9.
  3. L is now empty, so 10 and 12 are appended without comparisons.
  4. That is 5 comparisons for 7 items, fewer than the maximum of 6.

Answer: 5 comparisons. A merge uses at most n − 1 comparisons and stops early when one list empties.

Bottom-up merge sort and other variations

Merge sort need not be recursive. Bottom-up merge sort treats the input as \(n\) runs of length 1, merges adjacent pairs into runs of length 2, then 4, and so on, doubling the run length on each pass. There are \(\lceil \log_2 n \rceil\) passes, each costing \(\Theta(n)\), giving the same \(\Theta(n \log n)\) bound without any recursion. Natural merge sort goes further and starts from the runs already present in the data, so nearly sorted input needs very few passes. Python's built-in sort, Timsort, uses this idea together with insertion sort for short runs, which makes it \(\Theta(n)\) on already sorted data and \(\Theta(n \log n)\) in the worst case.

Merge sort is also the algorithm of choice for data that does not fit in memory. External merge sort reads chunks that fit in RAM, sorts each one, writes the sorted runs to disk and then merges many runs at once with a priority queue (lesson 5). Each pass reads and writes the data sequentially, which suits disks and networked storage far better than the random access other sorts need.

Counting inversions with merge sort

The number of inversions measures how different two rankings are, for example two people's rankings of the same films. Counting them by checking all pairs takes \(\Theta(n^2)\) time. A small change to merge sort counts them in \(\Theta(n \log n)\). During a merge, whenever an item from the right half is output before items remaining in the left half, it forms an inversion with every one of those remaining left items, because they are larger and came earlier. Adding that count at each step totals the inversions that cross between the halves; the recursive calls count those within each half.

Counting inversions while merging
def sort_count(a):
    """Return (sorted list, number of inversions) in Theta(n log n) time."""
    if len(a) <= 1:
        return list(a), 0
    mid = len(a) // 2
    left, x = sort_count(a[:mid])
    right, y = sort_count(a[mid:])
    out, i, j, cross = [], 0, 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            out.append(left[i]); i += 1
        else:
            out.append(right[j]); j += 1
            cross += len(left) - i       # right[j] precedes all remaining left items
    out += left[i:] + right[j:]
    return out, x + y + cross

import random
random.seed(7)
print(sort_count([3, 1, 4, 2])[1])
data = random.sample(range(2000), 2000)
fast = sort_count(data)[1]
slow = sum(1 for i in range(len(data)) for j in range(i + 1, len(data)) if data[i] > data[j])
print(fast, slow, fast == slow)
Output
3
998169 998169 True

The count for 3, 1, 4, 2 agrees with the hand calculation above, and on 2,000 random items the fast method matches the brute-force count. The random count is close to the expected \(n(n-1)/4 \approx 999{,}500\). Recommendation systems use exactly this measure, often normalized as Kendall's tau, to compare rankings.

Common mistakes

  • MistakeClaiming insertion sort is Θ(n²) in every case.

    InsteadIts running time is Θ(n + I), where I is the number of inversions: Θ(n) when sorted, Θ(n²) in the worst and average cases.

  • MistakeUsing < instead of <= when merging and calling the result stable.

    InsteadTake from the left list on equal keys; otherwise equal items can change order.

  • MistakeSaying merge sort is in place.

    InsteadArray merge sort needs a Θ(n) buffer; only complicated variants avoid it.

  • MistakeExpecting merge sort to be fast on already sorted input.

    InsteadTop-down merge sort still makes Θ(n log n) comparisons; natural merge sort and Timsort exploit existing runs.

  • MistakeCounting only the inversions found inside each half.

    InsteadAdd the cross inversions found during the merge, len(left) − i each time a right item is taken first.

Summary and key terms

  • Comparison sorts learn about keys only by comparing them; we count comparisons and also note stability and extra space.
  • Selection sort makes n(n − 1)/2 comparisons on every input but at most n − 1 swaps.
  • Insertion sort runs in Θ(n + I), where I is the number of inversions, so it is fast on nearly sorted data.
  • Merging two sorted lists of total length n takes at most n − 1 comparisons.
  • Merge sort satisfies T(n) = 2T(n/2) + Θ(n), so it runs in Θ(n log n) in every case, using Θ(n) extra space.
  • Merge sort is stable, parallelizes well and suits external sorting of data on disk.
  • A modified merge counts inversions in Θ(n log n) instead of Θ(n²).
Comparison sort
A sorting algorithm that gains information only by comparing pairs of keys.
Stable sort
A sort that keeps items with equal keys in their original relative order.
In-place sort
A sort that uses only O(1), or O(log n) stack, extra memory.
Inversion
A pair of positions i < j with a[i] > a[j].
Selection sort
Repeatedly select the minimum of the unsorted part and swap it into place.
Insertion sort
Insert each item into its place in a growing sorted prefix by shifting larger items.
Merge
Combine two sorted lists into one sorted list by repeatedly taking the smaller front item.
Merge sort
Divide and conquer: sort each half recursively, then merge.
Run
A maximal sorted stretch of consecutive items.
External sorting
Sorting data too large for main memory by sorting chunks and merging runs from disk.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. How many comparisons does selection sort make on 50 items?

    Show answer

    50 × 49 / 2 = 1,225, on every input.

  2. How many inversions does 5, 4, 3, 2, 1 have?

    Show answer

    10, the maximum for 5 items: 5 × 4 / 2.

  3. Insertion sort is run on a list with 12 inversions and 100 items. About how many comparisons?

    Show answer

    About n − 1 + I = 99 + 12 = 111 (each shift costs one comparison plus one stopping comparison per item).

  4. Merge [1, 6, 7] and [2, 3, 8]. How many comparisons?

    Show answer

    5: 1|2, 6|2, 6|3, 6|8, 7|8, then 8 is appended.

  5. Why is insertion sort used for short runs inside fast library sorts?

    Show answer

    It has very low overhead and is Θ(n) on nearly sorted data, so it beats Θ(n log n) methods for small n.

  6. What is the depth of merge sort's recursion for n = 1,024?

    Show answer

    log₂ 1024 = 10 levels of splitting below the root.

  7. Give an input where an unstable sort produces a different output from a stable one.

    Show answer

    Records (2, a), (1, b), (2, c) sorted by number: a stable sort keeps (2, a) before (2, c); an unstable sort may swap them.

  8. How many passes does bottom-up merge sort make on 1,000 items?

    Show answer

    ⌈log₂ 1000⌉ = 10 passes.

  9. Why does adjacent swapping need Ω(n²) swaps on average?

    Show answer

    Each adjacent swap removes at most one inversion and a random permutation has n(n − 1)/4 inversions on average.

  10. During a merge, left = [5, 8, 9] with i = 0 and the next item taken is 4 from the right. How many cross inversions are added?

    Show answer

    3, since 4 precedes 5, 8 and 9.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz