Inventing Big O Notation

Some programs finish in a blink. Others take hours — or years. In this chapter you'll discover why by measuring, counting, and eventually inventing a notation that captures it all.

Part 1: How Long Does It Take?~15 min

You've written functions. Some are fast, some are slow. But how slow? And what happens when the input gets bigger? Let's find out by measuring.

Here are two functions. sum_list adds up all the numbers in a list (one loop). has_pair_with_sum checks whether any two numbers in the list add up to a target (a loop inside a loop).

import time

def sum_list(lst):
    total = 0
    for x in lst:
        total += x
    return total

def has_pair_with_sum(lst, target):
    for i in range(len(lst)):
        for j in range(i + 1, len(lst)):
            if lst[i] + lst[j] == target:
                return True
    return False

We'll pass a target of -1 to has_pair_with_sum. Since all our numbers are positive, no pair will ever add up to -1 — this forces the function to check every pair before giving up, so we measure its full workload.

Exercise 1.1 — Time It

Time both functions on lists of size 1,000, 5,000, and 10,000.

For each size, record how long each function takes. When the input gets 5× bigger (1,000 → 5,000), how much slower does each function get?

Use time.time() before and after each call to measure:

lst = list(range(1000))

start = time.time()
sum_list(lst)
print("sum_list:", time.time() - start, "seconds")

start = time.time()
has_pair_with_sum(lst, -1)
print("has_pair:", time.time() - start, "seconds")

Try the same for n = 5000 and n = 10000.

Hint 1

For sum_list, going from 1,000 to 5,000 should take about 5× longer. For has_pair_with_sum, it should take about 25× longer. Why 25×? Because it has a loop inside a loop — 5× more items means 5 × 5 = 25× more pairs to check.

Solution

sum_list grows ~5× when n grows 5× (from 1,000 to 5,000). has_pair_with_sum grows ~25×. One grows proportionally to n, the other proportionally to n².

for n in [1000, 5000, 10000]:
    lst = list(range(n))

    start = time.time()
    sum_list(lst)
    t1 = time.time() - start

    start = time.time()
    has_pair_with_sum(lst, -1)
    t2 = time.time() - start

    print(f"n={n}  sum_list: {t1:.4f}s  has_pair: {t2:.4f}s")

Exercise 1.2 — Plot the Growth

Plot time vs input size for both functions on the same graph.

Use input sizes from 100 to 5,000. What shape does each curve have?

Provided — plotting setup
import matplotlib.pyplot as plt

sizes = [100, 500, 1000, 2000, 3000, 4000, 5000]
Hint 1

Loop over sizes, time each function, collect the times in two lists, then plot:

times_sum = []
times_pair = []
for n in sizes:
    lst = list(range(n))

    start = time.time()
    sum_list(lst)
    times_sum.append(time.time() - start)

    start = time.time()
    has_pair_with_sum(lst, -1)
    times_pair.append(time.time() - start)

plt.plot(sizes, times_sum, 'o-', label='sum_list')
plt.plot(sizes, times_pair, 's-', label='has_pair_with_sum')
plt.xlabel('Input size (n)')
plt.ylabel('Time (seconds)')
plt.legend()
plt.title('How does time grow with input size?')
plt.show()
Solution

sum_list is nearly flat — barely above zero. has_pair_with_sum curves upward steeply. One grows linearly, the other quadratically.

times_sum = []
times_pair = []
for n in sizes:
    lst = list(range(n))
    start = time.time()
    sum_list(lst)
    times_sum.append(time.time() - start)
    start = time.time()
    has_pair_with_sum(lst, -1)
    times_pair.append(time.time() - start)

plt.figure(figsize=(8, 5))
plt.plot(sizes, times_sum, 'o-', label='sum_list')
plt.plot(sizes, times_pair, 's-', label='has_pair_with_sum')
plt.xlabel('Input size (n)')
plt.ylabel('Time (seconds)')
plt.legend()
plt.title('How does time grow with input size?')
plt.grid(True, alpha=0.3)
plt.show()

One function's time grows proportionally to the input size. The other grows proportionally to the square of the input size.

This distinction is enormous. On a million items:

Understanding which category your algorithm falls into is one of the most important skills in programming.

