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 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.
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))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 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):
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 passes, the whole array is sorted.
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))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 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 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.
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))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
| Algorithm | Best | Average | Worst | Space | Stable? |
|---|---|---|---|---|---|
| Bubble sort | Yes | ||||
| Selection sort | No | ||||
| Insertion sort | Yes |
All three sort in place ( extra space) and none of them beat on average — that’s exactly the gap Merge Sort and Quick Sort close, next.
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 912 | Sort an Array | Medium | Implement any sort from scratch (a good excuse to compare against these baselines) |
| 147 | Insertion Sort List | Medium | The same shifting idea, but on a linked list instead of an array |
| 1051 | Height Checker | Easy | Count 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 , one pass. Space .
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 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 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 , one pass. Space .
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 — each insertion scans the sorted prefix. Space .
Two details:
- Save
nxtnxtbefore rewiring.head.nexthead.nextis 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 , which is exactly the property Timsort exploits at scale.
Follow-ups: “Sort it in 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 .
Recap
- Bubble sort swaps adjacent out-of-order elements; a
swappedswappedflag gives it a best case of on sorted input. - Selection sort always scans for the true minimum and does at most swaps total, but it’s not stable.
- Insertion sort grows a sorted prefix by shifting; it’s stable and genuinely fast () on nearly-sorted data.
- All three are on average and worst case, with extra space.
Next: Merge Sort — the first algorithm in this course to break past , using divide and conquer to guarantee every time.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
