Computing and the Command Line

Sorting

Putting things in order, the most studied problem in computing. Insertion sort, simple and O(n²); merge sort, which splits the list in half, sorts each half, and merges them, in O(n log n); both built in Python, counted, and timed, with insertion sort's comparisons quadrupling each time the input doubles. Why no sort that only compares items can beat about n log n comparisons; counting sort, which doesn't compare and so can; and Python's own sort, a merge sort that finds runs already in order, sorting 100,000 sorted items with 99,999 comparisons, keeps equal items in their original order, and takes a key function.

  • 11 min
  • 8 steps
  • 3 questions
  • Lesson 79 of 80

In this lesson

  1. Why sort
  2. Insertion sort
  3. Merge sort
  4. Measuring them
  5. The n log n limit
  6. Python’s sort
  7. Your turn
  8. So

Why sort

Sorted data is easier to use. Binary search (last lesson) needs it. Duplicates end up next to each other, which is why the Shell course runs sort before uniq. The smallest, largest, median, and top ten are all a slice away. Sorting is so useful that it’s the most studied problem in computing, and a good place to see how much an algorithm’s design matters.

Left, insertion sort on 5, 2, 4, 1, 3: take each item in turn and slide it left past the bigger items into place, like sorting a hand of cards; rows show 2 slid before 5, then 4 between 2 and 5, then 1 to the front, then 3 between 2 and 4. On random data that's about n squared over 4 comparisons. Right, merge sort on 5, 2, 4, 1, 3, 6, 8, 7: split into halves until single items, then merge pairs back up: 2 5, 1 4, 3 6, 7 8; then 1 2 4 5 and 3 6 7 8; then 1 2 3 4 5 6 7 8. There are log2 n levels, and each level merges all n items, so about n log n comparisons. Bottom, a table of measured comparisons on random data: for 1,000 items, insertion sort 251,307 and merge sort 8,718; for 2,000, 1,004,402 and 19,434; for 4,000, 3,977,281 and 42,852. Doubling n quadruples insertion sort's work and slightly more than doubles merge sort's.
Insertion sort slides each item into place; merge sort splits, sorts the halves, and merges. Credit: StudyCorner diagram · CC BY 4.0 · Source

Insertion sort

Insertion sort is how most people sort a hand of cards: take each item in turn and slide it left past the bigger ones until it’s in place among the items already sorted. It’s simple and does well on lists that are nearly sorted already, since little has to slide. But on random data each new item slides past about half of the sorted ones, so the comparisons add up to about n²/4: O(n²).

Merge sort

Merge sort is the classic example of divide and conquer: if the list has one item or none, it’s already sorted; otherwise, split it into two halves, sort each half with merge sort, and merge the two sorted halves into one 1. A function that calls itself on a smaller version of its own problem, like this, is recursive.

Merging is the easy part: look at the front item of each half, take the smaller, and repeat. Each comparison places one item, so merging n items takes at most n comparisons. Halving the list again and again reaches single items after about log₂ n levels, and each level merges all n items, so merge sort makes at most n log₂ n comparisons: O(n log n) 1.

Quick check

Why does merge sort take about n log n comparisons?

Measuring them

This program implements both, counts their comparisons, checks the results against Python’s own sorted(), and times all three on random numbers. Save it as sorts.py:

# sorts.py: insertion sort and merge sort, counted and timed against Python's sorted().
import random
import time

def insertion_sort(a):
    """Take each item in turn and slide it left into place among the sorted ones."""
    a = a[:]
    comparisons = 0
    for i in range(1, len(a)):
        item = a[i]
        j = i - 1
        while j >= 0:
            comparisons += 1
            if a[j] <= item:
                break
            a[j + 1] = a[j]             # shift the bigger item one place right
            j -= 1
        a[j + 1] = item
    return a, comparisons

def merge_sort(a):
    """Sort each half, then merge the two sorted halves."""
    if len(a) <= 1:
        return a, 0
    mid = len(a) // 2
    left, c1 = merge_sort(a[:mid])
    right, c2 = merge_sort(a[mid:])
    merged, i, j, comparisons = [], 0, 0, c1 + c2
    while i < len(left) and j < len(right):
        comparisons += 1
        if left[i] <= right[j]:         # take the smaller front item
            merged.append(left[i])
            i += 1
        else:
            merged.append(right[j])
            j += 1
    merged += left[i:] + right[j:]      # one side is used up; the rest is in order
    return merged, comparisons

if __name__ == "__main__":
    random.seed(2)
    for n in (1_000, 2_000, 4_000):
        data = [random.random() for _ in range(n)]
        for name, sort in [("insertion sort", insertion_sort), ("merge sort", merge_sort)]:
            start = time.perf_counter()
            result, comparisons = sort(data)
            elapsed = time.perf_counter() - start
            assert result == sorted(data)
            print(f"n={n:<6,} {name:<15} {comparisons:>10,} comparisons  {elapsed:8.4f} s")
        start = time.perf_counter()
        sorted(data)
        print(f"n={n:<6,} {'sorted()':<15} {'':>22}  {time.perf_counter() - start:8.4f} s")