Part 2: Counting Steps~20 min

Timing is noisy — it depends on your computer's speed, what else is running, even the room temperature. Can we predict how an algorithm grows without running it?

Yes: count the key operations.

Exercise 2.1 — Instrument Your Code

Go back to sum_list and has_pair_with_sum. Add a counter variable to each one that counts how many times the innermost operation runs. Return the count along with the result.

Then run your counted versions on sizes 10, 100, and 1,000. Print the counts.

How does the count relate to n in each case?

Hint 1

For sum_list, add count = 0 before the loop and count += 1 inside it:

def sum_list_counted(lst):
    count = 0
    total = 0
    for x in lst:
        total += x
        count += 1
    return total, count

Do the same for has_pair_with_sum — put the counter inside the inner loop.

Hint 2

For sum_list, count should equal n exactly. For has_pair, try dividing count by n² — you should get roughly 0.5 for every input size.

Solution

sum_list: count = n exactly. has_pair: count/n² ≈ 0.5 regardless of n. The quadratic function does about n²/2 operations.

def sum_list_counted(lst):
    count = 0
    total = 0
    for x in lst:
        total += x
        count += 1
    return total, count

def has_pair_counted(lst, target):
    count = 0
    for i in range(len(lst)):
        for j in range(i + 1, len(lst)):
            count += 1
            if lst[i] + lst[j] == target:
                return True, count
    return False, count

for n in [10, 100, 1000]:
    lst = list(range(n))
    _, c1 = sum_list_counted(lst)
    _, c2 = has_pair_counted(lst, -1)
    print(f"n={n}  sum_list: {c1}  has_pair: {c2}  has_pair/n²: {c2/n**2:.3f}")

Exercise 2.2 — Discover the Pattern

Look at the counts from has_pair_counted. Divide each count by n, then by n².

n count count / n count / n²
10 ? ? ?
100 ? ? ?
1000 ? ? ?

Which ratio stays roughly constant — count/n or count/n²?

When $n = 1{,}000{,}000$, the count will be about $500{,}000{,}000{,}000$ (500 billion). Does it matter that it's $n^2/2$ instead of $n^2$? Would the "÷2" make any practical difference at that scale?

Hint 1

count/n grows (4.5, 49.5, 499.5...) but count/n² stays at ~0.5. The growth is proportional to n², not n.

Hint 2

At n = 1,000,000: n² = 10¹², and n²/2 = 5×10¹¹. The difference is just 2× — the same factor whether n is 10 or a million. The ÷2 doesn't change how fast it grows.

Solution

count/n² stays constant at ~0.5. This tells us the count grows proportionally to n².

The ÷2 is just a constant multiplier — it makes everything exactly half as much work, but the growth rate is the same. Whether it's n², n²/2, or 7n², the count still quadruples when n doubles. The shape of growth is what matters, not the exact count.

Exercise 2.3 — Does the Constant Matter?

Write two functions that both loop through a list once, but one does 3× as much work per element. For example, one adds each element once, the other adds it three times.

Time both on sizes 10,000, 100,000, and 1,000,000. Compute the ratio of their times for each size.

Does the ratio stay constant or change as n grows?

Hint 1
def work_n(lst):
    s = 0
    for x in lst:
        s += x
    return s

def work_3n(lst):
    s = 0
    for x in lst:
        s += x
        s += x
        s += x
    return s
Hint 2

The ratio should be roughly the same (around 2–4×) regardless of n. The 3× constant makes it proportionally slower, but both grow at the same rate.

Solution

The ratio stays roughly constant. Both functions are "linear" — they grow at the same rate. The constant multiplier doesn't change the growth family.

def work_n(lst):
    s = 0
    for x in lst:
        s += x
    return s

def work_3n(lst):
    s = 0
    for x in lst:
        s += x
        s += x
        s += x
    return s

for n in [10000, 100000, 1000000]:
    lst = list(range(n))
    start = time.time(); work_n(lst); t1 = time.time() - start
    start = time.time(); work_3n(lst); t2 = time.time() - start
    print(f"n={n}  work_n: {t1:.4f}s  work_3n: {t2:.4f}s  ratio: {t2/t1:.1f}x")

