Sorting Algorithms I

Four ways to put a list in order, and why the "obvious" O(n²) approaches still matter next to the faster O(n log n) one

Introduction

Sorting is one of the oldest problems in computer science, and it's a perfect place to put Lesson 1's Big-O classes to work side by side. Bubble sort, selection sort, and insertion sort are all O(n²), simple to write, and easy to reason about, a genuinely fine trade-off for small input. Bubble sort and insertion sort go further and can finish in O(n) when the data is already close to sorted, a shortcut selection sort never gets, it stays O(n²) no matter what. Merge sort gives up that simplicity for a guaranteed O(n log n) regardless of input order, using the exact divide-and-conquer recursion from Lesson 5. Insertion sort and merge sort in particular are still doing real work today, both are core pieces of Python's own Timsort.

Bubble Sort

Repeatedly walk the list, swapping any adjacent pair that's out of order. Each full pass "bubbles" the largest remaining value to its final position at the end, so after k passes, the last k elements are guaranteed sorted and never touched again.

def bubble_sort(items: list[int]) -> list[int]:
    n = len(items)
    for i in range(n):
        swapped = False
        for j in range(n - i - 1):
            if items[j] > items[j + 1]:
                items[j], items[j + 1] = items[j + 1], items[j]
                swapped = True
        if not swapped:
            break   # nothing moved this pass, the list is already sorted
    return items
print(bubble_sort([5, 3, 8, 1, 9, 2]))
Expected Output:
[1, 2, 3, 5, 8, 9]

Each pass walks the list once, and by the end of it the largest value still in play has "bubbled" to its final slot. Watch that sorted tail (green) grow from the right, one element per pass:

Bubble Sort on [5, 3, 8, 1, 9, 2]
5
3
8
1
9
2
Unsorted input
3
5
1
8
2
9
Pass 1: the largest value, 9, bubbles to the final slot
3
1
5
2
8
9
Pass 2: 8 bubbles into place; the last two are final
1
3
2
5
8
9
Pass 3: 5 settles; the last three are final
1
2
3
5
8
9
Pass 4: 3 settles; the last four are final
1
2
3
5
8
9
Pass 5: no swaps happen, so the early-exit check ends the sort
Figure 1: The largest unsorted value bubbles to its final position (green) each pass. Pass 5 makes no swaps, triggering the early exit.
The Early-Exit Check Matters

If a full pass makes zero swaps, the list is already sorted and every remaining pass would be wasted work. That swapped flag turns bubble sort's best case (already-sorted input) into O(n) instead of a needless O(n²).

Selection Sort

Find the smallest remaining value and swap it into its correct position, then repeat for the rest of the list. Where bubble sort does many small swaps, selection sort does at most one swap per pass, at the cost of always scanning the entire unsorted remainder to find that minimum.

def selection_sort(items: list[int]) -> list[int]:
    n = len(items)
    for i in range(n):
        min_index = i
        for j in range(i + 1, n):
            if items[j] < items[min_index]:
                min_index = j
        items[i], items[min_index] = items[min_index], items[i]
    return items
print(selection_sort([5, 3, 8, 1, 9, 2]))
Expected Output:
[1, 2, 3, 5, 8, 9]

Where bubble sort grew a sorted region from the right, selection sort grows one from the left. Each pass finds the smallest remaining value and swaps it into the next position, so the green prefix extends by one every time:

Selection Sort on [5, 3, 8, 1, 9, 2]
5
3
8
1
9
2
Unsorted input
1
3
8
5
9
2
Scan positions 0-5: smallest is 1, swap it to the front
1
2
8
5
9
3
Scan 1-5: smallest is 2, swap to position 1
1
2
3
5
9
8
Scan 2-5: smallest is 3, swap to position 2
1
2
3
5
9
8
Scan 3-5: smallest is 5, already in place (no swap)
1
2
3
5
8
9
Scan 4-5: smallest is 8, swap to position 4; the last element is sorted too
Figure 2: One swap per pass, but the full remaining tail is scanned every time to find the minimum, which is why selection sort can never finish early.

Because it always scans to the end looking for the minimum, selection sort can't detect an already-sorted list early the way bubble sort can, it's O(n²) in the best, average, and worst case alike.

Insertion Sort

Build the sorted portion one element at a time, growing from the left. Each new element gets shifted backward, exactly Lesson 2's O(n) front-insertion cost, until it lands in its correct spot among everything already sorted.

def insertion_sort(items: list[int]) -> list[int]:
    for i in range(1, len(items)):
        key = items[i]
        j = i - 1
        while j >= 0 and items[j] > key:
            items[j + 1] = items[j]   # shift larger elements one slot right
            j -= 1
        items[j + 1] = key            # drop key into the gap it opened up
    return items
print(insertion_sort([5, 3, 8, 1, 9, 2]))
Expected Output:
[1, 2, 3, 5, 8, 9]

Insertion sort also builds a sorted prefix from the left, but by a different move: it takes the next element and slides it back into its correct spot among the already-sorted ones. When an element is already in order (like 8 and 9 below), it barely moves:

