Part 3 · Data Structures · Module 19

Complexity & Big-O

Before you build data structures you need a way to talk about how fast they are. This module gives you that language: counting the work a program does, describing how that work grows with the input using Big-O, and checking your reasoning with real timings.

⏱ ~2.5 hoursPrerequisites: M05, M06, M14

Why measure at all?

Imagine two programs that answer the same question: "does this list of user IDs contain any duplicates?" On your laptop, with 100 IDs, both finish instantly. In production, with 2 million IDs, one finishes in a third of a second and the other would run for days. Nothing about the hardware changed. The difference is entirely in how the amount of work grows as the input grows.

That growth is what complexity analysis studies. It lets you predict, before you run anything, how a program will behave on inputs far larger than the ones you tested with. It also gives you a shared vocabulary: when a colleague says "that lookup is O(n), use a set," everyone knows what they mean.

Why not just time the program? Timing is essential, and we will do plenty of it, but a stopwatch alone has three weaknesses:

Complexity analysis answers the "how does it grow" question in a machine-independent way. Timing then confirms the analysis and supplies the constant factors that analysis deliberately ignores.

You should see the pairwise version roughly quadruple each time n doubles, while the set version roughly doubles. That ratio, not the absolute number of seconds, is the fingerprint of an algorithm's complexity. By the end of this module you will be able to predict it from the code.

Counting operations

To analyse an algorithm without a stopwatch, we count basic operations: steps that take a roughly constant amount of time regardless of input size. Examples are an arithmetic operation, a comparison, assigning a variable, indexing a list, or calling len() on a list. We do not care whether a comparison takes 3 or 30 nanoseconds; we care how many of them happen.

We also need a measure of input size, conventionally called n. For a list it is the number of elements; for a string, its length; for a number-crunching function like "is k prime?", it might be the value k itself or the number of digits in it. Always say what n means.

A worked count

def total(nums):
    s = 0                 # 1 assignment
    for x in nums:        # loop body runs n times
        s += x            # 1 addition + 1 assignment, each time
    return s              # 1 return

The count is 1 + 2n + 1 = 2n + 2. If we also count the loop's own bookkeeping (fetching the next item), maybe it is 3n + 2. The exact constant depends on what you choose to count, and that is precisely why we will soon throw constants away. What does not depend on your choices is that the count is a linear function of n: double the list, double the work.

Nested loops

def count_pairs(nums):
    count = 0
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            count += 1
    return count

The inner loop runs n−1 times when i = 0, then n−2, and so on down to 0. The total is the sum (n−1) + (n−2) + … + 1 + 0. Pair the first and last terms, the second and second-to-last, and so on: each pair sums to n−1 and there are n/2 pairs, so the total is

n(n − 1) / 2 = ½n² − ½n

This "triangular number" shows up constantly: any time you look at every pair of items, you do about n²/2 work.

A trap in that cell: the loops do n² = 16 comparisons, but only 4 print. The cost is the 16 comparisons, not the 4 outputs. Count the work, not the visible effects.

Big-O, Big-Ω, and Big-Θ

Counts like 3n² + 5n + 2 are precise but noisy. For large n, the n² term dwarfs everything else: at n = 1,000 it contributes 3,000,000 while 5n + 2 contributes 5,002. And the leading constant 3 depends on what we decided to count. Asymptotic notation keeps only the part that matters as n grows: the dominant term, without its constant.

The intuition

The formal definitions

Let f(n) be the cost and g(n) a simple comparison function like n².

In words: "eventually (past n₀), f stays below some fixed multiple of g." The constant c is what lets us ignore leading coefficients; the threshold n₀ is what lets us ignore small inputs where lower-order terms still matter.

A proof you can follow

Claim: 3n² + 5n + 2 ∈ Θ(n²).

Both hold, so it is Θ(n²). The same argument works for any polynomial: a polynomial is Θ of its highest-degree term.

