◈ python mapVol 3 · Ch 31/47
Volume 3 Python, from the metal up · chapter 31

31What "fast" really means

In Volume 1 we built the machine from the switch up and taught it to run our code. Ever since, the only question that mattered was does it work? Opening Volume 3, we ask the sharper one: is it fast enough to survive? The obvious way to answer that is seconds on a stopwatch, and it turns out that way is a trap. Here's the plan. First we'll time the exact same work seven times and watch the clock hand back seven different answers. Then we'll pin down why the machine itself makes that unavoidable. Then we throw the stopwatch out and start counting the work an algorithm does instead, a number that doesn't care which laptop it runs on. And the whole way through we keep asking the one question that actually matters — not how long did this take, but how does its cost grow as the input grows? By the end you'll look at a loop and count its operations without running it. You'll tell whether it will still be breathing at ten million items. And you'll know exactly why "it runs fine on my laptop" is the most expensive sentence in software.

Before we run anything, let's pin down the word this whole volume orbits. An algorithm is just a recipe: a finite list of steps that turns an input into an answer. Sum these numbers, sort these songs, find this name — each one is a recipe you could follow by hand on paper. The machine matters too, and it helps to keep the two separate in your head. The recipe says what steps to take, and the kitchen decides how fast each step happens. A 2 GHz laptop is a kitchen that ticks two billion times a second, while an older one ticks slower. Same recipe, different kitchens, different clock readings. And Python adds a wrinkle of its own, because the interpreter is part of the kitchen too: CPython spends its own instructions carrying out each step of your recipe. Hold onto that split, because this chapter is the story of learning to judge the recipe on its own. The kitchen keeps trying to sneak into our measurements, and we're about to catch it red-handed.

★ YOU ALREADY RUN THIS · counting the workThe paper phone book
A thousand-page phone book on the table, and you need Raj Patel. You don't start at page one. Your thumb lands somewhere near the middle, you read one name off the top of the page, and half the book is instantly dead to you. Then the middle of what's left, then again, and twenty-odd looks later you're on his street. Nobody taught you that. And notice the one thing you were never thinking about while you did it: seconds. You didn't try to read faster than usual. You arranged to read fewer entries. That instinct is this whole chapter. You already judge a method by the work it does, not by the clock it happened to finish on.
your thumb splitting the bookone look → half the names eliminated
entries your eye actually readthe operation count
how long the search feltseconds — a fact about you, not the method
ten million names, same thumbthe count is what survives a bigger n
pin it: you never once started at page one, because fast was never about reading quicker — it was about reading less.
iolinked · chapter 31 — the checkpoints6 steps
$ sections covered in What "fast" really means
01The stopwatch is a liar
02Count the work, not the clock
03It's the growth that bites, not the number
04Best, worst, average — plan for the bad day
05"It runs fine on my laptop"
06The one question that turns a coder into an engineer

01The stopwatch is a liar

Let's start with the most honest experiment there is. Here's a tiny function that adds up the numbers 0, 1, 2, … n-1 and does nothing else. We'll call it seven times in a row, on one machine, doing byte-for-byte identical work every time. No network, no disk, no randomness anywhere, so the readings should surely agree. If timing can't give one answer for the same work on the same box, it can't be our measure of "fast." Watch what the stopwatch tells us, because it's about to lie straight to our face.

stopwatch.pypython
import time

def work(n):
    total = 0
    for i in range(n):     # exactly n additions, every single run
        total += i
    return total

n = 2_000_000
for _ in range(7):
    t0 = time.perf_counter()
    work(n)                # the identical call, the identical work
    ms = (time.perf_counter() - t0) * 1000
    print(round(ms, 1))    # 67.3 · 67.4 · 68.5 · 67.2 · 68.1 · 67.7 · 66.6

Line by line: work(n) runs a loop that does the same n additions no matter what. time.perf_counter() reads a high-resolution clock before the call and again after it. The difference, times 1000, is the elapsed time in milliseconds. We ran it and got seven different numbers, from 66.6 ms up to 68.5 ms, for byte-for-byte the same work. Seven honest readings, seven answers — that experiment fails the most basic test of a measurement. The spread was only about 3% here, because the machine was nearly idle. Open a browser, start a video call, or run this on a loaded server. Now the slowest reading can be several times the fastest. (These are wall-clock times on one specific Intel machine, and yours will differ — which is exactly the point.)