assert stops the program with an error if its condition is false, a cheap way to check that a sort really sorted. The if __name__ == "__main__": line runs the measurements only when the file is run directly, so next lesson can import the two functions without running them.

On a desktop PC:

me@linuxbox:~$ python3 sorts.py
n=1,000  insertion sort     251,307 comparisons    0.0109 s
n=1,000  merge sort           8,718 comparisons    0.0009 s
n=1,000  sorted()                                  0.0000 s
n=2,000  insertion sort   1,004,402 comparisons    0.0444 s
n=2,000  merge sort          19,434 comparisons    0.0018 s
n=2,000  sorted()                                  0.0001 s
n=4,000  insertion sort   3,977,281 comparisons    0.1934 s
n=4,000  merge sort          42,852 comparisons    0.0040 s
n=4,000  sorted()                                  0.0002 s

The doubling test from last lesson, in action: each time n doubles, insertion sort’s comparisons quadruple (very close to n²/4), while merge sort’s only slightly more than double. At 4,000 items merge sort is already about fifty times faster, and the gap keeps widening. Python’s built-in sorted() is faster still: the same idea, written in C and tuned for years.

The n log n limit

Can a cleverer comparison sort beat n log n? No. A list of n distinct items can be in any of n! (n factorial) orders, and a sort has to tell them all apart. Each comparison has only two outcomes, so k comparisons can distinguish at most 2^k cases; telling n! cases apart needs at least log₂(n!) comparisons, which is about n log₂ n. Any sort that learns about the items only by comparing them needs that many on some inputs, and on average for random ones 1. Merge sort is within a small constant of the best possible.

The limit applies only to comparing. If the items are small integers, say ages from 0 to 120, counting sort just counts how many of each value there are and writes them back out in order, in O(n + k) time for values below k, with no comparisons at all 1.

Python’s sort

Python’s list.sort() and sorted() use an adaptive, stable, natural merge sort 2. It scans the list for runs, stretches that are already in order (reversing descending ones), extends short runs to a minimum length with insertion sort, and then merges the runs 2. On random data it’s an ordinary merge sort; on data with order already in it, it can need as few as n − 1 comparisons 2 3.

You can count its comparisons by sorting objects that count each time they’re compared; Python’s sort compares only with < 4. Save this as adaptive.py:

# adaptive.py: count the comparisons Python's own sort makes on different inputs.
import math
import random

class Counted:
    comparisons = 0

    def __init__(self, value):
        self.value = value

    def __lt__(self, other):            # Python's sort only ever compares with <
        Counted.comparisons += 1
        return self.value < other.value

n = 100_000
random.seed(3)
inputs = {
    "random order": random.sample(range(n), n),
    "already sorted": list(range(n)),
    "reversed": list(range(n, 0, -1)),
}
for name, values in inputs.items():
    Counted.comparisons = 0
    sorted(Counted(v) for v in values)
    print(f"{name:<15} {Counted.comparisons:>9,} comparisons")
print(f"log2(n!), the minimum for random order: {math.lgamma(n + 1) / math.log(2):,.0f}")

__lt__ is the method Python calls for <; defining it lets a class be sorted. math.lgamma(n + 1) is the natural log of n!, computed without building the enormous number itself.

me@linuxbox:~$ python3 adaptive.py
random order    1,529,208 comparisons
already sorted     99,999 comparisons
reversed           99,999 comparisons
log2(n!), the minimum for random order: 1,516,704

On random data, within 1% of the theoretical minimum. On sorted or reversed data, one pass: 99,999 comparisons for 100,000 items.

Two more things to know 4:

  • It’s stable: items that compare equal keep their original order. So to sort by two things, sort by the less important one first, then by the more important one.
  • It takes a key function, applied to each item before comparing: sorted(words, key=len) sorts by length, sorted(names, key=str.casefold) ignores case, and a key that returns a tuple, such as key=lambda w: (len(w), w), sorts by length and then alphabetically.

Quick check

How many comparisons did Python’s sort need for 100,000 items that were already sorted?

Quick check

Python’s sort is stable. What does that mean?

Your turn

Exercises

  1. Trace insertion sort on [5, 2, 4, 1, 3] by hand, writing the list after each item is placed. How many comparisons did it take?
  2. Trace merge sort on [5, 2, 4, 1, 3, 6, 8, 7]: write the halves at each level, then each merge.
  3. In adaptive.py, add a “nearly sorted” input: list(range(n)) with 100 random pairs swapped. Predict the comparisons, then run it.
  4. Sort ["plane", "saw", "chisel", "awl", "rasp", "file", "adze"] by length, with words of the same length in alphabetical order, two ways: with two sorts, relying on stability, and with one tuple key.
  5. Write a counting sort for a list of ages from 0 to 120.