n cost n₀ c₂·g(n) = 10n² f(n) c₁·g(n) = 3n² past n₀, f is trapped between the two curves
Θ means "sandwiched": past some threshold, the cost stays between two constant multiples of the same function.

How people actually use the words

Technically O is only an upper bound, so a linear function is also O(n²) and O(2ⁿ) (both are true and useless). In practice, engineers say "O(n)" when they mean the tight bound Θ(n). That is fine in conversation, but know the difference: in an interview or paper, "this algorithm is Ω(n log n)" is a strong claim about a lower bound, while "O(n log n)" only promises it is no worse.

Rules for simplifying

The common growth classes

A handful of functions describe almost every algorithm you will meet. Listed from fastest to slowest:

ClassNameTypical examplen = 1,000,000 means roughly…
O(1)constantindex a list, dict lookup1 step
O(log n)logarithmicbinary search20 steps
O(n)linearsum a list, x in list10⁶ steps
O(n log n)linearithmicsorted(), merge sort2×10⁷ steps
O(n²)quadraticcompare all pairs10¹² steps (hours in Python)
O(n³)cubicnaive matrix multiply10¹⁸ steps (forget it)
O(2ⁿ)exponentialall subsetsimpossible past n ≈ 40
O(n!)factorialall orderingsimpossible past n ≈ 12

Play with the visualizer below. Toggle classes on and off and increase the maximum n. Notice how the ordering is fixed once n is large enough, even if a "slow" class looks competitive for small n.

A useful rule of thumb for pure Python: about 10⁷ simple operations per second. So an O(n²) algorithm is fine up to n ≈ 3,000 for an interactive response, and an O(n log n) one handles millions. Compiled libraries like NumPy are 10–100× faster per operation, but they do not change the class.

Logarithms from scratch

Logarithms scare people, but in computing they answer one friendly question:

How many times can I halve n before I reach 1?

Start with 16: 16 → 8 → 4 → 2 → 1. Four halvings, so log₂ 16 = 4. Equivalently, 2⁴ = 16: the logarithm is the inverse of exponentiation. log₂ n = k means 2k = n.

16 items 8 4 2 1 step 1 step 2 step 3 step 4 → log₂ 16 = 4
Each step throws away half the remaining work. That is the shape of every O(log n) algorithm.

Why logarithms are so slow-growing

Doubling n adds just one to log₂ n. A million is about 2²⁰, so log₂ 10⁶ ≈ 20. A billion is about 2³⁰, so log₂ 10⁹ ≈ 30. Binary search through a sorted list of a billion items needs at most about 30 comparisons. That is the power of halving.

Which base?

Computer scientists usually mean base 2, mathematicians base e, engineers base 10. For Big-O it does not matter: the change-of-base rule says loga n = logb n / logb a, and 1 / logb a is just a constant factor, which Big-O drops. So we write O(log n) without a base.

Useful identities

Analysing loops and recursion

Loop patterns

PatternIterationsCost
for i in range(n)nO(n)
for i in range(0, n, 2)n/2O(n)
for i in range(n): for j in range(n)n²O(n²)
for i in range(n): for j in range(i)n(n−1)/2O(n²)
i = 1; while i < n: i *= 2⌈log₂ n⌉O(log n)
for i in range(n): j = 1; while j < n: j *= 2n·log nO(n log n)
for i in range(100) (fixed bound)100O(1)

The function prints [3, 1, 2], but the real lesson is its cost: x not in out scans out, which can grow to n items, so the whole thing is O(n²) in the worst case (all distinct). Replacing out membership with a set makes it O(n).

Recursion and recurrences

For recursive functions we write a recurrence: an equation that defines the cost T(n) in terms of the cost on smaller inputs.

def length(items):            # T(n) = T(n - 1) + c
    if not items:
        return 0
    return 1 + length(items[1:])  # careful: the slice itself costs O(n)!

Ignoring the slice, each call does constant work and recurses on n − 1 items: T(n) = T(n−1) + c. Unrolling: T(n) = c + c + … + c (n times) = O(n). But the slice items[1:] copies n−1 items, so the true recurrence is T(n) = T(n−1) + cn, which sums to the triangular number: O(n²). Recursion with slicing is a classic accidental quadratic.

