Week 13 · Phase 4 · Choosing well

Sorting: Why Some Sorts Are Slow

Bubble and selection sort by hand, then Python's sorted() — n² vs n log n on a plot.

Big question: Why is the sort you invent yourself so much slower than the built-in one?sorting≈3 hours
By the end of this week you can
  • 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, sorted and key= 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.

bubble_sort.py
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]))
([1, 2, 4, 5, 8], 9, 4)

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.

selection_sort.py
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.

insertion_sort.py
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]))
([1, 2, 4, 5, 8], 7, 4)

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 passSelection — after each roundInsertion — 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, stoprest already in placeinsert 2 → [1,2,4,5,8]; insert 8 → done
9 comparisons, 4 swaps10 comparisons, 2 swaps7 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:

  1. Split the list into two halves.
  2. Sort each half (by the same method, on smaller pieces).
  3. 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.

nn² comparisonsn log₂ n comparisonsHow much better
10010 00066415×
1 0001 000 0009 966100×
10 000100 000 000132 877753×
1 000 00010¹²19 931 56950 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

your sort vs the real one
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!
n= 1000 bubble 0.0921s (499,500 comparisons) sorted() 0.00023s 401x n= 2000 bubble 0.3701s (1,999,000 comparisons) sorted() 0.00048s 771x n= 4000 bubble 1.4890s (7,998,000 comparisons) sorted() 0.00104s 1,432x n= 8000 bubble 5.9612s (31,996,000 comparisons) sorted() 0.00228s 2,615x

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 doublesbubble time × …sorted() time × …what the ratio says
1000 → 20000.37 / 0.09 ≈ 4.0×0.00048 / 0.00023 ≈ 2.1×4× is n²; 2.1× is n log n
2000 → 40001.49 / 0.37 ≈ 4.0×0.00104 / 0.00048 ≈ 2.2×same story, one class down
4000 → 80005.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.

What Python actually uses

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

the everyday toolkit
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
The classic beginner bug

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.

equal keys keep their order
scores = [("Ada", 88), ("Bilal", 88), ("Cem", 72)]

print(sorted(scores, key=lambda p: p[1], reverse=True))
[('Ada', 88), ('Bilal', 88), ('Cem', 72)]

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:

two keys at once
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])))
[('art', 'Ada'), ('art', 'Cem'), ('maths', 'Ada'), ('maths', 'Bilal')]

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

Task 1 — on paper first

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).

Task 2 — confirm the class

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.

Task 3 — best and worst case

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.

Task 4 — the honest comparison plot

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.

Task 5 — insertion sort loves order

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.

Task 6 — stability you can see

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:

Quadratic: doubling n quadruples the comparisons.

Where does the "log n" in n log n come from?

Split, sort halves, merge: log n levels of splitting, each doing O(n) merging work.

data = data.sort() leaves data holding:

Use 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?

Same class, different constant. This is exactly why we report both the class and the measurement.

In the doubling table, sorted()'s time multiplies by about 2.1 each time n doubles. That ratio is the fingerprint of:

A pure O(n) line would double (2.0×); O(n²) would quadruple (4×). Just above 2 is the signature of n log n.

A stable sort guarantees that:

Stability is about ties, not speed. It is what lets you sort by several keys in stages, each pass preserving the last.

13.11Homework

Due before week 14 — about three hours
  1. Create AA_Week13.ipynb and complete Tasks 1–6.
  2. Add insertion sort's comparison counts to the Task 4 figure so all three hand-written sorts and sorted() appear together.
  3. 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.
  4. 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).
  5. 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

TermMeaning in plain words
bubble sortRepeatedly swap out-of-order neighbours. O(n²), O(n) on sorted data.
selection sortRepeatedly pick the smallest remaining item. O(n²) always, few swaps.
insertion sortSlide each item back into its place. O(n²) worst, O(n) on already-sorted data.
divide and conquerSplit the problem, solve the parts, combine — the source of log n levels.
mergeCombine two sorted lists into one in a single pass.
TimsortPython's built-in sort: O(n log n) worst case, O(n) on already-ordered data.
stable sortOne that keeps equal items in their original order — lets you sort by keys in stages.
in placeModifies 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.

Problem 2-33 · a self-referential triangle

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:

rowentries
11
21  1  1
31  2  3  2  1
41  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:

triangle_rows.py
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)
row 1: sum 1 [1] row 2: sum 3 [1, 1, 1] row 3: sum 9 [1, 2, 3, 2, 1] row 4: sum 27 [1, 3, 6, 7, 6, 3, 1] row 5: sum 81 [1, 4, 10, 16, 19, 16, 10, 4, 1]

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.

Where this leads

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.