A fair objection: why not just average the seven runs and call that the answer? Averaging does help, and real benchmarking tools do exactly that, so the instinct is sound. But look at what the average actually pins down: how fast this code runs on this machine, under today's load, with this Python version. Change any of those and your carefully averaged number is stale. Your teammate's laptop gives a different average, and the production server gives a third. So a measured time is a fact about one kitchen on one afternoon, however carefully you take it. It can never be a portable fact about the code itself. We want a number we can write in a book, hand to a stranger, and have it stay true on their machine. The stopwatch, averaged or not, cannot give us that, so something else will have to. That is the trade this chapter makes: give up the seconds, keep the certainty.

Why does the clock wobble? Recall Volume 1's machine: the CPU runs at some clock rate, and it keeps its hot data in a small fast cache. Crucially, the core is also shared, so the operating system constantly pauses your program to give other processes a turn. Your program is a guest on the machine, and guests don't control the schedule. That means the seconds you measure are really operations × time-per-operation, and the second factor belongs to the machine. It is set by the hardware and by whatever else the box happens to be doing this millisecond. Change the laptop, change the load, or change nothing at all and run it again — the seconds move. Seconds measure the kitchen, not the recipe.

It's worth seeing that pause with an engineer's eyes, because it is wonderfully concrete. The operating system keeps a scheduler, a piece of code that decides which process owns each core right now. Many times a second it steps in, saves your program's registers to memory, and hands the core to someone else. Your program doesn't even know it happened, and that's the beautiful part. From the inside it just ran, yet the wall clock kept ticking through every interruption. Worse, the other process fills the cache with its own data while it runs. When your program gets the core back, its first memory reads miss the cache and crawl out to slow RAM. So two runs of identical code genuinely execute differently at the metal, down to the cache lines. The wobble in our seven numbers isn't sloppy measurement. It's the true story of a shared machine, measured honestly. None of this is Python's fault, by the way, because a C program on the same box wobbles for exactly the same reasons.

work(2_000_000) the same recipe fast, idle laptop 31 ms ordinary machine 67 ms old / heavily loaded 240 ms operations counted 2,000,000 identical on all three
Fig — The seconds are a property of the machine and its mood. The operation count is a property of the algorithm — the same everywhere.
InteractiveSlide the machine — watch the seconds lie
Same call: work(2_000_000) stopwatch says 67 ms operations counted 2,000,000 fixed ✓
67 ms
The red bar (seconds) whips back and forth as you change the hardware. The blue count never moves — that is the thing worth measuring.

If seconds are the wrong yardstick, what is the right one? The blue number that never flinched →

02Count the work, not the clock

The fix is to stop timing and start counting. Pick the operations the algorithm actually performs — a comparison, an addition, a swap, a pass of the loop. Then count how many of them it does as a function of the input size. That count is a fact about the algorithm itself, not about the machine that happens to run it. It doesn't care about your clock speed, your cache, or the video call in the background. Run it on a supercomputer or on a calculator from 1995 and the count is the same integer. That's the number we'll learn to read straight off the code.

Now, an honest objection before we go further: not all operations cost the same nanoseconds. On a real CPU an integer addition is cheap, while a memory fetch can cost many times more. So isn't calling each one "one unit" a lie? It's a deliberate simplification, we should label it as exactly that, and here's why it earns its keep. The per-operation price is a kitchen fact again: it changes with the chip, the cache, the compiler. The count of operations is the recipe fact, and it's the part that explodes when inputs grow. Blurring a 2× difference between operation types costs us little, because we're hunting differences of a million times, not two. In practice you count the operation that runs most often, treat it as one unit, and move on. We'll sharpen this bookkeeping in the next chapter, but for now one step is one unit, on purpose.

Two words we will lean on for the whole volume. n is the size of the input — the number of songs in the playlist, rows in the table, pixels in the image. It is the one knob everything turns on. A basic operation is a single step we agree to treat as one unit of work. Now count the sum loop:

count.pypython
def work_counted(n):
    total = 0
    ops = 0
    for i in range(n):
        total += i
        ops += 1           # tally one addition per pass
    return ops

print(work_counted(10))         # 10
print(work_counted(2_000_000))  # 2000000  — the same on every machine, every run

The loop body runs once per value of i, and there are n values, so it does exactly n additions. Notice that we didn't time anything — we counted, and counting is exact. For n = 10 the answer is 10, and for n = 2,000,000 it is 2,000,000. Three runs in a row gave the identical number, because unlike the clock, a count is reproducible on any machine you like. This algorithm's cost is n: double the input, and you double the work. That sentence, "double the input, double the work", is a complete, machine-independent description of how this code behaves. And we got it without a stopwatch anywhere in sight.

Try the counting move on two quick variations, because it should feel mechanical by now. Suppose each pass of the loop did an addition and a comparison, two units instead of one. The cost becomes 2n: at n = 10 that's 20 units, and at n = 20 it's 40. Now suppose the loop steps by two, visiting only every other number. The cost becomes n/2: 5 units at n = 10, then 10 units at n = 20. Check yourself with a pencil: at n = 40 the three costs are 40, 80, and 20, each exactly double their n = 20 values. Notice what stayed the same in every version. Doubling the input doubled the work every single time, whether the cost was n, 2n, or n/2. Those constant factors move the line up or down, but they never change its shape. That shape is the thing we're learning to read, and the next section shows a shape that behaves very differently.

for i in range(n): total += i unrolls into one addition per pass → +1 +1 +1 +1 · · · +1 +1 exactly n boxes total cost n operations
Fig — Counting is just unrolling the loop in your head: n passes, one unit each, cost = n. No clock required.
SYNTAX · timing an algorithm honestly — and the instrument that replaces the clockthe perf_counter bracket, timeit's repeat-and-take-the-minimum, and the op-counter that never wobbles
# --- THE BRACKET. only the thing under test goes inside it --- from time import perf_counter data = build(n) -- setup FIRST, outside the clock t0 = perf_counter() -- start out = fn(data) -- the ONE thing you are timing dt = perf_counter() - t0 -- elapsed seconds, as a float # --- NEVER ONE SAMPLE. repeat, then take the MINIMUM --- import timeit ts = timeit.repeat(lambda: fn(data), number=1, repeat=7) best = min(ts) -- min, not mean: noise only ever ADDS # --- ch29's one-liner, straight from the shell --- $ python -m timeit -s "from prog import work" "work(2_000_000)" # --- AND THE READING THAT NEVER WOBBLES: count, don't time --- def work_counted(n): ops = 0 for i in range(n): ops += 1 -- one tally per basic operation return ops -- an integer. identical on every machine.
perf_counterThe highest-resolution clock Python offers, and monotonic, so a system clock adjustment cannot drag it backwards.
setup outsideBuild the input before t0. Inside the bracket you are timing list-building, and charging it to your algorithm.
repeat=7 + minInterference can only make a run slower, never faster. The smallest sample is the closest thing to the truth.
number=How many times to run the statement inside one sample. Raise it until a sample lasts tens of milliseconds.
-m timeitChapter 29's shell form. -s runs the setup once, untimed; the statement after it is what gets the clock.
the wobble, measuredSeven brackets around byte-identical work on this box: 76.9 to 87.7 ms, a 14% spread.
the count, measuredThree runs, three identical integers: 2000000. That is the whole reason this volume counts.
the honest limitA count cannot tell you which of two O(n) loops is faster on real metal. Chapter 33 takes that up.
you type
# ---------- stopwatch.py: seven brackets around identical work ----------
$ python stopwatch.py

# ---------- the same work through timeit: repeat 7, report min/max/mean ----------
$ python -c "import timeit, stopwatch as s
ts = timeit.repeat(lambda: s.work(2_000_000), number=1, repeat=7)
print('best  %.1f ms' % (min(ts)*1000))
print('worst %.1f ms' % (max(ts)*1000))
print('mean  %.1f ms' % (sum(ts)/len(ts)*1000))"

