The Simple (Slow) Sorts
Bubble, selection, and insertion sort are the ones you’d invent yourself — and understanding why they’re slow teaches what fast sorts avoid.
The problem
Hand someone a shuffled deck of thirteen cards and say "put these in order." Watch what they actually do. Almost nobody invents merge sort on the spot. They pick up cards one at a time and slot each into the right place among the ones already in their hand. Or they scan for the lowest card, pull it out, then scan again. These are real algorithms — you just ran one in your head.
That instinct is worth studying, because the sorts you'd reinvent from scratch are also the slow ones. Seeing exactly where they bog down is the fastest way to understand what the clever sorts are so careful to avoid.
A first attempt
Take bubble sort, the most literal idea of all: repeatedly walk the list and swap any two neighbours that are out of order. Big values "bubble" toward the end one pass at a time.
def bubble_sort(nums):
n = len(nums)
for i in range(n):
for j in range(n - 1 - i):
if nums[j] > nums[j + 1]:
nums[j], nums[j + 1] = nums[j + 1], nums[j]
return numsTwo nested loops over n elements is O(n²). Double the input and the work quadruples.
Selection sort (repeatedly find the minimum and place it) has the same O(n²) shape. They
share one fatal habit: they compare and move the same elements over and over, throwing away
what they learned on the previous pass.
The insight
Not all O(n²) sorts are equally dumb. Insertion sort keeps the left part of the array
already sorted and grows it one element at a time — exactly the card-in-hand move. To
place the next element, it slides it leftward past everything larger until it lands.
The payoff is that insertion sort does almost no work when the data is already close to
sorted: each new element barely moves. On nearly-ordered input it runs in O(n), not
O(n²). That single property — being adaptive — is why real libraries use insertion sort
for small or almost-sorted slices even today.
Stability, quietly important
Insertion, bubble, and selection-with-care can all be stable: equal elements keep their original relative order. That matters when you sort records by one field after another (sort by name, then by date). Keep an eye on it as you meet faster sorts that lose it.
How it works
We'll focus on insertion sort — the one worth keeping.
Treat the first element as sorted
A single element is trivially in order. That one-element prefix is your sorted region.
Take the next element as the "key"
Look at the first unsorted element to its right. Hold onto its value — this is what you're inserting.
Slide larger elements right
Walk leftward through the sorted region. Every element bigger than the key shifts one slot right, opening a gap.
Drop the key into the gap
When you hit an element that isn't bigger (or the start), place the key there. The sorted region just grew by one. Repeat until the whole array is consumed.
Sorting [5, 2, 4, 1], with | marking the boundary of the sorted region:
[5 | 2 4 1] key=2 → slides past 5 → [2 5 | 4 1]
[2 5 | 4 1] key=4 → slides past 5 → [2 4 5 | 1]
[2 4 5 | 1] key=1 → slides past 5,4,2 → [1 2 4 5 |]The code
def insertion_sort(nums):
for i in range(1, len(nums)):
key = nums[i]
j = i - 1
while j >= 0 and nums[j] > key:
nums[j + 1] = nums[j] # slide right
j -= 1
nums[j + 1] = key # drop into the gap
return numsfunction insertionSort(nums: number[]): number[] {
for (let i = 1; i < nums.length; i++) {
const key = nums[i];
let j = i - 1;
while (j >= 0 && nums[j] > key) {
nums[j + 1] = nums[j]; // slide right
j--;
}
nums[j + 1] = key; // drop into the gap
}
return nums;
}void insertionSort(int[] nums) {
for (int i = 1; i < nums.length; i++) {
int key = nums[i];
int j = i - 1;
while (j >= 0 && nums[j] > key) {
nums[j + 1] = nums[j]; // slide right
j--;
}
nums[j + 1] = key; // drop into the gap
}
}void insertion_sort(int *nums, int n) {
for (int i = 1; i < n; i++) {
int key = nums[i];
int j = i - 1;
while (j >= 0 && nums[j] > key) {
nums[j + 1] = nums[j]; /* slide right */
j--;
}
nums[j + 1] = key; /* drop into the gap */
}
}#include <vector>
using namespace std;
void insertionSort(vector<int> &nums) {
for (size_t i = 1; i < nums.size(); i++) {
int key = nums[i];
int j = (int)i - 1;
while (j >= 0 && nums[j] > key) {
nums[j + 1] = nums[j]; // slide right
j--;
}
nums[j + 1] = key; // drop into the gap
}
}Complexity
| Sort | Best | Average / Worst | Space | Stable |
|---|---|---|---|---|
| Bubble | O(n) | O(n²) | O(1) | yes |
| Selection | O(n²) | O(n²) | O(1) | no* |
| Insertion | O(n) | O(n²) | O(1) | yes |
Insertion and bubble hit their O(n) best case only on already-sorted input. Selection
always does O(n²) comparisons regardless — it never gets to quit early.
When to use it
Small and nearly-sorted is their home turf
For big random data these are the wrong tool — use merge sort or quicksort. But insertion sort genuinely wins on small arrays (roughly n ≤ 16) and on nearly-sorted data, thanks to tiny constants and its adaptive best case. That's why industrial hybrid sorts fall back to it for small partitions. Selection sort's one merit is that it makes the fewest writes, useful when writing to memory is expensive.
Practice
Recap
- Bubble, selection, and insertion are the sorts you'd invent yourself — all
O(n²)on general data because they rework the same elements repeatedly. - Insertion sort is the keeper: it's stable, in-place, and adaptive, hitting
O(n)on nearly-sorted input. - Their slowness motivates the divide-and-conquer sorts — understanding the
O(n²)trap is what makesO(n log n)feel like a real achievement.
How is this guide?
Last updated on