Ch 11 / 30 Elementary Sorts and What They Teach 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 11

Part 3 · Recursion and Sorting · Chapter 11

Elementary Sorts and What They Teach

Nobody ships bubble sort. Everybody should watch it once — the slow sorts make the invariant visible, and the invariant is the transferable idea.

Reading
CLRS Ch. 2
Focus
Invariants
Cost
O(n²)
Sorts
bubble · selection · insertion

By the end of this chapter you can

  1. State the loop invariant of each elementary sort and use it to argue correctness
  2. Compare bubble, selection and insertion sort on comparisons, swaps and adaptivity
  3. Define stability and give a case where losing it produces a wrong answer
  4. Explain why insertion sort is used inside industrial-strength sorts despite being O(n²)
  5. Use sorted with key correctly, including multi-field sorts

1Why study sorts nobody uses

Not for the algorithms. For the habit of naming what stays true while everything else changes.

You will never write bubble sort in production. Python's sorted() is faster than anything you would write, and it is one word.

Elementary sorts are worth an hour anyway, for three reasons:

  1. They are the smallest complete examples of a loop invariant — the single most useful tool for reasoning about a loop you did not write.
  2. Insertion sort is genuinely used, inside every industrial sort, for small or nearly-sorted chunks.
  3. Stability is defined here, and it changes answers in real programs.
What a loop invariant is

A statement that is true before the loop starts, stays true after every iteration, and — combined with the loop's exit condition — gives you the result you wanted.

That is a proof of correctness in three lines, and it is also the fastest way to debug a loop that is wrong: find the iteration where the invariant first breaks.

2Selection sort

The most human algorithm: find the smallest, put it first, repeat. Predictable to a fault.

Sorting a hand of cards by scanning

Spread the cards face up. Scan them all, find the lowest, put it at the left end. Scan the rest, find the lowest of those, put it next. Repeat.

You do a lot of looking and very little moving — and that ratio is exactly what selection sort is good for.

selection_sort.py
def selection_sort(xs):
    n = len(xs)
    for i in range(n - 1):
        # INVARIANT: xs[0:i] holds the i smallest values, in final order.
        smallest = i
        for j in range(i + 1, n):
            if xs[j] < xs[smallest]:
                smallest = j
        xs[i], xs[smallest] = xs[smallest], xs[i]
        # The invariant now holds for i+1.
    return xs
Comparisons
always n(n-1)/2
Swaps
at most n-1
Best case
O(n²)
Space
O(1)
Stable?
No
Adaptive?
No

Two things make selection sort distinctive:

  • It does the same work on every input. Already sorted? Same number of comparisons. That is unusual, and occasionally useful when you need predictable timing.
  • It performs at most n−1 swaps — the minimum possible for any sort that moves elements. If a write is far more expensive than a read (flash memory, say, which wears out), that is a real argument.
Why it is not stable

The long-distance swap is the culprit. Sorting [3a, 3b, 1] by value, the first pass swaps 3a with 1, giving [1, 3b, 3a] — the two 3s have traded places despite being equal.

A stable sort never reorders equal elements. Section 5 explains why that matters.

3Bubble sort

Repeatedly swap out-of-order neighbours. Famous, slow, and the clearest picture of what sorting does.

Walk the array comparing adjacent pairs and swapping when they are out of order. One pass moves the largest remaining value all the way to the end — it “bubbles up”. Repeat until a pass makes no swaps.

bubble_sort.py
def bubble_sort(xs):
    n = len(xs)
    for i in range(n - 1):
        swapped = False
        for j in range(n - 1 - i):
            # INVARIANT: xs[n-i:] holds the i largest values, in final order.
            if xs[j] > xs[j + 1]:
                xs[j], xs[j + 1] = xs[j + 1], xs[j]
                swapped = True
        if not swapped:        # a whole pass with no swaps → already sorted
            break
    return xs
Best case
O(n)
Average
O(n²)
Worst case
O(n²)
Space
O(1)
Stable?
Yes
Adaptive?
Yes

The swapped flag is what makes bubble sort adaptive: on already-sorted input, one pass finds nothing to swap and it exits in O(n). Without that flag it is O(n²) on every input, which is how it is usually written and why it has such a poor reputation.

Bubble sort is stable because it only ever swaps adjacent elements, and only when they are strictly out of order. Equal neighbours are left alone, so equal elements can never cross.

The real lesson of bubble sort

Its problem is not that it does too many comparisons — selection sort does exactly as many. Its problem is that it does too much moving: an element can be swapped hundreds of times before reaching its place.

Every faster sort in Chapter 12 is an attack on that: move each element a long way in one step rather than one position at a time.

4Insertion sort

The one you actually use. It is how everybody sorts cards, and it is inside every industrial sort you have ever run.

Picking up a hand of cards

Cards come one at a time. You hold a sorted hand and slide each new card left until it fits. You never look at the whole hand — you stop the moment you find the right slot.

That “stop early” behaviour is why insertion sort is fast on nearly-sorted data, and it is the property the other two lack.

insertion_sort.py
def insertion_sort(xs):
    for i in range(1, len(xs)):
        # INVARIANT: xs[0:i] is sorted (among itself, not finally placed).
        key = xs[i]
        j = i - 1
        while j >= 0 and xs[j] > key:    # note: > not >=, which keeps it stable
            xs[j + 1] = xs[j]            # shift right
            j -= 1
        xs[j + 1] = key                  # drop into the gap
    return xs
Best case
O(n)
Average
O(n²)
Worst case
O(n²)
Space
O(1)
Stable?
Yes
Adaptive?
Very

Note the difference in invariant. Selection sort's prefix holds values that are finally placed. Insertion sort's prefix is sorted among itself but every element in it may still move right when a smaller value arrives. Both are correct; they are different promises.