Four recurrences worth memorising

RecurrenceSolutionExample
T(n) = T(n−1) + O(1)O(n)recursive sum with an index
T(n) = T(n/2) + O(1)O(log n)binary search
T(n) = 2T(n/2) + O(n)O(n log n)merge sort
T(n) = 2T(n−1) + O(1)O(2ⁿ)naive Fibonacci (roughly), Towers of Hanoi

The third one deserves a picture. Merge sort splits the list in half, sorts each half recursively, then merges in linear time. Draw the calls as a tree: the top level does n work merging; the next level has two calls doing n/2 each, still n total; and so on. There are log₂ n levels, each doing n work.

n n/2n/2 n/4n/4 n/4n/4 ⋮ … n leaves of size 1 … level work: n level work: n n
log₂ n levels × n work per level = n log n. This "levels times work per level" trick solves many recurrences.

The general tool for divide-and-conquer recurrences is the Master Theorem; you will meet it properly in M28 Recursion. For now, the four rows above and the tree method cover most of what you will see.

Each step of 5 multiplies the calls by about 11 (that is φ⁵, where φ ≈ 1.618 is the golden ratio). The exact class is Θ(φⁿ), which is exponential. Memoisation, covered in M32, cuts it to O(n).

Best, worst, and average case

The same algorithm can cost very different amounts on different inputs of the same size. Consider linear search:

def find(items, target):
    for i, x in enumerate(items):
        if x == target:
            return i
    return -1

Best case is rarely useful (every algorithm is fast on some lucky input). Worst case is the default when someone quotes a Big-O without qualification, because it is a guarantee. Average case matters when worst cases are rare, and it always needs an assumption about the input distribution.

Hash tables are the classic example where the cases diverge sharply: a dict lookup is O(1) on average but O(n) in the pathological worst case where every key collides. You will see why in M23. Quicksort is another: O(n log n) on average, O(n²) in the worst case.

Amortized analysis

Python's list.append is usually described as O(1). But a list is a contiguous block of memory, and when that block is full, appending means allocating a bigger block and copying every element across: O(n). How can both be true?

The answer is amortized cost: the total cost of a long sequence of operations, divided by the number of operations. Occasional expensive steps are fine if they are rare enough that their cost, spread over the cheap steps around them, stays constant per operation.

The doubling argument

Suppose the capacity doubles whenever it fills: 1, 2, 4, 8, 16, … To append n items we copy 1 item, then 2, then 4, … up to at most n. The total copying is 1 + 2 + 4 + … + n < 2n (a geometric series where each term is bigger than all previous terms combined). Add the n writes themselves and the whole sequence costs under 3n. Divided by n appends: fewer than 3 operations each. Amortized O(1).

The key is multiplicative growth. If the list instead grew by a fixed 10 slots each time, the copies would be 10 + 20 + 30 + … ≈ n²/20: amortized O(n) per append. CPython grows by roughly 1.125× plus a small constant; any factor greater than 1 gives amortized O(1), and a smaller factor trades a little more copying for less wasted memory.

Try both growth factors in the visualizer and watch the "total copies ÷ appends" ratio settle near a constant:

The size only jumps occasionally: those are the reallocations. Between them, appends write into pre-reserved slots. You will build this mechanism yourself in M20.

Space complexity

Memory is a resource too. Space complexity measures how much extra memory an algorithm needs as a function of n, usually excluding the input itself (called auxiliary space).

That last point is the most common trade-off in all of data structures: spend memory to save time. Caches, indexes, hash tables, and memoisation are all versions of it.

sys.getsizeof on a list reports only the pointer array, not the integers it points to, so the true footprint is even larger. The generator from M14 uses O(1) space no matter how large the range.

Measuring: timeit, perf_counter, and plots

Analysis predicts; measurement verifies. Python gives you two main tools.

time.perf_counter()