We care about the growth rate, not the exact count. Whether it's $n$, $2n$, or $100n$ — they all grow at the same rate. Whether it's $n^2$, $n^2/2$, or $5n^2 + 3n$ — they all grow quadratically.

We need a shorthand that captures the growth family and throws away the constants. How would you write "this algorithm grows like n²"?

Part 3: Inventing a Notation~20 min

You keep saying things like "this grows like n" or "this grows like n squared." Let's make that precise.

Exercise 3.1 — Name the Families

You've already seen two growth families: one where count = n (linear) and one where count ≈ n² (quadratic). But there are others.

Try this experiment. Write a function that searches a sorted list by repeatedly cutting the search range in half:

def halving_search_counted(lst, target):
    count = 0
    lo, hi = 0, len(lst) - 1
    while lo <= hi:
        count += 1
        mid = (lo + hi) // 2
        if lst[mid] == target:
            return count
        elif lst[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return count

Run it on sorted lists of size 10, 100, 1,000, 10,000, and 100,000 (searching for -1 so it always fails). How many steps does it take each time?

The count barely grows even as n goes from 10 to 100,000. What mathematical function grows this slowly?

Your answer:

Steps for n=10:

Your answer:

Steps for n=100,000:

Your answer:

What function of n gives these values:

Hint 1

You should see roughly 4, 7, 10, 14, 17 steps. Each time n gets 10× bigger, the count only increases by about 3.

Hint 2

Think: how many times can you halve a number before it reaches 1? That's $\log_2(n)$. Check: $\log_2(10) \approx 3.3$, $\log_2(100000) \approx 17$. Matches!

Solution

The count grows as $\log_2(n)$ — logarithmic growth. Doubling the input adds just one more step. This is spectacularly slow growth.

for n in [10, 100, 1000, 10000, 100000]:
    lst = list(range(n))
    steps = halving_search_counted(lst, -1)
    print(f"n={n:>6}  steps: {steps}")

Exercise 3.2 — The Growth Families

You've now discovered three growth families from real code:

What the code does Count grows like Name
Single loop over n items n ?
Loop inside a loop ?
Halve the range each step log(n) ?

There are also two more common ones:

Give each growth family a descriptive one-word name.

Your answer:

Always the same, regardless of n:

Your answer:

Grows like log(n):

Your answer:

Grows like n:

Your answer:

Grows like n × log(n):

Your answer:

Grows like n²:

Hint 1

Think of adjectives: something that never changes is constant. Something that grows in a straight line is linear. Something involving a square is quadratic.

Solution
  • 1 (always the same): Constant
  • log(n): Logarithmic
  • n: Linear
  • n × log(n): Sometimes called "linearithmic" — between linear and quadratic
  • n²: Quadratic

Exercise 3.3 — Drop the Noise

You discovered that $n^2/2$ and $n^2$ and $7n^2$ all grow the same way. The constant multiplier doesn't matter. And you discovered that when $n$ is large, $n^2 + 10n$ is basically just $n^2$ (the $+10n$ is negligible).

So to describe the growth family, we can simplify any expression by:

  1. Dropping constant multipliers ($3n$ → $n$)
  2. Keeping only the fastest-growing term ($n^2 + 10n$ → $n^2$)

Try it. Simplify each expression to just the growth family:

  1. $5n + 3$
  2. $2n^2 + 10n + 7$
  3. $100$ (just the number, no $n$)
  4. $n^3 + 1000n^2$
  5. $3n \log n + 5n$
Your answer:

5n + 3 →

Your answer:

2n² + 10n + 7 →

Your answer:

100 →

Your answer:

n³ + 1000n² →

Your answer:

3n·log(n) + 5n →

Hint 1

For each: find the term that grows fastest, drop its constant. $5n + 3$: fastest term is $5n$; drop the 5 → answer is $n$.

Hint 2

For $n^3 + 1000n^2$: even though 1000 is a big number, $n^3$ still dwarfs $1000n^2$ when n is large. Try n = 10000: $n^3 = 10^{12}$, $1000n^2 = 10^{11}$. The $n^3$ term is 10× bigger.

Solution
  1. $5n + 3$ → n
  2. $2n^2 + 10n + 7$ →
  3. $100$ → 1 (constant — doesn't grow)
  4. $n^3 + 1000n^2$ →
  5. $3n\log n + 5n$ → n·log(n)

Exercise 3.4 — Invent a Shorthand

You keep writing "this algorithm grows like n²" or "grows like n·log(n)." That's wordy. Invent a shorter way to write it.

Requirements for your shorthand:

Write your notation for each family. Any format you like.

Your answer:

Your shorthand for 'grows like 1 (constant)':

Your answer:

Your shorthand for 'grows like n':

Your answer:

Your shorthand for 'grows like n²':

Hint 1

Most people put the growth term inside some kind of wrapper — parentheses, brackets, a letter followed by the term. There's no wrong answer as long as it's unambiguous.

Solution

Any consistent notation works! You might have written ~n², Growth(n²), [n²], or something else entirely.

The standard notation used by computer scientists is O(n²) — the letter O followed by the growth term in parentheses. The O stands for "Order of" — as in "the order of growth."

Exercise 3.5 — See the Hierarchy

Plot all five growth families on the same graph for $n$ from 1 to 100:

Which families look practically the same at small n? Which one towers over everything?

Provided — setup
import numpy as np
import matplotlib.pyplot as plt

n = np.arange(1, 101)
Hint 1
plt.figure(figsize=(8, 6))
plt.plot(n, np.ones_like(n), label='O(1)')
plt.plot(n, np.log2(n), label='O(log n)')
plt.plot(n, n, label='O(n)')
plt.plot(n, n * np.log2(n), label='O(n log n)')
plt.plot(n, n**2, label='O(n²)')
plt.xlabel('n')
plt.ylabel('Operations')
plt.legend()
plt.title('The Big O Hierarchy')
plt.show()
Solution

The hierarchy is clear: $O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2)$. At n = 100, O(n²) = 10,000 while O(n) = 100 — a 100× difference that only gets worse as n grows.

plt.figure(figsize=(8, 6))
plt.plot(n, np.ones_like(n), '-', linewidth=2, label='O(1)')
plt.plot(n, np.log2(n), '-', linewidth=2, label='O(log n)')
plt.plot(n, n, '-', linewidth=2, label='O(n)')
plt.plot(n, n * np.log2(n), '-', linewidth=2, label='O(n log n)')
plt.plot(n, n**2, '-', linewidth=2, label='O(n²)')
plt.xlabel('n')
plt.ylabel('Operations')
plt.legend()
plt.title('The Big O Hierarchy')
plt.grid(True, alpha=0.3)
plt.show()

The notation you just invented is called Big O notation. Computer scientists write:

$$O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(n^3) < O(2^n)$$

$O(f(n))$ means "grows at most as fast as $f(n)$." When we say an algorithm is $O(n^2)$, we mean: as the input gets large, the time grows no faster than some constant times $n^2$.

The formal notation was introduced by Paul Bachmann in 1894 and popularised by Edmund Landau — but the idea is exactly what you discovered: classify algorithms by their growth family, ignore the constants.

Part 4: Analysing Real Code~20 min

Now that you have the notation, let's practise using it. Given a piece of code, how do you figure out its Big O without running it?

Exercise 4.1 — One Loop

What's the Big O of this function? The input is a list of length $n$.

def find_max(lst):
    best = lst[0]
    for x in lst:
        if x > best:
            best = x
    return best

How many times does the comparison x > best run?

Your answer:

Big O:

Hint 1

The loop runs once for every element in lst. That's n iterations, so n comparisons.

Solution

O(n) — one loop through n items.

Exercise 4.2 — Two Separate Loops

What about this one?

def double_scan(lst):
    total = 0
    for x in lst:       # first loop
        total += x
    for x in lst:       # second loop
        total += x * 2
    return total

The first loop does n operations, the second does another n. What's the total? What's the Big O?

Your answer:

Total operations:

Your answer:

Big O:

Hint 1

$n + n = 2n$. But we drop constants in Big O, so $2n$ simplifies to...

Solution

Total = $2n$. Big O = O(n). Two separate (non-nested) loops are still linear — the constant 2 drops away.

Exercise 4.3 — Nested Loops

And this?

def all_pairs(lst):
    pairs = []
    for i in range(len(lst)):
        for j in range(i + 1, len(lst)):
            pairs.append((lst[i], lst[j]))
    return pairs

For each value of i, how many times does the inner loop run? What's the total across all values of i?

Your answer:

Big O:

Hint 1

When i=0, the inner loop runs n-1 times. When i=1, n-2 times. And so on. Total = (n-1) + (n-2) + ... + 1 = n(n-1)/2 ≈ n²/2. Drop the constant → O(n²).

Solution

O(n²) — a loop inside a loop over n items.

Exercise 4.4 — No Loop at All

What's the Big O of this?

def get_first(lst):
    return lst[0]
Your answer:

Big O:

Hint 1

It does exactly one operation regardless of how big lst is.

Solution

O(1) — constant time. One index lookup, no loop.

Exercise 4.5 — Predict Then Measure

You just predicted the Big O of four functions: find_max (O(n)), double_scan (O(n)), all_pairs (O(n²)), and get_first (O(1)).

Now verify your predictions. For each function, add an operation counter (like you did in Part 2). Run on sizes 100, 1,000, and 10,000. Divide the count by your predicted growth function ($n$ or $n^2$ or $1$). If your prediction is correct, the ratio should stay roughly constant as n grows.

Hint 1

For find_max: add count += 1 inside the loop. Then check:

for n in [100, 1000, 10000]:
    lst = list(range(n))
    _, count = find_max_counted(lst)
    print(f"n={n}  count={count}  count/n={count/n}")

If count/n stays constant, it's confirmed O(n).

Hint 2

For all_pairs, check count/n². For double_scan, check count/n. For get_first, the count is always 1.

Solution

max/n stays at 1.00. pairs/n² stays at ~0.50. Predictions confirmed.

def find_max_counted(lst):
    count = 0
    best = lst[0]
    for x in lst:
        count += 1
        if x > best:
            best = x
    return best, count

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

import math
for n in [100, 1000, 10000]:
    lst = list(range(n))
    _, c_max = find_max_counted(lst)
    c_pairs = all_pairs_counted(lst)
    print(f"n={n}  max/n={c_max/n:.2f}  pairs/n²={c_pairs/n**2:.4f}")

Exercise 4.6 — Best, Worst, and Average

Consider linear search — scanning a list from left to right looking for a target:

def linear_search(lst, target):
    for i in range(len(lst)):
        if lst[i] == target:
            return i
    return -1
  1. Best case: What input makes this as fast as possible? How many comparisons?
  2. Worst case: What input makes this as slow as possible? How many comparisons?
  3. Average case: If the target is equally likely to be at any position, how many comparisons on average?

When someone says "linear search is O(n)" without further explanation, which case do they usually mean?

Your answer:

Best case — comparisons:

Your answer:

Worst case — comparisons:

Your answer:

Average case — comparisons:

Your answer:

O(n) usually refers to:

Hint 1

Best: target is the first element → 1 comparison. Worst: target is last or not in the list → n comparisons. Average: about n/2 comparisons.

Hint 2

n/2 is still O(n) since we drop the ½. So both worst and average case are O(n).

Solution
  • Best case: 1 comparison — target is at index 0. This is O(1).
  • Worst case: n comparisons — target is last or missing. This is O(n).
  • Average case: n/2 comparisons — still O(n) since we drop the ½.

Big O without qualification usually means worst case — it's a guarantee. "This algorithm is O(n)" means it will never take more than proportional-to-n time, no matter what input you give it.

Rules of thumb for reading code:

Pattern Big O
No loop — fixed number of operations O(1)
Single loop over n items O(n)
Two separate loops (not nested) O(n) — it's $n + n = 2n$, constants drop
Loop inside a loop O(n²)
Loop that halves the range each step O(log n)
Loop over n, each iteration does O(log n) work O(n log n)

Part 5: Memory Complexity~15 min

Time isn't the only resource that matters. Memory (space) does too. The same Big O notation works — instead of asking "how does time grow?" we ask "how much extra memory does this algorithm need as the input grows?"

Exercise 5.1 — How Much Extra Space?

For each function, figure out how much extra memory it creates (beyond the input itself). Don't count the input list — just the new things the function allocates.

Function A:

def sum_in_place(lst):
    total = 0
    for x in lst:
        total += x
    return total

Function B:

def make_copy(lst):
    copy = []
    for x in lst:
        copy.append(x)
    return copy

Function C:

def make_matrix(n):
    matrix = []
    for i in range(n):
        row = [0] * n
        matrix.append(row)
    return matrix

For each: how many new items does it create? How does that depend on n?

Your answer:

Function A — new items created:

Your answer:

Function B — new items created:

Your answer:

Function C — new items created:

Hint 1

A: creates one variable total — that's it. Doesn't depend on n. B: creates a new list with n items — grows with n. C: creates n rows of n items each — that's n × n items.

Solution
  • A: 1 variable → O(1) space — constant, regardless of n
  • B: n items → O(n) space — grows linearly
  • C: n × n items → O(n²) space — grows quadratically

Exercise 5.2 — See the Space Grow

Create lists of increasing size and n×n matrices of increasing size. Count the number of elements in each to verify the growth pattern.

For lists: how does the element count grow with n? For matrices: how does the element count grow with n?

Hint 1
print("List (n items):")
for n in [10, 100, 1000, 10000]:
    lst = list(range(n))
    print(f"  n={n:>5}  elements: {len(lst):>10}  elements/n: {len(lst)/n}")

print("\nMatrix (n×n items):")
for n in [10, 50, 100, 200]:
    mat = [[0] * n for _ in range(n)]
    total_items = sum(len(row) for row in mat)
    print(f"  n={n:>3}  elements: {total_items:>10}  elements/n²: {total_items/n**2}")
Solution

elements/n stays at 1.0 for lists → O(n) space. elements/n² stays at 1.0 for matrices → O(n²) space. Same Big O idea, applied to memory instead of time.

print("List (n items):")
for n in [10, 100, 1000, 10000]:
    lst = list(range(n))
    print(f"  n={n:>5}  elements: {len(lst):>10}  elements/n: {len(lst)/n}")

print("\nMatrix (n*n items):")
for n in [10, 50, 100, 200]:
    mat = [[0] * n for _ in range(n)]
    total_items = sum(len(row) for row in mat)
    print(f"  n={n:>3}  elements: {total_items:>10}  elements/n^2: {total_items/n**2}")

Exercise 5.3 — The Time–Space Tradeoff

You need to check if a list has any duplicate values. Here are two approaches:

Approach 1 — Nested loops (no extra memory):

def has_dup_loops(lst):
    for i in range(len(lst)):
        for j in range(i + 1, len(lst)):
            if lst[i] == lst[j]:
                return True
    return False

Approach 2 — Use a set (extra memory):

def has_dup_set(lst):
    seen = set()
    for x in lst:
        if x in seen:
            return True
        seen.add(x)
    return False
  1. What's the time complexity of each? (Think about the loop structure.)
  2. What's the space complexity of each? (How much extra memory does each create?)
  3. Which is "better"?
Your answer:

Approach 1 — time / space:

Your answer:

Approach 2 — time / space:

Your answer:

Which is better and why:

Hint 1

Approach 1: nested loops → O(n²) time, but it creates nothing new → O(1) space. Approach 2: single loop → O(n) time, but the set can grow up to n items → O(n) space.

Solution
Time Space
Nested loops O(n²) O(1)
Set O(n) O(n)

Which is "better"? It depends. If you have a billion items and limited RAM, the O(1) space approach might be your only option (even though it's slow). If speed matters and you have memory to spare, the set wins easily.

This is the time–space tradeoff — one of the most fundamental tensions in computer science. Faster algorithms often use more memory, and memory-efficient ones are often slower. There's rarely a free lunch.

Every algorithm has both a time complexity and a space complexity. Now you know how to read and evaluate both. As you work through the chapters ahead, you'll see this tradeoff again and again — fast algorithms that use extra memory vs slow algorithms that work in-place.

Part 6: Why It Matters~10 min

Let's see the full picture. How long does each complexity class take on real-world input sizes?

Exercise 6.1 — The Race

Assume each operation takes 1 microsecond (one millionth of a second). For each complexity class, compute how many operations it takes at different input sizes, then convert to human-readable time.

Fill in this table for $n$ = 10, 1000, $10^6$, and $10^9$:

n O(1) O(log n) O(n) O(n log n) O(n²)
10 ? ? ? ? ?
1,000 ? ? ? ? ?
1,000,000 ? ? ? ? ?
1,000,000,000 ? ? ? ? ?

Which complexity classes are still practical at $n = 10^9$? Which ones are hopeless?

Provided — time formatter
import math

def human_time(microseconds):
    if microseconds < 1000:
        return f"{microseconds:.0f} us"
    elif microseconds < 1_000_000:
        return f"{microseconds/1000:.1f} ms"
    elif microseconds < 60_000_000:
        return f"{microseconds/1_000_000:.1f} s"
    elif microseconds < 3_600_000_000:
        return f"{microseconds/60_000_000:.1f} min"
    elif microseconds < 86_400_000_000:
        return f"{microseconds/3_600_000_000:.1f} hrs"
    elif microseconds < 31_536_000_000_000:
        return f"{microseconds/86_400_000_000:.1f} days"
    else:
        return f"{microseconds/31_536_000_000_000:.1f} years"
Hint 1

For each n, compute the number of operations for each class, then pass to human_time:

for n in [10, 1000, 10**6, 10**9]:
    print(f"n={n}")
    print(f"  O(1):      {human_time(1)}")
    print(f"  O(log n):  {human_time(math.log2(n))}")
    print(f"  O(n):      {human_time(n)}")
    print(f"  O(n lg n): {human_time(n * math.log2(n))}")
    print(f"  O(n^2):    {human_time(n ** 2)}")
    print()
Solution

At $n = 10^9$: O(n) takes about 17 minutes. O(n²) takes 31,710 years.

O(1), O(log n), and O(n) are practical at any scale. O(n log n) is fine for billions. O(n²) is only viable for thousands. This is why Big O matters — it's the difference between "runs on my laptop" and "outlives the sun."

for n in [10, 1000, 10**6, 10**9]:
    print(f"n = {n:,}")
    print(f"  O(1)      = {human_time(1)}")
    print(f"  O(log n)  = {human_time(math.log2(n))}")
    print(f"  O(n)      = {human_time(n)}")
    print(f"  O(n lg n) = {human_time(n * math.log2(n))}")
    print(f"  O(n^2)    = {human_time(n ** 2)}")
    print()

What You Invented

Your Journey

Exercise What You Did Concept
1.1–1.2 Timed two functions, plotted growth curves Empirical performance measurement
2.1–2.3 Counted operations, discovered constants don't matter Operation counting, growth rate
3.1–3.2 Discovered logarithmic growth, named the families Growth families
3.3–3.4 Simplified expressions, invented a shorthand Big O notation
3.5 Plotted the hierarchy O(1) < O(log n) < O(n) < O(n log n) < O(n²)
4.1–4.5 Analysed code snippets, predicted and verified Big O Static analysis + verification
4.6 Explored best, worst, and average case Worst-case analysis
5.1–5.3 Counted extra memory, discovered the time–space tradeoff Space complexity
6.1 Computed real-world running times Why Big O matters in practice

Going further:

Glossary

Time complexity
How the running time of an algorithm grows as the input size increases.
Space complexity
How the memory usage of an algorithm grows as the input size increases.
Big O notation
A shorthand for describing growth rate. O(n) means the time grows at most proportionally to n.
O(1) — constant
The algorithm takes the same time regardless of input size. Example: looking up an item by index.
O(log n) — logarithmic
The algorithm halves the problem at each step. Example: binary search.
O(n) — linear
The time grows proportionally to the input size. Example: scanning a list once.
O(n²) — quadratic
The time grows with the square of the input. Example: checking every pair in a list.
O(n log n) — linearithmic
Slightly above linear. Example: efficient sorting algorithms.
Constant factor
The multiplier in front of the growth term (e.g. the 3 in 3n). Big O ignores it because it doesn't affect the rate of growth.
Dominant term
The fastest-growing term in an expression. In 2n² + 10n + 7, the dominant term is n².