Answers
  1. [2, 5, 4, 1, 3], [2, 4, 5, 1, 3], [1, 2, 4, 5, 3], [1, 2, 3, 4, 5]: 1 + 2 + 3 + 3 = 9 comparisons.
  2. Split: [5, 2, 4, 1] and [3, 6, 8, 7]; then [5, 2], [4, 1], [3, 6], [8, 7]; then single items. Merge: [2, 5], [1, 4], [3, 6], [7, 8]; then [1, 2, 4, 5] and [3, 6, 7, 8]; then [1, 2, 3, 4, 5, 6, 7, 8].
  3. Far closer to sorted than to random: one run gave 111,839 comparisons, against 1.5 million for random order.
  4. sorted(sorted(words), key=len) and sorted(words, key=lambda w: (len(w), w)) both give ['awl', 'saw', 'adze', 'file', 'rasp', 'plane', 'chisel']. With key=len alone, the stable sort keeps the original order within each length: ['saw', 'awl', 'rasp', 'file', 'adze', 'plane', 'chisel'].
  5. For example:
    def counting_sort(ages):
        counts = [0] * 121
        for age in ages:
            counts[age] += 1
        result = []
        for age, count in enumerate(counts):
            result.extend([age] * count)
        return result
    

So

Insertion sort slides each item into place: simple, quick on nearly sorted data, but O(n²), its comparisons quadrupling as the input doubles. Merge sort splits, sorts the halves recursively, and merges, in at most n log₂ n comparisons, and no sort that only compares items can do better than about log₂(n!) ≈ n log₂ n. Counting sort escapes the limit by not comparing at all. Python’s sort is an adaptive, stable merge sort that exploits order already present (99,999 comparisons for 100,000 sorted items) and accepts key functions.

Lesson complete

Nice work.

1day streak
0/1today's goal
–correct

Up next · 10 min

Measuring Real Programs

Next lesson
Sources for this lesson
  1. 1
    Pat Morin. Open Data Structures (in pseudocode), Edition 0.1G beta. opendatastructures.org (paperback by AU Press). verifiedFree textbook under a Creative Commons Attribution license. Used: ch. 1, the need for efficiency (a million searches of a million items is 10^12 inspections, over 16 minutes at a billion operations a second), interfaces versus implementations, the Queue (FIFO), Stack (LIFO), and Deque; ch. 2, array-based lists (constant-time get and set, add and remove at i costing O(n - i) for the shifting, growing by allocating an array of twice the size and copying, amortized O(1) growth); ch. 3, singly and doubly linked lists (nodes with references; get(i) walks from the head; insert or delete next to a known node in constant time); ch. 5, hash tables with chaining (an array of lists, hash(x) choosing the list, n kept at most the table length so lists average one item, doubling and reinserting when full) and hash codes (equal objects must have equal hash codes, unequal ones should rarely collide); ch. 6, binary trees (root, parent, child, leaf, depth, height) and the binary search tree property, O(n) worst case when unbalanced; ch. 7, random binary search trees, with search paths of length at most about 2 ln n; ch. 12, graphs (vertices, directed edges, paths, cycles; adjacency matrix with O(n^2) space versus adjacency lists with O(n + m); breadth-first search with a queue and a seen array, O(n + m), visiting vertices in order of distance and giving shortest paths; depth-first search, breadth-first search with a stack instead of a queue, used to detect cycles); ch. 14, B-trees, whose nodes have between B and 2B children and fit in one external-memory block, the primary data structure in file systems including ext4, NTFS, and HFS+, every major database, and cloud key-value stores.
  2. 2
    Tim Peters. listsort.txt: notes on CPython's list sort. CPython source repository (GitHub). verifiedDescribes CPython's sort as an adaptive, stable, natural mergesort (timsort): it scans left to right identifying runs already in order (reversing descending ones), boosts short runs to minrun elements with binary insertion sort, and merges runs, now with the powersort merge strategy; on partially ordered data it needs fewer than lg(N!) comparisons, and as few as N-1.
  3. 3
    Time complexity of operations on built-in types (Python documentation). Python Software Foundation. verifiedCosts of operations on list, tuple, dict, set, str, and range in CPython, in big-O notation. list: append O(1) amortized, get and set item O(1), insert(k, x) and pop(k) O(n - k), so index 0 moves the whole list, x in l O(n); for adding and removing at both ends, use collections.deque. dict and set: key in d, get, set, and delete O(1) on average, assuming few collisions; O(n) in the worst case when every key hashes alike.
  4. 4
    Sorting Techniques (Python documentation). Python Software Foundation. verifiedlist.sort() and sorted() take a key function called on each element before comparing (for example key=str.casefold); sorts are guaranteed stable, so equal keys keep their original order and multiple sorts can be chained; the sort routines compare with <, so defining __lt__() makes a class sortable.