A high-resolution, monotonic clock (it never jumps backwards when the system clock is adjusted). Call it before and after the code, subtract. Good for timing larger chunks of work once.

timeit

For small snippets, a single measurement is dominated by noise. timeit runs the code many times, turns off garbage collection during timing, and reports the total. timeit.repeat does that several times so you can take the minimum, which is the run least disturbed by other processes.

pixi run python -m timeit -s "d = list(range(10_000))" "9_999 in d"
pixi run python -m timeit -s "d = set(range(10_000))" "9_999 in d"

Plotting growth

The most convincing evidence is a plot of time against n. Even better is a log-log plot: if t ≈ c·nk, then log t = log c + k·log n, a straight line whose slope is the exponent k. Slope 1 means linear, slope 2 means quadratic.

What Python's built-ins cost

Knowing these by heart is one of the highest-value things in this module. Here, n is the container size and k the size of the argument or slice. "Avg" means average case for hash-based structures.

OperationCostWhy
a[i], a[i] = x, len(a)O(1)address arithmetic; length is stored
a.append(x), a.pop()O(1) amortizedwork at the end only
a.insert(i, x), a.pop(i)O(n − i)shift everything after i
a.insert(0, x), a.pop(0)O(n)shift everything
x in a, a.index(x), a.count(x), a.remove(x)O(n)linear scan
a[i:j]O(k) where k = j − icopies k pointers
a + b, a.copy(), list(a)O(n) / O(n + m)copies
a.extend(b)O(k) amortizedappends k items
a.sort(), sorted(a)O(n log n)Timsort; O(n) on already-sorted runs
a.reverse(), min(a), sum(a)O(n)touch every item
OperationAvgWorst
d[k], d[k] = v, del d[k], k in d, d.get(k)O(1)O(n)
len(d)O(1)O(1)
iterate d, d.keys(), d.items()O(n)O(n)
d.copy(), d | otherO(n)O(n)
d.popitem()O(1)O(1) amortized
OperationAvg
x in s, s.add(x), s.discard(x)O(1)
s | t (union)O(len(s) + len(t))
s & t (intersection)O(min(len(s), len(t)))
s - t (difference)O(len(s))
set(iterable)O(n)
OperationCost
s[i], len(s)O(1)
sub in s, s.find(sub)O(n) typical (fast search), up to O(n·m)
"".join(parts)O(total length)
s += t in a loopO(n²) in general (strings are immutable)
deque.append/appendleft/pop/popleftO(1)
heapq.heappush/heappopO(log n)
bisect.bisect on a sorted listO(log n) search (insort is O(n) because of the shift)

A printable version lives in the complexity cheatsheet, and the official source is the Python wiki's TimeComplexity page.

Big-O isn't everything

Big-O deliberately throws information away. Sometimes that information is what decides real performance.

Practice

Write count_steps(n) that actually runs the nested loop below and returns how many times the innermost line executes. Then write formula(n) that returns the same number in O(1) using a closed-form expression.

for i in range(n):
    for j in range(i):
        ...  # count this
Hint

The inner loop runs 0, 1, 2, …, n−1 times. That sum is the triangular number n(n−1)/2. Use // so the result stays an int.

Show solution
def count_steps(n):
    count = 0
    for i in range(n):
        for j in range(i):
            count += 1
    return count

def formula(n):
    return n * (n - 1) // 2

