Sorting: Why Some Sorts Are Slow
Bubble and selection sort by hand, then Python's sorted() — n² vs n log n on a plot.
- perform bubble, selection and insertion sort by hand on paper;
- implement all three, count their comparisons and swaps, and confirm the n² pattern;
- explain in plain words where n log n comes from, and see it beat n² in a measured table;
- measure the gap between your sort and Python's
sorted(); - use
sort,sortedandkey=properly, and explain what a stable sort buys you.
13.1Why study slow sorts?
You will never ship bubble sort. You study it because it is the clearest example in all of computing of a genuine trade: two methods, both obviously correct, separated by a complexity class you can feel on a laptop within thirty seconds.
Sorting also underpins week 12 (binary search needs sorted data), week 9 (the sort-and- compare anagram solution) and any report you will ever produce from data.
13.2Bubble sort
Walk along the row comparing neighbours; swap them if they are out of order. Each full pass drags the largest remaining value to the end — it "bubbles up". Repeat until a pass makes no swaps.
def bubble_sort(data):
"""Sorts a copy of data. Returns (sorted list, comparisons, swaps)."""
items = data[:] # work on a copy
comparisons = swaps = 0
n = len(items)
for end in range(n - 1, 0, -1): # shrinking unsorted region
swapped = False
for i in range(end):
comparisons += 1
if items[i] > items[i + 1]:
items[i], items[i + 1] = items[i + 1], items[i]
swaps += 1
swapped = True
if not swapped: # already sorted — stop early
break
return items, comparisons, swaps
print(bubble_sort([5, 1, 4, 2, 8]))
Cost. Passes shrink: (n−1) + (n−2) + … + 1 = n(n−1)/2 comparisons —
O(n²). The early exit gives a best case of O(n) on data that
is already sorted, which is bubble sort's only redeeming feature. (Here the early exit
fires after the third pass finds nothing to swap, so it stops at 9 comparisons instead of
the full 10.)
13.3Selection sort
Find the smallest item, put it first. Find the smallest of the rest, put it second. Continue. This is what most people do with a hand of cards.
def selection_sort(data):
items = data[:]
comparisons = swaps = 0
for start in range(len(items)):
smallest = start
for i in range(start + 1, len(items)):
comparisons += 1
if items[i] < items[smallest]:
smallest = i
if smallest != start:
items[start], items[smallest] = items[smallest], items[start]
swaps += 1
return items, comparisons, swaps
Same n(n−1)/2 comparisons — O(n²) in every case, best included, because it
cannot know it is finished early. But it performs at most n−1 swaps, against
bubble sort's possible n²/2. If moving items is expensive and comparing them is cheap,
that matters. Two algorithms in the same class can still differ in ways worth measuring.
13.4Insertion sort, and all three by hand
The third classic completes the set. Take each item and slide it back into its correct place among the already-sorted items on its left — the way most people order a hand of playing cards as they pick them up.
def insertion_sort(data):
items = data[:]
comparisons = shifts = 0
for i in range(1, len(items)):
current = items[i] # the card in your hand
j = i - 1
while j >= 0:
comparisons += 1
if items[j] > current: # make room by shifting right
items[j + 1] = items[j]
shifts += 1
j -= 1
else:
break
items[j + 1] = current # drop it into the gap
return items, comparisons, shifts
print(insertion_sort([5, 1, 4, 2, 8]))Insertion sort is O(n²) in the worst case, but genuinely fast on nearly-sorted data — on an already-sorted list every item drops straight in after a single comparison, giving O(n). That is why real sorting libraries use it for small chunks.
Now the point of this section: sort [5, 1, 4, 2, 8] by hand with each method,
writing the list after every pass, and confirm your counts against the three outputs above.
| Bubble — after each pass | Selection — after each round | Insertion — after each insert |
|---|---|---|
| start [5,1,4,2,8] | start [5,1,4,2,8] | start [5,1,4,2,8] |
| pass 1 → [1,4,2,5,8] | put smallest first → [1,5,4,2,8] | insert 1 → [1,5,4,2,8] |
| pass 2 → [1,2,4,5,8] | next smallest → [1,2,4,5,8] | insert 4 → [1,4,5,2,8] |
| pass 3 → no swaps, stop | rest already in place | insert 2 → [1,2,4,5,8]; insert 8 → done |
| 9 comparisons, 4 swaps | 10 comparisons, 2 swaps | 7 comparisons, 4 shifts |
Three correct answers, three different amounts of work on the very same five numbers. Selection sort always does its full 10 comparisons; bubble sort's early exit saves it one; insertion sort does the fewest here because the data was not badly scrambled. The class is the same for all three — the constants are not.
13.5Where n log n comes from
All three sorts above compare, in the worst case, every item with essentially every other. The better sorts refuse to do that, using a divide-and-conquer idea:
- Split the list into two halves.
- Sort each half (by the same method, on smaller pieces).
- Merge the two sorted halves by repeatedly taking the smaller front item — one pass, O(n).
How many times can you split a list of n items in half? log n times — the same halving from weeks 1 and 12. Each of those levels does O(n) work merging. So the total is n × log n.
| n | n² comparisons | n log₂ n comparisons | How much better |
|---|---|---|---|
| 100 | 10 000 | 664 | 15× |
| 1 000 | 1 000 000 | 9 966 | 100× |
| 10 000 | 100 000 000 | 132 877 | 753× |
| 1 000 000 | 10¹² | 19 931 569 | 50 000× |
You will not implement merge sort in this course — writing it well needs recursion, which belongs to the follow-on course. What you need is the intuition ("split, sort, merge — log n levels of n work each") and the ability to recognise its 2.1–2.2 ratio in a doubling experiment.
13.6Python's sorted(), and how far ahead it is
import time, random
for n in [1000, 2000, 4000, 8000]:
data = [random.randrange(1000000) for _ in range(n)]
start = time.perf_counter()
result_b, comps, swaps = bubble_sort(data)
t_bubble = time.perf_counter() - start
start = time.perf_counter()
result_p = sorted(data)
t_python = time.perf_counter() - start
print(f"n={n:>5} bubble {t_bubble:8.4f}s ({comps:,} comparisons)"
f" sorted() {t_python:.5f}s {t_bubble/t_python:,.0f}x")
assert result_b == result_p # both are correct!
The assert line matters: both produce identical output. The difference is
entirely in how the answer is reached. Read the two time columns down the page as a
doubling experiment, exactly as in weeks 5–7:
| n doubles | bubble time × … | sorted() time × … | what the ratio says |
|---|---|---|---|
| 1000 → 2000 | 0.37 / 0.09 ≈ 4.0× | 0.00048 / 0.00023 ≈ 2.1× | 4× is n²; 2.1× is n log n |
| 2000 → 4000 | 1.49 / 0.37 ≈ 4.0× | 0.00104 / 0.00048 ≈ 2.2× | same story, one class down |
| 4000 → 8000 | 5.96 / 1.49 ≈ 4.0× | 0.00228 / 0.00104 ≈ 2.2× | and it holds every row |
A ratio column that sits at 4 is the fingerprint of O(n²); one that sits just above 2 is
the fingerprint of O(n log n). You do not need to know the constant, the machine or the
language to read the class straight off those numbers — which is the whole method of this
course. And the final speedup column keeps growing precisely because the two lines belong
to different classes: at n = 100 000 bubble sort would need roughly 5 000 million
comparisons and about fifteen minutes; sorted() takes about a twentieth of a
second.
An algorithm called Timsort: merge sort combined with insertion sort for small runs, plus a trick that detects stretches of data that are already in order. It is O(n log n) in the worst case and O(n) on already-sorted data — and it is written in C. That is your baseline. Beating it is a research project, not a homework.
13.7Using sorting properly
data = [5, 2, 9, 1]
new = sorted(data) # returns a new list, leaves data alone
data.sort() # sorts in place, returns None
names = ["Cem", "ada", "Bilal"]
print(sorted(names)) # capitals first — probably not what you want
print(sorted(names, key=str.lower)) # case-insensitive
students = [("Ada", 88), ("Bilal", 72), ("Cem", 91)]
print(sorted(students, key=lambda s: s[1], reverse=True)) # by grade, best first
data = data.sort() throws your list away and leaves you holding
None. Use data.sort() on its own line, or
data = sorted(data) — never both at once.
One more cost worth knowing: sorting inside a loop is a classic accidental disaster. Sorting a list of n items m times is O(m · n log n) when sorting once outside the loop would have done. Ask yourself every time: does this need to happen again?
13.8Stability, and sorting by more than one key
A sort is stable when items that compare equal keep their original order.
Python's sorted and list.sort are stable — a guarantee, not an
accident — and it is more useful than it first sounds.
scores = [("Ada", 88), ("Bilal", 88), ("Cem", 72)]
print(sorted(scores, key=lambda p: p[1], reverse=True))Ada and Bilal both scored 88, and Ada stays ahead of Bilal because she came first in the input. An unstable sort would be free to swap them, and you would have no way to predict the order of ties.
Stability is what lets you sort by several keys in stages: sort by the least
important key first, then by the most important, and the earlier order survives inside each
group. Or, more directly, hand key a tuple and let Python compare left to right:
students = [
("maths", "Bilal"),
("art", "Cem"),
("maths", "Ada"),
("art", "Ada"),
]
# by subject A→Z, then by name A→Z within each subject
print(sorted(students, key=lambda s: (s[0], s[1])))
The tuple (subject, name) is compared position by position: subjects first,
and names only to break a tie on subject. To mix directions — say subject ascending but
grade descending — sort in two stable passes (grade first with reverse=True,
then subject), because a single tuple key cannot reverse just one of its parts.
13.9Try it yourself
Sort [5, 1, 4, 2, 8] by hand with bubble sort, writing out the list after
each pass, and count comparisons and swaps. Then run the code and check that your
numbers match its output exactly (9 comparisons, 4 swaps).
Run bubble_sort and selection_sort at n = 500, 1 000, 2 000,
4 000 on random data. Record comparisons and seconds for each, and compute the ratio
column for both.
Expected
Comparisons quadruple exactly; times quadruple approximately. Selection sort usually wins slightly on time despite identical comparison counts, because it does far fewer swaps — a constant-factor difference within the same class.
Run bubble sort on (a) already sorted data, (b) reverse-sorted data, (c) random data, at n = 2 000. Explain the three comparison counts.
Expected
(a) about 2 000 — one clean pass, then the early exit fires: O(n). (b) about 2 million — every comparison and every swap: the worst case. (c) roughly the worst case too, because a random list needs nearly all the passes. Selection sort shows no such variation at all.
Plot bubble sort, selection sort, insertion sort and sorted() on one
log–log figure across four sizes. Report the slope of each line in words, and state the
largest n you would be willing to hand each one.
Run insertion_sort on (a) already-sorted, (b) reverse-sorted, and (c) random
data at n = 2 000, reporting comparisons for each. Which input is its best case, and why
is the reverse-sorted input its worst?
Expected
(a) about 1 999 — every item is already in place, so each inner while stops
after a single comparison: O(n), its best case. (b) about 2 million — every item must
travel all the way to the front, shifting everything: the worst case, O(n²). (c) roughly
half the worst case. This sensitivity to how sorted the input already is is exactly why
Timsort reaches for insertion sort on short, nearly-ordered runs.
Build a list of (name, grade) tuples where several people share a grade.
Sort it by grade with sorted(..., key=lambda p: p[1]) and confirm that
same-grade people keep their input order. Then sort by (grade, name) and
describe, in one sentence, what changed and why.
Expected
With the single key, ties keep input order (that is stability). With the tuple key, ties are now broken by name alphabetically, because the second element of the tuple is consulted whenever the first is equal. Same sort, richer key.
13.10Self-check
Bubble sort on 4 000 random items does about 8 million comparisons. At 8 000 items, expect about:
Where does the "log n" in n log n come from?
data = data.sort() leaves data holding:
data.sort() alone, or data = sorted(data). This bug appears in every beginner cohort.Bubble sort and selection sort do the same number of comparisons. Why is selection sort often faster in practice?
In the doubling table, sorted()'s time multiplies by about 2.1 each time n doubles. That ratio is the fingerprint of:
A stable sort guarantees that:
13.11Homework
- Create
AA_Week13.ipynband complete Tasks 1–6. - Add insertion sort's comparison counts to the Task 4 figure so all three hand-written sorts and
sorted()appear together. - Reproduce the doubling table from §13.6 with your own timings, and state the ratio each algorithm settles on. Name the class of each from its ratio alone.
- Write the five-sentence interpretation for the Task 4 figure, including a prediction of bubble sort's runtime at n = 50 000 (do not run it — say how you extrapolated from the 4× ratio).
- Bring a working draft of your final project: both approaches implemented, at least one benchmark table produced. Week 14 is a workshop.
13.12Words from this week
| Term | Meaning in plain words |
|---|---|
| bubble sort | Repeatedly swap out-of-order neighbours. O(n²), O(n) on sorted data. |
| selection sort | Repeatedly pick the smallest remaining item. O(n²) always, few swaps. |
| insertion sort | Slide each item back into its place. O(n²) worst, O(n) on already-sorted data. |
| divide and conquer | Split the problem, solve the parts, combine — the source of log n levels. |
| merge | Combine two sorted lists into one in a single pass. |
| Timsort | Python's built-in sort: O(n log n) worst case, O(n) on already-ordered data. |
| stable sort | One that keeps equal items in their original order — lets you sort by keys in stages. |
| in place | Modifies the original rather than returning a new one (sort vs sorted). |
13.13Chapter problem set — Skiena 2.10
Sorting rested on a summation — adding up n(n−1)/2 comparisons — so this is a good week to practise reading a pattern and pinning down the expression behind it. Here is one of Skiena's, solved the way this course likes: spot the pattern, then say why.
Build a triangle in which every entry is the sum of the three entries directly above it (above-left, above, above-right — treat any missing neighbour as 0). It begins:
| row | entries | ||||||
|---|---|---|---|---|---|---|---|
| 1 | 1 | ||||||
| 2 | 1 1 1 | ||||||
| 3 | 1 2 3 2 1 | ||||||
| 4 | 1 3 6 7 6 3 1 | ||||||
Find an expression for the sum of the entries in the i-th row, and justify it.
Worked solution
Add up each row first and look for the pattern: row 1 sums to 1, row 2 to 1+1+1 = 3, row 3 to 1+2+3+2+1 = 9, row 4 to 1+3+6+7+6+3+1 = 27. That is 1, 3, 9, 27 — the powers of three. So the answer looks like 3i−1.
Now the why, which is the pretty part. Ask where each entry's value ends up in the next row down. An entry sitting in one row is one of the "three above" for exactly three positions in the row beneath it — the slot below-left of it, the slot directly below, and the slot below-right. So every entry contributes its full value to the next row three times over. Add that up across the whole row and you get:
(sum of next row) = 3 × (sum of this row)
The total triples at every step. Row 1 starts at 1, so row i is 1 tripled i−1 times — that is 3i−1. A short program confirms both the rows and their tripling totals:
def next_row(row):
padded = [0, 0] + row + [0, 0] # room for the row widening by one each side
return [padded[i] + padded[i + 1] + padded[i + 2] for i in range(len(row) + 2)]
row = [1]
for i in range(1, 6):
print(f"row {i}: sum {sum(row):>3} {row}")
row = next_row(row)Answer: the i-th row sums to 3i−1, because each entry feeds into three entries below it, so the grand total triples with every new row. The sums 1, 3, 9, 27, 81 are exactly the powers of three, and the reasoning — count how many times each value is reused — is the same "count the contributions" move that gave n(n−1)/2 for the sorts.
You now have the whole toolkit — measuring, naming, choosing, and the cost of the common structures. Week 14 ties it together with a checklist, the classic traps, and your final project.