# ---------- and from the shell, ch29's form ----------
$ python -m timeit -s "from stopwatch import work" "work(2_000_000)"

# ---------- now the count, three times in a row ----------
$ python -c "from count import work_counted
print([work_counted(2_000_000) for _ in range(3)])" 
you see
$ python stopwatch.py
[76.9, 84.0, 87.7, 86.3, 85.2, 87.3, 82.6]
spread: 14.0 %

$ python -c "... timeit.repeat(..., repeat=7) ..."
best  86.1 ms
worst 91.2 ms
mean  89.6 ms

$ python -m timeit -s "from stopwatch import work" "work(2_000_000)"
:0: UserWarning: The test results are likely unreliable. The worst
time (400 msec) was more than four times slower than the best time
(87.6 msec).
5 loops, best of 5: 87.6 msec per loop

$ python -c "... [work_counted(2_000_000) for _ in range(3)] ..."
[2000000, 2000000, 2000000]
where beginners trip
  • The chapter's machine said 67 ms; this one says 87. Same code, same n — and that gap is the lesson.
  • timeit printed the warning itself: one sample hit 400 ms against a best of 87.6. The tool knows the clock lies.
  • Take the min, never the mean. Our mean of 89.6 sat above five of the seven samples, dragged up by one bad one.
  • Put build(n) inside the bracket and you have timed list-building. Move it into -s setup, or before t0.
  • time.time() is wall-clock and adjustable, and on Windows it resolves to about 15.6 ms. Never benchmark with it.
  • perf_counter() counts from an arbitrary origin, so only differences mean anything. Printing one alone tells you nothing.
  • ops += 1 is not free either: the counter slows the loop it measures. Count in a copy, time the original.
  • The count needed no warm-up, no repeats, and no warning banner. It is the same integer on a supercomputer.