Why real sorts use it

Python's sorted() is Timsort. Java's Arrays.sort is a dual-pivot quicksort. Both switch to insertion sort for runs below about 32–64 elements. Two reasons:

  • Tiny constant factor. No recursion, no allocation, no partitioning — just a tight loop over contiguous memory that the CPU cache loves.
  • Adaptivity. Real data arrives partly ordered far more often than randomly. On nearly-sorted input the inner while exits immediately and the whole thing is O(n).
The number that decides it

Insertion sort's running time is proportional to the number of inversions in the input — pairs that are out of order relative to each other.

A sorted array has zero inversions, so the sort is O(n). A reversed array has n(n−1)/2 of them, the maximum. Every shift the inner loop performs removes exactly one inversion, which is both the running-time argument and the correctness argument in one.

5Stability, and why it changes answers

A property that sounds like trivia until the first time it silently corrupts a report.

A sort is stable if elements that compare equal keep their original relative order. Unstable sorts may reorder them arbitrarily.

It matters the moment you sort by one field having already sorted by another:

stability.py
people = [
    ("Ada",   "Engineering"),
    ("Grace", "Engineering"),
    ("Alan",  "Research"),
    ("Kay",   "Engineering"),
]

# Goal: grouped by department, alphabetical within each department.
# With a STABLE sort you can do it in two passes:

people.sort(key=lambda p: p[0])      # 1. by name
people.sort(key=lambda p: p[1])      # 2. by department

# → Ada, Grace, Kay (Engineering), then Alan (Research)
#
# The second sort did NOT disturb the name order established by the first,
# because within a department every element compares equal. That is
# stability doing real work.
#
# With an unstable sort the name order would be scrambled and you would
# have to sort by a compound key instead:
people.sort(key=lambda p: (p[1], p[0]))
SortStable?Why
BubbleYesOnly swaps adjacent elements, and only when strictly out of order
InsertionYesThe inner loop uses >, so it stops at an equal element
SelectionNoThe long-distance swap can jump one equal element over another
Merge (Ch. 12)YesThe merge takes from the left half on ties
Quicksort (Ch. 12)NoPartitioning swaps across long distances
Heapsort (Ch. 17)NoSame reason
Python sorted()YesTimsort is stable, and this is a documented guarantee
One character decides it

In insertion sort, while j >= 0 and xs[j] > key is stable. Change that to >= and it becomes unstable, because the loop now shifts past an equal element and drops the new one before it.

Same output on distinct values. Different output on real data with duplicate keys, which is most real data.

6Sorting in Python

What you will actually call, and the three arguments worth knowing.

sorted(xs) returns a new list; xs.sort() sorts in place and returns None. Both use Timsort: a merge sort that detects existing sorted runs and merges them, falling back to insertion sort on short runs. It is stable, and it is O(n) on already-sorted input.

python_sorting.py
xs = [3, 1, 2]

sorted(xs)                  # → [1, 2, 3];  xs is unchanged
xs.sort()                   # → None;       xs is now sorted
                            # `xs = xs.sort()` sets xs to None. Classic.

# key: a function computing what to sort BY
words = ["banana", "kiwi", "apple"]
sorted(words, key=len)                      # by length
sorted(words, key=str.lower)                # case-insensitive
sorted(people, key=lambda p: p.age)         # by an attribute

# reverse
sorted(xs, reverse=True)

# Multi-field: a tuple key sorts by the first, then the second, ...
sorted(people, key=lambda p: (p.dept, p.name))

# Mixed directions — negate a numeric field:
sorted(people, key=lambda p: (p.dept, -p.age))

# For a non-numeric field you cannot negate, use stability instead:
people.sort(key=lambda p: p.name)                  # ascending name
people.sort(key=lambda p: p.dept, reverse=True)    # descending dept, names intact
key is called once per element; cmp is not a thing

key is evaluated exactly once per element, and the results are sorted — so an expensive key function is fine. This is the decorate-sort-undecorate pattern, built in.

Python 3 removed the cmp argument, which was called O(n log n) times. If you truly need custom comparison logic, wrap it in functools.cmp_to_key — but a key function is almost always possible and always faster.

The comparison that matters

BubbleSelectionInsertionTimsort
Best caseO(n)O(n²)O(n)O(n)
AverageO(n²)O(n²)O(n²)O(n log n)
Worst caseO(n²)O(n²)O(n²)O(n log n)
SpaceO(1)O(1)O(1)O(n)
StableYesNoYesYes
SwapsO(n²)O(n)O(n²)
Use it whenNeverWrites are very expensiven < 50, or nearly sortedAlways

What to carry forward

  • A loop invariant is what stays true after every iteration. It is a correctness proof, and the fastest way to debug a loop that is wrong.
  • Selection sort: always n²/2 comparisons but at most n−1 swaps. Predictable, not adaptive, not stable.
  • Bubble sort: adaptive with the swapped flag, stable, and it moves elements one position at a time — which is exactly what the fast sorts fix.
  • Insertion sort: O(number of inversions), so O(n) on nearly-sorted data. Real sorts use it for runs under ~50 elements.
  • Stability means equal elements keep their order. It lets you sort by one field, then another, and keep both.
  • In Python, use sorted(). It is Timsort: stable, O(n log n) worst case, O(n) on sorted input. Learn key, and never use cmp.

>_Playground

Race the three sorts, count their operations, and see adaptivity and stability for yourself.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

Chapter 12 — Divide and Conquer: Merge Sort and Quicksort

Sorting a thousand cards alone is miserable. Sorting five and handing the rest to two friends who do the same is O(n log n).

Continue →