Skip to content

Elementary Sorts

Welcome to Phase 4. Before reaching for sorted()sorted(), it’s worth building the three simplest sorting algorithms by hand at least once. None of them scale past a few thousand elements, but the ideas inside them — comparing neighbors, tracking a running minimum, shifting elements into place — show up again and again once you get to smarter algorithms like Merge Sort and Quick Sort.

What you’ll learn

  • Bubble sort — repeatedly swap adjacent out-of-order elements.
  • Selection sort — repeatedly pick the minimum of what’s left.
  • Insertion sort — build a sorted prefix one element at a time.
  • Why all three are O(n2)O(n^2) on average, and which one is actually useful in practice (hint: it’s not the one most people reach for first).
  • Stability — what it means, and which of these three preserve it.

Bubble sort: swap your way to sorted

Bubble sort walks the array left to right, comparing every pair of neighbors. If a pair is out of order, it swaps them. One full pass guarantees the largest remaining value “bubbles” all the way to the end, so each pass can safely ignore one more element at the tail.

bubble_sort.py
def bubble_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        swapped = False
        for j in range(n - 1 - i):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        if not swapped:      # nothing moved this pass -- already sorted
            break
    return arr
 
 
nums = [5, 3, 8, 1, 9, 2, 7, 4]
print(bubble_sort(nums))
bubble_sort.py
def bubble_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        swapped = False
        for j in range(n - 1 - i):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        if not swapped:      # nothing moved this pass -- already sorted
            break
    return arr
 
 
nums = [5, 3, 8, 1, 9, 2, 7, 4]
print(bubble_sort(nums))

The swappedswapped flag is the one optimization worth remembering: if a full pass makes zero swaps, the array is already sorted and the algorithm can stop early — that’s what gives bubble sort its best-case O(n)O(n) on already-sorted input.

Watch a full pass: the sorted suffix (green) grows by one element every time, and every comparison either swaps (red) or leaves things alone (amber):

sketch Bubble sort: one comparison at a time p5.js
Adjacent bars are compared (amber). If the left bar is bigger, they swap (red). Once a pass finishes, the largest remaining value has bubbled to the end -- that slot turns green and is never touched again.

Selection sort: pick the minimum, every time

Selection sort flips the logic: instead of bubbling large values to the back, it scans the unsorted remainder for its minimum and swaps that minimum into the front of the unsorted region. After n1n - 1 passes, the whole array is sorted.

selection_sort.py
def selection_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        min_idx = i
        for j in range(i + 1, n):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]
    return arr
 
 
nums = [5, 3, 8, 1, 9, 2, 7, 4]
print(selection_sort(nums))
selection_sort.py
def selection_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        min_idx = i
        for j in range(i + 1, n):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]
    return arr
 
 
nums = [5, 3, 8, 1, 9, 2, 7, 4]
print(selection_sort(nums))

Notice selection sort always does the full (n2)\binom{n}{2} comparisons, even if the array is already sorted — there’s no early-exit optimization like bubble sort’s swappedswapped flag. Its one redeeming feature is that it does at most n1n - 1 swaps total, which matters if writes are far more expensive than comparisons (e.g. sorting on flash memory).

Insertion sort: grow a sorted prefix

Insertion sort keeps the front of the array as a growing sorted region. For each new element, it shifts everything in the sorted prefix that’s bigger than the new value one slot to the right, then drops the new value into the gap — exactly how most people sort a hand of playing cards.

insertion_sort.py
def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]   # shift bigger element right
            j -= 1
        arr[j + 1] = key          # drop key into the gap
    return arr
 
 
nums = [5, 3, 8, 1, 9, 2, 7, 4]
print(insertion_sort(nums))
insertion_sort.py
def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]   # shift bigger element right
            j -= 1
        arr[j + 1] = key          # drop key into the gap
    return arr
 
 
nums = [5, 3, 8, 1, 9, 2, 7, 4]
print(insertion_sort(nums))

Time and space complexity

AlgorithmBestAverageWorstSpaceStable?
Bubble sortO(n)O(n)O(n2)O(n^2)O(n2)O(n^2)O(1)O(1)Yes
Selection sortO(n2)O(n^2)O(n2)O(n^2)O(n2)O(n^2)O(1)O(1)No
Insertion sortO(n)O(n)O(n2)O(n^2)O(n2)O(n^2)O(1)O(1)Yes

All three sort in place (O(1)O(1) extra space) and none of them beat O(n2)O(n^2) on average — that’s exactly the gap Merge Sort and Quick Sort close, next.

LeetCode problem set

#ProblemDifficultyThe twist
912Sort an ArrayMediumImplement any O(nlogn)O(n \log n) sort from scratch (a good excuse to compare against these O(n2)O(n^2) baselines)
147Insertion Sort ListMediumThe same shifting idea, but on a linked list instead of an array
1051Height CheckerEasyCount how many positions differ from the fully sorted order; a one-line use case once you have a working sort

Practice — real LeetCode problems

Each exercise is the actual LeetCode problem with its real method signature and LeetCode’s own examples as the test. Write the body, press Run, and match the expected output.

LC 905 — Sort Array By Parity · Easy

Problem. Move all the even integers before all the odd integers. Any such arrangement is accepted.

