🃏 Analogy
Sorting a shuffled deck two ways. Merge sort: split the deck into tiny piles, then repeatedly merge two sorted piles into one bigger sorted pile — calm, predictable, but you need spare table space for the piles. Quicksort: pick a card, slap everything smaller to its left and bigger to its right, then sort each side the same way — no spare space, blazing fast, but pick a bad card every time and it crawls.
The O(n log n) wall
Any sort that works by comparing pairs of elements has a hard floor: there are n! possible orderings of n items, and each comparison gives you one bit — true or false — so it takes at least log₂(n!) ≈ n log n comparisons to pin down the one correct order. No clever comparison sort beats that asymptotically; merge sort and a well-pivoted quicksort both hit it.
(Specialized non-comparison sorts like counting or radix sort can do O(n) — but only by exploiting structure in the keys, not by comparing.)
Merge sort: stable, predictable, O(n) space
Merge sort splits the slice in half, sorts each half recursively, then merges the two sorted halves by repeatedly taking the smaller front element. It’s stable (equal elements keep their original order) and always O(n log n) — but it needs O(n) scratch space for the merge.
graph TD A["[5, 2, 8, 1]"] --> B["[5, 2]"] A --> C["[8, 1]"] B --> D["[2, 5]"] C --> E["[1, 8]"] D --> F["merge → [1, 2, 5, 8]"] E --> F
package main
import "fmt"
// mergeSort returns a new sorted slice. Stable, O(n log n) time, O(n) space.
func mergeSort(a []int) []int {
if len(a) <= 1 {
return a
}
mid := len(a) / 2
left := mergeSort(a[:mid])
right := mergeSort(a[mid:])
return merge(left, right)
}
// merge combines two sorted slices into one sorted slice.
func merge(left, right []int) []int {
out := make([]int, 0, len(left)+len(right))
i, j := 0, 0
for i < len(left) && j < len(right) {
// <= keeps equal elements in order, so the sort is stable.
if left[i] <= right[j] {
out = append(out, left[i])
i++
} else {
out = append(out, right[j])
j++
}
}
out = append(out, left[i:]...) // drain whatever is left
out = append(out, right[j:]...)
return out
}
func main() {
a := []int{5, 2, 8, 1, 9, 3, 7, 4, 6}
fmt.Println("input: ", a)
fmt.Println("sorted:", mergeSort(a))
}
Quicksort: in-place, fast average, risky worst case
Quicksort picks a pivot, then partitions the slice so everything smaller sits left of the pivot and everything larger sits right — the pivot lands in its final spot. It then recurses on each side. It sorts in place (O(log n) stack space) and is usually the fastest in practice because it’s cache-friendly.
The catch is the pivot. A balanced split gives O(n log n); a consistently lopsided one (e.g. always picking the smallest element on already-sorted data) degrades to O(n²). Real implementations defend against this with randomized or median-of-three pivots — and Go’s goes further, falling back to heapsort if it detects bad behavior.
// In-place quicksort (Lomuto partition). Average O(n log n), worst O(n²).
func quickSort(a []int, lo, hi int) {
if lo >= hi {
return
}
pivot := a[hi] // last element as pivot (simple, but vulnerable to sorted input)
i := lo
for j := lo; j < hi; j++ {
if a[j] < pivot {
a[i], a[j] = a[j], a[i] // swap small element left
i++
}
}
a[i], a[hi] = a[hi], a[i] // put pivot in its final place
quickSort(a, lo, i-1)
quickSort(a, i+1, hi)
}
In real Go, you call the stdlib
You almost never write the loops above in production. The standard library’s sort is a tuned introsort (a pattern-defeating quicksort that falls back to heapsort to guarantee O(n log n), with insertion sort for tiny ranges). Two front doors:
slices.Sort(Go 1.21+) for ordered element types, andslices.SortFuncwith a comparison function for anything else.sort.Slice, the older reflection-based form, taking aless(i, j)closure.
Neither is stable; use slices.SortStableFunc or sort.SliceStable when equal elements must keep their input order.
package main
import (
"fmt"
"slices"
"sort"
)
type Person struct {
Name string
Age int
}
func main() {
people := []Person{
{"Bob", 31},
{"Ada", 36},
{"Cleo", 31},
{"Dan", 24},
}
// slices.SortFunc: sort by Age ascending. cmp.Compare-style: <0, 0, >0.
slices.SortFunc(people, func(a, b Person) int {
return a.Age - b.Age
})
fmt.Println("by age: ", people)
// sort.Slice: the older closure form. Sort by Name ascending.
sort.Slice(people, func(i, j int) bool {
return people[i].Name < people[j].Name
})
fmt.Println("by name:", people)
}
⚠️ The stdlib sort is not stable
slices.Sort, slices.SortFunc, and sort.Slice may reorder elements that compare equal — handy when you sort by Age, then expect ties still grouped by a previous Name order. They won’t be. Reach for slices.SortStableFunc or sort.SliceStable when stability matters (at a small constant-factor cost). Also: slices.SortFunc’s callback returns an int (negative / zero / positive), while sort.Slice’s returns a bool (less) — mixing them up is a common compile error.
See also
- binary search — what sorting unlocks: O(log n) lookups.
- heaps — heapsort, the O(1)-space O(n log n) fallback introsort uses.
- Big-O — the n log n comparison floor and where it comes from.
- two pointers — the merge step is a two-pointer sweep.
Next: a different way to walk a sorted slice — two pointers.
Related topics
Find a value in a sorted slice in O(log n) by repeatedly halving the range — the correct loop, the off-by-one traps, and the stdlib helpers.
complexityBig-O & ComplexityMeasure cost before you optimize — Big-O for time and space, the common growth classes, and how to read the complexity of real Go code.
algorithmsTwo Pointers & Sliding WindowReplace nested loops with two indices that move with intent — converging pointers on sorted data and a sliding window for contiguous subarray and substring problems.
Check your understanding
Score: 0 / 51. Why can no comparison-based sort do better than O(n log n) in the worst case?
Any algorithm that only compares pairs must distinguish all n! permutations. A comparison yields one bit, and log₂(n!) ≈ n log n by Stirling's approximation — that's the information-theoretic floor.
2. What is quicksort's worst-case time, and when does it happen?
If each pivot splits off just one element, partitioning runs n times over shrinking ranges → O(n²). Good pivot choice (random or median-of-three) makes this case astronomically unlikely, keeping the average at O(n log n).
3. What does `slices.Sort` use under the hood, and is it stable?
Go's standard sort is a pattern-defeating introsort (quicksort that falls back to heapsort to dodge the O(n²) case). It is not stable; reach for the *Stable* variants when ties must preserve input order.
4. What does it mean for a sort to be stable?
Stability matters when you sort by one key after another: sort by name, then stably by age, and people of the same age stay in name order. Merge sort is stable; quicksort/introsort generally isn't — use slices.SortStableFunc / sort.SliceStable when you need it.
5. How can counting sort or radix sort beat the O(n log n) comparison floor?
The n log n floor applies only to comparison sorts. Counting sort buckets by value (O(n + range)); radix sort processes keys digit by digit (O(n·digits)). When keys are bounded integers or fixed-width, these skip comparisons entirely — at the cost of assumptions about the data and extra space.
Comments
Sign in with GitHub to join the discussion.