Learning Goals
3 minBy the end of this lesson you can:
- Implement bubble sort with a nested loop.
- Apply two optimisations — outer-bound shrink, early-exit on no swaps.
- Explain why bubble sort is O(n²) — and what that means for big lists.
- Know when not to use it (almost always — but it's great for tiny lists or when a list is almost sorted).
Warm-Up · The Bubbling Idea
5 minStart: [5, 3, 8, 1, 4]
↕
Pass 1: [3, 5, 8, 1, 4] compare (5,3) swap, (5,8) ok, (8,1) swap, (8,4) swap
[3, 5, 1, 8, 4]
[3, 5, 1, 4, 8] 8 has "bubbled" to the end
Pass 2: [3, 1, 5, 4, 8]
[3, 1, 4, 5, 8] 5 has bubbled to its position
Pass 3: [1, 3, 4, 5, 8] 3 bubbled left, 4 settled
Pass 4: [1, 3, 4, 5, 8] no swaps — DONEEach pass moves the largest unsorted item to its final position. After N-1 passes, everything is in order.
Bubble sort is dumb but correct. It's the canonical "O(n²) baseline" — every other sort is judged against it. Watching it run teaches you what sorting is.
New Concept · Two Loops + Two Optimisations
14 minNaïve version
def bubble_sort(lst): n = len(lst) for i in range(n): for j in range(n - 1): if lst[j] > lst[j + 1]: lst[j], lst[j + 1] = lst[j + 1], lst[j] data = [5, 3, 8, 1, 4] bubble_sort(data) print(data) # [1, 3, 4, 5, 8]
Outer loop runs N times. Inner loop scans N-1 pairs. Swap when out of order. N² comparisons in the worst case.
Optimisation 1 · Shrink the inner loop
After pass i, the last i items are guaranteed sorted. No need to scan them again.
def bubble_sort_v2(lst): n = len(lst) for i in range(n - 1): for j in range(n - 1 - i): # ← stop earlier each pass if lst[j] > lst[j + 1]: lst[j], lst[j + 1] = lst[j + 1], lst[j]
Roughly halves the work. Still O(n²) — but with a smaller constant.
Optimisation 2 · Early exit on no swaps
If a pass made no swaps, the list is already sorted. Stop.
def bubble_sort_v3(lst): n = len(lst) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if lst[j] > lst[j + 1]: lst[j], lst[j + 1] = lst[j + 1], lst[j] swapped = True if not swapped: return # sorted!
Best case (already sorted) becomes O(n) — one full pass with no swaps, then exit. Worst case (reverse-sorted) is still O(n²).
Pure-function version
If you want sorted output without mutating the input, return a new list:
def bubble_sorted(lst): result = list(lst) # copy bubble_sort_v3(result) return result
Tracking comparisons and swaps
def bubble_sort_traced(lst): n = len(lst) comparisons = 0 swaps = 0 for i in range(n - 1): swapped = False for j in range(n - 1 - i): comparisons += 1 if lst[j] > lst[j + 1]: lst[j], lst[j + 1] = lst[j + 1], lst[j] swaps += 1 swapped = True if not swapped: break return comparisons, swaps
Useful for empirical complexity verification.
When it's actually OK to use bubble sort
- Lists shorter than ~20 items — overhead of cleverer sorts isn't worth it.
- Lists that are already nearly sorted — with the early-exit, it's near-O(n).
- Teaching. (Today.)
For anything bigger, use Python's built-in sorted() — which uses Timsort, an algorithmically optimal hybrid. We'll meet it in PY-L3-41.
Worked Example · Watch It Sort
12 minSave as bubble_visual.py:
# bubble_visual.py — print each pass to see the bubbling def bubble_sort_visual(lst): n = len(lst) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if lst[j] > lst[j + 1]: lst[j], lst[j + 1] = lst[j + 1], lst[j] swapped = True print(f" Pass {i + 1}: {lst}") if not swapped: print(f" (early exit — sorted after pass {i + 1})") return data = [5, 1, 4, 2, 8, 3] print(f"Start: {data}") bubble_sort_visual(data) print(f"Final: {data}")
Output
Start: [5, 1, 4, 2, 8, 3] Pass 1: [1, 4, 2, 5, 3, 8] Pass 2: [1, 2, 4, 3, 5, 8] Pass 3: [1, 2, 3, 4, 5, 8] Pass 4: [1, 2, 3, 4, 5, 8] (early exit — sorted after pass 4) Final: [1, 2, 3, 4, 5, 8]
Read the diff
Each pass shows the "biggest unsorted item" moving to the right. Pass 1: 8 reaches the end. Pass 2: 5 is in place. Pass 3: list is fully sorted. Pass 4 confirms (no swaps) and exits. Six items, four passes — way fewer than the worst-case 30.
Try It Yourself
13 minOn paper, simulate bubble sort on [3, 1, 4, 1, 5, 9, 2, 6]. Show each pass. Count comparisons and swaps. Then run your code; confirm.
Modify bubble sort to sort in descending order. Change one operator.
Hint
def bubble_sort_desc(lst): n = len(lst) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if lst[j] < lst[j + 1]: # changed > to < lst[j], lst[j + 1] = lst[j + 1], lst[j] swapped = True if not swapped: return data = [5, 1, 4, 2, 8, 3] bubble_sort_desc(data) print(data) # [8, 5, 4, 3, 2, 1]
One operator change. The rest is identical.
Generalise to accept a key function. bubble_sort(lst, key=lambda x: x[1]) sorts by the second element of each item.
Hint
def bubble_sort_key(lst, key=lambda x: x): n = len(lst) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if key(lst[j]) > key(lst[j + 1]): lst[j], lst[j + 1] = lst[j + 1], lst[j] swapped = True if not swapped: return pairs = [("apple", 3), ("cherry", 1), ("banana", 2)] bubble_sort_key(pairs, key=lambda x: x[1]) print(pairs) # [('cherry', 1), ('banana', 2), ('apple', 3)]
Default key is identity (lambda x: x). Same shape as Python's built-in sorted's key=.
Mini-Challenge · Complexity Verification
8 minBuild bubble_complexity.py. Time bubble sort on random lists of sizes 100, 200, 400, 800, 1600. Plot mentally — time should roughly quadruple when N doubles. That's O(n²).
Show one possible solution
# bubble_complexity.py — confirm O(n²) import random, time def bubble_sort(lst): n = len(lst) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if lst[j] > lst[j + 1]: lst[j], lst[j + 1] = lst[j + 1], lst[j] swapped = True if not swapped: return print(f"{'N':<8}{'time (ms)':>12}{'ratio':>8}") prev = None for n in [100, 200, 400, 800, 1600]: data = [random.randint(0, 10000) for _ in range(n)] start = time.perf_counter() bubble_sort(data) elapsed_ms = (time.perf_counter() - start) * 1000 ratio = f"{elapsed_ms / prev:.1f}x" if prev else "—" print(f"{n:<8}{elapsed_ms:>12.2f}{ratio:>8}") prev = elapsed_ms
Non-negotiables: timing for 5 sizes, ratio per doubling. The ratio should be close to 4 — doubling N quadruples the time. That's O(n²) in empirical form.
Recap
3 minBubble sort: compare neighbours, swap if wrong order, repeat. Two simple optimisations — shrink the inner loop each pass, early-exit when no swaps. O(n²) average and worst case; O(n) when nearly sorted. Memorable, easy to teach, almost never the right choice in production. Tomorrow brings insertion sort — same complexity class but better in practice for nearly-sorted data.
Vocabulary Card
- bubble sort
- Repeatedly swap adjacent out-of-order pairs. O(n²) average.
- pass
- One full scan of the unsorted portion.
- early exit
- If a pass had no swaps, the list is sorted — return immediately.
- in-place sort
- Modifies the list it's given. No extra storage. Bubble sort is in-place.
Homework
4 minBuild bubble_kit.py:
- Standard
bubble_sort(lst)with both optimisations. bubble_sort_key(lst, key)from the stretch exercise.cocktail_sort(lst)— a variant that alternates direction each pass (also called bidirectional bubble sort). Roughly twice as fast as plain bubble.
Sample · cocktail sort
def cocktail_sort(lst): n = len(lst) lo, hi = 0, n - 1 swapped = True while swapped: swapped = False # Forward pass — bubble largest to right for j in range(lo, hi): if lst[j] > lst[j + 1]: lst[j], lst[j + 1] = lst[j + 1], lst[j] swapped = True if not swapped: break hi -= 1 # Backward pass — bubble smallest to left swapped = False for j in range(hi, lo, -1): if lst[j] < lst[j - 1]: lst[j], lst[j - 1] = lst[j - 1], lst[j] swapped = True lo += 1 data = [5, 1, 4, 2, 8, 3] cocktail_sort(data) print(data) # [1, 2, 3, 4, 5, 8]
Non-negotiables: standard bubble (both opts), key-based bubble, cocktail bidirectional. Cocktail handles "turtles" — small values near the end — way faster than plain bubble.