Write halvings(n) that returns how many times you can integer-halve a positive integer n (n = n // 2) before it becomes 1. So halvings(1) == 0, halvings(8) == 3, halvings(9) == 3. This is ⌊log₂ n⌋. Use a loop (do not import math).

Hint

while n > 1: n //= 2; count += 1.

Show solution
def halvings(n):
    count = 0
    while n > 1:
        n //= 2
        count += 1
    return count

The function below removes duplicates while keeping first-seen order, but it is O(n²) because x not in out scans a list. Rewrite unique_in_order(items) to be O(n) on average. The tests include a 40,000-item list that must finish in under a second.

def unique_in_order(items):
    out = []
    for x in items:
        if x not in out:
            out.append(x)
    return out
Hint

Keep a set called seen alongside the output list. Check and add to the set; append to the list. (list(dict.fromkeys(items)) also works. Why?)

Show solution
def unique_in_order(items):
    seen = set()
    out = []
    for x in items:
        if x not in seen:
            seen.add(x)
            out.append(x)
    return out

Write copies_for(n, growth) that simulates appending n items to a dynamic array with initial capacity 1. When an append finds the array full (size equals capacity), the array is resized to max(capacity + 1, int(capacity * growth)) and all current items are copied (count size copies). Return the total number of copies.

For growth=2: copies_for(1, 2) == 0, copies_for(2, 2) == 1, copies_for(5, 2) == 7 (1 + 2 + 4).

Hint

Loop n times. Inside: if size == capacity, add size to copies and grow the capacity. Then size += 1.

Show solution
def copies_for(n, growth):
    capacity = 1
    size = 0
    copies = 0
    for _ in range(n):
        if size == capacity:
            copies += size
            capacity = max(capacity + 1, int(capacity * growth))
        size += 1
    return copies

Write guess_class(sizes, times). Given increasing input sizes and the measured times, estimate the log-log slope between the first and last points: k = log(times[-1] / times[0]) / log(sizes[-1] / sizes[0]), then return the nearest of "O(1)" (k≈0), "O(n)" (k≈1), "O(n^2)" (k≈2), or "O(n^3)" (k≈3). Round k to the nearest integer and clamp it to 0..3.

Hint

Compute k, then k = max(0, min(3, round(k))), and index into a list of labels.

Show solution
import math

def guess_class(sizes, times):
    k = math.log(times[-1] / times[0]) / math.log(sizes[-1] / sizes[0])
    k = max(0, min(3, round(k)))
    return ["O(1)", "O(n)", "O(n^2)", "O(n^3)"][k]

Mini-project: an empirical complexity lab

Mini-projectcomplexity_lab.py

Build a small command-line tool that times functions over growing input sizes, estimates each one's complexity class from the log-log slope, and saves a plot. It is the tool you will reach for whenever you are not sure whether some code is linear or quadratic.

Set up

pixi init complexity-lab
cd complexity-lab
pixi add python=3.14 matplotlib
pixi run python complexity_lab.py

Spec

  • A function measure(fn, make_input, sizes, repeats=5) -> list[float] that, for each size, builds a fresh input with make_input(n) (outside the timed region), times fn(input) with time.perf_counter, and keeps the minimum of repeats runs.
  • A function fit_slope(sizes, times) -> float that computes the least-squares slope of log(time) against log(n) using all points, not just the endpoints.
  • A function classify(sizes, times) -> str that maps the slope to O(1), O(log n), O(n), O(n log n), O(n^2), or O(n^3). Hint: log n and n log n have slopes slightly above 0 and 1; compare the fit against log n and n log n curves directly by checking how constant time / g(n) stays.
  • Benchmark at least six candidates: len, binary search via bisect, sum, sorted on shuffled data, the O(n²) pairwise duplicate check, and x in list repeated n times.
  • Print a table (name, slope, guessed class) and save complexity.png with all curves on log-log axes.
  • The project runs with pixi run python complexity_lab.py and finishes in under a minute
  • Input construction is excluded from timings, and each point is the minimum of several repeats
  • fit_slope uses least squares over all points
  • The linear, quadratic, and constant candidates are classified correctly
  • sorted and bisect are distinguished from sum and len
  • complexity.png is saved with labelled axes and a legend
  • Functions have type hints and docstrings

Stretch goals

  • Add a pixi run lab task in pixi.toml.
  • Measure memory as well as time using tracemalloc.get_traced_memory() and classify space complexity.
  • Choose sizes adaptively: keep doubling n until a run takes over 0.2 s.
  • Report a confidence: how well does the best model fit (R² of the regression)?

Quiz

Recap

Going further