Insertion Sort on [5, 3, 8, 1, 9, 2]
5
3
8
1
9
2
The first element alone is a sorted prefix of length 1
3
5
8
1
9
2
Insert 3 into the prefix, shifting 5 right
3
5
8
1
9
2
8 is already larger than everything before it, no shift
1
3
5
8
9
2
Insert 1, shifting 3, 5, 8 right
1
3
5
8
9
2
9 is already in order, no shift
1
2
3
5
8
9
Insert 2, shifting 3, 5, 8, 9 right
Figure 3: Each new value is inserted into the sorted prefix by shifting larger elements right. Nearly-sorted input means almost no shifting, which is insertion sort's O(n) best case.
Best Case: Nearly-Sorted Data

If each new element is already close to where it belongs, the inner while loop barely runs. On already-sorted input, insertion sort is O(n), which is exactly why real-world sorts (including Python's own sorted(), built on Timsort) fall back to an insertion-sort-style pass for small or nearly-ordered chunks of data.

Merge Sort

All three algorithms above are O(n²) because they only ever compare and shift elements one at a time. Merge sort takes a completely different approach: split the list in half recursively (Lesson 5's call stack again) until each piece has one element, trivially sorted, then merge sorted pieces back together two at a time.

Merge Sort on [5, 3, 8, 1]

[5,3,8,1]→ [1,3,5,8][5,3]→ [3,5][8,1]→ [1,8][5][3][8][1]

Figure 4: Split all the way down to single elements, then merge sorted pairs back up

def merge_sort(items: list[int]) -> list[int]:
    if len(items) <= 1:
        return items
    mid = len(items) // 2
    left = merge_sort(items[:mid])
    right = merge_sort(items[mid:])
    return merge(left, right)

def merge(left: list[int], right: list[int]) -> list[int]:
    result: list[int] = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:       # "<=", not "<": keeps equal keys stable
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    result.extend(left[i:])
    result.extend(right[j:])
    return result
print(merge_sort([5, 3, 8, 1]))
Expected Output:
[1, 3, 5, 8]

The splitting is the easy half; the real work is the merge. Here is the final merge from the figure above, combining the two sorted halves [3, 5] and [1, 8]. Each step compares the two front elements and takes the smaller one, so the result comes out sorted:

Left remainingRight remainingCompareTakeResult
[3, 5][1, 8]1 < 31 (right)[1]
[3, 5][8]3 ≤ 83 (left)[1, 3]
[5][8]5 ≤ 85 (left)[1, 3, 5]
[ ][8]left is empty8 (rest of right)[1, 3, 5, 8]

Figure 5: One full merge does a single linear pass over both halves, which is the O(n) work done at each of the log n levels.

The split happens log₂(n) times, and merging all the pieces back together at each level costs O(n) total, exactly the same O(n log n) shape as Lesson 1's example of linearithmic growth.

Complexity at a Glance

AlgorithmBestAverageWorstExtra Space
Bubble SortO(n)O(n²)O(n²)O(1)
Selection SortO(n²)O(n²)O(n²)O(1)
Insertion SortO(n)O(n²)O(n²)O(1)
Merge SortO(n log n)O(n log n)O(n log n)O(n)

Merge sort's guaranteed O(n log n) comes at a real cost: unlike the other three, it needs O(n) extra space to hold the merged results, it can't sort purely by swapping within the original list.

Stability: The Property Big-O Doesn't Capture

A sort is stable if elements that compare equal keep their original relative order. This matters whenever you're sorting by one field but care about another, sorting orders by date should never scramble same-day orders that were already sorted by customer name.

Selection sort's swap-based approach breaks this: swapping the minimum into place can leapfrog it past an equal element that was already correctly positioned.

# Sort by score only; letter is just there to reveal original order
data = [(3, "a"), (1, "b"), (3, "c"), (2, "d"), (1, "e"), (3, "f")]

def selection_sort_by_score(items):
    n = len(items)
    for i in range(n):
        min_index = i
        for j in range(i + 1, n):
            if items[j][0] < items[min_index][0]:
                min_index = j
        items[i], items[min_index] = items[min_index], items[i]
    return items

print(selection_sort_by_score(data.copy()))
Expected Output:
[(1, 'b'), (1, 'e'), (2, 'd'), (3, 'c'), (3, 'a'), (3, 'f')]

Notice the two entries scoring 3, "a" and "c", ended up swapped from their original order, "c" now comes before "a". Bubble sort, insertion sort, and merge sort (thanks to that <= in merge) are all stable; selection sort is the one exception among the four in this lesson.

Key Takeaways

  • Bubble, selection, and insertion sort are all O(n²), but only bubble and insertion sort can finish in O(n) on already (or nearly) sorted input
  • Selection sort is O(n²) no matter what, it always scans the full remainder to find the minimum, so it can't shortcut on easy input
  • Merge sort guarantees O(n log n) by trading away in-place sorting for O(n) extra space
  • Stability means equal elements keep their relative order - bubble, insertion, and merge sort preserve it, selection sort doesn't
  • "Better Big-O" isn't the only axis that matters - space, stability, and best-case behavior all factor into which sort actually fits a given job