{} The Go Reference

Complexity · DSA · Start here

Big-O & Complexity

Measure cost before you optimize — Big-O for time and space, the common growth classes, and how to read the complexity of real Go code.

Complexity Start here ⏱ 5 min read Complete

📖 Analogy

Finding a name in a phone book two ways: flipping one page at a time from the front, or opening to the middle and deciding “earlier or later” each time. With a million names, the first way can take a million flips; the second takes about twenty. Big-O is the language for that gap — it tells you how the work grows as the book gets bigger, not how long one flip takes.

Why growth, not a stopwatch

A stopwatch measures one machine, one input, one day. Big-O measures how the work scales as the input size n grows — which is what actually decides whether your code survives 10× the data. Two rules make it portable:

  • Drop constants: O(2n) and O(n) are the same class; hardware changes the constant, not the shape.
  • Keep the dominant term: O(n² + n) is O(n²) — for large n, the biggest term wins.

So we care about the shape of the curve, not its exact height.

The growth classes you’ll meet

graph LR
A["O(1)<br/>constant"] --> B["O(log n)<br/>logarithmic"] --> C["O(n)<br/>linear"] --> D["O(n log n)<br/>linearithmic"] --> E["O(n²)<br/>quadratic"] --> F["O(2ⁿ)<br/>exponential"]
classDef good fill:#0f2a1a,stroke:#22c55e,color:#d7ffe9;
classDef bad fill:#2a0f17,stroke:#f43f5e,color:#ffd7e0;
class A,B good;
class E,F bad;
ClassNameExample in Gon=1,000
O(1)constantm[key], s[i], len(s)1
O(log n)logarithmicbinary search, balanced-tree lookup~10
O(n)linearone loop over a slice1,000
O(n log n)linearithmicsort.Slice, merge sort~10,000
O(n²)quadraticnested loop over the same slice1,000,000
O(2ⁿ)exponentialnaive recursive subsets/Fibonacciastronomically large

Reading it off real code

Count how many times the work in the innermost statement runs as a function of n:

// O(n) — the loop body runs once per element
func sum(a []int) int {
	total := 0
	for _, v := range a { // n iterations
		total += v
	}
	return total
}

// O(n²) — a loop inside a loop over the same input
func hasDuplicate(a []int) bool {
	for i := range a { // n
		for j := i + 1; j < len(a); j++ { // n
			if a[i] == a[j] {
				return true
			}
		}
	}
	return false
}

// O(log n) — the range halves every step
func countHalvings(n int) int {
	steps := 0
	for n > 1 {
		n /= 2 // discard half
		steps++
	}
	return steps
}

The same hasDuplicate becomes O(n) with a hash set — trading O(n) space to drop a whole factor of n off the time. That trade is the heart of most optimization.

See the gap

The same task — find 999 in a sorted slice of 1,000 — scanned linearly vs. halved. Run it:

complexity.go — editable & runnable
package main

import "fmt"

// O(n): check every element until found.
func linearSearch(a []int, target int) (idx, steps int) {
for i, v := range a {
	steps++
	if v == target {
		return i, steps
	}
}
return -1, steps
}

// O(log n): halve the range each step (input must be sorted).
func binarySearch(a []int, target int) (idx, steps int) {
lo, hi := 0, len(a)-1
for lo <= hi {
	steps++
	mid := lo + (hi-lo)/2
	switch {
	case a[mid] == target:
		return mid, steps
	case a[mid] < target:
		lo = mid + 1
	default:
		hi = mid - 1
	}
}
return -1, steps
}

func main() {
a := make([]int, 1000)
for i := range a {
	a[i] = i // sorted 0..999
}
_, ls := linearSearch(a, 999)
_, bs := binarySearch(a, 999)
fmt.Printf("n=%d  →  linear: %d steps,  binary: %d steps\n", len(a), ls, bs)
}

Space complexity counts too

The same growth classes describe extra memory. Reversing a slice in place is O(1) space; building a new reversed copy is O(n). Recursion adds O(depth) for the call stack — a recursion n deep costs O(n) space even if it returns one number.

⚠️ Worst, average, and amortized aren't the same

A hash-map lookup is average O(1) but worst-case O(n) when everything collides. A slice append is amortized O(1) — most are instant, but the one that reallocates copies all n elements. State which you mean. And remember Big-O hides constants: an O(n) pass with a huge constant can lose to an O(n log n) one on realistic sizes.

Complexity of common Go operations

A quick reference for the costs you’ll reason about constantly:

OperationComplexity
s[i], len(s), m[k] readO(1)
append(s, x)O(1) amortized
insert/delete in the middle of a sliceO(n) (shift)
m[k] = v, delete(m, k)O(1) average
sort.Slice(s, …)O(n log n)
binary search (sort.Search)O(log n)
linear scan / rangeO(n)
nested loop over the same sliceO(n²)
balanced-tree / heap insertO(log n)
naive recursive FibonacciO(2ⁿ)

See also

Next: the structure those costs live on — arrays and slices.

Check your understanding

Score: 0 / 5

1. What does Big-O notation describe?

Big-O describes the growth rate of time (or space) as input size n → ∞. Constant factors and lower-order terms are dropped, so O(2n + 5) is just O(n).

2. What is the time complexity of binary search on a sorted slice of n elements?

Each comparison discards half the remaining range, so it takes about log₂(n) steps — 10 steps for 1000 elements, 20 for a million.

3. Why is appending to a Go slice called amortized O(1)?

When cap is exceeded, Go allocates a bigger backing array (roughly doubling) and copies. Doubling means those O(n) copies happen rarely enough that the average over n appends is O(1).

4. Simplify O(n² + 3n + 7) to a single Big-O class.

As n grows, n² dwarfs 3n and 7, so the lower-order terms vanish and constant factors drop. The class is O(n²) — Big-O captures the shape of the dominant term only.

5. A recursive function calls itself to a depth of n before returning. What's its space complexity, even if it returns a single int?

Space complexity counts the call stack too. n nested calls hold n frames simultaneously before any unwinds, so the recursion costs O(n) space regardless of how small the returned value is. Converting to iteration (or tail-style accumulation) can drop it to O(1).

Comments

Sign in with GitHub to join the discussion.