Constraints. 1 <= len(nums) <= 50001 <= len(nums) <= 5000, 0 <= nums[i] <= 50000 <= nums[i] <= 5000.

Examples. [3,1,2,4][3,1,2,4] gives [2,4,3,1][2,4,3,1] — and [4,2,3,1][4,2,3,1] is equally valid

Editorial

This is a partition, the elementary operation underneath quicksort: rearrange so everything satisfying a predicate comes first.

Time O(n)O(n), one pass. Space O(1)O(1).

The one-liner [n for n in nums if n % 2 == 0] + [n for n in nums if n % 2][n for n in nums if n % 2 == 0] + [n for n in nums if n % 2] is also correct and much shorter — worth saying — but it allocates O(n)O(n) and makes two passes. The two-pointer version is in place.

Note the pointers are not symmetric in what they skip: leftleft advances past evens (already correct), rightright retreats past odds (already correct), and only when both are looking at a misplaced value does a swap happen. Getting the parity checks backwards produces odds-first, which the property check catches.

Follow-ups: “Preserve the relative order within each group?” — that needs a stable partition, so the list-comprehension version, or O(n)O(n) extra space. “Sort by parity of the index too (LC 922)?” — two write pointers stepping by 2. “Three categories?” — the Dutch national flag partition, which is LC 75.

LC 75 — Sort Colors · Medium

Problem. Sort an array containing only 00, 11 and 22 in place, so that equal values are adjacent and in the order 0, 1, 20, 1, 2. You may not use a library sort, and should do it in one pass.

Constraints. 1 <= len(nums) <= 3001 <= len(nums) <= 300, values are 00, 11 or 22.

Examples. [2,0,2,1,1,0][2,0,2,1,1,0] gives [0,0,1,1,2,2][0,0,1,1,2,2] · [2,0,1][2,0,1] gives [0,1,2][0,1,2]

Editorial

The Dutch national flag partition maintains three regions: [0, low)[0, low) holds 00s, [low, i)[low, i) holds 11s, and (high, end](high, end] holds 22s. Everything in [i, high][i, high] is still unclassified.

Time O(n)O(n), one pass. Space O(1)O(1).

The asymmetry is the whole problem. When you swap a 00 forward, the value arriving at ii came from the 11s region and is therefore known to be a 11 — so ii can advance safely. When you swap a 22 backward, the value arriving at ii came from the unexamined region, so ii must stay put and inspect it.

Advancing ii in both branches passes [2,0,1][2,0,1] by luck and fails on inputs like [2,2,0][2,2,0], which is why [2,2,2][2,2,2] and [1,2,0][1,2,0] are in the tests.

The two-pass counting-sort answer — tally the three values, then overwrite — is simpler and perfectly acceptable unless one pass is required. Say it first.

Follow-ups: “Two passes with counting?” — have it ready as the baseline. ”kk colours instead of 3?” — counting sort generalises; the flag partition does not directly. “Why is this relevant to quicksort?” — it is the three-way partition that makes quicksort efficient on duplicate-heavy input, as in Quickselect.

LC 147 — Insertion Sort List · Medium

Problem. Sort a linked list using insertion sort and return the sorted head.

Constraints. 1 <= number of nodes <= 50001 <= number of nodes <= 5000, -5000 <= Node.val <= 5000-5000 <= Node.val <= 5000.

Examples. [4,2,1,3][4,2,1,3] gives [1,2,3,4][1,2,3,4] · [-1,5,3,4,0][-1,5,3,4,0] gives [-1,0,3,4,5][-1,0,3,4,5]

Editorial

Insertion sort on a list is arguably more natural than on an array: there is no shifting, only a splice. The dummy head means inserting at the front needs no special case.

Time O(n2)O(n^2) — each insertion scans the sorted prefix. Space O(1)O(1).

Two details:

  • Save nxtnxt before rewiring. head.nexthead.next is overwritten by the splice, so without saving it you lose the rest of the input.
  • Restart from dummydummy. A singly linked list cannot be scanned backwards, so every insertion begins at the front. That restart is what makes it quadratic — and it is inherent to insertion sort, not a flaw in the implementation.

An optimisation worth mentioning: if the incoming node is already larger than the current tail, append it directly without scanning. That makes nearly-sorted input close to O(n)O(n), which is exactly the property Timsort exploits at scale.

Follow-ups: “Sort it in O(nlogn)O(n \log n) instead?” — merge sort, which is LC 148 and the natural fit for lists. “Why is insertion sort still used?” — it is fast for small or nearly-sorted inputs, which is why real sorts fall back to it below a size threshold. “Insertion sort on an array?” — shifting instead of splicing, same O(n2)O(n^2).

Recap

  • Bubble sort swaps adjacent out-of-order elements; a swappedswapped flag gives it a best case of O(n)O(n) on sorted input.
  • Selection sort always scans for the true minimum and does at most n1n - 1 swaps total, but it’s not stable.
  • Insertion sort grows a sorted prefix by shifting; it’s stable and genuinely fast (O(n)O(n)) on nearly-sorted data.
  • All three are O(n2)O(n^2) on average and worst case, with O(1)O(1) extra space.

Next: Merge Sort — the first algorithm in this course to break past O(n2)O(n^2), using divide and conquer to guarantee O(nlogn)O(n \log n) every time.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did