{} The Go Reference

Algorithms · DSA · Intermediate

Sorting

Why comparison sorts can't beat O(n log n) — merge sort and quicksort by hand, then the one line you actually write in Go: slices.Sort.

Algorithms Intermediate ⏱ 5 min read Complete

🃏 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
mergesort.go — editable & runnable
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, and slices.SortFunc with a comparison function for anything else.
  • sort.Slice, the older reflection-based form, taking a less(i, j) closure.

Neither is stable; use slices.SortStableFunc or sort.SliceStable when equal elements must keep their input order.

sortstructs.go — editable & runnable
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.

Check your understanding

Score: 0 / 5

1. 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.