Core patterns · Lesson 13 of 62 · about 45 min
Big-O: measuring algorithms
How to estimate how fast code is before running it, read problem constraints, and choose the right target complexity.
On this page · 9 sections
Counting steps#
Count how many basic steps (a comparison, an addition, an index) your code takes for input size n:
total := 0
for _, x := range nums { // runs n times
total += x // 1 step each
}That is about n steps: O(n), "linear". Double the input, double the time.
for i := range nums { // n times
for j := range nums { // n times for each i
if nums[i]+nums[j] == 0 { ... }
}
}That is n × n steps: O(n²), "quadratic". Double the input, four times the time.
See it: count the steps yourself
Instead of trusting the table, make the computer count. Each function below does nothing but add 1 to a counter for every basic step its loop shape performs:
package main
import "fmt"
func linear(n int) int {
steps := 0
for i := 0; i < n; i++ {
steps++
}
return steps
}
func quadratic(n int) int {
steps := 0
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
steps++
}
}
return steps
}
func logarithmic(n int) int {
steps := 0
for n > 1 {
n /= 2 // halve the problem
steps++
}
return steps
}
func main() {
fmt.Printf("%7s %7s %10s %9s\n", "n", "O(n)", "O(n²)", "O(log n)")
for _, n := range []int{10, 100, 1000, 10000} {
fmt.Printf("%7d %7d %10d %9d\n", n, linear(n), quadratic(n), logarithmic(n))
}
} n O(n) O(n²) O(log n)
10 10 100 3
100 100 10000 6
1000 1000 1000000 9
10000 10000 100000000 13Read each column top to bottom: every time n grows 10×, the linear count grows 10×, the quadratic count grows 100×, and the logarithmic count grows by only about 3 (because 2³ ≈ 10).
Why we drop constants#
Big-O keeps only the part that dominates when n is large:
| Exact count | Big-O | Why |
|---|---|---|
3n + 50 | O(n) | for large n, the 50 is irrelevant and 3 is a constant factor |
n²/2 + n | O(n²) | n² dwarfs n |
5 | O(1) | does not grow at all |
n log n + n | O(n log n) |
Constants still matter in practice (that is what benchmarks are for), but they never change which algorithm wins at large sizes.
The common classes#
| Big-O | Name | Typical code | n = 1,000,000 |
|---|---|---|---|
| O(1) | constant | index a slice, map lookup | 1 |
| O(log n) | logarithmic | binary search, halving | 20 |
| O(n) | linear | one loop | 1,000,000 |
| O(n log n) | linearithmic | sorting | 20,000,000 |
| O(n²) | quadratic | two nested loops | 10¹² (too slow) |
| O(2ⁿ) | exponential | all subsets | impossible |
| O(n!) | factorial | all orderings | impossible |
Where log n comes from
Anything that halves the problem each step is O(log n). Starting at 1,000,000 and halving, you reach 1 after about 20 steps, because 2²⁰ ≈ 1,000,000. Binary search, balanced trees and heaps all get their speed from this.
Reading constraints#
A typical judge runs about 10⁸ simple operations per second. So the input limit tells you the complexity you need:
| n up to | Target | Approaches that fit |
|---|---|---|
| 10 | O(n!) | try every permutation |
| 20 | O(2ⁿ) | try every subset, bitmasks |
| 500 | O(n³) | triple loops, Floyd–Warshall |
| 5,000 | O(n²) | all pairs, 2D dynamic programming |
| 200,000–1,000,000 | O(n log n) | sorting, heaps, binary search |
| 100,000,000 | O(n) | one pass, hashing, two pointers |
Make this your first step on every problem: read the constraint, pick the target, then look for an idea that fits it.
Space complexity#
Memory counts too. A solution that copies the input into a map uses O(n) extra space; one that only keeps a few variables uses O(1). Recursion uses stack space proportional to its depth.
Worked example: one problem, three solutions#
"Does this slice contain a duplicate?"
| Solution | Time | Extra space | Idea |
|---|---|---|---|
| compare every pair | O(n²) | O(1) | brute force |
| sort, then compare neighbors | O(n log n) | O(1)* | duplicates end up adjacent |
| remember what you've seen in a set | O(n) | O(n) | trade memory for time |
*Sorting changes the input, which may not be allowed.
package main
import (
"fmt"
"math/rand"
"slices"
"time"
)
// O(n²) time, O(1) space
func hasDupPairs(nums []int) bool {
for i := range nums {
for j := i + 1; j < len(nums); j++ {
if nums[i] == nums[j] {
return true
}
}
}
return false
}
// O(n log n) time; sorts a copy so the caller's slice is untouched
func hasDupSort(nums []int) bool {
s := slices.Clone(nums)
slices.Sort(s)
for i := 1; i < len(s); i++ {
if s[i] == s[i-1] {
return true
}
}
return false
}
// O(n) time, O(n) space
func hasDupSet(nums []int) bool {
seen := make(map[int]struct{}, len(nums))
for _, n := range nums {
if _, ok := seen[n]; ok {
return true
}
seen[n] = struct{}{}
}
return false
}
func main() {
fmt.Println(hasDupPairs([]int{1, 2, 3, 1}), hasDupSort([]int{1, 2, 3}), hasDupSet([]int{4, 4}))
nums := rand.Perm(20_000) // no duplicates: the worst case for all three
timeIt := func(fn func([]int) bool) time.Duration {
start := time.Now()
fn(nums)
return time.Since(start)
}
pairs, sorted, set := timeIt(hasDupPairs), timeIt(hasDupSort), timeIt(hasDupSet)
fmt.Println("pairs at least 10x slower than sort:", pairs > 10*sorted)
fmt.Println("pairs at least 10x slower than set: ", pairs > 10*set)
}true false true
pairs at least 10x slower than sort: true
pairs at least 10x slower than set: trueAt 20,000 elements the pairwise version does about 200 million comparisons, while sorting does about 300,000 and the set does 20,000 lookups. Print the actual durations with fmt.Println(pairs, sorted, set) and try 40,000 elements: the pairs time roughly quadruples while the others roughly double. (The program prints comparisons instead of times because timings differ on every machine.)
Spot the hidden loop
Here is the mistake from the callout above, measured. Both functions find the values two slices have in common. The first looks like one loop, but the inner for is exactly what slices.Contains(b, x) does for you. Each function counts its work:
package main
import "fmt"
// Looks like one loop over a, but scans b for every x: O(len(a) × len(b)).
func commonScan(a, b []int) (out []int, work int) {
for _, x := range a {
for _, y := range b { // what slices.Contains(b, x) does inside
work++
if x == y {
out = append(out, x)
break
}
}
}
return out, work
}
// Pays O(len(b)) once to build a set, then O(1) per lookup: O(len(a) + len(b)).
func commonSet(a, b []int) (out []int, work int) {
set := make(map[int]bool, len(b))
for _, y := range b {
set[y] = true
work++
}
for _, x := range a {
work++
if set[x] {
out = append(out, x)
}
}
return out, work
}
func main() {
a := make([]int, 1000) // 0..999
b := make([]int, 1000) // 500..1499
for i := range a {
a[i], b[i] = i, i+500
}
out1, work1 := commonScan(a, b)
out2, work2 := commonSet(a, b)
fmt.Println("scan:", len(out1), "common values,", work1, "steps")
fmt.Println("set: ", len(out2), "common values,", work2, "steps")
}scan: 500 common values, 625250 steps
set: 500 common values, 2000 stepsSame answer, over 300 times less work. Every value of a below 500 makes the scanning version walk all 1,000 elements of b without finding a match.
Summary#
- Big-O describes how work grows with input size, ignoring constants.
- Nested loops multiply; halving gives log n; sorting costs n log n.
- Read the constraints first and pick a target complexity before choosing an approach.
- Count extra memory too, including recursion depth.
- Prove performance claims with benchmarks.
Check your understanding
Answer before scrolling back up. If you miss one, reread the matching section, then try again.
Q1.What is the time complexity of two nested loops that each run n times?
Q2.Constraints say n ≤ 100,000. Which complexity is safe for about one second?
Q3.A loop halves n each time until it reaches 1. Its complexity is:
Q4.Why is
O(3n + 50)written asO(n)?Q5.What extra space does a hash-set duplicate check use?
Practice in Go
Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.
Solve it three ways (nested loops, sort, map) and benchmark all three.
Show hint
Sub-benchmarks with
b.Runat n = 100, 10,000 and 100,000 show the crossover clearly.- Missing Number ↗easy
Show hint
O(n) time and O(1) space: the expected sum 0+1+…+n minus the actual sum.
- Count Primes ↗medium
Show hint
Checking each number by trial division is too slow. The Sieve of Eratosthenes is O(n log log n).
- Complexity journalbuild
For every solution you write from now on, add a comment with its time and space complexity and one sentence explaining why.
Finished this lesson?
Mark it done to track your progress. It is saved in this browser only.