AmouAI Hub/Courses/Data Structures & Algorithms/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.
By the end of this chapter you can
- State the loop invariant of each elementary sort and use it to argue correctness
- Compare bubble, selection and insertion sort on comparisons, swaps and adaptivity
- Define stability and give a case where losing it produces a wrong answer
- Explain why insertion sort is used inside industrial-strength sorts despite being O(n²)
- Use
sortedwithkeycorrectly, 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:
- They are the smallest complete examples of a loop invariant — the single most useful tool for reasoning about a loop you did not write.
- Insertion sort is genuinely used, inside every industrial sort, for small or nearly-sorted chunks.
- Stability is defined here, and it changes answers in real programs.
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.
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.
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 xsTwo 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.
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.
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 xsThe 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.
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.
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.
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 xsNote 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
whileexits immediately and the whole thing isO(n).
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:
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]))| Sort | Stable? | Why |
|---|---|---|
| Bubble | Yes | Only swaps adjacent elements, and only when strictly out of order |
| Insertion | Yes | The inner loop uses >, so it stops at an equal element |
| Selection | No | The long-distance swap can jump one equal element over another |
| Merge (Ch. 12) | Yes | The merge takes from the left half on ties |
| Quicksort (Ch. 12) | No | Partitioning swaps across long distances |
| Heapsort (Ch. 17) | No | Same reason |
Python sorted() | Yes | Timsort is stable, and this is a documented guarantee |
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.
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 intactkey 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
| Bubble | Selection | Insertion | Timsort | |
|---|---|---|---|---|
| Best case | O(n) | O(n²) | O(n) | O(n) |
| Average | O(n²) | O(n²) | O(n²) | O(n log n) |
| Worst case | O(n²) | O(n²) | O(n²) | O(n log n) |
| Space | O(1) | O(1) | O(1) | O(n) |
| Stable | Yes | No | Yes | Yes |
| Swaps | O(n²) | O(n) | O(n²) | — |
| Use it when | Never | Writes are very expensive | n < 50, or nearly sorted | Always |
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. Learnkey, and never usecmp.
>_Playground
Race the three sorts, count their operations, and see adaptivity and stability for yourself.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
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).