The deeper cut — is one += really "one operation"?
Honestly, no — and this is a labeled simplification we will refine, not a fib. A single total += i in CPython is several bytecode instructions and dozens of hardware steps (Volume 1's fetch–decode–execute). Treating it as "1" works because every pass of the loop does the same fixed amount of that low-level work, so the true time is (some constant) × n. When we count operations we deliberately drop that constant, because it is exactly the machine-dependent part we are trying to escape. Chapter 1 makes this precise — it is the whole reason Big-O throws constants away — and Chapter 13 comes back to warn you that two algorithms with the same count can still differ in wall-clock time because of that constant (cache, branch prediction, data layout). For now: count the dominant repeated step, and don't sweat the multiplier.
Wait —
If the whole answer is just the number n, why do people make such a fuss? Because the number at one size tells you almost nothing. The shape is everything.

03It's the growth that bites, not the number

Knowing an algorithm costs "1000 operations at n = 1000" is nearly useless on its own. The question that matters is: when n gets bigger, how fast does the cost grow? Because inputs always get bigger. Today's 1000 songs are tomorrow's million. Watch what happens when we put one loop inside another — the playlist code that compares every song against every other song, to find duplicates say:

growth.pypython
def nested_ops(n):
    ops = 0
    for i in range(n):
        for j in range(n):    # a full inner sweep for EVERY outer step
            ops += 1
    return ops

for n in (5, 100, 1000):
    print("n =", n, " single loop:", n, " nested:", nested_ops(n))
# n = 5     single loop: 5     nested: 25
# n = 100   single loop: 100   nested: 10000
# n = 1000  single loop: 1000  nested: 1000000

The outer loop runs n times, and for each of those the inner loop runs a full n times. So the body fires n × n = n² times in total. We ran it, and at n = 5 the two loops are still close: 5 vs 25. At n = 100 they are 100 vs 10,000, and at n = 1000 they are 1000 vs a million. The single loop and the nested loop start life as near-neighbours and end up in different universes. That divergence, not any single value, is what decides whether your code survives its own success. And success, here, just means growth: more users, more songs, more rows, more n.

Here's why the square has to appear, so it never feels like a trick. Draw the work as a grid: one row per outer pass, one column per inner pass. With 5 songs you get 5 rows of 5 checks each, and 5 × 5 = 25 cells. That grid is literally a square, which is why this growth is called quadratic, from the Latin for square. Every time you add one song, you don't add one cell. You add a whole new row and a whole new column. At n = 1000 the grid holds 1000 × 1000 = a million cells, exactly the million we measured. A thousand-song playlist becomes a million-cell grid of checks, all from one innocent-looking nested loop. Step the walkthrough above and watch the inner counter restart from zero on every outer pass. That restart is the whole story: the inner loop refuses to remember its previous laps, so the work multiplies instead of adding.

InteractiveTurn the one knob — n — and watch two costs split apart
input size n = 18 single loop → n 18 nested loop → n × n 324 the nested loop does 18× more work
n²=324
Both bars share one scale. Small n: neighbours. Large n: the amber bar pins the wall while the blue one is still a sliver. That is n² pulling away from n.
Where you meet this
Google indexes on the order of hundreds of billions of web pages. An algorithm that compared every page to every other page — an n² sweep — would need something like 10²³ operations; at a billion operations a second that is millions of years per search. It is not "too slow," it is physically impossible, and that impossibility is why search is built from hashing and inverted indexes (Volume 1's dict, at scale) instead. The same wall is why a feature that flies in a demo can melt a company's servers on launch day: the demo had n = 50 rows; production had 50 million. The code never changed. The knob did.

Fine — count the operations and watch the growth. But count for which input? The luckiest one, or the cruelest? →

04Best, worst, average — plan for the bad day

Most algorithms don't do the same amount of work on every input of size n. Search a playlist for one song, scanning from the top and stopping when you find it. If the song sits at the first track, you make exactly one comparison and stop. If it's the last track, or missing entirely, you make all n. Same code and same n, yet wildly different cost, depending on the arrangement of the data. So we give the possibilities names, and we call them the three cases.

cases.pypython
def scan(playlist, target):
    comparisons = 0
    for song in playlist:
        comparisons += 1
        if song == target:
            return comparisons     # found it — stop early
    return comparisons             # fell off the end — not found

data = list(range(1000))                   # a 1000-item playlist
print(scan(data, 0))       # 1     BEST:    target is first
print(scan(data, 999))     # 1000  WORST:   target is last
print(scan(data, 1234))    # 1000  WORST:   target isn't there at all
from statistics import mean
print(mean(scan(data, s) for s in data))   # 500.5  AVERAGE over all present items

Best case is the luckiest input the algorithm can meet: target first, 1 comparison, the smallest cost it can get away with. Worst case is the cruelest input, with the target last or absent, costing all 1000 comparisons. That is the most work the algorithm can ever be forced to do. Average case is the expected cost over typical inputs, and we measured it honestly. We searched for each of the 1000 present songs once, and the mean came out at 500.5 comparisons. That is roughly n/2, which makes sense, because a random hit lands halfway down on average. All three are real, verified numbers from the run above, not textbook estimates.

Two of those numbers deserve a second look. First, the mean of 500.5 is not mysterious: the costs of the 1000 searches were simply 1, 2, 3, … up to 1000. Averaging a straight run of numbers gives the midpoint, (1 + 1000) / 2 = 500.5, and that's the whole calculation. Second, and this is the engineering habit: when the three cases disagree, plan on the worst. The best case is luck, and luck is not a design input. The average case leans on an assumption — that every song is equally likely to be asked for — and real users break assumptions daily. Only the worst case is a guarantee, a ceiling the algorithm cannot break no matter how cruel the input. When an engineer promises "this responds in under a second," it's the worst case doing the promising. Volume 3 will therefore speak mostly in worst cases, and now you know that's caution, not pessimism.

first middle last / gone BEST 1 comparison AVERAGE ≈ n/2 = 500 WORST n = 1000 We design for the worst case — it's the only promise we can actually keep.
Fig — Same algorithm, same n, three costs — set by where the answer hides. The worst case is the guarantee.
↺ The thing people get backwards
People assume the average case is the honest, grown-up number to quote — "worst case is just paranoia." Backwards. The average is a bet on your inputs being typical, and reality loves the atypical: the worst case is precisely what an adversary sends you, what a bug produces, what happens the day you go viral. Netflix at peak, a bank at market open, an emergency dispatcher — none of them care that the code is usually fast. Engineering means promising a ceiling, and a ceiling is a worst-case claim. Average is for expectations; worst is for guarantees.
Best case is almost never the interesting one
A slick-looking algorithm can have a wonderful best case and a catastrophic worst case (you'll meet exactly this in quicksort, ch7). Quoting the best case is how benchmarks lie. Unless stated otherwise, "the complexity of X" in this volume means its worst case.

Now we can state the yardstick precisely — and use it to defuse the most dangerous sentence a programmer ever says →

05"It runs fine on my laptop"

Here is the whole chapter's payoff, and it is genuinely scary. Take that nested n² loop. On this machine each inner step costs about 28 nanoseconds (measured: the n = 1000 run took 28.2 ms for a million steps). Now watch what the same code does as we feed it more data:

laptop.pypython
per_op = 28.16e-9              # seconds per inner step, measured on this machine

for n in (100, 10_000, 10_000_000):
    steps = n * n              # the nested loop runs the body n × n times
    seconds = steps * per_op
    print(n, steps, seconds)
# n=100        steps=10,000              -> 0.0003 s      a blink
# n=10,000     steps=100,000,000         -> 2.8 s         a pause
# n=10,000,000 steps=100,000,000,000,000 -> 2,816,000 s ≈ 32.6 DAYS

The demo runs at n = 100 and finishes in a third of a millisecond — instant. Ship it. Then real data arrives, and now there are ten million items to handle. The input got 100,000× bigger, but the work is n², so the work got 100,000² = ten billion times bigger. The "instant" program now needs a month to finish a single run, and nobody changed a line of code. Read that again, because it is the whole trap: same code, same correctness, and growth alone did this. For contrast, a single pass over those same ten million items (cost n, not n²) took 312 ms in our test. That means the smarter algorithm is roughly nine million times faster on the exact same hardware. That gulf is what this whole volume is for.

Don't take "a month" on faith, because the whole point of this chapter is that you can check it. At n = ten million, n² is 10⁷ × 10⁷ = 10¹⁴ steps, a hundred trillion of them. Each step costs about 28 nanoseconds on this machine, so the total is 10¹⁴ × 28 ns = 2.8 million seconds. A day holds 86,400 seconds, and 2,800,000 / 86,400 comes out near 32 days. That's the month, derived on the back of an envelope from one small measured run. And this is the superpower worth noticing: we predicted a catastrophe without suffering it. Nobody has to sit through a 32-day run to learn the design is doomed. Count the steps, multiply by one honest measurement, and the future of the program is readable in advance. Keep that envelope habit, because we'll use it on every algorithm this volume meets.

InteractiveDrag n from demo to production — watch "instant" become "months"
the same n² code, given n = 100 items n=100 (demo) n=10,000,000 (production) projected time for one run 0.0003 s a blink — ship it
0.0003 s
You never touched the algorithm. You only changed how much data it eats — and it crossed from "instant" to "walk away for a month."

Myth

"It's fast — I tested it and it came back instantly."

Reality

You tested it at the size you had on your laptop. "Instant at n = 100" and "instant at n = 10,000,000" are completely different claims for an n² algorithm — one is true and the other is off by a factor of ten billion. Speed you didn't measure at scale is speed you're guessing at.
The most expensive sentence in software
"It runs fine on my laptop" has shipped more outages than any bug. The laptop is the best case for input size — small, local, uncontended. Production is the worst case for all three. The fix isn't a faster laptop: a machine 100× faster only buys back the ground you lose by making n ten times bigger on an n² curve. You cannot out-hardware a bad growth rate — you have to change the curve.
InteractiveBuy a faster computer — watch it fail to save the bad curve
Same 1-second budget. Your machine is faster — how big an input still finishes in time? O(n) linear 1.0 billion ×1 O(n²) quadratic 31.6 thousand ×1 O(2ⁿ) exponential 29 items +0 items THE PART THAT SHOULD SCARE YOU ×1 the hardware → the exponential curve gains +0 items You can't out-buy a bad curve — you have to change it.
×1 faster
Bars are log-scaled: the largest input each algorithm can finish in the budget. Crank the hardware to a million× and the blue (linear) bar fills — but the amber n² bar crawls and the red 2ⁿ bar barely twitches. Faster silicon buys back a bad curve almost nothing.

So the real skill isn't timing code. It's reading its shape before it ever runs. That's a way of thinking — let's name the move →

06The one question that turns a coder into an engineer

Everything we have done so far collapses into a single reflex you can carry. When a beginner meets a piece of code, they ask "how long did it take?" That is a question about the kitchen, and its answer changes with every machine and every mood. The engineer asks "how does its cost grow as n grows?" That is a question about the recipe, and its answer is fixed forever. That swap, from measuring the run to reading the growth, is the mental move this whole volume installs. It is also why we could predict a 32-day runtime without waiting 32 days. From here on, every chapter in this volume asks that one question of a new family of algorithms.

The idea that you could separate the algorithm from the machine is not obvious. Somebody had to invent it. It's the heart of what's called asymptotic analysis. Donald Knuth brought it into computing and championed it in The Art of Computer Programming. The move is to judge an algorithm by how it behaves as n heads toward infinity. At that extreme the constant, machine-specific noise falls away, and only the growth remains. Knuth's bet was that the shape you find out there tells the truth about every realistic n too. The way of thinking underneath is worth stealing wholesale: to understand a system, find the one variable it scales with, then ask what happens when you push that variable to the extreme.

"How long did it take?" answer changes with the machine → the kitchen ✗ "How does it grow?" answer is fixed forever → the recipe ✓
Fig — The one-question upgrade. Stop timing the kitchen; start reading the recipe.

And here's the friendly part: you already run this analysis off the computer. You weigh whether to phone ahead or just drive to all five shops. You sense that inviting "a few more" people is fine, but that inviting "everyone" is not. You know a chore that's quick for one week's laundry becomes a nightmare for a year's. That instinct is growth-rate intuition, and you use it every day without ever naming it. This volume takes that instinct, makes it exact, and gives it a precise language to speak. By the end, you'll aim it at code as naturally as you aim it at errands.

NOW WRITE IT YOURSELFtwo searches, two counters — write them, then read the curves off three sizes
Write the two functions, both instrumented. linear_search(data, target) walks the list from the front, comparing each item, and returns (index, ops) where ops is the number of comparisons it made. binary_search(data, target) does the same job on sorted data by keeping a lo and a hi, looking at the middle, and throwing away the half that cannot hold the answer — and it returns the same (index, ops) pair. Return -1 for the index when the target is absent. Then measure nothing and count everything. Build data = list(range(n)) at n = 1,000, 100,000 and 10,000,000, search for a target that is not there — the worst case — and tabulate the two counts side by side with math.log2(n). Before you run it, write down your prediction for each: linear's count is exactly what, and binary's is within one of what? Then run the doubling test on both. Take n = 1000, 2000, 4000, 8000, still worst case, and report what happens to each count when n doubles. One of them multiplies; the other adds. Say which, and say why the code makes it inevitable. Finally, three questions to answer in writing. First: find one input where linear search beats binary search outright, and explain why that does not make it the better algorithm. Second: run each count three times and say what you got that a stopwatch could never have given you. Third: at n = ten million, binary search answers in a couple of dozen comparisons — so why does this chapter still call linear search "fine" for a playlist of two hundred songs? One hint and no more: binary_search needs the list sorted, and list(range(n)) already is — which is itself a cost somebody paid.
show the solution
# ---------- the two functions, each carrying its own counter ----------
import math

def linear_search(data, target):
    ops = 0
    for i, x in enumerate(data):
        ops += 1                       # one comparison per item, always
        if x == target:
            return i, ops
    return -1, ops                     # fell off the end

def binary_search(data, target):       # data must be SORTED
    ops = 0
    lo, hi = 0, len(data) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        ops += 1                       # one comparison per halving
        if data[mid] == target:
            return mid, ops
        if data[mid] < target:
            lo = mid + 1               # throw away the left half
        else:
            hi = mid - 1               # throw away the right half
    return -1, ops


# ---------- worst case at three sizes: the target is absent ----------
print("n          linear   binary   log2(n)   ratio")
for n in (1_000, 100_000, 10_000_000):
    data = list(range(n))
    _, lin = linear_search(data, n + 1)
    _, bin_ = binary_search(data, n + 1)
    print("%-10d %-8d %-8d %-9.2f %d" % (n, lin, bin_, math.log2(n), lin // bin_))

n          linear   binary   log2(n)   ratio
1000       1000     10       9.97      100
100000     100000   17       16.61     5882
10000000   10000000 24       23.25     416666

#   linear  = exactly n. every item, every time, no early exit available.
#   binary  = log2(n), rounded up and give or take one, because each pass
#             halves what is left: 1000 -> 500 -> 250 -> ... -> 1.
#   The predictions were checkable BEFORE the run, and they held.


# ---------- the doubling test ----------
prev_l = prev_b = None
for n in (1_000, 2_000, 4_000, 8_000):
    data = list(range(n))
    _, lin = linear_search(data, n + 1)
    _, bin_ = binary_search(data, n + 1)
    ...

n=1000   linear 1000   -       binary 10  -
n=2000   linear 2000   x2.0    binary 11  +1
n=4000   linear 4000   x2.0    binary 12  +1
n=8000   linear 8000   x2.0    binary 13  +1

#   Double n, and linear MULTIPLIES by 2. Binary ADDS 1.
#   Inevitable from the code: linear's loop body runs once per item, so
#   twice the items is twice the body. Binary's loop discards half the
#   remaining range per pass, so twice the data is exactly ONE more
#   discard before it is down to a single candidate.


# ---------- question 1: where linear wins ----------
data = list(range(1_000_000))
linear, target at index 0 : 1 comparisons
binary, target at index 0 : 19 comparisons
linear, target absent     : 1000000 comparisons
binary, target absent     : 19 comparisons

#   Linear beats binary 1 to 19 when the target sits in slot 0 -- and
#   that is exactly the BEST case, which section 04 told us is luck, not
#   design. Look at the same table's worst-case row: 1,000,000 against 19.
#   Quoting the best case is how benchmarks lie. The guarantee is the
#   ceiling, and binary's ceiling is 19 where linear's is a million.


# ---------- question 2: what the stopwatch could never give ----------
data = list(range(100_000))
[linear_search(data, 100_001)[1] for _ in range(3)]  -> [100000, 100000, 100000]
[binary_search(data, 100_001)[1] for _ in range(3)]  -> [17, 17, 17]

#   Three runs, zero variation. No warm-up, no "best of five", no
#   background-load caveat, and nothing to average. The count is a
#   property of the algorithm; the milliseconds were a property of the
#   afternoon. That is the whole trade this chapter makes.


# ---------- question 3: why linear is still fine at n = 200 ----------
#   Worst case, linear does 200 comparisons; binary does 8. Both are
#   invisible at ~10 ns a comparison: 2 microseconds against 0.08.
#   Growth is about the shape as n climbs, not about winning at n = 200.
#   And binary needs SORTED input, which costs O(n log n) to produce.
#   Sorting 200 songs to save 192 comparisons once is a loss. Sort once
#   and search a million times, and it is the best trade in the volume.

We have the idea: count operations, watch the growth, design for the worst case. Now we give that growth a precise name and a notation — meet Big-O, the symbol that lets you say "n²" out loud and have every engineer on earth know exactly what you promised. That's Chapter 01. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 31, in working code

Twelve tiny programs that never once touch a stopwatch — they count the work instead, so you can watch linear, quadratic, logarithmic, and exponential growth pull apart in plain integers that come out identical on every machine on Earth.

Count the work, not the clock
Throw out the stopwatch and tally operations instead. The count is a fact about the algorithm — the same integer on any machine, every run.
Linear vs quadratic — growth is the whole story
One loop costs n; a loop inside a loop costs n². Watch them start as neighbours and end up in different universes as n climbs.
Best, worst, average — plan for the bad day
The same linear search costs 1, or n, or about n/2 — set entirely by where the answer hides. We design for the worst case, because it is the only promise we can actually keep.
Why the shape wins
O(1) beside O(n), halving down to log n, and the full growth zoo — proof that at scale the growth rate crushes every constant.
end of chapter 31 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked