Complexity and Big-O
Describe how the work a program does grows with the size of its input, and learn the handful of growth rates that cover almost every program you will write.
What you will learn
- Count steps instead of seconds, and explain why that comparison is fair across machines
- Name the common growth classes from O(1) to O(2^n) and say which loop shapes produce them
- Simplify a step count to its Big-O form by dropping constants and smaller terms
- Distinguish best, worst and average case, and explain amortised cost
On this page
Two programs can give the same answer and still be very different. One finishes a million items in a blink; the other runs for an hour. You usually cannot tell by reading a single result, and you cannot tell by timing on small inputs either. Complexity analysis is the tool for predicting the difference without running the program on a huge input.
Count steps, not seconds
Timing a program in seconds depends on the computer, the language, what else is running, and the day. It is a poor way to compare two ideas. Instead, count how many basic steps the algorithm takes, and ask how that count changes as the input gets bigger. We write n for the size of the input: the number of items in a list, the number of characters in a string, the value of a number that sets how long a loop runs.
A “basic step” is anything that takes a roughly fixed amount of time: an addition, a comparison, reading one item by its index. The exact cost of one step does not matter, because the question is about growth. If doubling n doubles the steps, that is true on a laptop and on a supercomputer.
The common growth classes
Big-O notation names the growth rate, keeping only the part that dominates for large n. The classes you will meet, from slowest growth to fastest:
| Class | Name | What it feels like | Typical source |
|---|---|---|---|
| O(1) | constant | Does not depend on n |
Reading items[5]; adding two numbers |
| O(log n) | logarithmic | Double n, add one step |
Repeatedly halving the problem |
| O(n) | linear | Double n, double the work |
One loop over the input |
| O(n log n) | linearithmic | A little worse than linear | Efficient sorting |
| O(n²) | quadratic | Double n, four times the work |
A loop inside a loop over the same data |
| O(2ⁿ) | exponential | Add one to n, double the work |
Trying every subset; naive branching recursion |
The words “log n” mean the number of times you can halve n before reaching 1. For 1,000 that is about 10, and for a million it is about 20. That is why O(log n) is so cheap.
To feel the difference, here is roughly how many steps each class needs:
| n | O(1) | O(log n) | O(n) | O(n log n) | O(n²) | O(2ⁿ) |
|---|---|---|---|---|---|---|
| 10 | 1 | 3 | 10 | 33 | 100 | 1,024 |
| 100 | 1 | 7 | 100 | 664 | 10,000 | about 10³⁰ |
| 1,000 | 1 | 10 | 1,000 | 9,966 | 1,000,000 | about 10³⁰¹ |
The last column is not a typo. An O(2ⁿ) algorithm that is comfortable at n = 20 is hopeless at n = 100, and no hardware improvement will rescue it.
How to read the growth class off a program
You rarely need an exact formula. A few rules get you to the answer:
- Count how many times the innermost work runs, as a function of
n. - Loops over the same data, one after another, add. Two separate loops of
nsteps aren + n, which is still O(n). - A loop inside a loop multiplies. A loop of
ncontaining a loop ofnisn × n, so O(n²). - Drop constant factors.
3nandn/2are both O(n). They matter in practice, but not for the growth class. - Drop smaller terms.
n² + n + 7is O(n²), because for largenthen²swamps the rest. - A loop that halves (or doubles) its counter each time is O(log n).
Counting steps in code
Rather than trust the rules, count. Each function below does a loop shape and returns how many steps it took. The printed numbers are real counts, not estimates, and they are identical in every language.
Python
def linear_steps(n): steps = 0 for _ in range(n): steps += 1 return steps
def halving_steps(n): steps = 0 while n > 0: n = n // 2 steps += 1 return steps
def pair_steps(n): steps = 0 for i in range(n): for j in range(i + 1, n): steps += 1 return steps
for n in [10, 100, 1000]: print(f"n={n} linear={linear_steps(n)} halving={halving_steps(n)} pairs={pair_steps(n)}")JavaScript
function linearSteps(n) { let steps = 0; for (let i = 0; i < n; i++) { steps += 1; } return steps;}
function halvingSteps(n) { let steps = 0; while (n > 0) { n = Math.floor(n / 2); steps += 1; } return steps;}
function pairSteps(n) { let steps = 0; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { steps += 1; } } return steps;}
for (const n of [10, 100, 1000]) { console.log(`n=${n} linear=${linearSteps(n)} halving=${halvingSteps(n)} pairs=${pairSteps(n)}`);}Java
public class BigoSteps { static int linearSteps(int n) { int steps = 0; for (int i = 0; i < n; i++) { steps += 1; } return steps; }
static int halvingSteps(int n) { int steps = 0; while (n > 0) { n = n / 2; steps += 1; } return steps; }
static int pairSteps(int n) { int steps = 0; for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { steps += 1; } } return steps; }
public static void main(String[] args) { int[] sizes = {10, 100, 1000}; for (int n : sizes) { System.out.println("n=" + n + " linear=" + linearSteps(n) + " halving=" + halvingSteps(n) + " pairs=" + pairSteps(n)); } }}C
#include <stdio.h>
static int linear_steps(int n) { int steps = 0; for (int i = 0; i < n; i++) { steps += 1; } return steps;}
static int halving_steps(int n) { int steps = 0; while (n > 0) { n = n / 2; steps += 1; } return steps;}
static int pair_steps(int n) { int steps = 0; for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { steps += 1; } } return steps;}
int main(void) { int sizes[] = {10, 100, 1000}; for (int k = 0; k < 3; k++) { int n = sizes[k]; printf("n=%d linear=%d halving=%d pairs=%d\n", n, linear_steps(n), halving_steps(n), pair_steps(n)); } return 0;}All four print:
n=10 linear=10 halving=4 pairs=45n=100 linear=100 halving=7 pairs=4950n=1000 linear=1000 halving=10 pairs=499500Read down each column as n grows tenfold:
lineargrows tenfold too: O(n).halvinggrows from 4 to 7 to 10: three more steps each timenis multiplied by 10. That is O(log n).pairsgrows about a hundredfold each time (45, 4,950, 499,500). The exact count isn × (n − 1) / 2, which isn²/2 − n/2. Drop the constant and the smaller term and you get O(n²).
The pairs shape is the one to remember. Comparing every item with every other item is the most common accidental source of slowness, and it is easy to write without noticing.
Best, worst and average case
The step count for one algorithm can depend on the particular input, not only its size. Searching a list for a value by looking at each item in turn:
| Case | When it happens | Steps for a list of n |
|---|---|---|
| Best | The value is the first item | 1 |
| Worst | The value is last, or absent | n |
| Average | The value is somewhere in the middle, typically | about n/2 |
Unless told otherwise, Big-O quotes the worst case, because it is a guarantee: the algorithm will never be slower than that. Note that “average” is a statement about a typical input, while “best” and “worst” are about particular inputs. The best case alone is rarely useful, since you cannot choose your input.
Space complexity
The same notation describes memory. An algorithm that copies its input into a new list uses O(n) extra space; one that uses a few variables regardless of input size uses O(1). Often you can trade one for the other: using more memory to remember answers can save time, a bargain you will see in the problem-patterns lesson.
Amortised cost
Some operations are cheap almost always and expensive rarely. The classic case is adding to the end of a growable list (a dynamic array, from the arrays lesson). It keeps spare room, so most appends cost one step. When it runs out, it allocates a block twice as large and copies every item across, which costs size steps.
So is an append O(1) or O(n)? The honest answer is that a single append is occasionally O(n), but over a long run, the average cost per append is constant. That average over a sequence of operations is the amortised cost. Count it:
Python
def copies_for(appends): capacity = 1 size = 0 copies = 0 for _ in range(appends): if size == capacity: copies += size # moving everything to a bigger block capacity *= 2 size += 1 return copies
for appends in [10, 100, 1000]: print(f"appends={appends} copies={copies_for(appends)}")JavaScript
function copiesFor(appends) { let capacity = 1; let size = 0; let copies = 0; for (let i = 0; i < appends; i++) { if (size === capacity) { copies += size; // moving everything to a bigger block capacity *= 2; } size += 1; } return copies;}
for (const appends of [10, 100, 1000]) { console.log(`appends=${appends} copies=${copiesFor(appends)}`);}Java
public class BigoAmortised { static int copiesFor(int appends) { int capacity = 1; int size = 0; int copies = 0; for (int i = 0; i < appends; i++) { if (size == capacity) { copies += size; // moving everything to a bigger block capacity *= 2; } size += 1; } return copies; }
public static void main(String[] args) { int[] counts = {10, 100, 1000}; for (int appends : counts) { System.out.println("appends=" + appends + " copies=" + copiesFor(appends)); } }}C
#include <stdio.h>
static int copies_for(int appends) { int capacity = 1; int size = 0; int copies = 0; for (int i = 0; i < appends; i++) { if (size == capacity) { copies += size; /* moving everything to a bigger block */ capacity *= 2; } size += 1; } return copies;}
int main(void) { int counts[] = {10, 100, 1000}; for (int k = 0; k < 3; k++) { printf("appends=%d copies=%d\n", counts[k], copies_for(counts[k])); } return 0;}All four print:
appends=10 copies=15appends=100 copies=127appends=1000 copies=1023The total copying never exceeds about twice the number of appends (1,023 copies for 1,000 appends), so each append costs a constant two or so copies on average. Doubling the capacity is what makes this work. Growing by a fixed amount, say 10 slots at a time, would make the total copying quadratic.
Common pitfalls
- Treating Big-O as a speed. It describes growth, not seconds. An O(n) algorithm with a huge constant can lose to an O(n²) one for small inputs. Big-O tells you what happens when the input gets large.
- Forgetting that a single call can hide a loop. In many languages, checking whether a list contains an item, or inserting at the front, is itself O(n). A loop that calls it is then O(n²).
- Multiplying when you should add. Two loops one after the other are O(n + n) = O(n). Only nested loops multiply.
- Confusing the size of the input with its value. A loop
for i in range(n)is O(n) in the valuen, but ifnis the number of digits in the input, the cost is exponential in that size. - Quoting the best case. “It is O(1) if the value is first” is true and useless. State the worst case.
Each snippet below is pseudocode, and n is the number of items in the input list. Give each its Big-O class, then order them from slowest-growing to fastest-growing.
| Label | Snippet |
|---|---|
| A | return the first item |
| B | for each item, print it |
| C | for each item, for each item, print the pair |
| D | set k = n; while k > 1, set k = k / 2 |
| E | for each item, binary-search a sorted list of n items for it |
| F | print the first 10 items (or all of them if there are fewer) |
| G | for each i, for each j from i to the end, print the pair |
| H | fib(n): if n < 2 return n; otherwise return fib(n-1) + fib(n-2) |
Show solution
| Label | Class | Reason |
|---|---|---|
| A | O(1) | One step |
| B | O(n) | One pass |
| C | O(n²) | Nested loops, n × n |
| D | O(log n) | The counter halves each time |
| E | O(n log n) | n searches, each O(log n) |
| F | O(1) | At most 10 steps, however large n gets |
| G | O(n²) | About n²/2 pairs, and the /2 is a constant |
| H | O(2ⁿ) | Each call makes two more calls |
From slowest-growing to fastest-growing: A and F (tie) < D < B < E < C and G (tie) < H.
A loop starts with i = 1 and, while i < n, doubles i and counts one step. Write it and print the step count for these inputs, then state its Big-O class.
| n | Expected steps |
|---|---|
| 1 | 0 |
| 1000 | 10 |
| 1024 | 10 |
| 1025 | 11 |
Show solution
It is O(log n): the counter doubles each time, so the number of steps is the number of doublings needed to reach n. Note how 1024 (exactly 2¹⁰) gives 10, and one more gives 11.
Python
def doubling_steps(n): steps = 0 i = 1 while i < n: i *= 2 steps += 1 return steps
print("n=1:", doubling_steps(1))print("n=1000:", doubling_steps(1000))print("n=1024:", doubling_steps(1024))print("n=1025:", doubling_steps(1025))JavaScript
function doublingSteps(n) { let steps = 0; let i = 1; while (i < n) { i *= 2; steps += 1; } return steps;}
console.log("n=1:", doublingSteps(1));console.log("n=1000:", doublingSteps(1000));console.log("n=1024:", doublingSteps(1024));console.log("n=1025:", doublingSteps(1025));Java
public class DoublingSteps { static int doublingSteps(int n) { int steps = 0; int i = 1; while (i < n) { i *= 2; steps += 1; } return steps; }
public static void main(String[] args) { System.out.println("n=1: " + doublingSteps(1)); System.out.println("n=1000: " + doublingSteps(1000)); System.out.println("n=1024: " + doublingSteps(1024)); System.out.println("n=1025: " + doublingSteps(1025)); }}C
#include <stdio.h>
static int doubling_steps(int n) { int steps = 0; int i = 1; while (i < n) { i *= 2; steps += 1; } return steps;}
int main(void) { printf("n=1: %d\n", doubling_steps(1)); printf("n=1000: %d\n", doubling_steps(1000)); printf("n=1024: %d\n", doubling_steps(1024)); printf("n=1025: %d\n", doubling_steps(1025)); return 0;}Output:
n=1: 0n=1000: 10n=1024: 10n=1025: 11The pair_steps function above counts n × (n − 1) / 2 steps. Without running anything, work out the count for n = 2000, and say how many times larger it is than the count for n = 1000 (499,500).
Show solution
For n = 2000: 2000 × 1999 / 2 = 1,999,000. Divided by 499,500 that is just under 4. Doubling the input roughly quadrupled the work, which is the signature of O(n²). You can confirm by calling pair_steps(2000).
Recap
- Count steps as a function of the input size
n, not seconds. Big-O keeps only the dominant growth. - The classes to know are O(1), O(log n), O(n), O(n log n), O(n²) and O(2ⁿ). A single loop is O(n), nested loops multiply, and a loop that halves its counter is O(log n).
- Drop constants and smaller terms. Add sequential work; multiply nested work.
- Quote the worst case by default; remember that best and average are different questions.
- Amortised cost is the average over a sequence. Appending to a doubling array is O(1) amortised even though an occasional append copies everything.
Next, Searching and sorting puts these growth rates to work on two of the oldest problems in programming.