Ch 2 / 30 Big-O: Measuring Cost Without a Stopwatch 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 2

Part 1 · Foundations · Chapter 2

Big-O: Measuring Cost Without a Stopwatch

Your laptop is faster than mine, so timing code tells us nothing durable. Big-O measures the shape of the growth instead — and shapes survive hardware upgrades.

Reading
CLRS Ch. 3
Focus
Growth, not seconds
Notation
O · Ω · Θ
Uses
Every later chapter

By the end of this chapter you can

  1. Count an algorithm's operations as a function of input size, without running it
  2. Read and write Big-O for the seven complexity classes that actually occur
  3. Apply the two simplification rules — drop constants, keep the dominant term — and justify them
  4. Distinguish best, worst, average and amortised cost, and say which one matters when
  5. Analyse space complexity, and recognise when a recursive function is quietly using O(n) memory

1Why a stopwatch is the wrong instrument

Timing is the obvious way to compare two algorithms. It is also almost useless for the question we actually care about.

Suppose I tell you my sorting function takes 0.4 seconds. What have you learned?

Almost nothing. You do not know my CPU, whether the machine was busy, which language I used, whether the compiler optimised the loop away, how big the input was, or whether the data was already nearly sorted. Run the same code on a phone and a server and you get numbers ten times apart.

Worse: a measurement at one input size does not predict another. An algorithm that is faster on a thousand items can be catastrophically slower on a million. If you only ever measure the thousand, you will ship the wrong one.

The moving van

Two removal firms quote for a job. One says “three hours.” The other says “twenty minutes per room.”

The first quote is a measurement. The second is a model. Only the second one lets you answer “what about a house twice this size?” without hiring them again. Big-O is the second kind of quote.

What we measure instead

We count operations as a function of input size, and then we throw away every detail that depends on the machine. What remains is the growth curve — and the growth curve is the same on your laptop, on a server, and on hardware that has not been built yet.

2Counting operations

Before the notation, the habit: look at a loop and say how many times its body runs, in terms of n.

Call the size of the input n. For a list, that is its length; for a string, its number of characters; for a graph, usually its vertices and edges. Then count.

counting.py
def f1(xs):                 # n = len(xs)
    return xs[0]            # 1 operation, whatever n is        → O(1)

def f2(xs):
    total = 0               # 1
    for x in xs:            # the body runs n times
        total += x          #   1 operation each  → n
    return total            # 1
                            # total: n + 2  → O(n)

def f3(xs):
    for a in xs:            # n times
        for b in xs:        #   n times each
            print(a, b)     #     1 operation  → n × n
                            # total: n²  → O(n²)

def f4(xs):
    for a in xs:            # n times
        pass
    for b in xs:            # n more times — SEPARATE, not nested
        pass
                            # total: 2n  → O(n)

Two rules cover most cases and are worth saying out loud:

  • Nested loops multiply. A loop inside a loop, each over n items, runs n × n times.
  • Sequential loops add. One loop after another is n + n, which is 2n, which is still O(n).

That second one catches people out. Two passes over the data is not “twice as bad a complexity.” It is the same complexity, and typically not worth contorting your code to avoid.

The hidden loop

Some single-line operations contain loops you did not write:

  • x in my_listO(n), it walks the list
  • my_list.insert(0, x)O(n), it shifts everything
  • sorted(xs)O(n log n)
  • my_string += piece in a loop — O(n²), strings are immutable

A loop containing any of these is a nested loop wearing a disguise. This is the single most common source of accidentally quadratic code.

3Big-O and its two simplification rules

Now the notation. It is deliberately crude, and the crudeness is the point.

O(f(n)) means: as n grows large, the cost grows no faster than f(n), up to a constant factor. Two rules follow, and they are the whole of the notation in practice.

Rule 1 — drop constant factors

O(3n) is written O(n). O(n/2) is written O(n).

This looks like vandalism. The justification: the constant depends on things we deliberately refuse to model — your CPU, your language, whether the loop body has two statements or six. A machine three times faster turns 3n into n. It cannot turn n² into n.

Rule 2 — keep only the dominant term

O(n² + n + 50) is written O(n²).

nn50n² share of total
10100105063%
10010,0001005099%
1,0001,000,0001,0005099.9%
1,000,00010121065099.9999%

The smaller terms do not just become less important — they become invisible. By a million items, everything except n² is rounding error.

Here are the seven curves you will actually meet, racing each other. Watch how they are indistinguishable at small n and then separate violently:

ClassNameMeaning in one sentenceTypical example
O(1)ConstantCost does not depend on n at all.xs[5], dict lookup, stack push
O(log n)LogarithmicEach step discards a constant fraction of what is left.Binary search, balanced tree lookup
O(n)LinearTouch each item a constant number of times.Sum a list, linear search
O(n log n)LinearithmicDo linear work at each of log n levels.Merge sort, quicksort, any comparison sort
O(n²)QuadraticEvery item interacts with every item.Bubble sort, comparing all pairs
O(2ⁿ)ExponentialEach extra item doubles the work.All subsets, naive Fibonacci
O(n!)FactorialEvery ordering of the input.All permutations, brute-force TSP
The sentence to memorise
O(log n) means “halving.” O(n log n) means “linear work, log n times” — which is exactly what divide-and-conquer does. If you can spot those two shapes in code, you can classify most of what you will ever read.

4Best, worst, average — and amortised

“What is the complexity of linear search?” is an incomplete question. It depends which case you mean.

Take linear search. The target could be first, last, or absent:

Best case
O(1)
Worst case
O(n)
Average case
O(n)
Space
O(1)
  • Best case is usually useless. Every algorithm has a lucky input. Quoting it is marketing, not analysis.
  • Worst case is the default, and the one meant when nobody says otherwise. It is a guarantee: this will never be slower than that.
  • Average case needs an assumption about the distribution of inputs, which is why it is quoted less often — but it is why quicksort is used in practice despite its O(n²) worst case.

Amortised: the fourth one, and the interesting one

Some operations are usually cheap and occasionally expensive, in a pattern where the expensive ones are rare enough to pay for themselves. Appending to a Python list is the standard example. Watch it:

Most appends write one slot. Every so often the array is full, a block twice the size is allocated, and everything is copied. That append costs O(n).

But the expensive ones get rarer as the array grows — they happen at sizes 1, 2, 4, 8, 16, 32... The total copying work across n appends comes to roughly 2n, so the average cost per append is constant. That is amortised O(1).

Amortised is not average

Average case averages over different inputs, and needs an assumption about which inputs are likely.

Amortised averages over a sequence of operations on one structure, and needs no assumptions at all — it is a worst-case guarantee about the total. That makes it a much stronger claim, and it is worth being able to tell them apart.

5Space complexity, and the memory you forgot

Time is not the only bill. And recursive functions charge for memory in a way that catches almost everyone.

Space complexity counts the extra memory an algorithm needs, beyond the input it was handed. An algorithm using O(1) extra space is called in‑place.

space.py
def reverse_copy(xs):        # O(n) space — builds a whole second list
    out = []
    for x in reversed(xs):
        out.append(x)
    return out

def reverse_inplace(xs):     # O(1) space — three variables, whatever n is
    lo, hi = 0, len(xs) - 1
    while lo < hi:
        xs[lo], xs[hi] = xs[hi], xs[lo]
        lo += 1
        hi -= 1

The one people miss: the call stack

Every function call in progress occupies a frame in memory. A recursive function that goes n levels deep is using O(n) space — even if it allocates nothing itself.

hidden_space.py
def total(xs):
    if not xs:
        return 0
    return xs[0] + total(xs[1:])     # ← two hidden costs, not one

# 1. O(n) stack frames: n calls are open at once before any returns.
# 2. xs[1:] COPIES the rest of the list, every single call.
#    n copies of average length n/2 → O(n²) time as well.
#
# On a list of 10,000 this raises RecursionError before it gets slow enough
# for you to notice the second problem.

In the animation above, the depth of the tree is the peak stack usage. That is why the space complexity of naive Fibonacci is O(n) even though its time complexity is O(2ⁿ) — the tree is wide, but only one root-to-leaf path is open at a time.

The 1000-frame wall

Python caps recursion at about 1,000 frames by default and raises RecursionError. That limit is a feature: it turns a silent stack overflow into an error message. When you hit it, raising the limit is almost never the right fix — rewriting the recursion as a loop, or adding memoisation, usually is.

6Reading complexity off real code

The skill is not reciting definitions. It is looking at forty lines you did not write and saying which curve they are on.

A procedure that works nearly every time:

  1. Find the loops. Nested multiply, sequential add.
  2. Look inside each loop for hidden loopsin on a list, sorted(), slicing, string concatenation, a function call that itself loops.
  3. Ask how the loop variable changes. i += 1 gives n iterations. i *= 2 gives log n. That single character is the whole difference.
  4. Drop constants, keep the biggest term.
reading_code.py
# --- What is this? ---
def mystery(xs):
    seen = []
    for x in xs:              # n iterations
        if x not in seen:     # ← O(len(seen)) — up to n
            seen.append(x)
    return seen
# n iterations × O(n) membership test = O(n²).
# Fix: make `seen` a set. Same lines, O(n).


# --- And this? ---
def mystery2(n):
    count = 0
    i = 1
    while i < n:              # i doubles: 1, 2, 4, 8, ...
        count += 1
        i *= 2                # ← the multiplication is the whole story
    return count
# i reaches n after log₂(n) doublings → O(log n).


# --- And this? ---
def mystery3(xs):
    for x in xs:              # n
        for y in xs:          #   n
            for z in xs:      #     n
                if x + y + z == 0:
                    return True
    return False
# O(n³). On 1,000 items that is a billion iterations — about a second.
# On 10,000 it is a thousand seconds. Chapter 4 does this in O(n²).
The pragmatic bit

Big-O ignores constant factors, which occasionally makes it lie about small inputs. An O(n²) algorithm with a tiny constant genuinely beats an O(n log n) one with a large constant — up to some crossover, usually a few dozen items. This is why real library sorts switch to insertion sort for small chunks.

So: use Big-O to choose the approach, then measure to tune the constant. In that order. Measuring first, without a model, is how people spend a week optimising the wrong loop.

What to carry forward

  • Big-O describes how cost grows with input size, ignoring machine-dependent constants. It is a model, not a measurement.
  • Nested loops multiply, sequential loops add. Watch for hidden loops: in on a list, slicing, sorted(), string concatenation.
  • Two rules: drop constant factors, keep only the dominant term. Everything else is noise by the time n is large.
  • Worst case is the default. Amortised averages over a sequence of operations and is a genuine guarantee — that is why list append is O(1).
  • Space counts the call stack. Recursion n levels deep uses O(n) memory even if it allocates nothing.
  • i += 1 gives O(n). i *= 2 gives O(log n). One character.

>_Playground

Count the operations yourself. Each function below has a counter wired into it — run it at several sizes and see whether the numbers match the curve you predicted.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

Chapter 3 — Arrays and Dynamic Arrays

The simplest structure there is, the one everything else is built on, and the source of the most surprising performance cliff in everyday code.

Continue →