iolinked / Algorithms & complexity — Volume 3
Python · Volume 3 · algorithms & complexity

Algorithms — the art of doing more with less

An algorithm is a recipe for a machine; complexity is the true price of that recipe. Learn to read the price — and to lower it — and you cross the line from someone who writes code that works to someone who designs systems that scale. That line is the 1%.

Two programmers solve the same problem. One's code finishes before you blink; the other's is still running when the sun burns out — and on the screen they look nearly identical. This volume is the invisible difference between them, and how to always be the first programmer.

input size n → operations / cost → O(1) O(log n) O(n) O(n log n) O(n²) O(2ⁿ)
The whole volume in one picture — as the input n grows, the cost curves fan apart catastrophically. Which curve your algorithm rides decides whether it finishes instantly or never. Everything here is about choosing the lower curve. Keep these six colours in mind — they recur in every diagram.
🧭
How to read this volume
Volumes 1 and 2 taught you the machine and how to build software on it; this one teaches you to make that software fast and scalable — the skill that separates coders from engineers. Two companions run alongside: the Algorithms Lab lets you play with these ideas live, and the Volume 3 trace walkthroughs let you step all twenty-four algorithms one beat at a time. Read here for the why; explore and step there for the how.

00What "fast" really means

Before you can make code fast, you have to agree on what "fast" is — and the obvious answer, seconds on a stopwatch, is a trap. This chapter builds the yardstick every other chapter in this volume will use: not seconds, but a count of the work an algorithm does as its input grows. By the end you will be able to look at a loop, count its operations without running it, predict whether it will still be alive at ten million items, and know exactly why "it runs fine on my laptop" is the most expensive sentence in software.

The stopwatch is a liar

Here is the same tiny function — add up the numbers 0, 1, 2, … n-1 — timed seven times in a row on one machine, doing identical work each time.

python
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 and after; the difference, times 1000, is milliseconds. We ran it and got seven different numbers — 66.6 ms up to 68.5 ms — for byte-for-byte the same work. That 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 and the slowest reading can be several times the fastest. (These are wall-clock times on one specific Intel machine — yours will differ, which is exactly the point.)

Why does the clock wobble? Recall Volume 1's machine: the CPU runs at some clock rate, keeps hot data in a small fast cache, and — crucially — is shared. The operating system constantly pauses your program to let other processes have the core. So the seconds you measure are really operations × time-per-operation, and that second factor is set by the hardware and by whatever else the machine happens to be doing this millisecond. Change the laptop, change the load, change nothing at all and run it again — the seconds move. Seconds measure the kitchen, not the recipe.

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 →

Count 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 — and count how many it does as a function of the input size. That count is a fact about the algorithm. It doesn't care about your clock speed, your cache, or the video call in the background. Run it on a supercomputer or a calculator from 1995 and the count is the same integer.

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:

python
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. We didn't time anything — we counted. For n = 10 the answer is 10; for n = 2,000,000 it is 2,000,000; three runs in a row gave the identical number. This algorithm's cost is n. Double the input, 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.

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.
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.

It'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:

python
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; for each of those, the inner loop runs a full n times — so the body fires n × n = n² times. We ran it: at n = 5 the two loops are close (5 vs 25). At n = 100 they are 100 vs 10,000. 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 success.

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? →

Best, 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, top to bottom, stopping when you find it. If it's the first track, you make one comparison. If it's the last — or missing — you make n. Same code, same n, wildly different cost, depending on the arrangement of the data. So we name three cases.

python
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 — target first, 1 comparison; the smallest cost the algorithm can get away with. Worst case is the cruelest — target last or absent, all 1000 comparisons; the most it can ever be forced to do. Average case is the expected cost over typical inputs: we searched for each of the 1000 present songs once and the mean was 500.5 comparisons — 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.

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 →

"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:

python
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: ten million items. The input got 100,000× bigger. But the work — n² — got 100,000² = ten billion times bigger, and the "instant" program now needs a month to finish a single run. Nobody changed a line of code. For contrast, a single pass over those same ten million items (cost n, not n²) took 312 ms in our test — the smarter algorithm is roughly nine million times faster on the exact same hardware. That gulf is what this volume is for.

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.

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 →

The one question that turns a coder into an engineer

Everything so far collapses into a single reflex. When you meet a piece of code, a beginner asks "how long did it take?" — a question about the kitchen, whose answer changes with every machine and every mood. The engineer asks "how does its cost grow as n grows?" — a question about the recipe, whose answer is fixed forever. That swap, from measuring the run to reading the growth, is the mental move this whole volume installs. It is why we could predict a 32-day runtime without waiting 32 days.

The idea that you could separate the algorithm from the machine like this is not obvious — it had to be invented. It's the heart of what's called asymptotic analysis, brought into computing and championed by Donald Knuth in The Art of Computer Programming: judge an algorithm by how it behaves as n heads toward infinity, and let the constant, machine-specific noise fall away. 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 you already do this off the computer. Deciding whether to phone ahead or just drive to five shops; sensing that inviting "a few more" people is fine but inviting "everyone" is not; knowing a chore that's quick for one week's laundry becomes a nightmare for a year's — that's growth-rate intuition, and you run it every day without naming it. This volume makes it exact, and gives it a language.

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. →

01The growth zoo — Big-O, and its cousins

Here is the promise of this chapter: by the end you will be able to look at a piece of code and say, out loud, how its cost grows — instantly, without running it, without a stopwatch. You will know why we throw away the "3" in 3n² and the whole +100n+7 behind it, and why that is not sloppiness but the deepest honesty about what will actually break at scale. And you will meet the growth zoo — nine animals from the tame O(1) to the monstrous O(n!) — and learn, on sight, which cage your algorithm is in.

Complexity is a price tag, not a stopwatch

A stopwatch measures this run, on this machine, this second. Move the same code to a laptop twice as fast and the number halves. That number tells you almost nothing durable. What an engineer needs is the thing that doesn't change when you swap machines: how the work grows as the input grows. That is time complexity, and the language for it is Big-O.

The unit is not seconds — it is operations: comparisons, additions, array reads, the elementary steps the machine actually takes. Call the input size n (the length of the playlist, the number of users, the pixels in the frame). Then ask a single question: if I double n, what happens to the number of operations? Linear search through an n-song playlist does, worst case, n comparisons — double the playlist, double the work. We write that O(n) and read it "order n". The machine's speed sets how many operations fit in a second; Big-O sets how fast that count climbs. Those are two different facts, and Big-O is the one that decides whether your program survives contact with a big n.

Same algorithm, two machines — the clock disagrees, the curve does not. slow phone n=1M → 8.0 s fast server n=1M → 0.4 s both, as a curve both straight lines → O(n) opsn →
Fig — The machine sets the slope's steepness (the constant); Big-O captures the shape. Both machines run an O(n) algorithm — straight lines — no matter how far apart their stopwatches read.

So Big-O keeps the shape and drops the machine. But it drops something else too — it throws away most of the formula. Watch why that is the honest thing to do →

Why we throw away the constants and the small terms

Suppose you count a routine's operations exactly and get f(n) = 3n² + 100n + 7. Big-O reports just O(n²) — it deletes the coefficient 3, the entire 100n term, and the +7. That looks like vandalism. It is not. At the scale where performance actually matters, one term eats all the others alive. Let's not assert it — let's count it.

where the work goes in 3n² + 100n + 7 n = 10 n² 23% 100n 76% n = 1000 n² = 96.8% ← the mountain 100n shrinks to 3%
Fig — Multiply n by 100 and the amber n² block goes from a fifth of the work to almost all of it. Big-O reports whichever block wins this race — here, O(n²).
python
def cost(n):
    return 3*n**2 + 100*n + 7          # an exact op-count, three terms

for n in (1, 100, 1000, 1_000_000):
    total       = cost(n)
    square_part = 3*n**2               # just the n² term
    print(n, f"{100*square_part/total:6.4f}%  is the n² term")

# 1         2.7273%  is the n² term
# 100      74.9869%  is the n² term
# 1000     96.7740%  is the n² term
# 1000000  99.9967%  is the n² term

Line by line: cost(n) returns the full three-term count. For each n we compute the total, then isolate square_part — the contribution of the term alone — and print its share of the whole. The story is in the last column. At n=1 the term is a rounding error: 2.7% of the work. By n=1000 it is 96.8%. At a million it is 99.9967% — the 100n and the +7 together are three parts in a hundred thousand. Reporting anything but would be describing the dust and ignoring the mountain. That is the rule, and now you have watched it become true: keep only the fastest-growing term, and drop its constant multiplier, because the constant shifts the curve up and down but cannot change which term wins the race to infinity.

InteractiveSlide n — watch the n² term swallow the formula
f(n) = 3n² + 100n + 7 at n = 100 ▪ 3n² term: 75.0% ▪ 100n term: 25.0% ▪ +7: 0.0% As n climbs, the amber block eats the bar. That is why Big-O keeps only Big-O ⇒ O(n²)
100
The highest-order term always wins eventually — the only question is how big n has to get.
↺ The thing people get backwards
"Big-O ignores constants, so the constants don't matter." They matter enormously — just not for scaling. Two O(n) loops can differ by 50× in real time: one strides through a contiguous array (every cache line the CPU prefetched is a hit), the other chases pointers around a linked list (a cache miss per node — see Volume 1's memory hierarchy). Same Big-O, wildly different speed. Big-O tells you how the cost grows; the constant tells you how fast it is at your actual n. You need both — this volume teaches you to read the first, and later chapters teach you to fight for the second.
The deeper cut — what O(n²) actually means, formally

Big-O is a statement about an upper bound that holds eventually. Precisely: f(n) = O(g(n)) means there exist constants c > 0 and n₀ such that 0 ≤ f(n) ≤ c·g(n) for every n ≥ n₀. It's an existence claim — you win the argument by producing one pair (c, n₀) that works.

For our f(n) = 3n² + 100n + 7, take c = 4 and n₀ = 101. For n ≥ 101 we have 100n + 7 ≤ n² (at n=101: n² = 10201 vs 100n+7 = 10107), so 3n² + 100n + 7 ≤ 3n² + n² = 4n². Done — f(n) ≤ 4·n² forever after. That single witnessed pair is the whole proof, and it's exactly why the lower-order terms are allowed to vanish: they can always be absorbed into a slightly larger constant.

Wait —
If I have to count operations to get the formula, do I have to trace every line by hand? No — you can read the growth straight off the shape of the code. There's a trick, and it fits on a sticky note.

Read the code: count the deepest nest

You almost never count exact operations. You read structure, and three rules cover most code you will ever meet:

  • Sequential blocks add. Do an O(n) pass, then another O(n) pass — that's n + n = 2n, which is O(n). Work done one-after-another sums, and the sum's Big-O is just its biggest piece.
  • Nested loops multiply. A loop over n inside a loop over n runs the inner body n × n = n² times. Each level of nesting multiplies in another factor of n. Three deep is O(n³).
  • Halving gives log n. A loop that throws away half of what's left each step reaches the end in log₂ n steps, not n. That's the whole magic of binary search.

Which leaves one sticky-note rule to rule them all: find the most deeply nested piece of work, count how many times it runs, and that dominates the answer. Everything shallower is a lower-order term you're about to drop anyway.

The halving one deserves proof, because log n is the hinge that separates "instant" from "impossible" all over this volume. Watch a million collapse to a handful of steps:

python
n = 1_000_000
steps = 0
while n > 1:
    n //= 2          # throw away half of what's left, every step
    steps += 1
print(steps)         # 19        ← that's it. one million → 19 steps
import math
print(math.log2(1_000_000))   # 19.931568569324174

Line by line: we start at a million and keep integer-halving until nothing is left to split. It takes 19 steps — because log₂(1,000,000) ≈ 19.93, and you can't take a fraction of a step. Halving is a doubling running backwards: 2¹⁰ ≈ a thousand, 2²⁰ ≈ a million, 2³⁰ ≈ a billion. Every time you multiply n by a thousand, a halving algorithm needs only ten more steps. That is why a database can find one row among a billion with about thirty comparisons — and why binary-searching your bug (delete half the code, does it still break?) beats reading every line.

InteractiveDerive the Big-O — pick a code shape, read off its cost
for i in range(n): step() operations f(n) = n = 16 at n = 16 O(n) Deepest nesting wins: one level → n, two levels → n², halving → log₂n.
16
The Big-O never mentions the constant or the coefficient — only which shape you picked.
structure → growth, at a glance for i in n: O(n) for i in n: for j in n: O(n²) while n>1: n //= 2 O(log n)
Fig — You can see the growth in the indentation. One loop climbs like a line, a loop-in-a-loop bends into a parabola, and a halving loop flattens almost immediately.
🌍
🌍 Where you meet this — every single day
Halving (log n) is the reason a database with a B-tree index finds your row among a billion in ~30 hops — it's binary search wearing a disk costume. Nested loops (n²) are the "why is this page slow" bug in a thousand codebases: an inner query inside an outer loop. And sequential-adds is why chaining ten fast passes over your data is still fast — ten times O(n) is still O(n). You reason this way off the screen too: the way you find a word in a dictionary (open the middle, not page one) is O(log n) by instinct.

Three rules, and you can read most code's cost off its shape. But "O(n) worst case" hid a word — worst. What about the best case, and the typical one? Meet Big-O's two cousins →

Big-O, Ω, Θ — the upper, the lower, the tight

Big-O is precisely one thing: an upper bound. "This algorithm grows no faster than g(n)." It is a promise about the ceiling — the worst that can happen. But a full description of an algorithm has three walls, and they have three names:

  • O (Big-O) — the ceiling. Grows no faster than this. The worst case. "It will never be slower than O(n)."
  • Ω (Big-Omega) — the floor. Grows no slower than this. The best case. "It can't do better than Ω(1)."
  • Θ (Big-Theta) — the tight sandwich. When the ceiling and floor are the same shape, the algorithm is Θ(g(n)) — pinned from both sides. This is the strongest, most honest statement you can make.

Linear search makes it concrete. Best case, the song is first in the playlist — one comparison, Ω(1). Worst case, it's last or missing — n comparisons, O(n). The two walls are different shapes, so there is no single Θ for "one run." But averaged over a random position, it does about n/2 comparisons — still Θ(n) on average, because n/2 is just n with a constant we drop. In casual speech everyone says "Big-O" and means "the tight bound," and usually gets away with it. Knowing the three lets you be exact when it counts.

n → c₁·g(n) — O (ceiling) c₂·g(n) — Ω (floor) f(n) — the real cost both walls same shape ⇒ f(n) = Θ(g(n))
Fig — f(n) runs forever between a floor Ω and a ceiling O. When both walls are the same shape g(n), the function is Θ(g(n)) — squeezed tight.

Myth

"Big-O is the average or typical running time." So an O(n²) sort must "usually" do work.

Reality

Plain Big-O is the worst-case ceiling — an upper bound, nothing more. A hash lookup is O(n) worst case (every key collides) yet Θ(1) in practice. If you mean "tight, typical," the correct symbol is Θ. Conflating them is how people end up shocked when the rare worst case shows up in production.
Wait —
We keep saying O(n²) is "worse" than O(n log n). Worse by how much? Line the whole family up from cheapest to most catastrophic, and the gaps aren't gaps — they're chasms.

The growth zoo — from cheap to catastrophic

Here is the entire cast, ordered by how fast the cost climbs. Learn this ladder cold; ranking two algorithms is often just "whose class is lower on this list."

O(1) < O(log n) < O(√n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)

input size n → operations / cost → O(1) O(log n) O(n) O(n log n) O(n²) O(2ⁿ) at any fixed n, the vertical gap between curves is the whole story
Fig — The hero picture of the volume. Same axes, same n — and the curves fan apart catastrophically. Green stays on the floor; red leaves the building. Every optimisation in this book is a move from a higher curve to a lower one.

Curves are pretty; the shock is in the numbers. Below is the "how big can n be in one second" table. It uses a measured rate for CPython 3.12 on this machine — a tight Python loop runs roughly 3×10⁷ simple steps per second (I ran it: ~3.17×10⁷, best of five; heavily machine- and language-dependent — compiled C would be 10–100× more). What matters is not the exact rate but the gaps between the rows:

Classops at n=1,000,000largest n in ~1 s (this machine, rough)where it shows up
O(1)1no limitdict / array index, hash lookup
O(log n)~20astronomically largebinary search, B-tree index
O(√n)1,000~10¹⁵trial-division primality, grids
O(n)1,000,000~3×10⁷scan, sum, max, one pass
O(n log n)~2×10⁷~1.5×10⁶merge / quick / Timsort, FFT
O(n²)10¹²~5,600bubble / selection sort, all-pairs
O(n³)10¹⁸~320naïve matrix multiply
O(2ⁿ)~25every subset, naïve Fibonacci
O(n!)~10every ordering, brute-force TSP

Read the third column slowly. An O(n) algorithm chews through thirty million items a second. Drop to O(n²) and that collapses to about 5,600. Drop to O(2ⁿ) and you're stuck at 25 — one more item doubles the time. At O(n!), ten items is already the edge of the world; eleven items takes eleven times longer than ten. This is why "just brute-force it" quietly stops being an option, and why finding a smarter algorithm can be worth more than a thousand faster machines.

InteractiveSlide n — watch the growth zoo separate
operations at n = 16 · bar length is a log₁₀ scale (each unit = ×10) O(1) O(log n) O(n) O(n log n) O(n²) O(2ⁿ) At small n they're neck-and-neck. Push n right and the red bar laps the field.
16
Without the log scale, the O(2ⁿ) bar at n=64 would be longer than the observable universe is wide.

You now hold the map of the whole zoo. The last thing to install isn't a fact — it's a reflex, the one that turns all this into the way you attack a problem you've never seen →

The move: think in growth, hunt the exponent

Everything above collapses into one mental reflex — the move this whole volume is here to install. Faced with any new problem, before you write a line, ask three questions in order:

  • What is my n? Name the thing that grows — rows, users, pixels, moves. If nothing grows, complexity is irrelevant and you should just write the clear version.
  • What curve am I on? Read the deepest nest. A nested loop over the same data is a quiet O(n²) — fine at n=100, a fire at n=100,000.
  • Can I drop to a lower curve? This is the entire game. Sorting first to enable binary search trades O(n²) for O(n log n). A hash table (write it down once, look it up free) turns a repeated O(n) scan into O(1). Remembering answers instead of recomputing them turns exponential recursion linear — you'll see naïve Fibonacci fall from O(2ⁿ) to O(n) that way in a later chapter.
1 · What is my n? name what grows 2 · What curve? read the deepest nest 3 · Drop a curve? sort · hash · memoise The whole method on a sticky note — ask these three, in this order, before writing a line.
Fig — The reflex this chapter installs: name n, read the curve, then hunt for a cheaper one.

That is the difference the volume's opening promised. The mediocre engineer optimises the constant — a faster loop, a cleverer line. The strong engineer changes the curve, and a curve change beats every constant once n is big enough. It even leaks into ordinary life: you binary-search a bug (does it still break with half the code gone?), you hash instead of re-derive (write down the answer so you never solve it twice), you refuse to re-solve what you already solved. Complexity thinking isn't just for code — it's a way of noticing where effort is being wasted as things scale.

🧠
A note on where this notation came from
The "O" is genuinely old — it comes out of 19th- and early-20th-century number theory, where the "O" stood for the order of a function's growth. Donald Knuth carried it into computer science in the 1970s and sharpened the family, insisting programmers also use Ω for lower bounds and Θ for tight ones. The lesson in the history is the lesson of the chapter: the people who invented this weren't measuring seconds — they were asking a purer question, "how does this grow?", and that question turned out to outlive every machine they could have run it on.

You can now price an algorithm in time. But time is only half the bill — every algorithm also spends memory, and the two trade against each other in ways that decide real systems. Chapter 02 opens the second ledger: space complexity, the stack versus the heap, and why an O(n)-time algorithm can still blow up your RAM →

02Space, and the memory hierarchy — why equal Big-O runs 100× apart

Chapter 1 taught you to price an algorithm in time — operations as n grows. But time is only half the bill. This chapter adds the other half: space — the extra memory a program burns — and then it drops through the abstraction to the metal, where a shocking fact waits. Two algorithms with the identical Big-O can run many times apart in real seconds, and the reason has nothing to do with the exponent on n. By the end you'll be able to read the space cost of any routine, spend memory to buy speed on purpose, and predict when a "slower-looking" layout will crush a "faster" one — because you'll finally see the constant that Big-O throws away.

Space is a bill too — count the extra memory

Space complexity is the same idea as time complexity, pointed at memory: how much extra storage does the algorithm need as a function of the input size n? "Extra" is the key word — we don't count the input itself (it has to exist), we count the scratch space the algorithm allocates on top of it. Two routines that produce the same answer can sit in completely different space classes.

The cleanest split is in-place versus copying. An in-place routine rearranges the data it was given and allocates only a fixed handful of variables — a loop counter, a temp — no matter how big n is. That's O(1) extra space (also called constant space). A copying routine builds a whole new structure the size of the input: O(n) extra space. Watch the exact same "reverse the playlist" idea land in both classes:

python
import sys
songs = list(range(10000))          # a 10,000-element list

songs.reverse()                     # IN PLACE: swaps ends inward, no new list
# sys.getsizeof before and after: 80056 -> 80056 bytes  (extra: 0)

rev = songs[::-1]                    # SLICE: builds a brand-new 10,000-element list
# sys.getsizeof(rev): 80056 bytes    -> a second full copy, O(n) extra

Line 4, .reverse(), walks two fingers from the ends toward the middle swapping references — the list object's size is byte-for-byte unchanged (we measured 80056 before and after: zero extra). Line 7, [::-1], allocates a second list of the same length — 80056 more bytes that scale with n. Same output, different space class. The built-in pair makes the choice explicit: list.sort() sorts in place and returns None (O(1) extra); sorted() returns a new list (O(n) extra — we measured a 100,000-element sorted() result at 800056 bytes, a full second copy).

in place — O(1) extra A B C D E swap ends inward — one temp variable memory used = the array + a fixed handful of variables, the same whether n is 5 or 5 billion. copy — O(n) extra A B C D E original E D C B A new copy memory used = the array + a SECOND array that grows one-for-one with n.
Fig — Same reversal, two space classes. In place spends a constant; copying spends a second n. Space complexity counts only the extra you allocate.
📏
The recursion tax you can't see
Space isn't only the arrays you name. Every recursive call parks a stack frame (Volume 1, ch9) holding its locals until it returns — so a recursion d levels deep costs O(d) hidden space even if it allocates nothing. Naive recursive Fibonacci is O(n) space just from the call stack; that's a real cost, and it's why deep recursion can raise RecursionError.

So an algorithm has two prices, time and space. The engineer's move is to realise you can pay one to lower the other

The trade that runs this whole volume: buy time with space

Here is the single most useful sentence in algorithm design: you can almost always trade memory for speed. If a routine keeps recomputing something, store the answer the first time and look it up forever after. If an inner loop keeps searching for a value, build a lookup table so the search becomes an instant jump. You spend O(n) space; you buy back a factor of n in time. This trade is the engine under two techniques you'll meet all volume — hashing (ch6) and memoization (ch9) — and it's worth seeing raw, right now.

Take two-sum: is there a pair in a list that adds to a target? The brute force tries every pair — two nested loops, ~n²/2 checks, and O(1) extra space. The trade: keep a dict of numbers you've already seen; for each new number just ask "is target − x in there?" — an O(1) question. One pass, O(n) time, O(n) space. We ran both on n = 20,000 with a target that has no answer (so both must look at everything — the honest worst case):

python
def brute(nums, target):            # O(1) space, O(n^2) time
    for i in range(len(nums)):
        for j in range(i+1, len(nums)):
            if nums[i] + nums[j] == target:
                return (i, j)
    return None

def hashed(nums, target):           # O(n) space, O(n) time
    seen = {}                       # <-- the memory we BUY
    for i, x in enumerate(nums):
        if target - x in seen:      # an O(1) question, not an O(n) scan
            return (seen[target - x], i)
        seen[x] = i
    return None

The brute version's inner loop (lines 3–5) restarts a full scan for every element — that's where the comes from. The hashed version replaces "scan for the complement" with the in seen test on line 11, and seen is the price: a dict that grows to n entries. The payoff was not subtle. On our machine (timings are hardware-dependent), the brute force took ≈8,793 ms; the hashed version took ≈2.0 ms — about a 4,300× speedup — in exchange for a dict of ~590 KB. You handed the machine half a megabyte and it handed back nearly four-thousandfold. Here is that trade as a curve — every unit of memory you spend buys back a chunk of time:

time (cost) → extra memory spent → brute forceO(1) space · O(n²) time · slow hashedO(n) space · O(n) time · fast spend memory → time collapses
Fig — The space-time tradeoff. You move down the curve by spending memory; the brute force sits at the expensive top-left, hashing at the cheap bottom-right. Now turn it into a knob:
InteractiveSpend memory, buy time — the two-sum trade
two-sum, worst case — extrapolated from a measured point at n = 20,000 brute force · O(n²) time · O(1) space — ms hashed · O(n) time · O(n) space — ms memory bought (the dict) — this is the price — KB speedup: —
20000
Time falls from a quadratic to a line; memory rises as a line. You paid O(n) space to erase a whole factor of n from the clock.
🌍
Where you meet this
Every time your browser caches an image instead of re-downloading it; every CDN (Cloudflare, Akamai) storing copies near you; Redis and memcached, whole databases that exist only to trade RAM for latency; @lru_cache in Python; the rainbow tables attackers use to crack passwords (precompute hashes once, look them up forever). All one move: store it so you never have to compute it again.

Buying time with space assumes memory is one flat, uniform thing you simply "have." It isn't. Memory is a hierarchy, and that changes everything →

Not all memory is one place — the hierarchy

Volume 1 drew RAM as one long row of numbered boxes. True — but it hid a secret the CPU spends enormous effort on: reaching those boxes is slow. A modern processor executes an instruction in a fraction of a nanosecond, but a trip out to main memory (DRAM) costs it roughly 100 ns — during which the core could have run hundreds of instructions. So chip designers stack a pyramid of progressively smaller, faster memories in front of RAM, each a cache — a small store of recently-used data kept close to the core. From fastest and tiniest to slowest and vastest: registers → L1 → L2 → L3 → RAM → SSD → disk.

The latencies span a range that's hard to believe. These are typical order-of-magnitude figures for a modern machine (not measured on yours — real numbers vary by hardware), scaled into human time so you can feel them. If an L1 hit were one second:

registers ~0.3 ns L1 cache ~64 KB ~1 ns → 1 second L2 cache ~512 KB ~4 ns → 4 s L3 cache ~8–32 MB ~12 ns → 12 s RAM (DRAM) — gigabytes ~100 ns → 1.7 min SSD — terabytes ~100 µs → 28 hours spinning disk — a seek ~10 ms → 116 days Up = smaller & faster (closer to the core). Down = bigger & slower. L1 hit = 1 heartbeat. Falling to RAM = walking to the kitchen. Falling to disk = a whole season.
Fig — The memory hierarchy, latencies scaled so an L1 hit ≈ 1 second. The spread from cache to disk is roughly ten-million-fold — which is why where your data sits dwarfs almost everything else.

Read that pyramid again, because it reframes performance entirely. The CPU is a Formula-1 engine bolted to a supply chain. If the part it needs is in a register or L1, it's already in its hand. If it has to fall to RAM, it stalls for the human equivalent of a coffee break. Fall to disk, and it's waiting months. The whole game of fast code is keeping the data the CPU wants near the top of this pyramid — and the CPU can't do that alone. It guesses, using one simple bet.

🎯
The bet the hardware makes for you
When the CPU fetches one byte from RAM, it never grabs just that byte — it hauls in the whole surrounding cache line, typically 64 bytes, on the assumption that you'll want the neighbours next. It also runs a prefetcher that watches your access pattern and, if you're marching straight through memory, fetches the next lines before you even ask. Reward those two bets and RAM feels like L1. Betray them and you pay the full ~100 ns, over and over.

A 64-byte line, fetched on every miss, quietly holding eight of your neighbours. That single fact is why two O(n) loops can finish an order of magnitude apart →

Two O(n) loops, one many times slower — the access pattern decides

Now the payoff. Big-O counts operations and treats every memory access as one unit of cost. The hardware does not. An access that hits cache is roughly 100× cheaper than one that misses to RAM. So two algorithms that do the exact same number of accesses — same Big-O — can run wildly apart if one touches memory in an order the caches love and the other in an order they hate. The hidden multiplier Big-O drops on the floor, the constant factor, is set by the memory access pattern.

Picture two ways to hold a million numbers. A contiguous array lays them end to end in one block. Walk it front to back and every 64-byte cache line you pull delivers eight useful int64s in a row — one miss, then seven free hits — and the prefetcher, seeing your straight march, has the next line ready. Now a linked list: each number lives in its own little node, allocated whenever, scattered across the heap, each pointing to the next. Walk it and every node.next is a jump to an unrelated address — a fresh cache miss per node, seven-eighths of every fetched line wasted, the prefetcher blind because there's no pattern to predict. Same n steps. Same O(n). Utterly different metal.

contiguous array — one line = 8 useful values a0 a1 a2 a3 a4 a5 a6 a7 ↑ one 64-byte cache line (fetched once) 1 miss → 7 free hits. prefetcher grabs the next line early. linked list — one node fetched, 7/8 of the line wasted n0 junk n1 n2 every node is somewhere random on the heap → a full cache miss per node, the prefetcher blind. Same n reads. Same O(n). The array line delivers 8× the useful data per fetch.
Fig — The 64-byte line is why layout beats Big-O ties. Contiguous data amortises one miss over eight values; scattered data pays a miss for each.

We didn't just assert this — we timed it. First, the same math in two layouts: sum ten million integers as a Python list (a block of references pointing off to separately-boxed int objects) versus a NumPy array (the raw int64s packed contiguously). Same additions, same O(n):

python
import numpy as np
lst = list(range(10_000_000))          # 10M ints; Python list of references
arr = np.arange(10_000_000)            # 10M ints; contiguous int64 block

sum(lst)      # measured best: ~35.4 ms
arr.sum()     # measured best: ~ 4.1 ms   -> ~8.6x faster, same O(n)

Same operation count; ~8.6× apart (hardware-dependent), because the array is a tight contiguous block the caches and prefetcher love, while the list scatters its int objects across the heap — pointer-chasing, a miss risk per element. And the array is smaller too: we measured the list of ten million ints at ~360 MB (8-byte references plus a ~28-byte boxed int each) versus ~80 MB for the NumPy array — 4.5× less memory and faster. To prove the effect is the access order and nothing else, we then walked the identical data twice — once in sequential order, once through a random permutation (same reads, only the pattern differs). Through NumPy's C loop over a 320 MB block, random order ran ~4.6× slower; a pointer-chase through Python's interpreter (which dilutes the effect with its own overhead) still ran ~2.3× slower. The exponent on n never changed. Only the cache did.

InteractiveArray scan vs linked-list walk — both O(n), watch the gap hold
simple cache model: L1 hit ≈1 ns, RAM miss ≈100 ns, 8 int64 per 64-byte line, prefetch ignored array scan (contiguous): 1 miss per 8 → ≈13.5 ns/element linked walk (scattered): 1 miss per node → ≈101 ns/element gap: — Both are O(n). The gap is a constant the layout bakes in — it does not shrink as n grows.
1,000,000
Slide n across five orders of magnitude: both bars grow together, but the red one stays ~7.5× longer — forever. That fixed multiple is the constant inside Big-O.
↺ The thing people get backwards
The textbook says a linked list has O(1) insertion and an array has O(n) insertion, so people reach for linked lists to "go fast." In practice, for iterating or searching, the contiguous array usually wins by a landslide — because O(1) pointer surgery that triggers a cache miss every step loses to O(n) shifting of bytes that stay in cache and stream through the prefetcher. Big-O ranks the operation counts; the machine charges by the cache line. This is exactly why C++'s std::vector beats std::list for almost everything, and why Python's list (a contiguous array of references, Volume 1 ch6) is the right default, not a linked structure.

Myth

Same Big-O means the same speed. If both are O(n), pick either — they'll perform identically.

Reality

Big-O hides a constant that the memory pattern can swing by 10× or more. At equal Big-O, the cache-friendly layout wins — measured here 8.6× (array vs list sum), 4.6× (sequential vs random gather). The exponent ties; the constant decides.
🌍
Where you meet this
NumPy & pandas exist to keep numbers contiguous. Apache Arrow and columnar databases (DuckDB, ClickHouse, Parquet) store each column together so a scan streams through cache — the reason analytics queries fly. Game engines rebuilt themselves around "data-oriented design" and entity-component systems to keep hot data packed. Even mmap (Volume 1 ch11) and database B-trees are shaped around the line and the page. Contiguity is a business model.
The deeper cut — when the linked list still wins, and the numbers behind "100×"

Linked structures aren't wrong — they're right when you insert and delete in the middle constantly and rarely iterate (some LRU caches, certain allocators, lock-free queues). And the raw per-access latency ratio really is about 100×: an L1 hit (~1 ns) versus a main-memory miss (~100 ns). Our end-to-end benchmarks showed "only" 2.3×–8.6× because that 100× penalty is diluted by all the other work each iteration does (arithmetic, loop control, Python bytecode) — the stall is a large slice of a bigger pie, not the whole pie. Isolate the stall in a tight pointer-chase in C and you approach the full order of magnitude; the title's "100×" is that per-access truth, and the single-digit multiples are what survives once real work is mixed in. Both are honest — they measure different things.

You've now seen the two hidden costs Big-O ignores: extra space, and the constant set by locality. The last move is to turn that into a habit →

The 1% move: respect the constant, spend space on purpose

Chapter 1 gave you the lens to choose the lower growth curve. This chapter installs the reflex that separates engineers who "know Big-O" from those who ship fast systems: Big-O is where you start, not where you stop. Once you've picked the right complexity class, two more questions decide real speed — and most people never ask them.

Question one: what will this cost in space, and can I trade it? When something is slow because it recomputes or re-scans, ask what you could store to make the repeat instant. That's the hashing/memoization move (ch6, ch9) — and now you know its price is real memory, drawn from a finite budget, so you spend it deliberately, not reflexively. Question two: how will the data be laid out, and how will I walk it? Two designs with the same Big-O are not equal; the one that marches contiguously through memory, feeds the prefetcher, and keeps its hot data in cache will win. This is machine sympathy — writing code that flows the way the hardware wants to run.

⚠️
Order matters — and so does measuring
Chase the constant after the Big-O, never before: shaving a cache miss off an O(n²) loop is polishing a car that's driving off a cliff — fix the growth class first (ch1). And these constants are hardware-specific: cache sizes, line width and latencies differ by machine, so when the constant matters, measure on the real target (as we did here) rather than guessing. Big-O is portable truth; the constant is local truth.
1. pick theBig-O class (ch1) 2. trade spacefor time? 3. cache-friendlylayout? (constant) fast inreality the algorithm the two costs Big-O forgets Most people stop at step 1. The 1% run all three, every time.
Fig — The reflex this chapter installs: after the Big-O class, ask the space trade and the layout. That's where equal-complexity code splits into fast and slow.

The everyday transfer is real. "Buy time with space" is just writing it down: you don't re-derive a friend's phone number every time — you stored it once for O(1) recall. "Respect locality" is keeping what you're using within reach — the tools on the bench, not out in the garage. And the master habit is the one to carry into every problem for the rest of this volume: after you've found the right complexity, ask "what am I storing, and how am I walking it?" — because that question is the difference between code that looks fast on paper and code that is fast on the metal.

Wait —
if list.append sometimes has to copy the entire array to a bigger block, isn't it secretly O(n)? Then how can we honestly call it O(1)?

That paradox — an operation that's occasionally expensive yet genuinely cheap on average — is the whole of Chapter 3: amortized analysis, the honest accounting that explains why a million appends stay flat despite the resize spikes. →

03Amortized analysis — the honest average

Chapter 1 gave you one number for an algorithm's cost. But most algorithms don't have one cost — they have a best day, a worst day, and a typical day, and the gap between them can be the gap between "instant" and "the site is down." This chapter teaches you to price all three, and then a subtler idea that trips up almost everyone: how a single operation can be wildly expensive and yet be genuinely cheap. After it, you'll be able to look at list.append — an operation that occasionally copies your entire list — and prove, with a stopwatch and a derivation, that it costs a constant. That proof is called amortized analysis, and it is one of the most useful ways of thinking an engineer owns.

One algorithm, three honest questions

Take the simplest search there is: walk a list left to right, comparing each item to your target, and stop when you find it (you stepped this in trace T22). How many comparisons does it cost? The honest answer is it depends on where the target sits — so we ask three separate questions, and each has a precise meaning.

  • Best case — the luckiest input. The target is in slot 0; you find it in 1 comparison. Cost that never gets beaten: O(1).
  • Worst case — the unluckiest input. The target is in the last slot, or absent entirely; you check all n items. Cost that is never exceeded: O(n).
  • Average case — the expected cost over a distribution of inputs. If the target is equally likely to be anywhere, you scan n/2 items on average — still O(n), but with a hidden factor of one-half.

These are three different measurements of the same code — nothing about the algorithm changed, only the input did. Best case is a fact about luck and rarely worth designing around. Average case needs an assumption about which inputs show up, and that assumption can be wrong. Worst case needs no assumptions at all — it is a promise: "no matter what you throw at me, I will not cost more than this." That is why, when an engineer says an algorithm "is O(n)" with no qualifier, they almost always mean the worst case. It's the only one you can hand a stranger.

linear search for a target in a list of n = 8 best 1 comparison → O(1) average n/2 ≈ 4 → O(n) worst n = 8 → O(n) same code, three inputs — coloured boxes are the ones actually examined
Fig — Best, average, and worst are three questions about one algorithm. Only the worst case is a promise you can make without knowing the input.

Fine for a toy search. But does the gap between "typical" and "worst" ever actually change which algorithm you'd pick? Oh, it decides careers. →

Why the case you measure decides the algorithm you choose

Here is where this stops being bookkeeping. Quicksort (stepped in trace T28) is, on typical data, one of the fastest sorts ever written: on random input it does about n·log₂n comparisons. But its speed comes from a gamble — it picks a pivot and splits the list around it, hoping for two roughly equal halves. Feed it an input that always splits into "one item" and "everything else," and every split does real work while the problem barely shrinks: the cost collapses to O(n²). And the input that triggers this? For the textbook "pivot = last element" version, it's an already-sorted list — the most innocent-looking data imaginable. Let's not take that on faith; let's count the comparisons.

python
def quicksort_count(a):
    comps = 0
    def qs(lo, hi):
        nonlocal comps
        if lo >= hi: return
        pivot = a[hi]                 # naive: last element is the pivot
        i = lo
        for j in range(lo, hi):
            comps += 1                # count every key comparison
            if a[j] < pivot:
                a[i], a[j] = a[j], a[i]; i += 1
        a[i], a[hi] = a[hi], a[i]
        qs(lo, i-1); qs(i+1, hi)
    qs(0, len(a)-1)
    return comps

# n = 2000
# random input : 25,335 comparisons    (~ n·log2 n = 21,932)
# sorted input : 1,999,000 comparisons (~ n^2 / 2  = 2,000,000)   -> 79x worse

Line by line: comps tallies each comparison. qs partitions the slice a[lo..hi] around pivot, sweeping j across it and counting one comparison per step, then recurses on the two sides. On 2,000 random numbers it spent 25,335 comparisons — right in the n·log n neighbourhood. On the same 2,000 numbers already sorted, it spent 1,999,000 — which is exactly 2000·1999/2, the sum 1 + 2 + … + 1999, the fingerprint of a fully lopsided split every single time. Same algorithm, same n, 79× more work — because the case changed.

💥
"It ran fine on my test data"
Test data is often small, random, or hand-made — precisely the average case. Production data is sorted exports, repeated values, and inputs an attacker chose on purpose — the worst case. An algorithm judged only on its good days ships a time bomb. Real quicksort defuses this by choosing the pivot at random (chapter 15) so no fixed input is its enemy.

Now the mirror image, and the reason your dict and set feel like magic. A hash lookup is O(1) on average — it jumps straight to a bucket (Volume 1, chapter 7). Its worst case, when every key collides into one bucket, degrades to an O(n) scan. Usually that worst case is a fairy tale you never meet — but you can force it, and watch O(1) rot into O(n) in real time:

python
class Bad:
    def __hash__(self): return 0     # EVERY key lands in the same bucket
    def __eq__(self, o): return self.v == o.v

# average nanoseconds per membership test ("x in the_set"):
# size 1000:  normal set     72 ns      colliding set    69,024 ns
# size 2000:  normal set     72 ns      colliding set   113,662 ns
# size 4000:  normal set     74 ns      colliding set   354,515 ns

Read the two columns. The normal set answers in ~72 nanoseconds whether it holds a thousand items or four thousand — flat, the signature of O(1). The sabotaged set, where every key hashes to 0, gets steadily slower as it grows — the signature of O(n). (Timings are from this laptop; the shape, not the exact digits, is the point.) This isn't academic: feeding a web server keys crafted to collide is a real denial-of-service attack called hash flooding, which is exactly why modern languages randomize their hash seed at startup.

InteractiveAverage vs worst — slide n and watch them tear apart
quicksort comparisons at n = 64 average 384 ≈ n·log₂n worst 2016 = n²/2 bars use a log scale — even so, worst pulls away as n climbs
The readout is worst ÷ average. Small n hides the danger; large n makes it a chasm.
good pivots → balanced log₂n levels · n·log n work depth stays shallow bad pivots (sorted input) → skewed n levels · n² work one long stick — depth = n
Fig — The same partition step, two fates. Balanced splits give log n shallow levels; lopsided splits give a stick n levels deep. Depth times per-level work is the whole story.
Wait —
if worst-case is the promise we design around, then list.append is in trouble. Its worst case copies the entire list — that's O(n). So why does everyone call append "cheap"? Are they wrong?

Amortized: cheap over a lifetime, not by luck

They're not wrong — they're using a third kind of accounting, and it's the star of this chapter. Amortized cost is the total cost of a whole sequence of operations, divided by the number of operations. The word comes from finance — to amortize a cost is to spread a big one-time expense across many periods so each period carries a small, even share. A £1,200 annual subscription isn't "£1,200 expensive"; amortized over the year it's £100 a month. The £1,200 charge is real, but no single month feels it.

The magic move is this: some operations are occasionally expensive but usually trivial, and the expensive ones are rare enough that when you add up the whole bill and divide, the average is small — often a constant. When we can prove that, we say the operation is O(1) amortized. And here is the part that makes it powerful rather than hand-wavy: this is still a worst-case statement. It doesn't say "append is usually fast." It says "any sequence of m appends costs at most c·m total — guaranteed, for every possible sequence." No luck, no distribution, no coin flips. A promise.

operation number → cost → resize: copy all n amortized average — low and flat
Fig — Most operations (green) cost almost nothing; a rare one (red) pays for a huge job. Spread the tall bars over all the short ones and the average (amber dashes) sits low and steady.

Abstract enough. Let's pin it to the one operation you've typed ten thousand times — and finally cash the promissory note chapter 1 wrote when it said "append is O(1)." →

The canonical case: why append is O(1) amortized

You met the machinery in Volume 1, chapter 6, so we'll use it, not rebuild it. A Python list is a dynamic array: a contiguous block of 8-byte reference slots, deliberately over-allocated with a few spare seats at the end. append normally just drops one reference into the next spare seat — one write, cost O(1), no matter how long the list is. But when the spare seats run out, there's no room to grow in place (the neighbouring memory is taken). So CPython rents a bigger block elsewhere, copies every existing reference across, frees the old block, and then writes your new item. That copy touches all n references: cost O(n). Watch the spare seats refill with sys.getsizeof, which reports the container's own byte size:

python
import sys
lst = []
for i in range(1, 33):
    lst.append(i)
    # container bytes jump ONLY at len 1, 5, 9, 17, 25 …
# capacity sequence as it grows one at a time:
#   4, 8, 16, 24, 32, 40, 52, 64, 76, 92, 108, 128 …
list length (items appended) → allocated capacity → length = capacity (full) flat tread = free appends riser = relocation (copy all n) 4 8 16 24 32
Fig — Capacity climbs in a staircase. You coast across each flat tread on spare seats (cheap); you pay only at the red risers, where the block is full and everything relocates — and the treads get longer as the list grows, so paying gets rarer.

The bytes don't climb smoothly — they jump, then hold flat for several appends, then jump again. Each jump is a relocation (an O(n) copy); each flat stretch is a run of cheap O(1) appends landing in spare seats. The relocations get rarer as the list grows, because each new block is proportionally bigger than the last. That "rare, and rarer over time" is the whole trick — and it's exactly the tall-red-bar-among-green picture from a moment ago. This is the concrete shape of a per-operation cost that is mostly constant with occasional spikes. Turn the knob and feel it:

InteractiveAppend cost — spikes rise, the average holds flat
append number (1 … n) → cost this append → avg
16
Green = cheap (1 write). Red = a resize copying the whole array. The amber line — total cost ÷ n — stays in a tight band near 2 and never climbs as n grows.

Drag n and notice the two things happening at once. The red spikes get taller — a resize at length 32 copies 32 references, at length 64 copies 64. Yet the amber average line refuses to climb with n; it just wobbles in a tight band around 2 — nudged up the instant a resize lands, then settling back as the next run of cheap appends dilutes the cost. Rising spikes, bounded average. That contradiction — the individual worst case grows without bound while the per-operation average stays pinned inside a constant band — is amortized analysis in one picture. The next section proves why the line can't rise.

🧩
A labelled simplification
The widget uses the classic doubling model (capacity ×2 when full), which keeps the amortized average a small constant that settles around 2 (and provably never exceeds 3). Real CPython grows by only about 1/8 each time (≈1.125×), not 2×. Same Big-O — O(1) amortized — but a bigger constant, which we'll measure exactly in the next section. The refinement is coming; the model is just the clean version of the same idea.

Two nagging questions remain. Why is the average exactly 2 and not creeping upward forever? And what does the real, non-doubling CPython actually cost? Time to add up the whole bill. →

The aggregate method: add up the whole bill, then divide

The cleanest way to prove an amortized bound is the aggregate method: compute the total cost of a sequence of operations, then divide by how many there were. No per-operation cleverness — just one big sum. Take m appends into a list that doubles when full. The cheap part is easy: every append does exactly one write, so that's m writes total. The expensive part is all the copying at resizes. With doubling, resizes happen at lengths 1, 2, 4, 8, …, up to m, and each copies the block it's leaving — so the copies are 1 + 2 + 4 + 8 + … up to roughly m.

That sum is the punchline. A doubling geometric series always adds up to less than twice its largest term: 1 + 2 + 4 + … + m < 2m. So all the copying across the entire sequence costs under 2m, and the writes cost m — a grand total under 3m for m appends. Divide by m: under 3 per append, a constant. The list can grow to a billion; the average never budges. That is the derivation behind "append is O(1) amortized."

successive resizes — each copies the block it leaves 1 2 4 8 16 32 1+2+4+…+32 = 63 < 2 × 32 = 64 all copying < 2m total → under 3 writes per append
Fig — The copy costs double, but a doubling series sums to under twice its last term. All the relocations in an entire lifetime cost less than 2m — so each append is a constant on average.

Enough theory — let's put a real stopwatch on a real million appends and check the promise against CPython, spares, non-doubling growth and all:

python
import time
N = 1_000_000
lst = []
t0 = time.perf_counter()
for i in range(N):
    lst.append(i)
print((time.perf_counter() - t0) * 1000, "ms")   # ~= 55 ms  -> ~55 ns / append

# counting the ACTUAL relocations over the same run:
#   resizes            : 86
#   total slots copied : 8,445,096   ->  8.4 copies per append
#   writes per append  : ~9.4        (constant - does NOT grow with N)

A million appends finished in about 55 milliseconds — roughly 55 nanoseconds each (this laptop; yours will vary). For comparison, writing into a list that's already the right size — pure O(1) writes, zero growth — clocked ~50 ns each. So all of CPython's growing, relocating, and freeing added only about 5 ns per append on average. The counters explain why: across a million appends there were only 86 relocations, and the total copying came to ~8.4 slot-copies per append. That's the honest CPython constant — about 9 writes per append, not the model's 2, because CPython grows by ~1/8 rather than doubling. Smaller growth means more frequent resizes means a bigger constant. But constant it is: independent of N, exactly as promised. The doubling model told the right story; the machine just uses a different constant.

🌍
Where you meet this — everywhere you grow a collection
This exact grow-and-copy trick is the beating heart of the growable array in every major language: Python's list, C++ std::vector, Java ArrayList, Go's append on slices, JavaScript arrays, Rust's Vec. The same idea makes hash tables fast: a dict or set that fills up rehashes into a bigger table — one occasional O(n) job that keeps every insert O(1) amortized. Databases amortize too: log-structured stores (the tech under Cassandra, RocksDB, and your phone's storage engine) do cheap appends and pay for order in rare, batched compactions. Even a garbage collector is amortized thinking — many cheap allocations, an occasional expensive sweep.

The trap: amortized is not average-case

This is the distinction that separates people who've memorized the words from people who understand them — and the myth is worth killing out loud.

The myth

"Amortized O(1) and average-case O(1) are two phrasings of the same idea — both mean usually fast, occasionally slow, so on average fast."

The reality

Average-case invokes probability over inputs — a bet on which data shows up. Amortized invokes no probability at all — it's a guaranteed total over any sequence. One is a forecast; the other is a contract.

Line them up. Quicksort's average O(n log n) is a statement about a distribution of inputs: "if your input is random, expect n log n." Hand it a sorted array and the promise evaporates — you get n². The good behaviour was conditional on luck. Now append's amortized O(1): there is no "if your appends are random." Every sequence of a million appends costs about 9 million writes — the sorted case, the adversarial case, the you-chose-the-inputs-to-hurt-me case, all identical. You cannot construct a bad sequence, because the guarantee is over the sum, not over a typical draw. Average-case can be defeated by a clever enemy; amortized cannot.

AMORTIZED guaranteed total ÷ count holds for ANY sequence — no dice AVERAGE-CASE typical bad input expected over random input a bet on typical data — beatable
Fig — Amortized averages over the sequence you run (a contract). Average-case averages over the inputs you might get (a forecast). Same word "average," opposite kinds of promise.
↺ The thing people get backwards
People hear "amortized O(1)" and relax as if the operation is always fast. It isn't — one append in the sequence really does stall while it copies the whole array, and if you're writing hard real-time code (audio, control loops) that single spike can miss a deadline. Amortized doesn't promise every operation is cheap; it promises the total is. Knowing which promise you hold tells you whether that lurking spike is harmless (a web request) or fatal (a pacemaker) — and when to pre-size the list to dodge it entirely.
The deeper cut — the accounting method, and why copies are provably bounded

The aggregate sum is one proof; here's the more portable one, the accounting (banker's) method, developed alongside amortized analysis by Robert Tarjan. Overcharge the cheap operations and save the surplus as credit, then let the expensive one spend that saved credit. Charge every append 3 units: 1 pays for its own write, and 2 go into a savings account attached to that slot. When the array is full and must copy, every element being moved has already banked credit since the last resize — and with doubling, exactly half the elements are "new" since the last resize, each holding 2 credits, which is precisely enough to pay 1 to copy itself and 1 to copy an "old" element whose credit was already spent. The bank balance never goes negative, so no operation ever effectively costs more than its 3 prepaid units. Amortized cost ≤ 3 = O(1). The beauty: this argument needs no probability and no summation — it's a local invariant, "the bank is always solvent," which is why the method generalizes to data structures where the aggregate sum is hard to write down.

One honest footnote on CPython specifically: its growth request is roughly new = size + (size >> 3) + 6 (the exact constants are an implementation detail and have shifted across versions). The >> 3 is the ≈12.5% headroom. Because the block still grows proportionally to its size, each element is still copied only a bounded number of times over the list's life — the accounting argument goes through with a larger charge (~9 rather than 3), and append stays O(1) amortized. The language spec only promises the amortized bound; the constant is CPython's business.

🧠
The 1% move: judge the sequence, not the instance
Amortized thinking is a lens you can point at anything. Don't ask "how bad is the worst single operation?" — ask "how bad is the total, spread over everything I'll actually do?" It's why you batch database writes, buffer file I/O, prepay an annual plan, cook once and reheat all week, and tolerate a slow occasional cleanup that keeps every ordinary day fast. The expensive step isn't a bug to fear; it's an investment that makes a thousand cheap steps possible. Spot that pattern — rare big cost buys many small ones — and you'll design systems, and a schedule, that stay fast under load.

You can now price an algorithm three honest ways and prove a spiky operation is secretly cheap. But pricing is diagnosis, not cure. Next: the design loop itself — start with brute force, then ask the one question that turns a coder into an engineer, "where is the wasted work?" — and watch an O(n²) two-sum collapse to O(n) before your eyes. →

04Brute force, then spot the waste

Chapters 0–3 gave you the yardstick: count operations, watch them grow, respect the constant. This chapter gives you the method — a five-step loop you run on any problem, from an interview whiteboard to a production incident. By the end you'll have a reflex that fires before you write a single clever line: solve it the dumb way, find the wasted work, delete it, re-check the cost. That loop is the spine of every remaining chapter, and it is the actual difference between someone who codes and someone who engineers.

Start with brute force. It is step one, not shame.

Brute force means: try every possibility and check each one. It has a bad reputation it does not deserve. Brute force is where every hard problem starts, because it does two irreplaceable jobs. First, it pins down what "correct" even means — you cannot optimize an answer you can't yet produce. Second, it hands you a baseline complexity: the number to beat. Skip it and you're optimizing in the dark.

Take the problem we'll ride all chapter — two-sum. Given a list of numbers and a target, find two of them that add up to the target. Concretely: your playlist has tracks of length [200, 90, 150, 60] seconds, and you want two that exactly fill a 240-second (4:00) slot. The brute-force reading is literal: look at every pair and test its sum. How many pairs are there? Pick the first element (n choices), pick a different second (n−1 choices), and since the pair (i, j) is the same as (j, i), divide by two. That's n(n−1)/2 pairs — a number we counted, not looked up. For n = 4 that's exactly 6 pairs.

every pair (i, j) with i < j — the shaded triangle is all brute force checks j0j1j2j3j4j5 i0i1i2i3i4i5 n(n−1)/2 = 15 pairs (here n=6) grey diagonal = a thing paired with itself (skipped); lower half = duplicates of the upper
Fig — brute force checks the shaded upper triangle: n(n−1)/2 pairs. We derived that by counting, not by memory.
python
def two_sum_brute(nums, target):
    n = len(nums)
    for a in range(n):                       # pick the first element
        for b in range(a + 1, n):            # pick a LATER second element
            if nums[a] + nums[b] == target:  # test this pair's sum
                return (a, b)                # done the instant we find one
    return None                              # no pair adds up

Read it as the picture: the outer loop picks a, the inner loop picks every b to its right (that's the a + 1 — it's why we only fill the triangle, never re-checking a pair), and the if tests the one thing we care about. It is obviously, boringly correct. That is its whole value right now. Ship the boring version first; you now have a truth you can measure and a truth you can test against.

The baseline is an asset
Never delete your brute-force solution when you optimize. Keep it as an oracle — the slow, obviously-correct version you check the fast one against. We'll do exactly that at the end of the chapter.

We have a correct answer. But "correct" isn't "good." How expensive is this, really — and where does it hurt? →

Measure the baseline — count, then feel the growth

Deriving the cost is just reading the loops (the skill from chapter 1): the inner body runs once per pair, so the operation count is n(n−1)/2. Drop the constant and the lower term and it's O(n²). But a formula is abstract — let's make the growth land in your gut by running it. I counted the exact inner-loop checks in the worst case (no pair exists, so it scans everything):

nbrute checks = n(n−1)/2× bigger than n
10454.5×
1004,95049.5×
1,000499,500499.5×
10,00049,995,0004,999.5×
1,000,000499,999,500,000499,999.5×

Every 10× more input is 100× more work — that's the signature of squaring. The same signature shows up on the clock. I timed the real function (worst case, on this laptop — your absolute numbers will differ, the shape will not):

each step DOUBLES n — watch the bar roughly QUADRUPLE 19 ms n=1000 79 ms n=2000 317 ms n=4000 1276 ms n=8000 ×4 ×4 ×4
Fig — measured brute-force time on this machine: 19 → 79 → 317 → 1276 ms. Double the input, quadruple the time. That is O(n²) you can see.

Now the trap that ends careers quietly. At n = 1,000 this brute force finishes in ~19 ms — instant, ships, "works on my laptop." Push it to a million items and the squared growth means about 250,000× the work of n=2,000: extrapolated from the measured times, roughly 5.5 hours for one call. The demo was fine. Production is on fire. Nothing changed but n.

🔥
"It's fast enough" is a claim about n, not about code
Fast at n=1,000 tells you almost nothing about n=1,000,000 when the curve is O(n²). Before you call anything fast, ask: fast at what input size, and what happens at 1000× that?

So the baseline is quadratic and it will detonate. Good — now the single most valuable question in all of engineering. →

The one question: where is the wasted work?

Here is the mental move that turns a coder into an engineer. You look at the brute force and ask: where am I doing work I've already done, or don't need to do? Wasted work hides in three shapes, and you learn to smell all three:

  • Recomputing — calculating the same value over and over (you'll kill this with memoization / dynamic programming in chapter 9).
  • Re-scanning — sweeping through data you already looked at (killed with hashing, ch6, and two-pointers, ch7).
  • Ignoring structure — the data is sorted, or bounded, or monotonic, and you're not exploiting it (killed with binary search, ch12, and more).

Point the question at two-sum. For each element x, brute force computes what it needs (need = target − x) and then re-scans the whole rest of the list hunting for that number. But look at what the scan is searching through: numbers we already walked past. We had them in our hands one step ago and we threw them away, so now we walk the list again, and again, and again. The wasted work is the re-scan. The inner loop is a search for a value we've already seen.

for each i, the inner loop RE-SCANS everything to the right — over the same numbers 200 90 150 60 i=0 scans 90,150,60 i=1 scans 150,60 — AGAIN i=2 scans 60 — AGAIN 60 gets looked at 3×
Fig — the same tail elements are re-examined on every outer pass. That repetition — the overlap between the scans — is the redundancy we're about to delete.

How much work is actually wasted? Slide it and see. The brute force does n(n−1)/2 comparisons; a solution that looked at each element just once would do n. The gap between those two bars is the redundant work — and it's almost the entire bar.

InteractiveThe gap between the bars is the wasted work
brute wasted one pass n counts:
16
The green sliver is the work you actually need; the red is work you're repeating. Bigger n → bigger waste fraction.
Wait —
if the inner loop is just "have I already seen the number I need?", why am I searching a list for it every time instead of just remembering what I've seen?

That question is the whole solution. The fix is the most human idea in computing: write it down. →

Kill it with a technique — remember what you've seen

When you keep re-searching for something, you stop searching and start remembering. In code, "remember, and look up instantly" is a dict — the hash table from Volume 1 that answers "is X in here, and where?" in roughly one step, O(1), no matter how full it gets. (That's a whole technique; chapter 6 is devoted to it.) So we walk the list once. At each element x we ask the dict a single question — "have I already seen target − x?" — and if not, we drop x into the dict and move on. The inner scan doesn't get faster; it disappears.

re-scan a list — O(n) 200 90 150 60 check every box — n steps "is my partner in here?" answered by looking at ALL of them ask a dict — O(1) seen = {200:0, 90:1, …} hash the value → jump straight there 1 step, no matter how full
Fig — the same question — "is my partner here?" — costs n steps against a list but ~1 against a dict. Replacing the scan with a lookup is what collapses O(n²) to O(n).
python
def two_sum_fast(nums, target):
    seen = {}                        # value -> the index we saw it at
    for i, x in enumerate(nums):     # ONE pass, no inner loop
        need = target - x            # the partner that would complete the pair
        if need in seen:             # already met it? O(1) dict lookup
            return (seen[need], i)   # found the pair
        seen[x] = i                  # otherwise, remember x for later
    return None

Line by line: seen is the memory — a value mapped to where we found it. The single loop takes each x in turn. need is the number that would complete the pair. need in seen is the move: instead of re-scanning, we ask the dict, and it answers in one hop. If the partner is there, we're done; if not, we record x so a future element can find us. Every past element is already written down, so no element ever gets looked at twice. Run it on the playlist and watch the memory fill until it pays off:

output — two_sum_fast([200, 90, 150, 60], 240)
i=0 x=200 need=40  40 in seen? no   -> remember  seen={200:0}
i=1 x=90  need=150 150 in seen? no  -> remember  seen={200:0, 90:1}
i=2 x=150 need=90  90 in seen? YES  -> return (1, 2)     # 90 + 150 = 240

Three steps and it stops. Step 0 remembers 200. Step 1 remembers 90. Step 2 needs 90 — and 90 is already written down, at index 1 — so it returns instantly. The list of 90 and 150 was never scanned twice; the dict just recognized the partner the moment it walked by. Step it in full in trace T31.

InteractiveStep the loop — watch memory replace the re-scan
nums = [200, 90, 150, 60], target = 240 200 90 150 60 x = 200 → need = 240 − 200 = 40 is 40 in seen? — seen (memory): { } start — drag the slider →
0
Each step asks the dict ONE question. The re-scan never happens — the partner announces itself.
🧠
This is memoization in miniature
"Stop re-deriving what you already worked out — write it down and look it up" is the exact idea behind dynamic programming (ch9). Here we remember values we've seen; there we'll remember answers we've computed. Same move, bigger stakes.

The re-scan is gone and the answer's still correct. But "feels faster" is a feeling. Prove it. →

Re-check the complexity — did it actually get better?

Never trust a "faster" version until you've re-derived its cost — clever code fools people constantly. So count again. The fast version has one loop over n elements. Each iteration does a fixed amount of work: one subtraction, one dict lookup, maybe one dict insert — all O(1) on average (Volume 1's hash table). One times n is O(n). We traded n(n−1)/2 for n. Not a shave — a change of curve, from the exploding red to the gentle blue.

And because it's a curve change, the win doesn't just exist — it grows with n. I measured both versions on the same inputs (worst case, this laptop; treat the exact milliseconds as approximate):

nbrute forcehash versionspeedup
1,000~19 ms~0.07 ms~280×
2,000~79 ms~0.13 ms~590×
4,000~317 ms~0.36 ms~880×
8,000~1,276 ms~0.71 ms~1,800×

Look at that speedup column climbing. It isn't a fixed "10× faster" — the multiplier itself doubles as n doubles, because O(n²) vs O(n) means the ratio is n/2. That million-element call that would've taken ~5.5 hours by brute force? The hash version finishes it in well under a tenth of a second. Same laptop, same problem, different curve. Turn the knob below and watch the wall-clock cross from milliseconds into years:

InteractiveSlide n — the red marker marches toward "years", the green barely moves
1µs 1ms 1s 1min 1hr 1day 1yr hash brute
10,000
O(n) creeps; O(n²) sprints off the end of the axis. Same problem, same machine — only the curve differs.
input size n → operations → brute O(n²) hash O(n) the gap grows with n
Fig — the two versions don't run parallel — they diverge. The gap between the curves is the wasted work you deleted, and it widens forever.

There's an honest cost, and a 1% engineer names it out loud: the dict holds up to n entries, so we spent O(n) extra memory to buy O(n) time. That's the space–time tradeoff (ch2) — hashing and memoization both pay memory to delete work. Almost always worth it. Not free.

The myth

Optimization is a bag of micro-tricks — swap a loop for a comprehension, add a cache decorator, rewrite the hot line in C, and hope.

The reality

Real optimization is deleting redundant work to lower the growth curve. Micro-tricks shave the constant; changing O(n²) to O(n) changes what's possible. Find the waste first.

↻ The thing people get backwards
People think "brute force" and "the smart solution" are two different kinds of programmer — one for beginners, one for experts. They're not. They're two steps of the same process. The expert didn't skip the brute force; they wrote it in their head in five seconds, measured it, spotted the waste, and deleted it. You always start dumb. The skill is what you do next.

Two-sum was one problem. But that sequence — dumb, measure, spot, delete, re-check — wasn't about two-sum at all. It's the loop. →

The loop is the whole game

Step back and name what we just did, because you'll do it in every chapter from here on. It's a five-step cycle, and it never changes:

1 Bruteforce it 2 Measurecount ops 3 Find thewaste 4 Apply atechnique 5 Re-checkthe cost still too slow? go round again
Fig — the design loop. Every technique in this volume is a tool for step 4; steps 2 and 5 keep you honest. Round and round until it's fast enough.

That's it. That's the operating system. And notice: the rest of Volume 3 is just a toolbox for step 4. Divide & conquer (ch5), hashing (ch6), two pointers and sliding windows (ch7), greedy (ch8), dynamic programming (ch9), backtracking (ch10) — each one is a specific way to delete a specific kind of waste. You don't memorize which to use; you find the waste, and the technique that kills that waste is the one you reach for. Steps 2 and 5 — count the operations — are the same skill from chapters 0 and 1, and they're what keep you from lying to yourself. Play the whole loop live in the Algorithms Lab.

The transfer out of code is real and it's daily. Binary-search your debugging: don't read all 400 commits, bisect them. Hash your memory: don't re-derive a phone number every time, write it down for instant recall. Greedy your errands: nearest stop first. Don't re-solve what you've solved. The loop — "what am I doing over and over that I could do once?" — is a way of seeing, and it works on spreadsheets, supply chains, and your own calendar.

🌍
Where you meet this — the exact same move, at scale
The "replace a re-scan with a remembered lookup" trick is everywhere. Every SQL database turns a slow nested-loop join (O(n·m), check every row against every row) into a hash join (O(n+m), build a hash of one table, probe it once) — two-sum's exact idea, running your bank's queries. Fraud systems scan transactions for pairs that sum to a flagged amount the same way. Compilers hash symbol tables instead of re-scanning source. git finds objects by content hash, not by reading history. You've been served by this loop a thousand times today.
The deeper cut — the oracle, and why you can't parallelize out of a bad curve

Brute force as a permanent asset. The slow version isn't disposable — it's your correctness oracle. Because it's obviously right, you can throw thousands of random inputs at both versions and assert they agree (this is "property-based testing", Vol2 ch19). I did exactly that here: 200,000 random lists and targets through both the brute and hash versions — 0 mismatches. That's how you trust an optimization you can't eyeball.

python
for _ in range(200_000):                      # throw random cases at both
    nums = [randint(-20, 20) for _ in range(randint(0, 8))]
    target = randint(-40, 40)
    assert (brute(nums, target) is None) == (fast(nums, target) is None)   # oracle: both agree WHETHER a pair exists (the two may pick different valid indices)
# ran clean: 0 mismatches

You cannot buy your way out of a worse Big-O. The brute force is beautifully cache-friendly — it marches through a contiguous list in order, and the CPU prefetches perfectly (Volume 1's memory model). You could vectorize it, or split it across 8 cores. But 8 cores turns 5.5 hours into ~40 minutes; the O(n) hash version does the same job in ~70 ms on one core. Hardware multiplies the constant by a fixed factor; a better algorithm bends the curve. At large n, the curve wins every time — which is exactly why "spot the waste" beats "throw more machine at it," and why this loop, not raw compute, is the real lever.

🎯
The reflex to install
On any new problem, two questions, in this order: (1) What's the input size n? and (2) Where's the wasted work? Solve it dumb, then ask them. Run this loop relentlessly and it stops being a checklist and becomes how you see — which is the entire gap between the 99% and the 1%.

Step 4 needs techniques. The first and most powerful one is a single idea: what if, instead of one big problem, you had two half-sized ones — and each of those split again? Chapter 5 is the power of halving. →

05Divide & conquer — the power of halving

Here is a superpower hiding in one sentence: if you can cut a problem cleanly in half, you can solve a version a thousand times bigger for the price of ten more steps. This chapter takes that idea apart to the studs. You will learn the three-line template every divide-and-conquer algorithm shares, derive — not memorise — why halving gives you log n depth, read a recurrence like a sentence, and meet two genuine marvels: raising a number to the millionth power in twenty-five multiplications, and multiplying two 300,000-digit numbers faster than the schoolbook method any human was ever taught. By the end you will reach for "can I split this?" the way a locksmith reaches for a pick.

Split, solve, stitch — the three-line template

Every divide-and-conquer algorithm is the same three moves wearing different clothes. Divide: break the input into smaller pieces of the same shape as the original. Conquer: solve each piece — and because a piece is just a smaller version of the whole problem, you solve it by calling the very same procedure (that is recursion, from Volume 1: a function that calls itself, each call a fresh frame on the stack). Combine: stitch the sub-answers into the answer for the whole. The art is almost never in the split; it is in the combine. Get the stitch right and the recursion does the rest for free.

Merge sort is the cleanest example alive. To sort a list: split it into two halves, sort each half (by calling merge sort on it), then merge the two sorted halves into one sorted list by repeatedly taking the smaller of the two front elements. The split is trivial — cut the list in the middle. The genius is the merge: two already-sorted lists fuse into one in a single walk down both. We step this line by line in trace T30 — here we care about the shape, not the mechanics.

python
def merge_sort(a):
    if len(a) <= 1:                 # a list of 0 or 1 is already sorted — the base case
        return a
    mid   = len(a) // 2             # DIVIDE: split point, right in the middle
    left  = merge_sort(a[:mid])     # CONQUER: sort the left half (same function, smaller input)
    right = merge_sort(a[mid:])     # CONQUER: sort the right half
    return merge(left, right)       # COMBINE: fuse two sorted halves into one

def merge(x, y):
    out, i, j = [], 0, 0
    while i < len(x) and j < len(y):   # walk both, always take the smaller front
        if x[i] <= y[j]: out.append(x[i]); i += 1
        else:            out.append(y[j]); j += 1
    return out + x[i:] + y[j:]         # one side is exhausted; append the rest

Read the top function as English: a one-element list is done; otherwise split, sort both sides, merge. The if len(a) <= 1 line is the base case — the floor where recursion stops instead of splitting forever. The left and right lines are the two recursive descents. The merge helper is the whole payoff: because both inputs are already sorted, comparing only the two front elements is enough to know which value comes next globally. I ran this on random lists from 1 to 100,000 elements; every output came back in perfect ascending order, and the number of comparisons tracked n·log₂n closely (100,000 items → 1,536,134 comparisons, against n·log₂n ≈ 1,660,964).

DIVIDE ↓ (split in half) COMBINE ↑ (merge) [5 2 8 1 9 3 7 4] [5 2 8 1] [9 3 7 4] [5 2] [8 1] [9 3] [7 4] 5·2·8·1·9·3·7·4 ← singles (base case) [1 2 3 4 5 7 8 9] ✓ merge → [1 2 5 8] merge → [3 4 7 9]
Fig — The same list travels down (blue: split until every piece is a trivially-sorted single) and back up (green: merge sorted pieces pairwise). Divide is dumb; combine is where the sorting happens.
💡
The tell for a divide-and-conquer problem
Ask: if I already had the answer for the halves, could I build the whole answer cheaply? For sorting, yes — merge two sorted halves in one pass. For "is x in this sorted list?", yes — you only need to look in one half. When the answer is yes, split. When combining the halves costs as much as solving from scratch, don't bother.

The split gives you a tree. How tall is that tree — and why does its height decide everything? →

Why halving buys you log n

Start with a search space of one billion items and keep throwing away half. A billion → 500 million → 250 million → … how many throws until one item is left? Not a billion. Not a thousand. Thirty. That number — how many times you can halve n before you hit 1 — is the definition of log₂ n, the base-2 logarithm. It is the exact inverse of doubling: doubling asks "start at 1, how many doublings to reach n?"; halving asks the same question backwards. I checked the whole ladder in Python: n = 1,000,000 takes 20 halvings, 1,000,000,000 takes 30, and 8,000,000,000 — every human alive — takes just 33.

python
import math
for n in [1_000, 1_000_000, 1_000_000_000, 8_000_000_000]:
    print(n, "halvings to reach 1:", math.ceil(math.log2(n)))
# 1000 -> 10 · 1_000_000 -> 20 · 1_000_000_000 -> 30 · 8_000_000_000 -> 33

This is exactly why binary search (stepped in trace T23) finds any name in a sorted phone book of eight billion in 33 comparisons: each comparison discards half the remaining names. And it is why merge sort's tree is only log₂ n levels deep — every level halves the piece size, so after log₂ n halvings the pieces are single elements. A million-element merge sort is a tree just 20 levels tall. Depth is logarithmic because halving is the inverse of doubling; everything else in this chapter is a consequence of that one fact.

each step throws away half the search space: 1.0e9 5.0e8 2.5e8 1.25e8 … 24 more halvings … 1 item left — after 30 steps total
Fig — A billion collapses to one in ~30 halvings. The bar length falls off a cliff; the count of steps barely moves. That gap is the whole magic of log n.

Turn the knob yourself. Push n up by a factor of a thousand and watch the recursion depth crawl up by ten — the number that would make a linear algorithm choke doesn't even make the tree taller.

InteractiveExplode n — watch the depth refuse to grow
items to search, n = 1,048,576 halvings needed (recursion depth) = log₂ n = 20
k=20
Every extra doubling of n adds exactly one level. A thousand-fold jump in the data is ten more steps. That is what "logarithmic" feels like.
↺ The thing people get backwards
"Divide and conquer means splitting in half." Halving is only the famous special case. The real requirement is that the pieces be independent and strictly smaller, and that combining them be cheap. You can split into three, into √n pieces, into uneven chunks — quicksort (trace T31) splits at a pivot that is rarely the middle. What controls the speed is not "half" but the balance between the work of splitting/combining and the work waiting inside the pieces — which is precisely what the next section makes exact.

So the tree is log n tall. But each level does some work too. How do the two multiply into a total? →

Reading the recurrence: T(n) = a·T(n/b) + f(n)

The running time of any divide-and-conquer algorithm is captured by one line, the recurrence. Read it as a sentence. T(n) is "the time to solve size n." It equals a — the number of pieces you recurse into — times T(n/b) — the time for one piece, which is 1/b the size — plus f(n), the work you do yourself at this level to split and combine. For merge sort: you make a = 2 recursive calls, each on a half so b = 2, and the merge scans all n elements so f(n) = n. That is T(n) = 2·T(n/2) + n.

You can solve it by pure counting, no theorem required. Draw the tree. The top level does n units of merge work. It has two children, each doing n/2 — together n again. Their four grandchildren each do n/4 — together n once more. Every level sums to n, because the pieces shrink by exactly the factor that they multiply in number. And how many levels are there? From the last section: log₂ n. So the total is n per level times log₂ n levels — n·log₂ n. You just derived merge sort's complexity by adding up a tree.

work done at each level of the merge-sort tree: n = n n/2 n/2 = n n/4 n/4 n/4 n/4 = n ⋮ (log₂ n levels total) ⋮ n per level × log₂ n levels = n·log₂ n the pieces shrink by exactly the factor they grow in number — so each row is a full n
Fig — Solve the recurrence by adding up the tree: every level totals n, there are log₂ n levels, the product is n·log₂ n. No formula memorised — just counting.

The Master Theorem is just this counting argument, generalised, boiled down to a three-way comparison you can do in your head. It pits the work you do at the top (f(n)) against the work piling up in the leaves (which grows like n^(log_b a)). Whichever side is bigger wins the total:

  • Leaves win (many small pieces, cheap combine): the answer is dominated by the bottom of the tree — total ≈ n^(log_b a).
  • It's a tie (both sides balanced — merge sort's case): every level contributes equally, so you pay the per-level work times the log n levels — total ≈ f(n)·log n.
  • The top wins (expensive combine, few pieces): the very first level dominates and the recursion is a rounding error — total ≈ f(n).

That is the whole theorem in plain words: compare the splitting/combining work to the work waiting in the subproblems; the bigger one sets the price. You almost never need the formal version — but here it is for when you do.

The deeper cut — the Master Theorem, stated properly

For T(n) = a·T(n/b) + f(n) with a ≥ 1, b > 1, let c* = log_b a (the "watershed" exponent — the growth rate of the leaf count). Compare f(n) to n^(c*):

  • Case 1: if f(n) = O(n^(c*−ε)) for some ε > 0 (leaves grow faster), then T(n) = Θ(n^(c*)).
  • Case 2: if f(n) = Θ(n^(c*)) (a tie), then T(n) = Θ(n^(c*)·log n).
  • Case 3: if f(n) = Ω(n^(c*+ε)) and the regularity condition a·f(n/b) ≤ k·f(n) holds for some k < 1, then T(n) = Θ(f(n)).

Merge sort: a=b=2 so c* = log₂2 = 1, and f(n)=n=n¹ — a tie, Case 2 — giving Θ(n log n). Binary search: a=1, b=2 so c*=0, f(n)=Θ(1)=n⁰ — a tie again — giving Θ(log n). The theorem has a blind spot (the gap cases, e.g. f(n)=n log n against n); those need the Akra–Bazzi generalisation. But the three-way "who's bigger" instinct covers almost everything you will meet.

Watch the tie happen. Slide n and see every level of the tree carry the same total load, with the number of levels — and so the total — following n log₂ n, while the quadratic method it beats balloons out of frame.

InteractiveEvery level weighs n — count the levels
merge-sort tree for n = 1024 n·log₂n = 10,240 units n² (schoolbook) = 1,048,576 units
n=1024
Each green bar is one level doing n work. There are log₂ n of them. Their sum n·log₂n stays tiny next to — the whole reason we split.
Wait —
if every level of a splitting tree costs n, what happens when the "work" at each level isn't scanning a list, but squaring a number? Could raising 2 to a huge power collapse from a million steps to twenty?

Fast exponentiation — xn in log n multiplications

Here is the marvel. To compute xn the obvious way you multiply x by itself n−1 times: 2¹⁰⁰⁰⁰⁰⁰ would be 999,999 multiplications. But multiplication lets you double the exponent with a single squaring: squared is x⁴, squared again is x⁸, then x¹⁶… each squaring doubles the exponent instead of adding one to it. And doubling, we now know cold, reaches any target in log₂ n steps. So build the exponent up in powers of two, and multiply in the pieces the binary form of n tells you to keep.

That is exponentiation by squaring. Write n in binary; walk its bits from the top; square the running result at every bit, and multiply in an extra x whenever the bit is 1. The multiplication count is (bit_length − 1) squarings plus (popcount − 1) extra multiplies, where popcount is the number of 1-bits. I ran the counter:

python
def fast_pow(x, n):
    result = 1
    for bit in bin(n)[2:]:      # binary digits of n, most-significant first
        result = result * result        # SQUARE — doubles the exponent so far
        if bit == '1':
            result = result * x          # MULTIPLY — add one to the exponent
    return result

# verified against Python's own x**n on 2000 random cases: all equal.
# 2 ** 1_000_000:  bin(n) has 20 bits, 7 of them are 1
#   -> 20 squarings + 7 multiplies = 27 executed (the leading 1*1 square and 1*x multiply are trivial, so 19+6 = 25 do the real work)
#   the naive loop would need 999,999.

Line by line: result starts at 1. For each binary digit of n from the most significant down, we square result — that doubles the exponent we have accumulated so far — and if the current bit is a 1 we do one extra multiply by x, nudging the exponent up by one to match that bit. Because 1000000 is binary 11110100001001000000 (20 bits, 7 ones), the machine needs 19 squarings + 6 multiplies = 25 multiplications to produce a number with 301,030 decimal digits. Twenty-five, not a million. I verified fast_pow against Python's built-in x**n on 2,000 random inputs — every result matched (and Python's own integer ** uses exactly this trick internally).

NAIVE — +1 each step (n−1 multiplies) x⁴ … 999,996 more … x¹⁰⁰⁰⁰⁰⁰ SQUARING — ×2 each step (≈ log₂n squarings) x⁴ x⁸ x¹⁶ x³² … x¹⁰⁰⁰⁰⁰⁰ in 25 total square same destination — the green ladder gets there in log₂n leaps instead of n crawls
Fig — Naive exponentiation adds 1 to the exponent per multiply; squaring doubles it. Reaching a million by doubling takes 20 rungs, not a million.

Turn the exponent yourself. Watch the binary of n decide which steps are squares and which add a multiply, and watch the fast count sit near log₂ n while the naive count runs off to n.

InteractiveSet the exponent — count the multiplications
exponent n = 1000000 n in binary (each box = one bit → one squaring; a 1 also costs a multiply): fast_pow: 25 multiplications naive loop: 999,999 multiplications
1000000
The green count is (bits−1) + (ones−1) — it grows like log₂ n. The red count is n−1. The gap is exponential.
🌍
Where you meet this — every time you see a padlock
The https:// in your address bar, every Bitcoin signature, every SSH login: all rest on modular exponentiation — computing baseexponent mod m for numbers thousands of bits long. Python spells it pow(base, exp, m), and it is exactly the squaring trick with a mod after every step so the numbers never blow up. I timed a real 2048-bit case (RSA scale): pow(base, exp, m) returned in about 17 ms on this machine — roughly 2,046 squarings. The naive version would need about 2²⁰⁴⁶ multiplications: more operations than there are atoms in the observable universe, many times over. Fast exponentiation is not a nicety here. It is the only reason public-key cryptography can run at all.

Squaring made powers cheap by halving the exponent. Could the same halving instinct make plain old multiplication of giant numbers cheaper than the method you learned in school? →

Karatsuba — multiplying giants below

The multiplication you learned as a child is O(n²): to multiply two n-digit numbers, every digit of one meets every digit of the other — n × n little products. Double the digit count and the work quadruples. For the 300,000-digit numbers that 2¹⁰⁰⁰⁰⁰⁰ produces, that quadratic cost is brutal. For a long time it was widely assumed that was simply the price of multiplication. In 1960, following a question the mathematician Andrey Kolmogorov had posed, a young student named Anatoly Karatsuba found a method that beat it — and the surprise reshaped how people thought about the limits of arithmetic.

The trick is divide and conquer with one stroke of genius. Split each n-digit number into a high half and a low half: x = a·10^(n/2) + b, y = c·10^(n/2) + d. The schoolbook product needs the four cross-products ac, ad, bc, bd — that is 4·T(n/2), and four is too many: it gives back . Karatsuba noticed that the middle term ad + bc can be recovered from products you already need plus one more, because (a+b)(c+d) = ac + ad + bc + bd, so ad + bc = (a+b)(c+d) − ac − bd. That means three multiplications suffice instead of four: ac, bd, and (a+b)(c+d). One saved multiplication, taken recursively at every level, changes the exponent itself.

ab × cd n-digit × n-digit, each split in half SCHOOLBOOK — 4 half-products → T(n)=4T(n/2)+n = Θ(n²) a·c a·d b·c b·d KARATSUBA — 3 half-products → T(n)=3T(n/2)+n = Θ(n^1.585) a·c b·d (a+b)(c+d) middle = this − ac − bd
Fig — One fewer multiplication per level. Four sub-products keep you at ; three drop the exponent to log₂3 ≈ 1.585. The recurrence changed because a constant did.

Why does dropping 4 to 3 change the exponent? Straight from the recurrence. Schoolbook is T(n) = 4·T(n/2) + n; Karatsuba is T(n) = 3·T(n/2) + n. By the watershed rule, the leaf work grows as n^(log₂ a): log₂4 = 2 gives ; log₂3 ≈ 1.585 gives n^1.585. A single fewer branch per node, compounded over log n levels, bends the whole curve. This is not folklore — Python's own big integers use it. CPython multiplies small integers the schoolbook way but switches to Karatsuba once the numbers pass a threshold. I timed multiplication of random numbers as their size doubled:

python
import random, time
def t(bits, reps):
    a, b = random.getrandbits(bits), random.getrandbits(bits)
    a * b                                   # warm up
    s = time.perf_counter()
    for _ in range(reps): a * b
    return (time.perf_counter() - s) / reps

#  bits     time/mult     ratio when bits double   (schoolbook would be 4.0x)
#  64000    8.2e-4 s
# 128000    2.4e-3 s      2.92x      implied exponent log2(2.92) ~ 1.55
# 256000    7.5e-3 s      3.05x      implied exponent log2(3.05) ~ 1.61

Read the ratios. Every time the numbers doubled in length, the time grew by about , not the that schoolbook demands. Take log₂ of that measured ratio and you get roughly 1.58 — landing right on Karatsuba's theoretical log₂3 = 1.585. (These are wall-clock timings on this machine; the exact milliseconds will differ on yours, but the ratio — the slope — is a property of the algorithm, not the hardware.) You are watching a 1960 theorem tick inside your interpreter every time you multiply large integers.

✗ The myth

To multiply huge numbers faster, you need a faster chip.

✓ The reality

You need a better recurrence. Karatsuba (and its heirs Toom–Cook and FFT-based multiplication) beat a supercomputer running schoolbook, on the same chip, purely by rearranging which products get computed. Algorithm > hardware, at scale.

🔎
A real edge you'll hit
CPython 3.11+ refuses to convert an integer with more than 4,300 digits to a string by default — str(2**1000000) raises ValueError: Exceeds the limit (4300 digits) for integer string conversion. It is a deliberate guard against a denial-of-service via giant-number formatting. Raise it with sys.set_int_max_str_digits(...). The multiplication itself is unlimited; only the decimal printing is capped. (I hit this exact error building 2¹⁰⁰⁰⁰⁰⁰ for this chapter.)

Splitting bought us logarithmic depth, cheap powers, sub-quadratic products. But there's one more prize hiding in the word independent — and it's about the metal. →

The metal, and the mental move

Look back at the merge-sort tree. The left half and the right half never touch until the merge. They are independent — nothing the left recursion computes changes anything the right one does. That independence is a gift to the hardware. In Volume 1 you met the modern reality that a CPU has many cores. Independent subproblems can be handed to different cores at the same time: sort the left half on core 0 while core 1 sorts the right, then merge. Divide and conquer is the most natural way there is to write parallel code, because the algorithm has already carved the work into pieces that don't interfere. This is not a metaphor — it is the shape of MapReduce, the pattern that let Google process the whole web across thousands of machines: split the data, solve the shards in parallel, combine. Divide and conquer at datacenter scale.

There is a cache dividend too, from Volume 1's memory hierarchy. When a subproblem shrinks enough to fit entirely in fast cache, every remaining operation on it runs at cache speed instead of paying for trips to main memory. Recursion naturally drives the working set down to that sweet spot — which is why cache-aware divide-and-conquer sorts and matrix multiplications routinely beat "flat" algorithms with the identical Big-O. Same curve on paper; the one whose data sits in cache wins the wall clock. (This is the recurring lesson of the whole volume: at equal Big-O, constants and data layout decide the race.)

sort(whole) sort(left half)CPU core 0 sort(right half)CPU core 1 at thesame time merge → done
Fig — Independent halves are trivially parallel: two cores, one moment. This is the seed of MapReduce and every parallel sort — the algorithm did the hard partitioning for you.

Now the mental move — the thing to carry out of this chapter into problems that have nothing to do with sorting. When you face an unknown, ask what single question cuts the space of possibilities in half. A bug appears somewhere in 2,000 lines of new code: don't read line 1 and creep forward — comment out half, see which half misbehaves, repeat. Eleven steps, not two thousand. (This is literally what git bisect does: it binary-searches your commit history for the one commit that broke the build, in log₂ of your commits.) A number is somewhere between 1 and a million and you get hot/cold hints: guess the middle every time and you corner it in twenty guesses. A wide spreadsheet has one wrong total: split it, sum each half, chase the half that's off. Halving is not a sorting technique. It is a way of attacking uncertainty — and once you see it, you will see it everywhere.

🧠
The 1% habit
The engineer who stands out doesn't think "how do I search all of it?" — they think "what one probe halves what's left?" Linear thinking reads everything; logarithmic thinking eliminates half of everything, then forgets it existed. Retrain the reflex: before you start scanning, ask whether the problem can be cut. Most of the time, if the data has any order to it, it can.
Wait —
divide and conquer works when the halves are independent. What about problems where the pieces overlap — where the same little subproblem shows up again and again, and re-solving it each time is the whole disaster?

Next chapter turns from splitting to a different instinct entirely: hashing — how spending a little memory lets a dict answer "is this thing in here?" in a single O(1) jump instead of an O(n) scan, deleting whole inner loops on sight and collapsing O(n²) to O(n). What can you buy by trading space for time? → When can grabbing the best-looking option right now possibly be the globally optimal move? →

06Hashing — buying O(1) with space

Almost every slow program is slow for one reason: somewhere inside a loop, it is searching — asking "is this thing in here?" and walking the whole collection to find out. This chapter hands you the single most powerful move in practical algorithms: turn that search into a jump. By the end you will look at any nested loop whose inner half is a lookup and delete the inner loop on sight — collapsing O(n²) into O(n) — using nothing but a set or a dict. Volume 1 chapter 7 showed you how a hash table works inside; here you learn what to do with it.

The question that costs O(n) — and the trick that makes it free

Start with the most ordinary question a program ever asks: "is X in this collection?" With a list, Python has no choice but to look. It starts at index 0 and compares, element by element, until it finds a match or falls off the end. If the thing is absent, it touches every element. That is a linear scan, and its cost grows in lockstep with the size of the list — one comparison per item, n items, O(n). We counted it, no hand-waving:

python
def scan_ops(haystack, needle):
    ops = 0
    for x in haystack:          # look at one element…
        ops += 1
        if x == needle:         # …compare it…
            return ops          # …stop the instant we match
    return ops                  # a MISS touched everything

for n in (10, 100, 1000, 10000):
    print(n, scan_ops(list(range(n)), -1))   # -1 is absent → worst case
# 10 10 · 100 100 · 1000 1000 · 10000 10000

Line by line: the loop visits elements one at a time; each visit is one ops += 1; a match returns early, but a value that isn't there forces the loop to the very end. The output is the whole story — the comparison count is exactly n. Double the list, double the work. A million-item list means a million comparisons for one "is it here?".

Now the trick. A set (and a dict, its key-value sibling) does not store your values in a row to be walked. It computes where the value belongs from the value itself: feed the value to a hash function, get back a number, use that number to jump straight to one slot. So membership is hash-the-query, jump, look — one hash, one jump, one compare, a cost that does not grow with n at all. That is O(1), "constant time." We timed the exact same "is this missing value here?" question against both:

python
import timeit
for n in (1_000, 10_000, 100_000, 1_000_000):
    lst, st = list(range(n)), set(range(n))
    t_list = timeit.timeit(lambda: -1 in lst, number=1000) / 1000
    t_set  = timeit.timeit(lambda: -1 in st,  number=1000) / 1000
    print(n, round(t_list*1e9), "ns   vs", round(t_set*1e9), "ns")
# 1000       6006 ns  vs 65 ns
# 10000     61067 ns  vs 37 ns
# 100000   600563 ns  vs 38 ns
# 1000000 6392406 ns  vs 39 ns   (6.4 MILLISECONDS vs 39 nanoseconds)

Read the two columns. The list column marches: 6 µs, 61 µs, 600 µs, 6.4 ms — a straight multiple of n, exactly as the op-count predicted. The set column does not move: ~38 nanoseconds whether it holds a thousand items or a million. At a million items the set answers the question roughly 160,000 times faster. (Timings are from this machine and will vary; the shape — one line climbing, one line flat — is universal.)

list — walk every box until you match or run out (O(n)) 200 247 245 255 634 compare, step, compare, step, … up to n times set — hash the query, jump to the one slot (O(1)) hash(634) → slot 4 0 1 2 3 634 5 one probe — and it never gets slower as the set grows
Fig — A list makes you look. A hash lets you compute the address from the data — no search at all. That is the whole game.
InteractiveSlide n — watch the scan tower over the jump
work (log scale) list scan n compares set jump ≈1 probe
10,000
The set's answer costs the same at n=10 and n=1,000,000. That flat green line is what O(1) looks like.

One question, made free. But the real prize isn't one lookup — it's what happens when a lookup was hiding inside a loop

The set: membership and dedup in one pass

A set is a bag of unique, hashable values with no order and no duplicates — a dict that kept only the keys. Two everyday jobs fall out of it for free. First, membership: x in some_set is the O(1) jump you just met. Second, deduplication — collapsing a collection to its distinct values — because a set physically cannot hold the same value twice. Adding a value that's already there is a no-op:

python
plays = ["Levels", "Titanium", "Levels", "Wake Me Up", "Titanium", "Levels"]
unique = set(plays)
print(unique)         # {'Wake Me Up', 'Levels', 'Titanium'}  (order not preserved)
print(len(unique))    # 3  — six plays, three distinct songs

That set(plays) is a single pass — it hashes each of the six songs and drops it into its slot; repeats land on a slot that's already taken and vanish. One line, O(n), done. Compare the beginner's instinct — a nested loop that asks "have I seen this before?" by scanning a growing result list — which is O(n²) for the exact same answer. The set replaces that inner scan with a hash.

🎯
The reflex to build
The instant you catch yourself writing if x not in result: where result is a list, stop. If you only need to know "have I seen it?", make result a set — the in test drops from O(n) to O(1) and the whole loop from O(n²) to O(n). Same code shape, a different container, a different universe of speed.
plays — 6 items, with repeats Levels Titanium Levels Wake Me Up Titanium Levels dashed = duplicate, hashes to a taken slot → dropped set(plays) — one pass, O(n) Levels Titanium Wake Me Up unique — 3 items, no order, no repeats
Fig — A set is a dedup machine: repeats hash to an occupied slot and quietly disappear. One pass, not a nested scan.
Wait —
A set throws away the duplicates. But what if the duplicates were the point — what if I wanted to know that "Levels" played three times, not just that it played?

The tally: counting with a dict (and Counter)

Keep the keys, but give each one a value that counts. That is a tally: a dict from thing to how-many. For each item you jump to its key and bump the number by one — every step O(1), the whole tally O(n). The idiom uses .get(key, 0), which reads "the current count, or 0 if we've never seen this key":

python
tally = {}
for s in plays:
    tally[s] = tally.get(s, 0) + 1     # jump to s, read count, add 1, store
print(tally)     # {'Levels': 3, 'Titanium': 2, 'Wake Me Up': 1}

Three lines of mechanism: tally.get(s, 0) fetches the running count in O(1); we add one; tally[s] = … writes it back in O(1). No list, no scan, no re-counting — each play is touched exactly once. Python ships this pattern pre-built as collections.Counter, which does the same thing and adds the questions you actually ask of a tally:

python
from collections import Counter
print(Counter(plays).most_common(1))          # [('Levels', 3)]  — the top song
print(Counter("mississippi").most_common(2))  # [('i', 4), ('s', 4)]

That second line is a whole class of interview questions ("most frequent character", "top-k words", "find the anagram groups") reduced to one line — because counting is just hashing with a running total.

💡
Counting is hashing
"How many of each?" and "which appears most?" are the same O(n) tally underneath. A word-frequency counter for a novel, a vote tally, a histogram of pixel colours, "how many 404s per URL" in a server log — all one pass with a dict. The moment a problem says count, frequency, how many, or duplicate, your hand should already be reaching for a dict.
for s in plays: tally[s] = tally.get(s, 0) + 1 3Levels 2Titanium 1Wake Me Up .most_common(1) → ('Levels', 3)
Fig — A dict-of-counts is a histogram you build in one pass. Counter is that pattern with the top-k question attached.

Membership, dedup, counting — all one pass because the lookup is free. Now the headline act: a lookup that was trapped inside a second loop, and how a dict sets it free →

The "seen" dict: how a hash deletes an inner loop

Here is the pattern that separates the two programmers from the volume's opening. Take two-sum: given a list of numbers and a target, find two of them that add to the target. The obvious solution tries every pair — for each number, loop over all the others looking for its partner. That inner loop is a search, and it runs for every element, so the cost is every-pair: n(n−1)/2, which is O(n²). We counted the pair-checks exactly:

python
def two_sum_naive(nums, target):          # O(n^2): every pair
    for i in range(len(nums)):
        for j in range(i+1, len(nums)):   # the inner loop IS a search
            if nums[i] + nums[j] == target:
                return (i, j)

def two_sum_hash(nums, target):           # O(n): one pass, a dict remembers
    seen = {}                             # value → index we saw it at
    for i, x in enumerate(nums):
        need = target - x                 # the exact partner x is looking for
        if need in seen:                  # O(1) — ask the dict, don't scan
            return (seen[need], i)
        seen[x] = i                       # write x down; value → index seen

The insight is small and total. In the naive version, when we hold x we don't know where its partner is, so we search for it. But we know exactly what the partner must be: target - x. So instead of searching, we keep a "seen" dict of every number we've passed, and simply ask it "have you got target - x?" — an O(1) jump. The inner loop doesn't get faster. It disappears. We ran both and counted operations at the worst case (the answer is the last pair):

python
# n     naive ops     hash ops
# 10          45          10
# 100       4,950         100
# 1000    499,500        1000     ← n(n-1)/2 vs n

At n=1000 the nested loop does 499,500 pair-checks; the dict version does 1,000 lookups. That is not a tweak — it is 500× at a thousand items and 500,000× at a million. Two-sum is stepped line by line in trace T31; the point here is the move, not this one problem.

↺ The thing people get backwards
People say hashing "makes the search faster." It does something better: it abolishes the search. There is no faster search hiding in two_sum_hash — there is no search at all. You spent memory (the seen dict) to buy the right to never look. The whole discipline of this chapter is that trade: when you can't afford to search, remember instead.
nested loop — every pair n(n-1)/2 checks one pass + a "seen" dict each cell: "is target−x in seen?" → O(1) n checks the whole triangle collapses onto a single row
Fig — The O(n²) triangle of pairs collapses to an O(n) row. The dict is what lets each element skip straight to its partner.
InteractiveGrow n — watch the nested loop balloon while the pass stays a line
nested loop n(n-1)/2 dict pass n
12
At n=12 the nested loop does 66 pair-checks; the dict pass does 12. The gap is n/2 and it only widens.
🌍
🌍 Where you meet this
The "seen" dict is everywhere the moment you look. A database hash index answers WHERE id = ? without scanning the table — a dict on disk. Redis and memcached are, essentially, one giant dict shared over the network — the layer that keeps big sites from re-computing everything on every request. Git stores every file and commit under the hash of its own contents (content-addressed storage: the address is the SHA of the data). Spell-check, git diff, and DNA read-alignment all hash fixed-length chunks to find matches fast. And Python itself is built on this: every variable name, every attribute, every keyword argument, every module's namespace is a dict lookup — you have been riding O(1) hashing on every line you have ever written.

Remembering what you've seen kills the search. Remembering what you've already computed kills something bigger — repeated work →

The cache: answer each question once

The "seen" dict remembers values you've passed. A cache remembers answers you've computed, so you never compute the same one twice. This is memoization, and it is the same dict trick pointed at a function. The classic victim is naive recursive Fibonacci, which re-solves the same subproblems an exponential number of times. A dict cache turns the tree of repeated calls into a straight line:

python
cache = {}
def fib(n):
    if n < 2:
        return n
    if n not in cache:                    # already answered? jump to it
        cache[n] = fib(n-1) + fib(n-2)    # first time only: compute, store
    return cache[n]

Line by line: base cases return immediately; otherwise we ask the cache in O(1) whether we've solved this n before; if not, we compute it once and write it down; every later request for the same n is a free jump. The effect is violent. We instrumented both versions with a call counter:

python
# fib(30):
#   naive recursion : 2,692,537 calls
#   with the cache  :        59 calls

Two and a half million calls become 59. The naive tree recomputes fib(28) tens of thousands of times; the cache computes it once and answers the rest from memory. (The recursion tree exploding then collapsing is a live widget in the Algorithms Lab, and stepped in the traces.)

naive fib(5) — every branch recomputes f5 f4 f3 f3 f2 f2 f1 amber nodes = the SAME subproblem, solved again and again → 2,692,537 calls at fib(30) cached fib — each value once f5 f4 f3 f2 repeat request? → O(1) cache hit, no recursion at all the branching tree straightens into a line → 59 calls at fib(30)
Fig — The cache turns an exponential tree of repeated subcalls into a linear chain. The dict is the memory that makes "already solved" mean "free."
🧭
This is the doorway to Chapter 9
"Don't re-solve a subproblem you've already solved" is the entire seed of dynamic programming (Chapter 9). Memoization is DP discovered from the top down, with a dict as the memory. You just met the technique; Chapter 9 turns it into a way of designing algorithms.
The deeper cut: you rarely write the cache by hand

The hand-rolled cache = {} above is exactly what functools.lru_cache (and functools.cache) do for you — decorate the naive fib with @cache and the dict is created, keyed, and consulted automatically, no body change. The catch is the same one that runs through this whole chapter: the arguments must be hashable, because they become dict keys. That's why a memoized function taking a list argument fails — and why the standard fix is to pass a tuple. The cache also costs memory that lives as long as the function does; lru_cache(maxsize=N) caps it by evicting the least-recently-used answers, which is the same eviction policy a CPU's hardware cache and a CDN use. Chapter 9 builds the intuition for which subproblems are worth remembering.

Wait —
If a hash lookup is always O(1) and costs a little memory, why doesn't Python make everything a dict? What's the catch nobody mentions?

The bill: space, hashable keys, and the worst case

O(1) is bought, not free. Three costs come with it, and an engineer names them out loud.

Cost one — space. A set or dict holds your data plus a table of slots that is deliberately kept larger than the data (so collisions stay rare — Volume 1 chapter 7). Trading memory for time is the whole deal; on a huge dataset that memory can be the thing you don't have.

Cost two — the keys must be hashable. To hash a key its value must be fixed, so its hash never changes underneath the table. That means immutable keys only. Lists are mutable, so they're barred — loudly:

python
{ ["Levels", 200]: 1 }         # TypeError: unhashable type: 'list'
{ ("Levels", 200): 1 }         # fine — a tuple is immutable, so hashable
print([].__hash__)             # None   — lists opt out of hashing entirely

A list's __hash__ is literally None: it refuses to be a key. Tuples of immutable things are the standard fix — that's why grid coordinates, dates, and (name, version) pairs are so often tuples.

⚠️
Immutable keys are a safety rail, not a nuisance
If a key's hash could drift after you filed it, the table would look in the wrong slot and your value would vanish while still sitting in memory. "Keys must be hashable" is Python refusing to build a container that silently loses data — stopping you at the door with a TypeError instead of at 3 a.m. with a missing record.

Cost three — O(1) is the average, not a guarantee. The jump is fast only when values scatter across slots. If many keys land in the same slot — a collision — the table has to walk that pile-up, and in the pathological case where everything collides, the lookup degrades to the very linear scan we were escaping. We forced it, with keys whose hash is a constant so they all target one slot:

python
class AllSame:                       # a deliberately terrible key
    def __init__(self, v): self.v = v
    def __hash__(self): return 42    # EVERY instance → the same slot
    def __eq__(self, o): return self.v == o.v

# building a set of these, timed on the test machine:
#   n=1000   0.030 s      n=2000   0.109 s      n=4000   0.442 s
#   → doubling n roughly QUADRUPLES the time: O(n^2) build, O(n) per op

When every key collides, doubling the data quadruples the work — the signature of O(n²). The O(1) promise didn't just weaken; it collapsed all the way back to a scan. In normal use this never happens, because a good hash function scatters keys evenly. But "normal use" assumes nobody is choosing the keys on purpose.

good hash — keys spread, ~1 per slot (O(1)) A B C D E F any lookup = jump to one slot, done flooded — every key hashes to one slot (O(n)) · · A B C · · D E F … n keys stacked here any lookup = walk the whole pile → a scan
Fig — The hash function is the whole difference between O(1) and O(n). Scatter buys constant time; a pile-up gives it all back.

Myth

"A dict is O(1), full stop — you can quote it in an interview and never think about it again."

Reality

O(1) is the average with a good hash and honest inputs. The worst case is O(n) per operation, and an attacker who can pick your keys can force it — a real denial-of-service.

That attack has a name: hash flooding. Around 2011, researchers showed that many web platforms — Python, PHP, Java, Ruby, ASP.NET and more — built dicts from untrusted input (POST fields, JSON keys, HTTP headers) with a fixed, published hash function. An attacker could craft thousands of keys that all collide, turning one small request into an O(n²) grind that pinned a server's CPU. The fix Python shipped is quietly brilliant: randomize the hash. Each process seeds its string hash with a secret chosen at startup, so an attacker can't predict which keys collide. You can watch the seed change between runs:

python
# two separate Python processes, same line:
hash("Levels")   #  4417089987191320694   (first process)
hash("Levels")   #   -41086165888829000   (second process — different!)
hash(200)        #  200   (integers still hash to themselves, both runs)

Same string, different hash every process — that unpredictability is the defense. (Integers hash to themselves, which is why the crafted-collision demo above needed a broken __hash__: CPython's probing scatters even adversarial integer keys.) This is why you must never rely on a set or dict's iteration order being stable across runs, and why security-sensitive code exists to make hashing unguessable.

🔥
The mutable-key trap, for real
Cost two isn't hypothetical. Put a mutable object in a dict via a custom __hash__ that reads a field, then change that field: the object is now filed under its old hash, so obj in d looks in the new slot and returns False — the entry is still in the table, permanently unreachable. Immutable keys aren't pedantry; they're the guarantee that "I put it in" and "I can find it" stay the same statement.
The deeper cut: what actually replaced the broken hash

Python's answer to hash flooding was PEP 456 (Python 3.4), which swapped the old fast-but-guessable string hash for SipHash — a keyed hash designed exactly for this: fast on short inputs, but effectively impossible to find collisions for without the secret key. The per-process secret is the PYTHONHASHSEED; hash randomization has been on by default since Python 3.3. Set PYTHONHASHSEED=0 and hashing becomes deterministic again — on this machine that pins hash("Levels") to a fixed 9063540593168268057 every run (useful for reproducible tests, dangerous in production). One honesty note on cost one: the per-lookup time is independent of n, but not of the key — hashing a 10,000-character string reads all 10,000 characters, so O(1) here means "constant in the number of items," with the key's own size folded into the constant.

InteractivePile keys into one slot — watch O(1) rot into O(k)
hash table — one slot, and the pile-up inside it · · slot · · 1 key here → 1 probe. This is the O(1) you paid for. worst-case lookup = length of the chain
1
A good hash keeps k≈1 for everyone — that's O(1). An attacker who forces every key into one slot makes k=n — that's O(n).

Hashing bought O(1) by scattering keys to random slots — deliberately destroying order for speed. The next chapter asks the opposite question: the next chapter buys that same speed for free — no extra memory at all — with two pointers and a sliding window: array sweeps that compute the change between overlapping states instead of restarting the work each time → →

07Two pointers & the sliding window

Chapter 6 bought speed with a hash table — extra memory in exchange for O(1) lookups. This chapter buys the same speed with nothing extra at all. You'll learn two array patterns that collapse a nested double loop into a single sweep: two pointers that march from both ends toward the middle, and a sliding window that reuses the work it already did instead of starting over. After this you'll be able to look at an O(n²) loop and see, instantly, whether it's secretly O(n) in disguise — and how to set it free.

Two fingers, marching inward

From Volume 1 you know an array is a row of contiguous slots, and that a[i] is a single multiply-and-jump — O(1) random access. The usual way to walk it is one index sliding left to right. But some problems have a hidden symmetry: the answer depends on the two ends at once. For those, put one finger on the first slot and one on the last, and step them toward each other until they meet.

Reversing a list is the cleanest case. Swap the two ends, then step both inward. When the fingers meet in the middle, you're done — you've touched each slot exactly once and never allocated a second array.

python
def reverse(a):
    lo, hi = 0, len(a) - 1
    while lo < hi:                    # stop the instant they meet
        a[lo], a[hi] = a[hi], a[lo]   # swap the two ends in one statement
        lo += 1; hi -= 1              # step both pointers inward
    return a

def is_palindrome(s):
    lo, hi = 0, len(s) - 1
    while lo < hi:
        if s[lo] != s[hi]:            # a single mismatch settles it — bail early
            return False
        lo += 1; hi -= 1
    return True

print("".join(reverse(list("REWARD"))))   # DRAWER   (3 swaps for 6 letters)
print(is_palindrome("racecar"))           # True
print(is_palindrome("python"))            # False

Read reverse line by line. lo, hi start at the two ends. The while lo < hi guard is the whole trick — the loop runs only while the fingers haven't crossed. Each pass swaps the pair they point at and pulls both inward by one. For 6 letters that's 3 swaps (I ran it: REWARDDRAWER); for n items it's exactly n // 2 swaps. Count the operations: one loop, running n/2 times, constant work inside — that's O(n) time. And crucially, O(1) extra space: no copy, no second array. is_palindrome is the same skeleton with a comparison instead of a swap, and it can bail early the moment two ends disagree.

start — swap ends, step inward R E W A R D lo hi swap 1 swap 2 · swap 3 (center pair) result D R A W E R
Fig — Two pointers converge: three swaps turn REWARD into DRAWER. n/2 swaps, one pass, zero extra memory. (Reverse-in-place is stepped in trace T32.)
⚙️
Why in-place is the machine's favourite
The slick one-liner a[::-1] also reverses — but it allocates a whole new list of n references (O(n) space) and copies into it. The two-pointer version touches the array where it already lives: the slots are contiguous, so lo and hi walk straight through cache lines the CPU has already prefetched (Volume 1, chapter 2). Same Big-O, but no allocation and friendlier to the memory hierarchy. In pure CPython the C-level slice is often faster on wall-clock; the in-place pattern is what you reach for in a compiled language or when memory is tight.

Both-ends only helps when you act on both ends. But watch what happens when we point those two fingers at a sorted array and go hunting for a pair. →

When it's sorted, every look kills half the mistakes

Here's the classic: find two numbers in an array that add up to a target. The brute force is two nested loops — try every pair — which is n(n-1)/2 comparisons, O(n²). In chapter 6 we crushed it to O(n) with a hash "seen" set. But that costs O(n) extra space and works on any order. If the array is already sorted, two pointers do it in O(n) time and O(1) space — no hash at all.

Put lo at the smallest value, hi at the largest, and look at their sum. Now the magic, and it's worth savouring: because the array is sorted, that one comparison tells you exactly which pointer to move.

  • If a[lo] + a[hi] is too small, no pair using a[lo] can ever reach the target — a[hi] is already the biggest partner available, and it wasn't enough. So a[lo] is hopeless: drop it, lo += 1.
  • If the sum is too big, by the same logic a[hi] is hopeless with every remaining partner: drop it, hi -= 1.
  • If it's equal, you found the pair.

Every single step permanently eliminates one number from all its remaining pairings — a whole row (or column) of that n² grid of pairs, gone. Since there are only n numbers to eliminate, the pointers meet in at most n − 1 steps.

python
def two_sum_sorted(a, target):        # a is sorted ascending
    lo, hi = 0, len(a) - 1
    while lo < hi:
        s = a[lo] + a[hi]
        if s == target:
            return lo, hi             # found it
        elif s < target:
            lo += 1                    # too small -> raise the low end
        else:
            hi -= 1                    # too big  -> lower the high end
    return None

a = [2, 7, 11, 15, 20, 24, 40]
print(two_sum_sorted(a, 26))          # (0, 5)  ->  2 + 24 = 26,  found in 2 steps

I instrumented both versions. On that little array the two-pointer walk found 2 + 24 = 26 in 2 steps. On a sorted array of 100,000 numbers it found a middle pair in 11,232 steps — comfortably under the n − 1 = 99,999 ceiling — while the brute force would have needed up to n(n−1)/2 ≈ 5,000,000,000 comparisons. Five billion versus eleven thousand, from the same data, just by letting sortedness do the eliminating.

2 7 11 15 20 24 40 lo hi target = 26 2 + 40 = 42 too big → drop 40 one comparison eliminates one end — and every pair it belonged to
Fig — The sum overshoots, so the largest value (40) can't be in any answer — discard it and pull hi in. Each step deletes a whole family of pairs, so the search is O(n), not O(n²).
InteractiveWatch the two pointers close in
2 + 35 = 37 too big → move hi left target = 29
step 0
Every step throws away one end for good — so a converging walk finishes in at most n − 1 steps, never n².

Myth

"Finding a pair that sums to a target always needs a hash map for O(n)."

Reality

If the data is sorted, two converging pointers get O(n) time with O(1) space — no hash, no extra memory. The hash wins when the data is unsorted and you can't afford to sort it; the two-pointer wins when it's already ordered.

⚠️
The precondition is load-bearing
The whole argument — "too big means hi is hopeless" — is only true because the array is sorted. On unsorted data this returns wrong answers. Sorting first costs O(n log n) (that machinery lives elsewhere in this volume), so if you have to sort just for this, the O(n) hash from chapter 6 is usually the better trade. Reach for two pointers when the data arrives sorted for free.

The same "move the pointer that can't possibly help" idea powers container with most water: given wall heights, pick two walls holding the most water. Start wide, and always move the shorter wall inward — moving the taller one can only ever shrink the width without lifting the limiting height. I ran it on [1,8,6,2,5,4,8,3,7] → max area 49, in a single O(n) pass.

So far the pointers move toward each other. Now aim them the same way — one leading, one trailing — and the gap between them becomes a window you drag across the data. →

The sliding window: never recompute the overlap

New problem: in an array of numbers, find the largest sum of any k consecutive elements. The obvious approach: for each starting position, add up its k elements and keep the best. There are n − k + 1 windows and each costs k − 1 additions, so the total is (n − k + 1)(k − 1) — roughly n·k, which is O(n²) when k grows with n. I ran it for n = 1000, k = 100: 89,199 additions.

Now stare at two neighbouring windows. They overlap in k − 1 elements — almost everything. The brute force re-adds that shared middle every single time. That re-adding is the wasted work. Kill it: when the window slides one step right, the new sum is just the old sum, minus the element that fell off the left, plus the element that entered on the right. One subtraction, one addition — exactly 2 operations per step, no matter how big k is.

3 1 4 1 5 9 old window [0..3] shared by both windows — re-added by brute force, reused by sliding new window [1..4] − 3 (leaves) + 9 (enters)
Fig — Adjacent windows share k − 1 cells. Sliding computes the change — subtract the red leaver, add the green enterer — instead of re-summing the amber overlap. O(1) per step.
python
def max_window(a, k):
    s = sum(a[:k])              # first window: k-1 additions, paid once
    best = s
    for r in range(k, len(a)):
        s += a[r] - a[r - k]    # +entering, -leaving : exactly 2 ops, any k
        if s > best:
            best = s
    return best

Line by line: sum(a[:k]) pays for the first window one time. Then r walks the entering index across the rest of the array. The single line s += a[r] - a[r-k] is the entire idea — a[r] joins, a[r-k] (exactly k slots back, the one falling off the left) leaves, and s is patched in O(1). No inner loop. Total work: (k − 1) for the first window plus 2 per slide — about 2n, which is O(n). I ran it against the brute force: same answer, but 1,899 additions instead of 89,199 — 47× fewer. On a bigger case (n = 200,000, k = 1000) the wall-clock was roughly 0.66 s → 0.014 s, about 46× faster on this machine (timings vary by hardware, but the shape doesn't).

InteractiveSlide the window — count the work
+ slide (Δ) 0 ops recompute 0 ops cumulative additions to reach this window: sliding vs recomputing from scratch
0
Crank k up: the red "recompute" bar balloons while the green "slide" bar barely stirs — the bigger the overlap, the more sliding wins.
🌍
Where you meet this — and why it's literally called a "window"
The name isn't a metaphor programmers borrowed; it is the networking term. TCP's sliding window is how every internet connection controls flow: the receiver advertises how many bytes it can accept, and the sender slides a window of unacknowledged bytes forward as acks arrive — reusing the connection's state instead of renegotiating from zero. You also meet the pattern in rate limiting ("max 100 requests per 60 seconds" is a window of timestamps that slides with the clock), in streaming analytics and moving averages (a rolling mean updates by dropping the oldest sample and adding the newest — the exact subtract-left/add-right trick), and in log and text scanning.

Fixed-size windows are the easy half. The real power shows up when the window has to breathe — growing and shrinking on its own to keep some rule true. →

A window that breathes — grow right, shrink left

Problem: find the length of the longest substring with no repeated character. The window here isn't a fixed size — it's defined by an invariant: "the characters inside are all distinct." Keep two pointers, left and right. Push right forward to grow the window. The moment the new character is already inside, the invariant breaks — so pull left forward until it holds again. At every step, the window is the longest valid one ending at right; track the best length you ever see.

python
def longest_unique(s):
    last = {}                        # char -> most recent index it appeared
    left = 0
    best = 0
    for right, ch in enumerate(s):
        if ch in last and last[ch] >= left:
            left = last[ch] + 1      # jump left PAST the earlier copy, in one move
        last[ch] = right
        best = max(best, right - left + 1)
    return best

print(longest_unique("abcabcbb"))    # 3   ("abc")
print(longest_unique("playlist"))    # 6   ("aylist")

Walk it: last remembers where each character was seen (a hash — chapter 6 never really leaves you). For each new ch at index right, if we've seen it and its last position is inside the current window (>= left), we jump left to just past that old copy — restoring "all distinct" in a single move rather than stepping one at a time. Then we record ch's new position and update best. I ran it: "abcabcbb" → 3, and our running example "playlist" → 6 (the window "aylist", after the repeated l forces left forward).

p l a y l i s t old l (dup) right: new l enters left jumps past the old l → window becomes "ayl…" invariant restored: every character in the window is distinct
Fig — When the second l arrives, left leaps to just after the first l. The window shrinks exactly enough to stay valid, then keeps growing.
InteractiveScrub the window across "playlist"
left right window "p" · length 1 longest so far: 1
0
Right only ever moves right; left only ever moves right. Neither backtracks — that's why the whole scan is O(n), not O(n²).
↺ The thing people get backwards
That code has a pointer-advance nested inside a for loop, so most people slap an O(n²) label on it and move on. Backwards. You don't count nesting depth — you count total pointer travel. right advances at most n times over the whole run, and left advances at most n times over the whole run, because neither ever moves backward. Total moves ≤ 2n → O(n). I stress-tested it on a 5000-character string built to force endless shrinking: right advanced 5000 times, left advanced 4998 times, total 9998 — right under the 2n = 10000 ceiling. This is amortized reasoning (chapter 3): the expensive-looking shrink is paid for by the growth it undoes.
The deeper cut
There are two flavours of "shrink." The longest_unique above jumps left in one assignment using the remembered index — clean, but it needs a hash to know where to jump. The alternative is a while loop that steps left forward one cell at a time, discarding characters from a set until the duplicate is gone. Both are O(n) overall by the same total-travel argument; the step-by-step version is what generalizes to windows where you can't precompute the jump target — like the minimum-window problem next, where "how far to shrink" depends on a running coverage count you can only discover by stepping.

Longest-valid grows greedily and shrinks only when forced. Flip the goal — find the smallest window that satisfies a condition — and the window learns a second move: squeeze. →

The minimum window — cover, then squeeze

The hardest classic in the family: given a text and a set of characters you need, find the smallest slice of the text that contains all of them. Think "shortest stretch of a log that mentions all three error codes." The window now works in two phases it alternates between: expand right until the window covers everything needed, then squeeze left as far as it can while staying covered — recording the smallest covered window each time — until it's no longer covered, and expand again.

The invariant tracker is a single counter, missing: how many needed characters are still uncovered. It updates in O(1) as characters enter and leave, so we always know the moment coverage flips — no rescanning the window to check.

python
from collections import Counter

def min_window(text, need):
    want = Counter(need)               # how many of each char we still want
    missing = len(need)                # total chars still uncovered
    left = 0
    best = ""
    for right, ch in enumerate(text):
        if want[ch] > 0:               # this char was actually needed
            missing -= 1
        want[ch] -= 1                  # may go negative: a surplus copy
        while missing == 0:            # covered! squeeze from the left
            if best == "" or right - left + 1 < len(best):
                best = text[left:right + 1]
            want[text[left]] += 1
            if want[text[left]] > 0:   # we just dropped a NEEDED char
                missing += 1           # uncovered again -> stop squeezing
            left += 1
    return best

print(min_window("ADOBECODEBANC", "ABC"))   # 'BANC'

The heart is the two counters. want[ch] tracks how many of each character the window still needs; it goes negative for surplus copies, which is what lets the squeeze phase know a character is expendable. missing hits 0 exactly when the window first covers the target — that's the trigger for the inner while, which shrinks from the left, each time checking if this is the smallest covered window yet, and stops the instant it drops a genuinely needed character (want[...] > 0 after adding it back). I ran it on the textbook input "ADOBECODEBANC" needing "ABC"'BANC', the shortest slice holding an A, a B, and a C. Same total-travel argument as before: both pointers only move forward, so it's O(text length), not O(n²).

A D O B E C O D E B A N C first cover: "ADOBEC" (6) — expand right until A, B, C all present smallest cover: "BANC" (4) squeeze left as far as coverage allows need = {A, B, C}
Fig — Two phases alternate: expand right to reach coverage, then squeeze left to the tightest slice still covered. The missing counter flips at exactly those two moments, so nothing is ever rescanned.
🧭
A recipe for any window problem
Ask three questions. (1) What is the window's invariant? — "all distinct," "sum ≥ target," "covers the need set." (2) When does growing right break it, and how do I restore it by moving left? (3) What single O(1) quantity tracks the invariant so I never rescan — a running sum, a "seen" set, a missing counter? Answer those and the code writes itself.
Wait —
reverse, palindrome, sorted two-sum, max-window, longest-unique, min-window — six different problems, all solved by two indices that never backtrack. Is there a single idea underneath all of them?

The one move: reuse the overlap

There is, and it's the whole point of the chapter. A brute-force nested loop restarts its inner work from scratch at every outer step — re-summing an overlap, re-scanning for a partner, re-checking a window it just checked. But consecutive states of a scan overlap almost completely. The move that separates a coder from an engineer is to compute the change between states, not each state from zero. Two pointers and the sliding window are just the array-shaped incarnation of that one reflex: don't restart — update.

input size n → work done → recompute — O(n²) slide — O(n) same problem, same answer — the gap is the wasted re-work you deleted
Fig — The volume's opening fan-out, in miniature: restarting the inner loop rides the red curve; reusing the overlap rides the green one. That gap is pure recovered time.

Where this reflex earns its keep in the wild goes far past interview arrays. Finance: a rolling volatility or a moving-average crossover is a sliding window over a price stream — recomputing the whole trailing average on every tick would be O(n·window); the delta update is O(1). Networking: TCP's window is why the internet doesn't melt under its own bookkeeping. Video codecs encode each frame as the difference from the last, not the whole picture again — the same "store the change" instinct at 30 frames a second. Rate limiters guarding every serious API are sliding windows of timestamps. Even your eyes, reading this line, drag a small focus window across the text rather than re-photographing the page each word.

🔥
The trap that hides in plain sight
The most common O(n²) blunder isn't an obviously bad algorithm — it's an innocent-looking loop that recomputes something it already knew. "Sum this window," "does the rest of the list contain X," "recheck the max so far" — each rebuilt from scratch inside another loop. Whenever you see an inner loop, ask: how much of this did the previous iteration already compute? If the answer is "almost all of it," a pointer or a window is waiting to save you a factor of n.

The everyday transfer is real too. To answer "what changed in this document?" you don't re-read it top to bottom — you track the delta. When a list is sorted, you rarely need to inspect everything; you can walk from the ends and let the ordering eliminate whole regions, the way binary search bisects a bug. That is two-pointer thinking off the screen: let structure do the discarding, and never redo work you can update.

Every technique so far has been careful — reuse the overlap, restore the invariant, discard the impossible. Chapter 8 gets reckless on purpose: grab the best-looking option in front of you right now and never look back. It sounds like a way to be wrong — so when does that greedy gamble actually hand you the optimal answer, and when does it quietly betray you? →

08Greedy — when the local choice wins globally

A greedy algorithm makes the choice that looks best right now and never takes it back. That sounds reckless — and sometimes it is — but for a startling family of problems it lands the globally best answer with almost no work. This chapter teaches you the two things courses skip: exactly why greedy sometimes nails the optimum (the exchange argument), and exactly why it sometimes fails badly (a two-coin counterexample you'll run yourself). After it you'll be able to look at a new problem and say — before writing a line — whether the fast, simple, never-look-back move is safe, or a trap.

The greedy move: commit now, never look back

Every optimization problem hides a tree of choices. To make change for 6 units you could hand over a 4 then a 1 then a 1; or a 3 then a 3; or six 1s — each sequence of picks is a path down a branching tree, and the leaves are the finished answers. Brute force (chapter 4) walks the whole tree and compares every leaf. That is correct but explosive: a tree with a two-way choice at each of n steps has 2ⁿ leaves.

Greedy refuses to branch. At every node it applies one rule — "take the biggest coin that still fits", "take the meeting that finishes soonest" — commits to it, and descends. One path, root to leaf. No backtracking, no comparing alternatives. If the tree is n deep, greedy does O(n) work (plus, usually, an O(n log n) sort to decide the order it considers things in) where brute force does O(2ⁿ). The entire gamble is this: can a sequence of locally-best moves add up to the globally-best answer? When it can, you get an exponential speedup for free.

??????? start greedy: one path — O(n) brute force: every leaf — O(2ⁿ)
Fig — Brute force weighs all 2ⁿ leaves; greedy commits to one rule at each node and rides a single root-to-leaf path. The speed is not in doubt — only the correctness.
🧩
The greedy template — every greedy algorithm is this
1) Sort or arrange the options by a chosen greedy criterion. 2) Walk them once, and take each one if it doesn't break the rules. 3) Return what you took. The only creativity is choosing the right criterion — and proving it's safe.

So what decides whether "best now" equals "best overall"? Let's find a problem where it's obviously true, and one where it's disastrously false. Start with the disaster. →

When greedy lies: the coin that costs you

Making change is the folk-tale example of greedy: to pay 87 cents, a cashier grabs a quarter, another quarter, another quarter, a dime, two pennies — biggest coin that fits, every time. For the coins in your pocket that habit is not just fast, it's optimal — it always uses the fewest coins. So people conclude greedy "usually works". Watch it break with a tiny change to the coin set.

python
def greedy(coins, target):
    n = 0
    for c in sorted(coins, reverse=True):   # biggest coin first
        while target >= c:                  # grab it while it fits
            target -= c
            n += 1
    return n

def optimal(coins, target):                 # dynamic programming — chapter 9
    best = [0] + [float("inf")] * target
    for amt in range(1, target + 1):        # smallest sub-amount up
        for c in coins:
            if c <= amt:
                best[amt] = min(best[amt], best[amt - c] + 1)
    return best[target]

print(greedy([1, 3, 4], 6))   # 3   ->  4 + 1 + 1
print(optimal([1, 3, 4], 6))  # 2   ->  3 + 3

Line by line: greedy sorts the coins large-to-small, then for each denomination subtracts it as many times as it fits, counting coins. optimal is the honest answer — it builds up best[amt] = the fewest coins for every amount from 1 to the target, each one reusing the smaller answers already computed (that's the dynamic programming of chapter 9; here it's just the referee). The last two lines are the verdict, and I ran them: greedy prints 3, optimal prints 2.

Greedy grabbed the 4 because it was biggest, then had to patch the leftover 2 with two 1s: 4 + 1 + 1, three coins. The right answer ignores the shiny 4 entirely: 3 + 3, two coins. The greedy criterion — "biggest first" — walked confidently into a worse leaf and, having no backtracking, never noticed. Across the first 100 targets with the set [1, 3, 4], greedy is beaten on 24 of them (6, 10, 14, 18, 22, …). It isn't occasionally unlucky; it is systematically wrong for this coin system.

Make 6 from coins 1 · 3 · 4 greedy — biggest first 4 1 1 = 3 coins ✗ optimal — fewest coins 3 3 = 2 coins ✓ The 4 looked best locally and poisoned the rest. Greedy can't un-grab it.
Fig — One greedy step (take the 4) forces two bad steps after it. Local best, global loss.
InteractiveGreedy vs optimal — find where greedy breaks
coins 1·3·4 — make 6 greedy optimal greedy is optimal here.
6
The US set never loses — it's a "canonical" system. Slide the target on 1·3·4 or 1·7·10 and watch greedy fall behind.
↺ The thing people get backwards
People treat greedy as a reliable default that "usually" works and occasionally slips. It's the reverse: greedy is a claim about a specific problem — "local optimum implies global optimum here" — that happens to be true for a lucky few problems and false for most. Fast never means correct. The 4-coin above is proof: instant, confident, and wrong.

If greedy is a claim, we need to test the claim. When it's true, there's a beautiful reason — and it comes with its own famous winning problem. →

The exchange argument: proving greedy right

Here is the problem greedy was born for. You have one lecture hall and a pile of talks, each with a start and finish time; overlapping talks can't share the room. Which talks do you accept to fit the most into the day? The winning rule is almost insultingly simple: always take the talk that finishes earliest among those that still fit. Not the shortest, not the earliest-starting — the earliest-finishing.

python
def schedule(acts):                       # acts: (name, start, finish)
    chosen, last_finish = [], -1
    for name, s, f in sorted(acts, key=lambda a: a[2]):   # by finish time
        if s >= last_finish:              # doesn't overlap what we took
            chosen.append(name); last_finish = f
    return chosen

acts = [("A",1,4),("B",3,5),("C",0,6),("D",5,7),
        ("E",3,9),("F",5,9),("G",6,10),("H",8,11)]
print(schedule(acts))     # ['A', 'D', 'H'] — 3 talks

The loop sorts by finish time, then walks once: keep a talk only if it starts at or after the last kept talk ended. I ran it against a brute-force check of every one of the 2⁸ possible talk subsets — the biggest compatible set really is 3, and greedy finds it. Why does finishing-earliest win? Because it leaves the room free as early as possible, which can never hurt: the more time left, the more room for what's next.

That intuition becomes a proof by the exchange argument — the master key for every greedy correctness claim. Take any optimal schedule. Look at its first talk versus greedy's first talk. Greedy's finishes no later (that's how greedy chose it). So swap the optimal's first talk for greedy's: the new first talk frees the room at least as early, nothing else overlaps, and the schedule is still valid and still the same size — still optimal. Repeat down the line, and you've morphed some optimal solution into greedy's, step by step, without ever losing a talk. So greedy's answer is optimal too. No choice greedy makes can be "traded up" to a better one — because the best solution can always be traded down to greedy's.

024681011 A ✓ B C D ✓ E F G H ✓ time → keep the earliest-finishing talk that still fits: A → D → H
Fig — Sorted by finish time, greedy grabs A (finishes at 4), skips everything overlapping it, grabs D (finishes at 7), then H. Three talks — provably the most possible.

Why earliest-finishing and not, say, shortest? Because a different rule fails the exchange test — and you can catch it with a three-talk counterexample. Take X(1–5), Y(4–7), Z(6–10). "Shortest first" grabs Y and blocks both X and Z, ending with 1 talk. Earliest-finish grabs X then Z — 2 talks. I ran both; the numbers are exactly that. The criterion isn't decoration; it's the whole proof.

The deeper cut — the two properties every greedy proof needs
A greedy algorithm is provably optimal when the problem has both: (1) the greedy-choice property — some globally optimal solution contains the greedy first choice (this is what the exchange argument establishes: you can always swap it in), and (2) optimal substructure — after making that choice, the best solution to the leftover subproblem completes the best overall solution. Activity selection has both. So does Huffman coding, and Dijkstra, and the minimum-spanning-tree algorithms. Coin change with [1,3,4] fails the greedy-choice property — taking the 4 is not in any optimal solution for target 6 — which is precisely why the exchange step can't be done, and why greedy is wrong there.

One earliest-finish rule, one exchange proof, an exponential speedup. Now meet the greedy algorithm running silently inside almost every file on your disk. →

Huffman: the greedy algorithm in every file you own

Text is stored as bits. The lazy scheme gives every character the same width — 8 bits in ASCII — whether it's a hyper-common e or a rare q. That's wasteful: frequent symbols should get short codes and rare ones can afford long codes, like Morse code giving e a single dot. In 1952 a student named David Huffman found the provably shortest such code, and the algorithm is pure greed: repeatedly merge the two rarest symbols into one combined node, until a single tree remains.

python
import heapq

def huffman(freq):
    forest = [[w, i, {s}] for i, (s, w) in enumerate(freq.items())]
    heapq.heapify(forest)                    # a min-heap by weight
    code, nxt = {s: "" for s in freq}, len(freq)
    while len(forest) > 1:
        w1, _, s1 = heapq.heappop(forest)    # the rarest bundle
        w2, _, s2 = heapq.heappop(forest)    # the next rarest
        for s in s1: code[s] = "0" + code[s] # prepend a bit to each side
        for s in s2: code[s] = "1" + code[s]
        heapq.heappush(forest, [w1+w2, nxt, s1|s2]); nxt += 1
    return code

freq = {"a":45, "b":13, "c":12, "d":16, "e":9, "f":5}
code = huffman(freq)
bits = sum(freq[s] * len(code[s]) for s in freq)
print(bits, "vs", 3 * sum(freq.values()))   # 224 vs 300

The forest starts as one tiny tree per symbol, kept in a min-heap (chapter-1 structure) so "the two rarest" is always two heappops. Each round fuses them: every symbol on the left branch gets a 0 prepended to its code, every symbol on the right a 1, and the merged node (its weight the sum) goes back in the heap. After n−1 merges one tree is left, and each symbol's code is the path of 0s and 1s from the root. I ran it: the classic frequencies compress to 224 bits versus 300 for a fixed 3-bit code — a (the most common) got the 1-bit code 0, while rare f got 1100. That's a 25% saving, and it is provably the smallest any code can achieve for these frequencies.

01 01 01 01 01 100 55 25 30 14 a:45 c:12 b:13 d:16 f:5 e:9 codes = path from root: a=0 b=101 f=1100
Fig — Built bottom-up by merging the two rarest each step (f+e→14, c+b→25, 14+d→30, 25+30→55, a+55→100). Common a sits one hop from the root (1 bit); rare f sits four hops down (4 bits).
Wait —
a is the single bit 0, yet b is 101 and e is 1101. Reading a bare stream of bits with no commas, how does the decoder know where one letter stops and the next begins?

Because every symbol is a leaf, no code can be the start of another one — the instant the bits trace a path down to a leaf, that's the letter, and decoding jumps back to the root for the next. This prefix-free property is the quiet genius of building codes from a tree: the stream is unambiguous with no separators at all. That's not a lucky side effect of greed — it's guaranteed, because greedy only ever puts symbols at leaves.

🌍
Where you meet this — you used it to load this page
Huffman coding is inside ZIP, gzip and PNG (via DEFLATE), JPEG and MP3 (the entropy-coding stage), and the gzip-compressed HTTP responses your browser fetches all day. Every photo you send and every song you stream is riding a greedy algorithm a graduate student invented for a term paper — reportedly to get out of taking the final exam.

The knob below is why compression works at all. Skew the frequencies and watch the savings appear; flatten them and watch the savings vanish. It runs the real Huffman merge live.

InteractiveSkew the letters — watch compression appear
Huffman: 3.000 bits/symbol (2424 bits total) saving vs fixed 3-bit code: 0.0%
0
Equal letters can't be compressed — a fixed code is already optimal. The more lopsided the frequencies, the more Huffman wins; that lopsidedness is called low entropy.
The deeper cut — why merging the two rarest is provably optimal
The exchange argument again. In the shortest possible code the two least-frequent symbols must sit as the two deepest leaves, and as siblings — because you can always swap any deepest leaf with a rarest symbol without increasing the total bit count (that's the exchange). So making them siblings first is safe, and merging them into one symbol of combined weight leaves a strictly smaller problem with the same structure (optimal substructure). Induction finishes it. The result reaches the entropy floor of the source to within less than one bit per symbol — Shannon's theoretical minimum for lossless coding.

Greedy commits to one choice and proves it never needed the others. Its rival keeps all the choices alive. Time to put them face to face. →

Greedy vs dynamic programming: commit vs consider

The coin change split the world in two. Greedy took the biggest coin and moved on. The optimal function did something different: it computed the best answer for every smaller amount and combined them — it never committed early, it kept every sub-answer and reused them. That's dynamic programming (chapter 9), and the contrast is the cleanest way to understand both.

Greedy — commit

Makes one choice at each step by a fixed rule and never revisits it. One path down the tree. Fast (O(n) or O(n log n)), tiny memory. Only correct if the greedy-choice property holds — otherwise silently wrong, like [1,3,4].

DP — consider & remember

Explores every sub-choice but stores each subproblem's answer so it's solved once, not re-solved. Slower and hungrier (often O(n·target) time and memory). Always finds the optimum when the problem has optimal substructure.
greedy: one committed path O(n) · no memory DP: every subproblem, stored once 0 1 1 1 2 2 amt012345 best[amt] — reused, never recomputed O(n·target) · full table
Fig — Greedy spends nothing on memory because it never looks back. DP spends memory precisely so it never has to guess — it has already solved every piece.
💡
The reflex to build
Try greedy first — it's the cheapest thing that could possibly work, and if you can prove it (exchange argument) you're done. If you can find even one counterexample, greedy is dead; reach for DP, which trades memory for a guarantee. Chapter 9 makes DP a repeatable procedure.

Two techniques, one decision. So how do you know, on a brand-new problem, which one you're holding — before you've bet the system on it? →

How to know before you trust it

You'll rarely be told "this is a greedy problem". You'll be handed a goal — schedule the most jobs, wire the network for the least cable, route the packet the cheapest way — and you have to decide whether the greedy shortcut is a proof or a trap. Here is the drill, and it's the same every time.

name a greedyrule exchange argument?and hunt a counterexample proof holds it breaks ship greedyO(n log n) use DP (ch 9)or backtracking
Fig — The whole discipline: state the rule, then attack it from both sides at once — try to prove it, try to break it. Whichever succeeds first tells you which tool to ship.

Name the greedy rule ("take the earliest-finishing", "merge the two rarest"). Then run two attacks in parallel. Prove it: can you show the greedy choice is always swappable into some optimal solution — the exchange argument? If yes, you're done — greedy is optimal and fast. Break it: at the same time, throw small inputs at it and compare against a brute-force referee, exactly as we did with the coins. A single counterexample kills greedy forever; you don't get to say "but it works most of the time". If it breaks, you already know the escape hatch: dynamic programming.

⚠️
"It passed my tests" is not a proof
Greedy on [1,3,4] is correct for targets 1–5, then fails at 6. If your tests stopped at 5 you'd have shipped a bug that only fires on certain inputs in production. For greedy, either prove it or find the counterexample — passing a handful of cases proves nothing.

This chapter's real gift is a way of thinking, and it's bigger than algorithms. You use greedy reasoning every day: paying with the biggest bills, running errands nearest-first, packing the fullest boxes. It's a superb heuristic — a fast, good-enough rule of thumb. The 1% move is to hold two thoughts at once: reach for the simple local rule first, and in the same breath ask what it's ignoring. Greedy fails exactly when a locally-worse choice now buys a better position later — the 3 you skipped, the talk you passed up, the move that sets up the next three. Naming that failure mode is a life skill: it's why you sometimes take the smaller reward now to keep the bigger one open, and why "obviously best right now" always deserves one hard look before you commit.

🌍
Where else greedy quietly runs the world
GPS routing and internet packet routing lean on Dijkstra (chapter 13), a greedy frontier that always expands the closest unvisited place. Kruskal and Prim greedily wire minimum-spanning-tree networks — power grids, chip layouts, clustering. Your OS and CPU evict cache lines greedily (approximate LRU). Each is a local rule with a proof behind it — greed that was checked before it was trusted.

But the coin change that greedy botched still needs its right answer — and so do knapsacks, edit distance, and the longest path through a lattice of choices. The fix is to stop re-solving what you have already solved. Chapter 9 turns "remember your subproblems" into the most powerful technique in this whole volume: dynamic programming. →

09Dynamic programming — remember to win

This is the crown jewel of the volume. Dynamic programming — DP — is the single move that turns problems the universe couldn't finish before it ends into problems your laptop finishes in a blink. The whole trick is embarrassingly human: never solve the same sub-problem twice. By the end you will be able to look at a fresh problem, decide in seconds whether DP fits, name its state, write its recurrence, and fill a table that hands you the answer — the exact skill that separates the engineer the interviewer remembers from the one they forget.

The two signals that scream "dynamic programming"

Start where you already stand. In the traces you stepped naive Fibonacci (T29) and its memoised twin (T30). Naive Fibonacci is three honest lines: fib(n) returns n when n < 2, otherwise fib(n-1) + fib(n-2). It is correct. It is also a catastrophe — and watching why is the whole chapter in miniature.

Count the calls, don't guess them. Let C(n) be the number of times fib is invoked to compute fib(n). Every call either stops immediately (n < 2) or spawns two more, so C(n) = 1 + C(n-1) + C(n-2) — the count itself grows like Fibonacci. The tree of calls nearly doubles at every level, and that is the definition of exponential. Here is the real toll, measured, not imagined:

python
calls = 0
def fib(n):
    global calls
    calls += 1
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

for n in [30, 35, 40]:
    calls = 0; v = fib(n)
    print(n, v, calls)
# 30 832040    2692537
# 35 9227465   29860703
# 40 102334155 331160281

Read those counts. Computing fib(35) — one small number — cost 29,860,703 function calls. Push to fib(40) and it is 331 million. Extrapolate honestly (the count formula is C(n) = 2·fib(n+1) − 1) and fib(100) would take about 1.15 × 10²¹ calls — at the roughly 30 million-calls-per-second this machine managed, on the order of a million years (order-of-magnitude; timings are machine-dependent). For a problem a child can do on paper.

Where is all that work going? Nowhere new. The tree for fib(5) asks for fib(3) twice, fib(2) three times, fib(1) five times — the same tiny questions, re-asked, re-answered, from scratch, forever. That is the first signal, and it has a name.

F5 F4 F3 F3 F2 F2 F1 …more …more …more same sub-problem, solved twice one fib(5) = 15 calls · F3×2 · F2×3 · F1×5 · F0×3 — same colour, same wasted work
Fig — Naive fib(5) re-derives identical answers all over the tree. Same colour = same question, asked again. The redundancy is the exponential.

Signal one — overlapping sub-problems: the recursion keeps bumping into the same smaller problem. (Contrast merge sort in ch5: it splits into fresh, non-overlapping halves — no repeats, so no DP needed.) Signal two — optimal substructure: the best answer to the whole is built from best answers to its parts. fib(n) is literally fib(n-1) + fib(n-2); the shortest route through a city is shortest-route-to-here plus one more step. Richard Bellman, who developed DP in the 1950s, called this the principle of optimality. When both signals fire, you are looking at a DP problem — and you are about to stop wasting a million years.

Wait —
if the only sin is re-answering the same question, the fix is almost insultingly simple: answer it once and write the answer down. What does that do to 29 million calls?

Two directions, one idea: memoise down, tabulate up

Keep a notebook. The first time you compute fib(k), store it; every later time, read it back instead of recomputing. That's memoisation — top-down DP: still recursive, but each distinct sub-problem is solved exactly once. Count the calls again and the collapse is total.

python
def fib_memo(n, memo):
    if n in memo:            # already solved? read it back — O(1)
        return memo[n]
    if n < 2:
        return n
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]           # write the answer down before returning

# fib_memo(35, {}) makes 69 calls, not 29,860,703.
# fib_memo(100, {}) makes 199 calls — the "million years" becomes microseconds.

Line by line: if n is in the notebook, hand it back — this is the line that kills the redundancy. Otherwise handle the two base cases, then compute the two smaller Fibonaccis once, store the sum in memo[n], and return it. Because each n from 0 to the target gets filled exactly once and then only ever read, the number of calls drops from exponential to 2n − 169 calls for fib(35), 199 for fib(100). Same three-line logic; one dictionary; an astronomically different bill. Measured on this machine, naive fib(35) took about 1.0 s and the memoised version about 18 microseconds — roughly a 50,000× gap (machine-dependent, but the shape of the gap is not). (You don't even have to write the notebook by hand: Python's @lru_cache from functools — Volume 1 ch11 — bolts this exact memoisation onto any function with one line.)

InteractiveWatch the exponential collapse into a line
naive memo bar length is log-scaled — the numbers on the right are the real toll Same answers exist — the naive tree recomputes them over and over.
×0
The readout is how many times more work the naive version does. Memoising doesn't shave the cost — it changes the growth class.

There is a second way to hold the same idea, and it is often the better one. Instead of recursing down and caching on the way back, walk up: fill a table from the smallest sub-problem to the biggest, in an order where every value you need is already sitting there. That's tabulation — bottom-up DP.

python
def fib_table(n):
    dp = [0, 1] + [0]*(n-1)          # dp[k] will hold fib(k)
    for k in range(2, n+1):
        dp[k] = dp[k-1] + dp[k-2]    # both already filled — just read them
    return dp[n]

No recursion, no call stack, no dictionary — one array, filled left to right, each cell reading its two neighbours. It's O(n) time and (with two rolling variables) O(1) space. Same recurrence as memoisation, run in the opposite direction.

dp[0] dp[1] dp[2] dp[3] dp[n] memoise — recurse DOWN from dp[n], cache on the way back tabulate — fill UP from dp[0], each cell reads its neighbours
Fig — Memoisation and tabulation are the same table filled from opposite ends. Top-down is lazy and only touches sub-problems it needs; bottom-up is contiguous and cache-friendly — usually the faster constant.
⚙️
The machine cares which direction you pick
At equal O(n), tabulation often wins the stopwatch (ch2's lesson). Its dp array is one contiguous block the CPU can prefetch and stream through L1 cache; the top-down version pays for a hash lookup per read and grows the call stack frame by frame. Same Big-O, different constant — and the constant is decided by the memory access pattern.

So the fix is a notebook. But Fibonacci handed us the recurrence for free. The real skill — the thing that actually separates people — is inventing the recurrence for a problem nobody wrote down for you. There's a procedure for that. →

The recipe: name the state, write the recurrence

Everyone can memoise a function someone else wrote. The rare skill is turning a word problem into a table. It is not inspiration — it is a five-step procedure you can run on anything:

  1. Define the state. Decide what a sub-problem is, and write down in one plain sentence what dp[…] means. This is 80% of the work. Get it right and the rest falls out.
  2. Write the recurrence. Express dp[state] using strictly smaller states — the same "best of parts" the problem's optimal substructure promised.
  3. Fix the base cases. The smallest states you can answer with no recurrence at all.
  4. Choose the fill order. Any order where a cell's dependencies are computed before it.
  5. Read off the answer. Name the cell that holds it.

Run it on coin change: given coin values and a target amount, what is the fewest coins that make the amount? State: dp[a] = the fewest coins that make amount a. Recurrence: to make a, the last coin is some c; whatever's left is a − c, which we've already solved — so dp[a] = 1 + min(dp[a − c]) over every coin c ≤ a. Base: dp[0] = 0 (zero coins make zero). Order: a from small to large. Answer: dp[amount].

python
def min_coins(coins, amount):
    INF = float('inf')
    dp = [0] + [INF]*amount          # dp[0]=0; the rest "unknown"
    for a in range(1, amount+1):
        for c in coins:              # try every coin as the LAST coin
            if c <= a:
                dp[a] = min(dp[a], dp[a-c] + 1)
    return dp

print(min_coins([1,3,4], 6))
# [0, 1, 2, 1, 1, 2, 2]  ->  dp[6] = 2   (that's 3+3)

Line by line: dp starts with dp[0]=0 and everything else infinite ("no way found yet"). The outer loop walks amounts upward; the inner loop tries each coin as the final coin and keeps the cheapest option. When it finishes, dp[6] = 2 — two coins, the pair of 3s. Notice what the table exposes: greedy (ch8) would grab the biggest coin first — 4 + 1 + 1 = three coins — and lose, because on the denominations [1,3,4] the locally-best move isn't globally best. DP doesn't guess a strategy; it quietly considers every last coin and remembers the winner.

dp 0a=0 1a=1 2a=2 1a=3 1a=4 2a=5 2a=6 last coin = 3 -> dp[3]+1 = 2 dp[6] = 1 + min( dp[5], dp[3], dp[2] ) = 1 + min(2, 1, 2) = 2 try last coin in {1 ->dp[5], 3 ->dp[3], 4 ->dp[2]}; keep the cheapest
Fig — Every cell of the coin-change table is built from cells to its left. dp[6] asks "what was the last coin?" and reuses an answer already computed — the recurrence made literal.
InteractiveFill the coin-change table, one amount at a time
dp[a] = fewest coins to make amount a, with coins {1, 3, 4}. Slide to fill it.
The deeper cut — why coin change needs DP but real cash registers get away with greedy
A coin system is called canonical when greedy (always take the largest coin that fits) is guaranteed optimal — real currencies like US [1,5,10,25] are deliberately canonical, which is why min_coins([1,5,10,25], 63)[63] is 6 — and greedy finds the same 6. The moment a system is non-canonical (like [1,3,4]), greedy can lose, and only DP is safe. The lesson generalises: the algorithm you need depends on structure you can't eyeball — so prove it, or let DP consider everything for you.

Coin change had a one-dimensional state — a single number a. Some problems need the table to grow a second axis. That's where DP starts to feel like real power. →

0/1 knapsack — the value-under-a-budget grid

You have a weight budget and a set of items, each with a weight and a value; take each item once or not at all; maximise total value without blowing the budget. This is the shape of a thousand real decisions — which features fit in a sprint, which trades fit a risk limit, which files fit on the disk. Greedy by "best value-per-kilo" can fail here too. DP cannot.

The state needs two facts: which items you're allowed to consider, and how much budget is left. State: dp[i][w] = the best value using only the first i items within a budget of w. Recurrence: for item i you make one binary choice — skip it (dp[i-1][w]) or, if it fits, take it (dp[i-1][w − weight_i] + value_i) — and keep the larger. Base: zero items or zero budget → value 0. Answer: dp[n][W].

python
items = [("guitar",1,1500), ("laptop",3,2000),
         ("stereo",4,3000), ("necklace",2,2500)]   # (name, weight, value)
W = 4
wt  = [w for _,w,_ in items]; val = [v for _,_,v in items]; n = len(items)
dp = [[0]*(W+1) for _ in range(n+1)]

for i in range(1, n+1):
    for w in range(W+1):
        dp[i][w] = dp[i-1][w]                         # skip item i
        if wt[i-1] <= w:                              # if it fits, try taking it
            dp[i][w] = max(dp[i][w], dp[i-1][w-wt[i-1]] + val[i-1])

print(dp[n][W])   # 4000  — guitar (1kg,1500) + necklace (2kg,2500)

Line by line: dp is an (n+1) × (W+1) grid of zeros — the base row and column. For each item and each budget, first assume we skip it (inherit the answer from the row above). Then, if the item fits, compare against taking it: its value plus the best we could do with the remaining budget w − weight using the earlier items. Keep the max. The final cell dp[4][4] is 4000 — and it's the light, valuable pair (guitar + necklace), not the single 3000 stereo that a value-greedy grab might chase. Two nested loops, O(n·W) cells, each O(1): the whole grid is one cheap sweep.

budget w -> (each row adds one more item to consider) w=01234 none+guitar+laptop+stereo+necklace 00000 01500150015001500 01500150020003500 01500150020003500 0150025004000 4000 skip necklace -> 3500 take necklace -> 1500+2500 max = 4000
Fig — The knapsack grid (real values, run in Python). The winning cell dp[4][4] is max(skip, take) — a green cell above and a purple cell up-and-left combine into the cyan answer.
🌍
Where you meet this
Knapsack is the math of allocation under a ceiling: cloud schedulers packing jobs onto a fixed machine, cargo and container loading, ad servers choosing which ads maximise value under a latency budget, and — squint — portfolio and capital-budget selection in finance (pick the projects that maximise return under a spend limit). Whenever you hear "get the most value without exceeding X," a knapsack is hiding inside.
The deeper cut — knapsack is "pseudo-polynomial," and that word matters
O(n·W) looks polynomial, but W is a number's value, not its size. Written in binary, W takes about log₂(W) bits, so the runtime is exponential in the input length. That's why 0/1 knapsack is NP-hard: crank W up to billions and the grid becomes unfillable. DP tamed the overlap, not the fundamental hardness. Knowing the difference is exactly the honesty that stops you promising a table that will never finish.

Coin change and knapsack both optimised a number. The most beautiful DP of all optimises something you touch every hour without noticing: the distance between two pieces of text. →

Edit distance — the algorithm inside diff, spell-check, and DNA

How different are two words? Define it precisely: the edit distance (Levenshtein distance) between strings a and b is the minimum number of single-character inserts, deletes, and substitutions to turn one into the other. "color" → "colour" is 1 (insert a u). "kitten" → "sitting" is 3. And the way we compute it is the same table-filling move — now on a grid.

State: dp[i][j] = the edit distance between the first i letters of a and the first j letters of b. Recurrence: look at the last letters. If a[i-1] == b[j-1], they cost nothing — the answer is dp[i-1][j-1]. If they differ, you must do one edit, and you take the cheapest of three moves: delete a's last letter (dp[i-1][j] + 1), insert b's last letter (dp[i][j-1] + 1), or substitute (dp[i-1][j-1] + 1). Base: turning a length-i word into the empty string costs i deletes, so dp[i][0] = i and dp[0][j] = j. Answer: the bottom-right cell.

python
def edit_distance(a, b):
    m, n = len(a), len(b)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(m+1): dp[i][0] = i        # a[:i] -> ""  costs i deletes
    for j in range(n+1): dp[0][j] = j        # "" -> b[:j]  costs j inserts
    for i in range(1, m+1):
        for j in range(1, n+1):
            cost = 0 if a[i-1] == b[j-1] else 1
            dp[i][j] = min(dp[i-1][j] + 1,        # delete
                           dp[i][j-1] + 1,        # insert
                           dp[i-1][j-1] + cost)   # match / substitute
    return dp[m][n]

print(edit_distance("kitten", "sitting"))   # 3
print(edit_distance("recieve", "receive"))  # 2   (the classic typo)
print(edit_distance("color",   "colour"))   # 1

Line by line: build the grid, seed the first row and column with the "delete/insert everything" base costs, then fill each interior cell from its three neighbours — up (delete), left (insert), and up-left (match or substitute, free if the letters agree). The bottom-right cell is the answer. Here is the actual table Python filled for kitten → sitting — trace the diagonal of small numbers and you can literally read the alignment:

εsitting εkitten 01234567 11234567 22123456 33212345 44321234 55432234 66543323 the diagonal of low numbers is the cheapest alignment answer = 3
Fig — The real kitten → sitting table (bottom-right = 3). Follow the circled path: k→s sub, e→i sub, insert g — three edits. Every one of the 56 cells came from just three neighbours.

Now build it yourself. Drag the slider and watch the grid fill in reading order — each interior cell lighting up its three source neighbours and showing the exact min(…) it evaluates. This is the "fill the grid" mental model made physical: nothing here is clever per cell; the whole answer emerges from local moves.

InteractiveFill the edit-distance grid, cell by cell
Slide right — the table fills itself, each cell from three neighbours (up = delete, left = insert, up-left = match/sub).
🌍
Where you meet this — probably in the last five minutes
Your phone's autocorrect ranks dictionary words by edit distance to what you fumbled. git diff and every "compare files" tool find the cheapest edit script between two versions. Spell-checkers, fuzzy search, and plagiarism detectors all lean on it. And in a different lab entirely, biologists align DNA and protein sequences with Needleman–Wunsch — literally edit distance with a scoring matrix — to measure how related two organisms are. The same 56-cell grid you just filled by hand runs, at galactic scale, inside genomics pipelines and your keyboard.

Notice the human move that unlocked all of this. Comparing two whole strings is hopeless — too many alignments. The insight was to stop comparing the wholes and compare all their prefixes: once you know the distance for every shorter pair, the full pair is one cheap step away. That reframing — "solve it for all smaller versions and the big version is trivial" — is the beating heart of DP, and it was independently discovered in linguistics, computer science, and molecular biology because it is simply how this class of problem wants to be solved.

Wait —
if a two-string comparison becomes a grid, what happens when the thing you're comparing is a single list against itself, hunting a hidden increasing streak? Same idea, new state.

Two more shapes: common subsequence & increasing subsequence

Once you can spot the state, new problems stop feeling new. Two you'll meet constantly:

Longest common subsequence (LCS) — the longest run of characters appearing in both strings in order (not necessarily adjacent). Its state is edit distance's cousin: dp[i][j] = LCS length of the first i of a and first j of b; if the last letters match, dp[i-1][j-1] + 1, else max(dp[i-1][j], dp[i][j-1]). This is the engine of git diff: the unchanged lines between two file versions are exactly their LCS, and everything else is an add or a delete.

Longest increasing subsequence (LIS) — the longest run of values that increases, picked from a list in order. State: dp[i] = the length of the longest increasing subsequence ending at index i. Recurrence: dp[i] = 1 + max(dp[j]) over every earlier j with seq[j] < seq[i]. Both were run in Python:

python
lcs("ABCBDAB", "BDCAB")            # length 4  -> "BCAB"
lis([10, 9, 2, 5, 3, 7, 101, 18]) # length 4  -> [2, 5, 7, 101]
# per-index dp for LIS: [1, 1, 1, 2, 2, 3, 4, 4]

Read the LIS dp array: at index 6 (value 101) the best increasing run ending there is length 4 — it chained through 2, 5, 7. The answer is the max over the whole array. That's O(n²) here (each cell scans all earlier cells); a sharper version uses binary search (ch12) to reach O(n log n) — a preview of how one technique feeds the next.

10 9 2 5 3 7 101 18 dp 11122344 longest increasing subsequence: 2 -> 5 -> 7 -> 101 (length 4) dp[i] = 1 + max(dp[j]) over earlier j with seq[j] < seq[i]
Fig — The real LIS run. The chain skips past 10, 9, 3, and 18; each dp[i] records the best streak ending at that bar, and the answer is their maximum.

The myth

"DP means grinding 200 problems until you've memorised the tricks. It's a bag of unrelated puzzles."

The reality

DP is one recipe — state, recurrence, base, order, answer — applied to a new state each time. Fibonacci, coin change, knapsack, edit distance, LCS, LIS: the same five steps, different sentences on step one. Learn the recipe, not the list.

You've now filled five different tables with one procedure. Time to name the mental move you actually installed — and know exactly when it will and won't save you. →

When to reach for it — and the move you keep

Reach for DP when both signals fire: overlapping sub-problems (you keep re-solving the same smaller thing) and optimal substructure (the best whole is built from best parts). Miss either and DP is the wrong tool. No overlap? You have plain divide-and-conquer (ch5) — merge sort gains nothing from a memo because its halves never repeat. No optimal substructure? Neither greedy nor DP is guaranteed, and you may be forced to search everything (ch10).

It helps to place DP among its neighbours. Divide-and-conquer splits into independent pieces and never looks back. Greedy (ch8) commits to the best-looking move and never reconsiders — fast when it's provably right, wrong on [1,3,4] coins. DP is the careful middle: it considers every choice like brute force, but remembers each sub-answer so it pays for each only once. That's why the one-liner for DP is "brute force that doesn't repeat itself."

Divide & conquer split into pieces that DON'T overlap merge sort · binary search Dynamic programming consider every choice, REMEMBER each answer overlap + optimal substructure Greedy grab the best move now, never reconsider fast — but PROVE it first
Fig — The three paradigms on one line: split-independent, remember-everything, commit-and-run. DP is brute force with a memory.
↺ The thing people get backwards
People think the DP decision is "recursion vs loops," and they think memoisation and tabulation are rival techniques. They're neither. Memoisation is just recursion holding a notebook; tabulation is the same recurrence written forward. The real object — the thing you invent — is the recurrence over a well-chosen state. Direction (top-down or bottom-up) is a downstream detail you pick for convenience or cache-friendliness. Nail the state and the recurrence; the code is bookkeeping.

And here is the space bonus most people skip: once you have the recurrence, look at what each cell actually depends on. Fibonacci's dp[k] needs only the last two cells — so keep two variables and drop O(n) memory to O(1). Edit distance's row i depends only on row i-1 — so keep one row and drop O(m·n) space to O(min(m,n)). The recurrence tells you not just the answer but exactly how little you need to remember.

🧠
The 1% meta-move
DP installs one reflex you carry into every hard problem: never solve the same sub-problem twice. Before you write a line, ask "am I about to recompute something I already know?" — and if yes, write it down. This is the everyday transfer. You already hash your memory when you keep a notes file instead of re-deriving a fact. You DP your decisions when you record why you rejected an option so you never re-argue it. You build answers from sub-answers when you break a scary project into solved pieces. The engineers who look fast aren't computing faster — they're refusing to repeat work.

So the arc of this chapter is a single sentence: spot the overlap, define the state, write the recurrence, fill the table — and a problem that would outlast the universe finishes before your coffee cools. That is dynamic programming, and it is, more than any other single idea in this volume, the one interviewers use to separate the 1% from the rest.

DP works only when sub-problems overlap. But what about the problems where they don't — where every path is unique and the tree of choices is astronomically wide, yet most branches are hopeless the moment you start them? Next: recursion & backtracking — how to search a giant tree of choices, and how pruning a doomed branch turns "explores a billion dead ends" into "explores a thousand live ones." DP was backtracking that remembered; ch10 is backtracking that knows when to quit. →

10Recursion & backtracking — searching a tree of choices

Some problems have no clever formula. The only honest answer is: try the possibilities — arrange the songs, place the queens, fill the grid. Do it naively and you drown in an ocean of combinations. This chapter hands you the two tools that turn that ocean into a puddle: recursion to explore the possibilities one choice at a time, and backtracking to slam the door on billions of dead ends before you ever walk through them. After it you'll be able to look at "find a valid/optimal arrangement" and see a tree of choices — and know exactly where to prune it.

Recursion: shrink the problem until the answer is obvious

Recursion is one idea: solve a big problem by solving a smaller version of the same problem, and keep shrinking until the version is so tiny the answer needs no thought at all. That tiny, no-thought version is the base case — the floor you fall to. Every step above it is the recursive case — do a sliver of work, then hand the rest to a smaller copy of yourself.

Take the total length of a playlist. The total of [200, 247, 245] is "the first song, plus the total of everything after it." That second half is the same problem on a shorter list. Shrink it far enough and you hit the empty playlist, whose total is obviously 0. That is the whole recipe.

python
def total(songs):
    if not songs:                       # BASE CASE: empty list → answer is 0
        return 0
    return songs[0] + total(songs[1:])  # first + total of the REST (a smaller list)

print(total([200, 247, 245]))           # 692
print(total([]))                        # 0

Line 2–3 is the floor: an empty playlist sums to 0, no recursion needed. Line 4 is the shrink: peel off songs[0], then ask the very same function to total songs[1:] — a list one shorter. Ran on CPython 3.12.7, total([200, 247, 245]) returns 692 (200 + 247 + 245) and total([]) returns 0. Notice there is no loop anywhere. The repetition is the function calling itself.

Where does the machine keep its place across all those nested calls? On the call stack — Volume 1, chapter 9. Each call gets a frame: a scrap of memory holding that call's own songs, frozen mid-addition, waiting for its smaller copy to return a number. The frames stack up until the base case, then collapse back down, each one finishing its + with the value handed up from below. The call stack is recursion's bookkeeping — you don't manage the partial state, the stack does.

calls stack UP (each waits on a smaller copy) total([200,247,245]) = 200 + ? total([247,245]) = 247 + ? total([245]) = 245 + ? total([]) = 0 ← BASE CASE returns collapse DOWN with values: ↑ 692 ↑ 492 ↑ 245 ↑ 0 unwinding Four frames alive at the deepest point — each holds its own half-done sum. The stack IS the memory of "where was I?" — you never wrote a line to manage it.
Fig — recursion grows the call stack on the way down and collapses it on the way up; each frame is a frozen partial state the machine remembers for you.
💥
A recursion with no floor never lands
Forget the base case (or fail to shrink toward it) and the stack grows without end. CPython caps it — sys.getrecursionlimit() is 1000 by default — and stops you with RecursionError: maximum recursion depth exceeded (both quoted from a real 3.12.7 run). Every recursion needs a floor and a guarantee it moves toward it.

Summing a list only ever shrinks one way — chop the head, recurse on the tail, a straight line down. But what if at each step you face several options? Then the single line splits into a branching tree — and trees grow fast. →

Every choice sprouts a branch — the combinatorial explosion

Recursion becomes powerful — and dangerous — the moment each step offers more than one option. Then it stops drawing a line and starts drawing a tree of choices: the root is "nothing decided yet," each branch is one option, and each leaf at the bottom is one complete solution. To generate every possibility, you walk every branch.

Count the leaves and you feel the danger. Arrange 3 songs in order: the first slot has 3 candidates, then 2 remain for the second, then 1 — that's 3 × 2 × 1 = 6 = 3! orderings (a permutation is one ordering of all the items). Pick a subset instead — each song is independently in or out — and you get 2 × 2 × 2 = 2³ = 8 subsets. We didn't assert those numbers; we counted the choices at each level and multiplied. That multiplication is the whole story of why brute force dies.

Because it doesn't add — it multiplies. Ten songs: 10! = 3,628,800 orderings, still nothing. Twenty songs: 20! = 2,432,902,008,176,640,000 — 2.4 quintillion. At a billion arrangements per second that's about 77 years to merely list them (both figures computed in Python below). Add one more item and you multiply the wait again. This is the combinatorial explosion, and it is the reason the rest of the chapter exists.

Wait —
if arranging just 20 items can't be brute-forced in a lifetime, how does a delivery app find a near-best route through thousands of stops in under a second?
start A B C 3 first choices B C A C A B ×2 ABCACB BACBCA CABCBA 3 × 2 × 1 = 6 leaves = 3! permutations multiply the branches at each level — that product is why brute force explodes
Fig — a choice tree: multiply the options at each level to count the leaves. Six here; 2.4 quintillion at twenty items.
InteractiveSlide the input size — watch brute force die
count of possibilities (bars are log-scaled: each 21px ≈ ×10) orderings n! subsets 2ⁿ to list all orderings at 1 billion/sec: …
8
n! grows faster than any exponential — that gap between the red and amber bars is the wall you hit first.
⚠️
"It runs on my example" is a trap here
A permutation solver that feels instant on 8 items has done 40,320 steps. The same code on 15 items faces 1.3 trillion. Nothing in the code changed — the tree got taller, and each level multiplied the last. Always ask what happens at the input sizes you'll actually see.

If listing 20! possibilities takes 77 years, brute force is hopeless — yet solvers crack far harder-looking puzzles in milliseconds. Their secret isn't a faster computer. It's refusing to visit most of the tree. →

Backtracking: walk the tree, undo on the way back

Backtracking is how you actually traverse a choice tree without building it all in memory at once. You go depth-first: make one choice, dive deeper as if it were final, and when you've either found a solution or run out of options, step back and undo that choice so the next branch starts from a clean slate. Three beats, forever: choose → explore → un-choose. That un-choose is the "back" in backtracking — it restores the shared partial state so the sibling branch isn't polluted by the one you just abandoned.

Here it is generating every subset of the playlist. At each song you branch twice: skip it, or take it. Two options × n songs = 2ⁿ leaves, exactly as we counted.

python
def subsets(items):
    out, chosen = [], []
    def bt(i):
        if i == len(items):        # reached the bottom: record this subset
            out.append(chosen.copy())
            return
        bt(i + 1)                  # BRANCH 1: skip items[i]
        chosen.append(items[i])    # CHOOSE items[i]
        bt(i + 1)                  # BRANCH 2: explore with it taken
        chosen.pop()               # UN-CHOOSE — restore state for the caller
    bt(0)
    return out

print(subsets(["Levels", "Titanium", "Wake Me Up"]))

Line 4–6 is the base case: once i walks past the last song, chosen holds one complete subset, so we save a copy (not the live list — it's about to change). Line 8 explores the "skip this song" branch. Lines 9–11 are the choose/explore pair: append the song, recurse to decide the rest with it included. Line 12 is the crucial un-choose: chosen.pop() removes the song so that when control returns to the caller, chosen looks exactly as it did before this call meddled with it. Ran on 3.12.7, the call produces all 8 subsets — from [] through ['Levels', 'Titanium', 'Wake Me Up']. Swap the two-way branch for a "place each item that's still unused" loop and the identical pattern spits out all 6 permutations of [1, 2, 3]: [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] (also a real run).

at a node an option remains? CHOOSE (append) EXPLORE (recurse) UN-CHOOSE (pop) One shared list. Choose adds, explore descends, un-choose removes — so every branch starts from the exact state its parent had.
Fig — the backtracking cycle. The un-choose step is what lets a single mutable list serve the whole tree without corruption.
🧩
The template you'll reuse forever
Almost every backtracking solution is: if solution complete → record it; else for each option → choose, recurse, un-choose. Permutations, subsets, N-queens, sudoku, maze-solving — same skeleton, different "options" and "complete." Learn it once; recognise it everywhere. (This is depth-first search on an implicit tree — the same DFS you'll meet on real graphs in chapter 13.)

So far we still visit all 2ⁿ leaves — we've organised the search, not shortened it. The real magic is a single extra if: check a partial solution before descending, and cut whole subtrees you can prove are hopeless. →

Pruning: the whole game

Here is the idea that separates a toy from a solver. As you build a solution one choice at a time, keep asking: can this partial choice possibly complete into a valid answer? The instant the answer is "no," abandon the entire subtree beneath it — every arrangement that started with this bad prefix is dead, so you never generate a single one of them. That is pruning, and it is the difference between exploring 10⁹ dead ends and 10³ live ones.

The classic showcase is the N-queens puzzle: place N chess queens on an N×N board so none attacks another (no two share a row, column, or diagonal). Put one queen per row — that guarantees no row-clash for free — and at each row try each column. The pruning rule: the moment a queen conflicts with one already placed, skip that column entirely; don't recurse into the rows below it. A whole doomed subtree vanishes.

Count the savings on the standard 8×8 board — and watch three numbers fall off a cliff. Place 8 queens on any of 64 squares blindly: that's C(64,8) = 4,426,165,368 placements — 4.4 billion (computed in Python). Be a little smarter, one queen per row, all column combinations: 8⁸ = 16,777,216 leaves. Now add the pruning if, and backtracking touches just 2,057 partial placements to find all 92 solutions — over 8,000× fewer nodes than even the row-by-row brute force, and two million times fewer than the naïve count. Same puzzle. One conditional. The tree barely grows before pruning mows it down.

place one queen per row · a conflict prunes the whole branch below it ✕ = attacked → forbidden row 0 placed CONFLICT prune ✂ The red subtree is never built. Every board that starts with the conflict is killed at birth.
Fig — pruning kills a branch before descending. One if at the top of a subtree erases everything beneath it.

Turn the knob yourself. Below, slide N and compare the brute-force tree against what backtracking actually visits — the gap is the wasted work you never do.

InteractiveN-queens: nodes visited vs the tree you skipped
search size (bars log-scaled: each 28px ≈ ×10) brute force Nᴺ backtracking pruning skips …
8
The backtracking count is computed live by actually running the solver in your browser — same logic, same numbers as the Python run.
↺ The thing people get backwards
People hear "backtracking" and think slow, exhaustive, tries everything. Backwards. The brute force tries everything. Backtracking's entire reason to exist is to not try everything — to prove a prefix is doomed and delete its whole subtree unseen. The exhaustive tree is the enemy; pruning is the weapon. A backtracking solver's speed is measured by how much it refuses to look at.

✗ The myth

N-queens has an exponential worst case, so backtracking is a dead end — you might as well not bother.

✓ The reality

Worst-case exponential ≠ typical-case slow. Good pruning makes the real search tiny: 8-queens needs 2,057 nodes, not 16 million. The worst case is a ceiling you rarely touch, not the bill you actually pay.

Pruning tames N-queens because a bad prefix is provably hopeless. But some problems hide their answer so well that no pruning rule cracks the tree open. What then? →

When the tree won't shrink: NP-hard, and taming the beast

For some problems, nobody has found a pruning rule (or any trick) that reliably avoids the exponential — and there's deep evidence none exists. These are the NP-hard problems. The travelling salesman: visit N cities by the shortest possible loop. With 20 cities there are (20−1)!/2 = 60,822,550,204,416,000 distinct tours — 60 quadrillion, computed in Python. SAT (is there an assignment of true/false making a logic formula true?) has 2ⁿ assignments. There is no known algorithm that beats the exponential in the worst case for either. The tree is genuinely dense with plausible-looking branches.

And yet we solve big instances daily. Not by a magic fast algorithm — by taming the tree with three moves you can name:

  • Branch and bound — carry a running "best answer so far," and before exploring a subtree, compute a bound on the best it could possibly yield. If even the optimistic bound can't beat what you already have, prune it. (Pruning, upgraded from "invalid" to "can't-win.")
  • Heuristics / ordering — try the most promising branch first. The order you visit children in doesn't change the answer, but it changes how fast you find a good "best so far," which lets branch-and-bound prune harder. Chess engines order moves this way.
  • Constraint propagation — after each choice, cheaply deduce forced consequences that shrink the remaining options (a sudoku solver fills every forced cell before it ever guesses).

This is why real SAT solvers chew through formulas with millions of variables, why sudoku apps solve instantly, and why chess AI before neural networks was backtracking the game tree with alpha-beta pruning — the same "this branch can't beat what I've found, cut it" logic. The exponential never went away; it got outmanoeuvred.

best = 42 bound = 51 bound = 60 > 42 → cut ✂ explore explore If a subtree's BEST possible score can't beat what you already hold, you skip the whole thing — pruning by bound, not just by validity.
Fig — branch and bound: an optimistic estimate that still loses to the current best lets you cut a subtree without exploring it.
🐌
Catastrophic backtracking — the bug that DDoSes you
Regular-expression engines backtrack too, and a careless pattern turns that into an exponential trap. The pattern (a+)+$ on a string of a's that ends in a non-match forces the engine to try every way of splitting the a's. Timed on this machine (3.12.7; values are hardware-dependent): 20 a's took ~0.06 s, 24 a's ~0.98 s, 26 a's ~4.1 s, 28 a's ~17 s — each extra character roughly doubles the time. That's a real denial-of-service class called ReDoS: a 30-character input can hang a server. Same tree explosion, hiding inside a one-line regex.
🌍
Where you meet this
Backtracking with pruning is the engine inside constraint solvers and SAT/SMT solvers (hardware verification, program analysis), puzzle and game engines (sudoku, crosswords, chess/Go search with alpha-beta), regex matchers (and their ReDoS blowups), compilers and type checkers (searching for a valid parse or type assignment), route and schedule optimisers (branch and bound), and every "find a valid configuration" wizard you've ever clicked through.
The deeper cut — why visit-order doesn't change the answer but changes the speed

Backtracking explores the same set of nodes regardless of child order — unless you prune by bound or stop at the first solution. Then order is everything. Suppose you want the single best tour. If your first dive happens to find a near-optimal tour early, your "best so far" is strong, so branch-and-bound's cut condition (bound ≥ best) fires on almost every later subtree — you prune a forest. If instead you find a terrible tour first, your bound is weak and you explore far more. Good heuristics exist to make that first dive good: nearest-neighbour for TSP, most-constrained-variable for sudoku ("fill the cell with fewest legal options first"), killer/history heuristics for chess. None of them change what the optimum is — they change how quickly you can prove the rest can't beat it. This is the quiet art of practical exponential-problem solving: you can't shrink the tree's size, so you shrink the part of it you're forced to look at.

Branch and bound prunes by "can't win." But there's a fourth weapon, and it's the bridge to the crown jewel of the last chapter: what if the tree keeps re-solving the same subproblem? →

Backtracking + memory = dynamic programming (the mental move)

Walk a choice tree and you'll often notice the same subproblem showing up on many different branches. The naïve recursive Fibonacci is the purest example (stepped in traces T29/T30): fib(5) calls fib(3) down two separate paths, and each rebuilds the same subtree from scratch. The tree is exponential not because the answer is hard, but because you keep re-deriving what you already knew.

The fix is one line of thinking: remember the answers you've already computed. Cache each subproblem's result the first time; on every later encounter, read it instead of recomputing. That collapses the exponential tree into a linear walk. Backtracking that caches overlapping subproblems is dynamic programming — chapter 9's crown jewel is exactly this chapter's search plus a memo. The two conditions to look for: the subproblems overlap (worth caching), and the best full answer is built from best sub-answers (safe to build up).

f5 f4 f3 f3 f2 f2 f1 f3 computed twice f2 computed three times …the tree re-solves what it knows add a memo memo f2 → 1 f3 → 2 f4 → 3 each solved once Overlapping subproblems + a cache = dynamic programming (chapter 9).
Fig — when the same subproblem recurs across branches, a memo turns an exponential search into a linear one — that's the DP bridge.

That reframing is the transferable move — the 1% habit this chapter installs. Faced with any "find a valid or optimal arrangement," don't reach for a formula. Ask three questions in order: (1) Can I frame this as building a solution one choice at a time — a tree of choices? (2) Can I prune — is there a cheap test that proves a partial choice is hopeless, so I skip its subtree? (3) Do subtrees repeat — should I memoise? Choice-tree, then prune, then memo. That sequence turns "impossible, 2.4 quintillion cases" into "feasible, a few thousand."

You already run a crude version of this off-screen. Debugging: you form a hypothesis, follow it, and the moment the evidence contradicts it you abandon that whole line of theories instead of stubbornly exploring a dead branch — that's pruning. Planning a route through errands, ruling out a plan the instant it can't fit your lunch meeting — pruning. Backtracking isn't an exotic algorithm; it's the disciplined, countable version of how a careful mind already searches. Learn to name the tree and the prune, and you can attack problems that have no formula at all.

📌
The one-sentence takeaway
Recursion explores a tree of choices; backtracking is that exploration with an undo; pruning is the art of never entering the branches that can't win — and memoising the branches you'd otherwise re-solve is where backtracking becomes dynamic programming.

Backtracking searches when there's no formula. Next we return to a problem that does have fast algorithms — sorting — and meet a stunning result: a hard mathematical ceiling proving you can never sort by comparisons faster than n log n… and the clever trick that sneaks under it anyway. A beautiful impossibility, in chapter 11. →

11Sorting, properly — and a beautiful impossibility

You already own the five classic sorts: you stepped them line by line in the trace walkthroughs and raced them in the Algorithms Lab. This chapter is not about how they work — it is about the wall they all slam into, and the one trick that walks straight through it. By the end you will be able to prove, on the back of a napkin, that no sort built from comparisons can ever beat n log n — and then sort a million items in a single linear pass, faster than that limit, by refusing to compare at all.

The five sorts share one hidden move

Bubble, selection, insertion — the three that cost O(n²) when the list is big. Merge and quicksort — the two that cost O(n log n) and win at scale. Five different dances, and the Lab lets you watch each one shuffle the bars into place. But squint at any of them and you see the same atomic move repeated: pick two items, ask which is bigger, act on the answer. Everything else — the swaps, the splits, the merges — is bookkeeping around that one question.

That shared move is the key to the whole chapter. If the only thing your algorithm can learn about the data is the outcome of a < b questions, then the number of questions is the true cost, and there is a hard floor on how few you can get away with. Call any such algorithm a comparison sort. All five of yours are comparison sorts. So is almost every sort you have ever seen.

the one operation underneath all five sorts Levels 200 Titanium 245 a < b ? one bit of information: yes / no bubble · selection · insertion · merge · quick → all ask the same question
Fig — every comparison sort learns about the data one yes/no answer at a time. Count the questions and you have counted the real cost.
Wait —
if a comparison is just one bit — yes or no — how many bits do you need before you know the whole order of the list? Answer that and you have found the speed limit of sorting. →

The speed limit — and why nobody can break it

Here is the most beautiful argument in this volume, and it is pure counting. Lay out every decision your sort could make as a tree. At the top, the first comparison it does. Two branches — yes and no — lead to the next comparison, which depends on the answer. Keep going. This is a decision tree: internal nodes are comparisons, each edge is an answer, and every path from the top down is one possible run of the algorithm on some input.

Where does a path end? At a leaf — the moment the sort stops asking and commits to an output order. Now the punch line. A list of n distinct items has n! possible starting arrangements (n choices for first, n−1 for second, and so on), and each one needs a different reshuffle to come out sorted. So the tree must have at least n! leaves — one distinct verdict per arrangement. Miss even one, and there is an input your sort gets wrong.

decision tree for sorting 3 items — every path is a run of the sort a < b? yes no b < c? b < c? a b c a < c? a < c? c b a a c b c a b b a c b c a 6 leaves = 3! orderings · deepest leaf is 3 comparisons down = the worst case tree height h ≥ log₂(3!) = log₂ 6 ≈ 2.58 → at least 3 comparisons
Fig — the sort's whole strategy, drawn out. To tell all 6 arrangements apart it needs 6 leaves; to hold 6 leaves a yes/no tree must be at least 3 levels deep — so some input always costs 3 comparisons.

Now weigh the tree. Each comparison drops you one level. So the height of the tree — the longest root-to-leaf path — is the worst-case number of comparisons. And a binary (yes/no) tree of height h can hold at most leaves: 2 at depth 1, 4 at depth 2, doubling each level. Put the two facts together:

max leaves the tree can hold n! leaves it must have to be correct h ≥ log₂(n!) ≈ n log₂ n take log₂ of both sides — the worst case can never be smaller than this
Fig — three symbols and it is done. 2ʰ ≥ n!, so h ≥ log₂(n!), and Stirling's formula turns that into ≈ n log n. A genuine impossibility, proven by counting leaves.

Read what just happened. We never named a specific algorithm. We said: whatever your comparison sort is — one you know, one you will invent, one no human has thought of yet — it is some decision tree, and every decision tree that sorts n items is at least log₂(n!) levels tall. That is why n log n is not "the best we have managed." It is the best that can exist. Merge sort already sits on that floor; you cannot pay less.

The numbers are concrete and I checked each one in Python. To sort 3 items you need at least ⌈log₂ 6⌉ = 3 comparisons; 5 items, at least 7; 12 items, at least 29; 100 items, at least 525. And log₂(n!) hugs n log₂ n so tightly that by a million items the floor is already 93% of it — same curve, same Θ(n log n) band, forever.

InteractiveSlide n — watch the floor rise with n log n
orderings to tell apart (leaves the tree needs): 6 floor — ⌈log₂(n!)⌉ comparisons, unbeatable 3 n · log₂ n — what a good comparison sort spends 4.8 floor is 63% of n log₂ n
3
The green floor and the blue target never separate — both live in the Θ(n log n) band. The number of orderings (purple) explodes, but the number of questions to resolve them grows only like n log n.
↺ The thing people get backwards
The lower bound is not a challenge waiting for a smarter engineer. People hear "you can't sort faster than n log n" and assume it means "nobody has managed it yet." No — it is a proof of impossibility, the same species as "you can't draw a triangle with two right angles." The catch is hidden in one word: comparison. Break that assumption and the floor vanishes. That is the next section.
The deeper cut — Stirling, and the one place the floor is a hair too low

Where does log₂(n!) ≈ n log₂ n come from? Stirling's approximation: log₂(n!) = n log₂ n − n log₂ e + O(log n). The leading term is n log₂ n; the correction subtracts about 1.44n. I checked it in Python — at n = 10000, exact log₂(n!) = 118458.14 versus the two-term estimate 118450.17, a gap under 8 in a hundred thousand.

The bound counts information — the minimum questions to pin down one of n! answers — so it is a floor, not always a reachable target. Usually it is reachable. But not always: sorting 12 items needs at least ⌈log₂ 12!⌉ = 29 comparisons, yet the best method anyone has (the Ford–Johnson "merge insertion" algorithm) needs 30. A one-comparison gap between what information theory permits and what any real algorithm achieves — and 12 is the smallest n where it bites. The floor is exact; touching it is sometimes impossible from the other side too.

🌍
Where you meet this — the shape of "20 questions"
This is the same argument that powers the game of twenty questions (2²⁰ ≈ a million things you can pin down with 20 yes/no answers) and, in a later chapter, the reason you cannot losslessly compress below a file's entropy: a yes/no channel carries one bit, and k bits can name only 2ᵏ distinct things. Sorting, guessing, compressing — one counting argument underneath all three. Learn to count the distinct outcomes and take the log, and you can find the floor of problems you have never seen.

The floor only holds if you insist on comparing. What if the values could tell you where they belong — without a single comparison? →

Walk through the wall — counting sort

Suppose you are sorting a million song ratings, each an integer from 0 to 5. The comparison floor says n log₂ n ≈ 20 million questions. But you never need a question. A rating of 5 doesn't need to be compared with anything — it just is a 5, and you already know exactly where all the 5s go: after the 4s, before nothing. The value is its own address.

That is counting sort. Make one tally box per possible value. Walk the data once, dropping each item into its box. Then walk the boxes in order, pouring them back out. No item is ever weighed against another. Here it is, and it really runs:

python
def counting_sort(a, k):        # every value is an int in range 0..k-1
    count = [0] * k             # one tally box per possible value
    for x in a:                 # PASS 1 - n steps: tally each item
        count[x] += 1           #   the value IS the index. no comparison.
    out = []
    for v in range(k):          # PASS 2 - k steps: walk boxes in order
        out.extend([v] * count[v])   # pour box v back out, count[v] times
    return out

print(counting_sort([3,1,4,1,5,9,2,6,5,3,5,0,7,2,6], 10))
# [0, 1, 1, 2, 2, 3, 3, 4, 5, 5, 5, 6, 6, 7, 9]   <- matches sorted()

Line by line: count = [0]*k builds k empty boxes. The first loop runs n times — count[x] += 1 uses the value x directly as an array index (Volume 1: indexing a list is O(1), a straight jump to a memory address). The second loop runs k times, emitting each value as many times as it was tallied. Total work: n + k. I ran it against Python's sorted() on the same data — identical output.

input: 3 1 4 1 5 2 0 … PASS 1 — each value jumps to its own box (no comparing) 01 12 21 31 41 51 PASS 2 — read the boxes left → right 0 1 1 2 3 4 5 … ← sorted, in n + k steps k boxes, one per value
Fig — the value is the address. Tallying is n steps, reading the boxes is k steps — total O(n + k), and not one comparison in sight.

When k is small next to n, this is linear — it strolls under the comparison floor like it isn't there. I measured the op counts: a million items with values in 0–255 costs about 1,000,256 steps, where a comparison sort would spend ~20 million — roughly 20× fewer. There is no contradiction with the last section. The floor was proven for sorts that only compare. Counting sort never compares. Different game, different rules.

The price is written in the method. You need k boxes, so the values must be bounded integers (or things you can map to a small integer range) — you cannot counting-sort arbitrary floats or strings this way — and you pay O(k) memory for the boxes. Push k up toward "any 64-bit integer" and you would need billions of boxes; the trick collapses. Counting sort wins precisely when the range of values is small, whatever the count.

InteractiveSlide the key range k — find where counting stops winning
n = 1000 items, values in 0 … k−1 counting sort — n + k steps 1010 comparison sort — n · log₂ n ≈ 9966 steps (fixed) 9966 counting wins by 9.9×
10
Small range → counting sort is a linear stroll. Push k past ~9000 and the boxes outnumber the useful work — the comparison sort wins again. The crossover is exactly where n + k meets n log₂ n.

Myth

Counting sort is "the fast one" — always beats n log n, so use it when speed matters.

Reality

It beats the floor only while k stays near n. With a million-wide value range it does a million pointless box-reads. Its speed is a trade — cheap time bought with O(k) space and a bounded-integer key.

Bounded to 0–5 is easy. But a million distinct 9-digit ID numbers? That is k = a billion boxes. There is a way to keep the linear speed anyway — sort them one digit at a time. →

Radix sort — counting, one digit at a time

Big numbers break counting sort because their range is huge. But each individual digit has a tiny range — 0 to 9. So sort by the last digit, then the next, up to the first, using a stable counting sort on each pass. After the final digit, the whole list is sorted. This is radix sort, and it is exactly how the old punched-card machines physically sorted — a fact we'll come back to.

python
def radix_sort(a):
    m = max(a)
    exp = 1
    while m // exp > 0:              # one pass per digit of the biggest number
        a = counting_by_digit(a, exp)   # stable counting sort on this digit
        exp *= 10                    # move to the next digit up
    return a

print(radix_sort([170, 45, 75, 90, 802, 24, 2, 66]))
# [2, 24, 45, 66, 75, 90, 170, 802]   <- matches sorted()

The loop runs once per digit — call that d passes — and each pass is a counting sort over ten boxes, costing O(n + 10). Total: O(d·(n + 10)), which for fixed-width keys is just O(n). I ran it against sorted(); the outputs match. The whole thing works only because each pass is stable — it never disturbs the order the earlier passes established. That word, stable, is doing enormous quiet work here, and it deserves its own section next.

pass 1 · ones 170 90 802 02 024 045 075 066 pass 2 · tens 802 02 024 045 066 170 075 090 pass 3 · hundreds 002 024 045 066 075 090 170 802 done ✓ 2 24 45 66 75 90 170 802 each pass = one stable counting sort over 10 boxes · d passes · O(d·(n+10)) = O(n) for fixed-width keys
Fig — sort by the least significant digit first, then work leftward. Because every pass keeps earlier ties in place, the final pass over the top digit lands everything home.
🌍
Where you meet this — machines that never compared
In the 1880s Herman Hollerith built electric tabulating machines that sorted the 1890 U.S. census by dropping punched cards into bins one column at a time — physical radix sort, decades before "algorithm" was a word programmers used. Today the same idea sorts billions of keys on GPUs (radix sort is the workhorse of CUDA sorting libraries) and builds the suffix arrays behind DNA aligners like BWA and Bowtie and the bzip2 compressor. Wherever keys are fixed-width integers and there are a lot of them, radix quietly beats every comparison sort on Earth.

Everything in radix hangs on one promise: equal-keyed items keep their order across passes. Break that promise and the whole scheme scrambles. Time to make it precise. →

Stability — when equal keys must keep their order

A sort is stable if, when two items compare equal on the sort key, they come out in the same order they went in. Unstable sorts are free to shuffle ties. It sounds like a technicality. It is the difference between a spreadsheet that does what you meant and one that quietly corrupts your data.

Why it matters: it is how you sort by two keys. Say you want your playlist grouped by artist, and within each artist, ordered by length. Sort by the secondary key first (length), then by the primary key (artist) with a stable sort — the stable pass keeps the length-order intact inside each artist group. I ran exactly this:

python
songs = [("Levels","Avicii",200), ("Wake Me Up","Avicii",247),
         ("Titanium","Guetta",245), ("Without You","Guetta",210),
         ("Hey Brother","Avicii",254)]

step1 = sorted(songs, key=lambda s: s[2])   # first by seconds (secondary key)
step2 = sorted(step1, key=lambda s: s[1])   # then by artist (primary), STABLE

# ('Levels','Avicii',200) ('Wake Me Up','Avicii',247) ('Hey Brother','Avicii',254)
# ('Without You','Guetta',210) ('Titanium','Guetta',245)

Read the result: the Avicii block is internally sorted by seconds (200, 247, 254) and the Guetta block too (210, 245) — even though the second sort only looked at artist. The stable pass never touched the ties, so the first sort's work survived underneath it. Two stable sorts in a row give you a two-level ordering, for free. An unstable sort would have jumbled the seconds within each artist, and you'd have to sort with a compound key instead.

input, already ordered by seconds within each pair: Levels · Avicii · 200 Wake Me Up · Avicii · 247 stable sort by ARTIST: ties (both Avicii) keep input order ✓ Levels · Avicii · 200 Wake Me Up · Avicii · 247 an UNSTABLE sort is free to swap these — order lost
Fig — stability is a promise about ties: equal keys keep their entry order. That promise is what lets you layer sorts to sort by artist, then length, then title.
📊
Where you meet this — every spreadsheet
Click a column header in Excel, Google Sheets, or a pandas DataFrame, then click another — the second sort keeps the first as a tiebreak, precisely because these sorts are stable. Leaderboards, file managers sorting by name-then-date, SQL ORDER BY a, b — all lean on stability. It is the feature you never notice until an unstable sort scrambles it.

Python's sorted() is stable, and I have been leaning on that all chapter. But what is sorted()? It isn't plain merge sort. It is something cleverer — and it gets suspiciously fast on data that is already a bit sorted. →

What sorted() actually runs — Timsort

Real data is rarely random. Logs arrive almost in time order; a list you append to and re-sort is mostly sorted already; sensor readings drift. Timsort — the algorithm behind Python's list.sort() and sorted() — is built around that observation. It is a merge sort / insertion sort hybrid that first scans for runs: stretches already in order. Every run it finds is work it doesn't have to do. Then it merges the runs, using insertion sort to tidy short pieces and clever "galloping" to skip through long ones.

The consequence is dramatic, and I timed it on this machine (best of 5 runs, a million floats — treat the milliseconds as indicative, not exact):

python
n = 1_000_000                       # sorted() on different input orders
# random order    :  146.1 ms       <- the full n log n price
# already sorted  :    9.6 ms       <- ~15x faster: one giant run, near-linear
# reverse sorted  :   10.9 ms       <- ~13x faster: spots the descending run, flips it
# 20 sorted runs  :   10.6 ms       <- ~14x faster: merges 20 pre-made runs

On already-sorted data Timsort makes essentially one pass, confirms it is one long run, and stops — O(n), not O(n log n). Reverse-sorted is nearly as fast: it detects a strictly descending run and reverses it in place. This is why re-sorting a list you just tweaked is almost free. The general bound is O(n log r) where r is the number of runs — when the data is one big run (r = 1) that collapses to O(n); when it's fully scrambled (r ≈ n) you pay the full n log n. It never beats the comparison floor on random data — the last section forbids that — but it pockets every scrap of existing order.

1 · scan for natural runs (ascending stretches already in order) 3 7 9 12 · run A 1 4 8 · run B 2 5 6 10 11 · run C 2 · merge runs pairwise (galloping through long agreeing stretches) 1 3 4 7 8 9 12 · merged A+B run C waits cost O(n log r), r = number of runs · one run → O(n) · scrambled → n log n
Fig — Timsort's edge: it never re-sorts order that already exists. Fewer runs in the input means fewer merges and a cost that slides all the way down to linear.
InteractiveHow ordered is your data? Slide the run count
n = 1,000,000 items · r natural runs Timsort ≈ n + n·log₂ r (its O(n log r)) n log₂ n — the random-data ceiling already sorted (r=1) → ~n, linear
1
One run (sorted input) sits at the far left — linear. Push r up to n and the bar walks right to meet the n log n ceiling. Timsort's whole gift is charging you only for the disorder that's actually there.
🌍
Where you meet this — the sort in your pocket
Tim Peters wrote Timsort for CPython in 2002. It was so good at real-world data that it spread: Java uses it to sort objects (Arrays.sort), Android runs it, and since 2018 V8 — the engine in Chrome and Node.js — makes Array.prototype.sort a Timsort, so every time a web page sorts a table you are running this algorithm. Rust's standard stable sort is a Timsort descendant too. The line sorted(data) you type without thinking is one of the most-run algorithms in the history of computing.
↺ The 1% move this chapter installs
When you hit a wall, ask which assumption built the wall — then check whether your real problem actually needs it. The n log n floor is unbreakable given comparisons; counting sort wins by dropping comparisons; radix wins by shrinking the key range; Timsort wins by assuming the input isn't random. None of them beat the theorem — each one steps outside its premise. That is the engineer's reflex the whole volume is training: don't fight a proven limit, find the hidden "if" it rests on and ask whether it's yours to keep.

Sorting arranges data so you can find things fast.

Sorting arranges data so you can find things fast — the next chapter is about the finding itself: searching & selection. How binary search pinpoints any item in a sorted array in O(log n) by halving the haystack instead of scanning it — and how the same halving idea plucks the median out of a billion numbers without ever sorting them. →

by computing where it lives instead of searching for it, and why that near-magic occasionally, spectacularly, collides. →

12Searching & selection — binary search is bigger than you think

Trace T23 stepped binary search line by line: two markers, one probe, half the list gone every time. This chapter is about what that little loop is really teaching you. First we nail the one idea that makes it correct — the invariant — and see why a three-line loop has bitten the best programmers alive. Then the reveal almost nobody learns as a beginner: you can binary-search things that aren't arrays at all. And finally quickselect — how to pull the median out of a billion numbers without ever sorting them. By the end you'll spot binary search hiding in problems that look nothing like search.

The invariant — the promise the loop never breaks

Binary search keeps three integers: lo, hi, and a scratch mid. That is the entire state — no copies of the list, whatever its size (O(1) extra space, as T23's memory panel showed). What makes the loop correct is a single sentence that is true before the loop, stays true after every pass, and is still true when it stops. That sentence is the loop invariant:

If the target is in the list at all, it is somewhere in the window [lo, hi].

Watch how each step defends it. You probe the midpoint. If arr[mid] is smaller than the target, then — because the list is sorted — every slot from lo up to and including mid is also too small. The target cannot be there. So you move lo = mid + 1: you threw away half the window and the promise still holds, because you only discarded slots you proved couldn't hold the target. The mirror move, hi = mid - 1, does the same on the other side. The window can only shrink, and it never sheds the answer.

python
def bsearch(arr, target):
    lo, hi = 0, len(arr) - 1          # invariant: answer, if present, is in [lo, hi]
    while lo <= hi:                    # window still holds at least one slot
        mid = (lo + hi) // 2
        if arr[mid] == target:
            return mid                # found it
        elif arr[mid] < target:
            lo = mid + 1              # left half proven too small — discard it
        else:
            hi = mid - 1              # right half proven too big — discard it
    return -1                         # window emptied — the target was never here

Now derive the speed, don't quote it. Each pass replaces the window with one of its halves: n becomes n/2, then n/4, then n/8… How many halvings until only one slot is left? That is the number of times you can divide n by two before hitting 1 — the definition of log₂ n. So the loop runs at most about log₂ n times. Counting the real worst-case probes (a target just past the end, forcing the deepest possible search) confirms it exactly:

python
n=            1  probes(worst)=  1   ceil(log2(n+1))=1
n=           10  probes(worst)=  4   ceil(log2(n+1))=4
n=        1,000  probes(worst)= 10   ceil(log2(n+1))=10
n=    1,000,000  probes(worst)= 20   ceil(log2(n+1))=20
n=1,000,000,000  probes(worst)= 30   ceil(log2(n+1))=30

Read the last line and let it land: a billion sorted items, found in 30 comparisons. Multiply the data by a thousand and the work grows by ten. That is what living on the O(log n) curve (green's frugal cousin from the volume's opening chart) buys you — the search race in the Algorithms Lab lets you watch linear's counter blow past it live.

invariant: if present, the target lives inside the cyan window 11 22 33 44 55 66 77 mid 33 < target 66 proven too small — discard, lo = mid+1 new window — half the size, target still inside target 66
Fig — one comparison eliminates a whole half — but only the half it has proven empty. The invariant is what guarantees the survivor still contains the answer.
InteractiveGrow the haystack — count the probes
worst-case checks to find one item in a sorted array of n linear scan up to 1,000 binary search 10 bars are log-scaled (a linear bar would run off the page) n = 1,000
30 probes
Every time you double n, binary search adds exactly one probe. That is what "logarithmic" feels like from the inside.

Three lines, one promise. So why did one of the field's most famous essays claim that almost no one writes them correctly? →

Why binary search is the bug that ate the industry

Jon Bentley, in Programming Pearls, famously reported that when he asked roomfuls of professional programmers to write a bug-free binary search, the great majority failed — a result widely repeated because it keeps coming true. The loop is tiny. The bugs hide in four tiny decisions: < versus <=, and mid versus mid ± 1 on each side. Get one wrong and the invariant tears. Here are two classic tears, run for real:

python
arr = [11, 22, 33, 44, 55]

# BUG A — write  lo = mid  instead of  lo = mid + 1
def bug_infinite(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if   arr[mid] == target: return mid
        elif arr[mid] <  target: lo = mid       # <- forgets the +1
        else:                    hi = mid - 1
    return -1

# BUG B — write  while lo < hi  instead of  lo <= hi
def bug_misses(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo < hi:                               # <- a 1-slot window never gets checked
        mid = (lo + hi) // 2
        if   arr[mid] == target: return mid
        elif arr[mid] <  target: lo = mid + 1
        else:                    hi = mid - 1
    return -1

Line by line on Bug A: search for 55. The window narrows to lo=3, hi=4; mid = (3+4)//2 = 3; arr[3]=44 < 55, so lo = mid = 3unchanged. Next pass: same lo, same hi, same mid, forever. Because mid rounds down, dropping the +1 lets lo get stuck on a slot it already rejected. The invariant said "discard mid"; the code kept it.

Bug B is subtler and nastier — it returns a confident wrong answer. Using lo < hi, the loop quits the instant the window shrinks to a single slot (lo == hi), without checking that slot. So any element that ends up alone in the final window is reported as absent. Here is what the two bugs actually print:

python
correct(arr, 55)      -> 4
bug_infinite(arr, 55) -> INFINITE LOOP (hit safety cap at 1000 iterations)

correct(arr, 55)      -> 4
bug_misses(arr, 55)   -> -1
bug_misses fails to find: [22, 55]

Bug B doesn't fail on 55 alone — it silently loses 22 too, and on a bigger array it loses a different scattered handful. No crash, no red. Just occasionally, quietly, the wrong answer. That is why binary search is legendary: the bugs don't announce themselves.

🩸
The dangerous bug is the quiet one
Bug A (infinite loop) is loud — your program hangs, you notice in seconds. Bug B is the one that ends careers: it returns a plausible number, ships to production, and corrupts data for months before anyone traces it back to a missing =. Loud bugs are gifts. Fear the quiet ones.
Bug A — the window that will not shrink 44 (i3) 55 (i4) mid = (3+4)//2 = 3 44 < 55 → lo = mid = 3 lo, hi, mid all unchanged → back to the top, unchanged, forever the fix lo = mid + 1 excludes the rejected slot → window shrinks
Fig — Bug A in one frame: because mid rounds down, lo = mid can equal the old lo. The +1 is not decoration — it is what forces progress.
Bug B — the last slot the loop refuses to look at 55 (i4) lo = hi = 4 ← the target is right here while lo < hi → 4 < 4 → False → loop exits, returns -1 the fix is one character: lo <= hi keeps a 1-slot window alive to be checked
Fig — Bug B is a single missing =. A one-slot window is a real candidate; lo < hi throws it away unread, and the target it was holding is reported absent.
↺ The thing people get backwards
People try to fix binary search by testing more inputs until it "seems to work." That is how Bug B ships — it works for most values. The real fix is to write down the invariant first (the answer is always in [lo, hi]) and then choose each <= and each ±1 as whatever keeps that sentence true. Correctness comes from the promise, not from the test cases.

Myth

"It's five lines — how wrong can it go? Eyeball it and move on."

Reality

The 2006 report from a lead Java engineer, "Nearly All Binary Searches… Are Broken," found the bug even in the standard library. It sat there for years.
The deeper cut

That standard-library bug wasn't <= or ±1 — it was mid = (lo + hi) // 2 itself. In a fixed-width integer language (Java, C, Go…), lo + hi can exceed the largest representable int and wrap around to a negative number, producing a negative index and a crash — but only on arrays big enough to make the sum overflow. Simulated with signed 32-bit wraparound:

python
lo, hi = 1_500_000_000, 1_700_000_000   # both valid indices
true lo+hi            = 3200000000
as signed 32-bit int  = -1094967296     <- overflowed NEGATIVE
bad mid = (lo+hi)//2  = -547483648      <- negative index -> crash
safe mid = lo+(hi-lo)//2 = 1600000000   <- never overflows

The fix is mid = lo + (hi - lo) // 2 — same value, no oversized sum. Python is immune to this exact bug: its ints are arbitrary-precision (Volume 1, ch 8), so lo + hi simply cannot overflow — (10**18 + 10**18+6)//2 returns 1000000000000000003 without a blink. But the reflex is worth keeping: the safe form costs nothing and travels to every other language you'll touch.

You now own the loop cold. Time for the part they don't tell beginners: the array is optional. →

Binary-search the answer, not the array

Strip binary search down to what it truly needs. Not a list. Not even numbers-in-a-row. It needs exactly one thing: a yes/no question that is monotonic — once the answer flips to "yes," it stays "yes" for everything past that point. A sorted array is just one place that shows up ("is arr[i] ≥ target?" flips from no to yes exactly once). But any monotonic yes/no gives you the same superpower: binary-search the space of possible answers.

Here is the move on a problem with no array to search. A ship loads packages (in fixed order) off a conveyor; with a weight capacity C it takes some number of days. You must deliver everything within D days. What is the minimum capacity that still makes the deadline? Brute force would try every capacity from small to large. But notice the structure: a bigger ship is never worse — more capacity can only mean the same or fewer days. So "can we finish in D days with capacity C?" is monotonic in C: false, false, false, then true forever. We don't search packages; we binary-search the capacity.

python
weights = [1,2,3,4,5,6,7,8,9,10];  D = 5

def days_needed(cap):                 # simulate one candidate capacity — O(n)
    days, load = 1, 0
    for w in weights:
        if load + w > cap:            # this package won't fit today
            days += 1; load = 0       # start a new day
        load += w
    return days

def feasible(cap): return days_needed(cap) <= D    # the monotonic yes/no

lo, hi = max(weights), sum(weights)   # smallest sane cap … ship-it-all-in-a-day cap
while lo < hi:
    mid = (lo + hi) // 2
    if feasible(mid): hi = mid        # mid works — the answer is mid or smaller
    else:             lo = mid + 1    # mid too small — need strictly more
answer = lo

Line by line: days_needed walks the packages once and counts the days a given capacity forces — that's the O(n) feasibility check. feasible wraps it into the monotonic question. The search bounds come from the problem itself: capacity can't be less than the heaviest single package (it must at least fit), and never needs to exceed the total (that ships everything in one day). Then the familiar loop — but over capacities, not indices. Watch it run:

python
search range [10, 55]  (46 possible capacities)
minimum feasible capacity = 15   (days it needs: 5)
feasibility checks performed = 5           <- vs up to 46 for a linear sweep

  window [10,55]  try C=32  -> 2 days  feasible=True
  window [10,32]  try C=21  -> 3 days  feasible=True
  window [10,21]  try C=15  -> 5 days  feasible=True
  window [10,15]  try C=12  -> 6 days  feasible=False
  window [13,15]  try C=14  -> 6 days  feasible=False
Wait —
what if the feasibility question isn't monotonic — if it's true, then false, then true again? Then binary search will happily return a wrong answer with total confidence: it assumes one cliff and probes as if the rest is settled. Monotonicity isn't a nicety here; it's the precondition that makes the whole method valid. No monotone flip, no binary search.

Five checks instead of forty-six, and it scales the same beautiful way: log₂(range) feasibility checks. The cost is O(n · log(range)) — the range can be astronomically large and you'd barely notice. The whole trick was spotting the monotonic yes/no and building bounds around it. Integer square root is the same idea in miniature: "is mid² ≤ n?" is monotonic in mid, so a hand-written isqrt(n) is a binary search on the answer — and it agrees with the standard library's math.isqrt right up to isqrt(10**18) = 1000000000.

feasible(C) = "can we ship in 5 days?" — one clean cliff FALSE C = 10 … 14 TRUE (stays true forever) C = 15 … 55 the cliff — answer = 15 no zig-zag anywhere monotonic ⇒ binary search finds the cliff in ~log₂(46) ≈ 6 probes
Fig — monotonicity is the whole license. Because feasibility never flips back, the answer space is a sorted array of False/True — and you already know how to search one.
InteractiveBinary-search the answer — halve the capacity range
answer space — capacities 10 … 55 infeasible feasible (≥ 15) C=32 10 55 start: window is the whole range [10, 55]
step 0/5
You are searching a range of numbers with no array in sight — the "sorted list" is the run of False then True.
🌍
Where you meet this
"Binary search on the answer" is one of the highest-signal moves in technical interviews and competitive programming — problems like minimum time to finish, split an array into K parts minimizing the largest sum, and allocate books to students are all this one pattern. In the wild it's capacity planning (smallest cluster that meets a latency target), rate-limit tuning, and any "find the smallest budget that still works" question.

Searching finds a value. What if you want a value by its rank — the median, the 90th percentile — out of a billion unsorted numbers? →

Quickselect — the median without sorting

The obvious way to get the median is to sort and grab the middle. But sorting does far more work than you asked for: it puts every element in order when you only wanted one position. That's O(n log n) to answer an O(n) question. Quickselect pays only for what you need.

It borrows quicksort's partition step (detailed in trace T28): pick a pivot, then rearrange so everything smaller sits left of it and everything larger sits right. After one partition the pivot is in its final sorted position — say index p — for free. Now the key difference from quicksort: quicksort recurses into both sides; quickselect looks at p, compares it to the rank k you want, and recurses into only the side that contains k. The other half is thrown away, unsorted, unexamined.

python
def quickselect(a, k):                 # k-th smallest, 0-indexed
    a = a[:]                           # copy; partition rearranges in place
    lo, hi = 0, len(a) - 1
    while lo < hi:
        p = partition(a, lo, hi)       # pivot lands at its final index p
        if   p == k: break             # the pivot IS the k-th smallest — done
        elif p <  k: lo = p + 1         # k is to the RIGHT — discard the left
        else:        hi = p - 1        # k is to the LEFT  — discard the right
    return a[k]

Now count the work, don't quote it. The first partition scans all n elements. Then you keep one side — on average about half — so the next partition scans ~n/2, the next ~n/4, and so on. Sum it: n + n/2 + n/4 + … = 2n. A geometric series that converges — total work proportional to n, i.e. O(n) average. Sorting's recursion keeps both halves and never gets to drop that series; quickselect's "recurse one side" is exactly what collapses the log n factor. Measured on random data, the comparisons stay a flat constant-times-n no matter how big n gets:

python
median of [200, 90, 150, 60, 247, 245, 33]
  quickselect -> 150   (statistics.median -> 150)   using only 14 comparisons
  the list was never sorted

 n      : avg quickselect comparisons (median, 100 runs) : ratio to n
   1000 :         3322                                    : 3.32 x n
   2000 :         6631                                    : 3.32 x n
   8000 :        26319                                    : 3.29 x n
  16000 :        53182                                    : 3.32 x n

The ratio holds at ~3.3 regardless of n — the signature of linear growth. (The constant sits above the ideal 2 because a random pivot isn't a perfect median; still a constant.) For n = 16,000, that's about 53,000 comparisons versus roughly 223,000 for a full comparison sort — you got the median with a quarter of the work and never produced a sorted list you didn't want.

after one partition: pivot is in its final place; k decides which half survives < pivot (indices 0 … p-1) pivot index p > pivot (indices p+1 … hi) want rank k here k > p → discard this whole block thrown away unsorted — never looked at again only this half is partitioned next → n + n/2 + n/4 + … = 2n
Fig — the one-line difference from quicksort: recurse into a single side. Dropping the other half each time turns O(n log n) into an O(n) geometric series.
InteractiveStep quickselect — find the median, keep one side
find k = 3 (the median) — dimmed cells are discarded start — nothing partitioned yet
step 0/4
Each step keeps just one side. The median falls out without the list ever being fully sorted.
The deeper cut

"Average O(n)" hides a worst case: if the pivot is always the smallest or largest element, each partition peels off just one item and you get n + (n-1) + (n-2) + … = O(n²) — the same disaster as quicksort on sorted input. Two fixes. Cheap and standard: pick the pivot at random, which makes the bad case astronomically unlikely — no fixed input can be your worst case if your choices are random (this volume returns to "randomness as armor" later). The guaranteed fix is median-of-medians: split into groups of five, take each group's median, then recursively take the median of those medians as the pivot. That pivot is provably good enough to discard a constant fraction every time, giving O(n) worst case — a beautiful result, though its constant is large enough that random pivots usually win in practice. This is why NumPy's np.partition/argpartition and C++'s nth_element use a hybrid ("introselect": start with quickselect, fall back to median-of-medians if it goes bad).

Fast, correct, frugal. Now — where does all this actually run, and what's the mental move to carry out of the chapter? →

The move to steal: turn problems into monotone questions

You already used binary search this week without noticing. When you run git bisect to find the commit that introduced a bug, git binary-searches your history: it checks out the middle commit, you say "good" or "bad," and half the timeline is eliminated — the same monotonic structure (once broken, stays broken) as the ship. A thousand commits, found in about ten checkouts. Debugging a long function by commenting out the first half to see if the bug survives? Same move. Bisection is the everyday face of binary search, and naming it makes you faster at it.

Selection shows up just as widely. Every time a dashboard shows you a p95 latency or a median income, something computed a rank statistic — and at scale it is not sorting billions of rows, it is quickselect or its streaming cousins (approximate-quantile sketches like t-digest). Database query planners lean on selectivity estimates to guess how many rows a filter keeps. "Give me the top 100 without ordering all ten million" is argpartition, not sort.

💡
The transferable question
Facing a hard "find the smallest/largest X that works" problem, ask two things: (1) Is there a yes/no feasibility check I can write? (2) Is it monotonic — once true, always true? If both are yes, you don't need a clever closed-form formula. You binary-search the answer, and your only real job was the feasibility check. Optimization becomes repeated verification.

That is the 1% reframe hiding in this chapter. Beginners memorize "binary search = find a number in a sorted array." Engineers see the deeper shape: binary search is a tool for any monotone predicate, and selection is a tool for any rank — and both come from refusing to do more work than the question demands. Don't sort when you need one position. Don't scan when the structure is monotone. Ask what you actually need, then pay exactly that.

git bisect — binary search over your commit history probe the middle commit → good or bad? good — bug not here yet bad — bug already present the "good → bad" flip is monotone, so half the history is eliminated per probe
Fig — the same three-integer loop you now own, spread across a thousand commits. Once you see the monotone flip, you see binary search everywhere.
🧭
Reach for the built-ins
In real Python you rarely hand-write the loop: bisect.bisect_left(seq, x) is the standard-library binary search (it returns where x is, or would go — verified: bisect_left([11,22,33,44,55], 44) gives 3), and statistics.median / numpy.partition do selection. Write your own only when the thing you're searching is an answer space, not a list — because no library knows your feasibility function.

We've been searching straight lines and sorted ranges. But most real problems — maps, friend networks, web links, task dependencies — aren't lines at all; they're webs. Chapter 13 opens with the most general structure in all of computing: a graph, nothing but nodes and the edges between them — and the two ways to explore one that unlock GPS routing, PageRank, and six degrees of separation. →

13Graphs — the universal model

Almost every hard problem you will ever meet is secretly the same problem wearing a different costume. Roads, friendships, web links, task lists, molecules, the moves in a chess game — draw dots for the things and lines for the connections and they all become one shape: a graph. This chapter builds that shape from zero, shows the two ways a machine stores it, and hands you the three tools that answer almost every question you can ask of it — BFS (the ripple), DFS (the plunge), and Dijkstra (the weighted route). After it, your first move on a new problem changes forever: you will ask "what are the nodes, what are the edges?" — and half the difficulty will dissolve on the spot.

Dots and lines — the most general structure there is

A graph is just two sets. A set of nodes (also called vertices) — the things. And a set of edges — the connections between pairs of things. That is the entire definition. There is nothing else to it, and that emptiness is exactly why it is the most powerful idea in this volume: because we said nothing about what the things are, anything can be a node.

Cities are nodes and roads are edges. People are nodes and friendships are edges. Web pages are nodes and hyperlinks are edges. Tasks are nodes and "must-happen-before" is an edge. Atoms are nodes and chemical bonds are edges. The valid states of a Rubik's cube are nodes and a single twist is an edge. Every one of these is literally a graph — not "like" a graph, not "modelled as" a graph. Same structure, same algorithms, one skill.

Edges come in two flavours, and the distinction runs through the whole chapter. An undirected edge is a two-way street: if Ada is friends with Bex, Bex is friends with Ada. A directed edge points one way: page A links to page B, but B need not link back; "socks before shoes" does not mean "shoes before socks". And an edge can carry a weight — a number attached to the connection: the minutes to drive that road, the cost of that flight, the strength of that bond. No weight means every edge is worth the same; weights are where Dijkstra will earn its keep.

A B C D undirected edge directed edge (B→C) 7 weight node (vertex)
The whole vocabulary in one picture — nodes (the things), an undirected edge (two-way), a directed edge (one-way, drawn with an arrow), and a weight (a number on the connection). Master these four words and you can describe roads, friend networks, and build systems in the same breath.
🌍
Where you meet this — everywhere, all day
Your GPS routing home is a shortest path on a road graph. Google ranked the web by treating pages-and-links as one giant graph (PageRank). "People you may know" walks a friendship graph. git stores your history as a graph of commits. Your package manager refuses to install two libraries that need conflicting versions by reasoning over a dependency graph. A compiler decides which variables share a CPU register by colouring a conflict graph. Each of those is a headline product — and each is a graph algorithm from this chapter under the hood.

We have the picture. But a machine cannot store a picture — it stores bytes. So the first real question is brutally practical: how do you write a graph down? →

Two ways to write a graph down — and the tradeoff that decides everything

There are two honest ways to hand a graph to a computer, and choosing between them is the first engineering decision of every graph problem.

The adjacency list: for each node, store a list of its neighbours. Ada → [Bex, Cy]; Bex → [Ada, Dot, Eli]; and so on. In Python this is just a dictionary of lists — a hash table (Volume 1) whose values are dynamic arrays. To find Ada's neighbours you jump straight to her list; to store the whole graph you spend one slot per node plus one slot per edge-endpoint. Count it: V nodes and E edges cost V + 2E slots for an undirected graph (each edge appears at both of its ends). That is O(V + E) space — you pay only for connections that actually exist.

The adjacency matrix: a V×V grid of 0s and 1s, where cell [i][j] = 1 means "there is an edge from i to j". Want to know if Ada and Fin are directly connected? Read one cell — O(1), instant, no scanning a list. But the grid has V² cells whether or not the edges exist, so it costs O(V²) space no matter how few edges there are. The matrix reserves a box for every possible connection in the universe; the list writes down only the ones that are real.

the graph 0 1 2 3 adjacency LIST — O(V+E) 0 → [1, 2] 1 → [0, 2] 2 → [0, 1, 3] 3 → [2] only real edges stored adjacency MATRIX — O(V²) 0 1 2 3 0123 0 1 1 0 1 0 1 0 1 1 0 1 0 0 1 0 a box for every possible edge
One graph, two encodings — the list stores only the four edges that exist; the matrix reserves all 16 cells whether or not they hold an edge. The matrix trades memory for an instant "is i linked to j?" lookup — a good deal only when the graph is dense.

That difference is not academic — it is the difference between fitting in memory and not. Real graphs are sparse: a person has hundreds of friends, not millions; a web page links to dozens of pages, not billions. Here is the gap, computed for a million users each with about ten friends:

python
V = 1_000_000        # a million users
avg_deg = 10         # ~10 friends each
E = V * avg_deg // 2 # undirected edges  -> 5,000,000

matrix_cells = V * V           # 1,000,000,000,000  (a trillion)
list_slots   = V + 2 * E       #        11,000,000  (eleven million)
print(matrix_cells // list_slots, "x bigger")   # -> 90909 x bigger

Line by line: V and avg_deg set the scale; E works out the edge count (halved because each friendship is one edge shared by two people). matrix_cells is V² — a trillion boxes. list_slots is V + 2E — eleven million. The matrix is ~90,909× larger. Even packed to a single bit per cell, that trillion-cell matrix is 125 GB just to record who-knows-whom; the list fits in a few hundred megabytes. For a sparse graph, the matrix is a catastrophe you can measure.

InteractiveSlide the edges — watch the matrix waste, and the list stay honest
adjacency MATRIX (8×8 = 64 cells, always) adjacency LIST (V + 2E slots) 8 slots
6
The matrix reserves all 64 boxes no matter what. The list only ever holds V + 2E. They tie only when the graph is nearly complete — which real graphs never are.

Myth

The matrix is the "real" representation — it's a clean square grid, so it must be the simple, correct default.

Reality

The list is the default. Real graphs are sparse, and the matrix's O(V²) memory explodes long before your data does. Reach for a matrix only when the graph is genuinely dense, or when you need O(1) "is there an edge?" checks so often that the memory is worth it.

Now the graph is in memory. The real magic starts: asking it questions. The first and most human question — "what's the shortest way from here to there?" — is answered by dropping a stone in a pond. →

BFS — the ripple that finds the shortest path

Drop a stone in still water and a ring spreads outward. Every point the ring touches at the same instant is the same distance from the splash. Breadth-first search (BFS) is exactly that ring, run on a graph: start at a node, visit all its neighbours (distance 1), then their unvisited neighbours (distance 2), then theirs (distance 3), rippling outward one layer at a time until the whole graph is soaked.

The engine that makes the ripple stay a ring is a queue — the first-in-first-out line from Volume 1. You add newly-discovered nodes to the back and always process from the front. Because the front always holds the earliest-discovered (nearest) node, you finish an entire distance-layer before the next one begins. Watch it run on a tiny friendship network:

python
from collections import deque
def bfs(graph, start):
    dist = {start: 0}                 # how many hops from start
    q = deque([start])                # the ripple's frontier (a queue)
    while q:
        u = q.popleft()               # take the NEAREST unfinished node
        for v in graph[u]:            # look at each neighbour
            if v not in dist:         # first time we reach v...
                dist[v] = dist[u] + 1 # ...so this IS its shortest distance
                q.append(v)           # add it to the back of the line
    return dist

print(bfs(friends, "Ada"))
# -> {'Ada': 0, 'Bex': 1, 'Cy': 1, 'Dot': 2, 'Eli': 2, 'Fin': 3}

Line by line: dist doubles as "have I seen this node?" and "how far is it?". q starts holding only the source. Each loop takes the front node u — guaranteed to be the nearest unfinished one — and scans its neighbours. The load-bearing line is if v not in dist: the first time BFS reaches a node is along the shortest possible route, because every shorter route would have arrived in an earlier ring. So dist[v] = dist[u] + 1 is not a guess — it is the final answer, set once and never revised. The run confirms it: Ada→Fin is 3 hops, and no path is shorter.

🎯
Why BFS = shortest path (in an unweighted graph)
A ring cannot skip. To reach a node at distance 3, the ripple had to pass through a node at distance 2 the moment before. So the first arrival is always the shortest — no back-tracking, no second-guessing. This only holds when every edge counts the same (one hop = one unit). Add weights and the ripple lies; that is precisely the crack Dijkstra fills, two sections down.
Ada Bex Cy Dot Eli Fin hop 0hop 1hop 2hop 3
The ripple, frozen — BFS from Ada assigns every node its hop-distance in expanding rings: 0, then 1 (Bex, Cy), then 2 (Dot, Eli), then 3 (Fin). The colours are the distance. The shortest Ada→Fin route is simply "walk back down the ring numbers".
🕸️
The "six degrees of separation" is BFS
The famous claim that any two people are linked by a short chain of acquaintances is a statement about distances in the friendship graph. When a social network actually measures it, they run BFS. A 2016 Facebook study reported that the average distance between two of its users was about 3.5 hops — the world is a shallower ripple than folklore says.

Now stop reading and run the ripple yourself. Below, BFS spreads across a grid with walls. Press Step and watch the wavefront fan out ring by ring, colouring each cell by its distance, until it reaches the goal — and the shortest path falls out. Then flip to DFS and watch the very same search abandon the rings and plunge into one corridor instead.

InteractiveStep the search — ripple (BFS) vs plunge (DFS) on the same maze
green = start · amber = goal · dark = wall · fill = order/distance
ring 0 · 1 cell
BFS finishes a whole ring before the next — so it reaches the goal in the fewest possible steps (14 here). DFS commits to one path and only backs up when it dead-ends.

You just watched BFS spread and DFS plunge on the same maze. That "plunge" is not a bug — it is a second superpower, and it happens to be one line of code away. →

DFS — the plunge, and the one-line secret it shares with BFS

Depth-first search (DFS) picks a direction and commits: from a node it walks to a neighbour, then a neighbour of that, going as deep as it can before it hits a dead end and backs up to try the last unexplored turn. Where BFS fans out in careful rings, DFS dives to the bottom first. Its natural engine is the stack — last-in-first-out — which is also why DFS is usually written as a plain recursive function: the call stack from Volume 1 is the stack.

Here is the insight that makes graph search click, and it is one of the most satisfying in the whole volume. BFS and DFS are the same algorithm. The only difference is which end of the frontier you take the next node from. Take from the front (a queue) and you get rings — BFS. Take from the back (a stack) and you get a plunge — DFS. One line changes:

python
def traverse(graph, start, use_stack):
    seen = {start}; frontier = deque([start]); order = []
    while frontier:
        u = frontier.pop() if use_stack else frontier.popleft()   # <-- the ONLY difference
        order.append(u)
        for v in graph[u]:
            if v not in seen:
                seen.add(v); frontier.append(v)
    return order

print(traverse(friends, "Ada", False))  # queue -> ['Ada','Bex','Cy','Dot','Eli','Fin']
print(traverse(friends, "Ada", True))   # stack -> ['Ada','Cy','Eli','Fin','Dot','Bex']

Line by line: the setup is identical for both. The whole personality of the search lives in one expression — frontier.pop() (take from the back, LIFO, a stack) versus frontier.popleft() (take from the front, FIFO, a queue). Swap it and nothing else, and the same code produces two utterly different explorations: the queue visits Ada's world layer by layer; the stack shoots straight down one chain to Fin before it ever finishes with Bex. That is the deep unity — search is just "keep a frontier and choose whom to expand next"; the data structure is the strategy.

⚠️
The seen set is not optional
Both searches carry a set of already-visited nodes (seen here, dist in BFS). Delete it and, the instant the graph has a cycle, the search walks A→B→A→B forever — a hang, not a crash, which is worse. A tree has no cycles, so on trees you can skip the set; on a general graph, never. This one missing line is among the most common real-world graph bugs.
↺ The thing people get backwards
DFS is not "a worse BFS that fails to find shortest paths". It is a different tool for different jobs. DFS is how you detect a cycle (if your plunge meets a node still on the current path, there's a loop), how you find connected components (start a fresh DFS from every unvisited node; each run scoops up one island), and how you compute a topological sort. You would never reach for BFS for those. Asking "which is better, BFS or DFS?" is like asking "which is better, a ruler or a compass?" — they answer different questions.

The crown jewel of DFS is topological sort: given tasks with "must-come-before" dependencies (a directed graph), produce an order in which every task appears after everything it depends on. Course prerequisites, spreadsheet cell recalculation, and — most famously — build systems and package managers all live or die by it. Run it on a course catalogue:

python
# prereqs: a course maps to the courses it requires first
print(toposort(prereqs))
# -> ['Intro', 'DataStruct', 'OS', 'Algorithms', 'Databases', 'Compilers']

prereqs["Intro"] = ["Compilers"]   # make Intro depend on Compilers -> a loop
print(toposort(prereqs))           # -> None   (no valid order exists)

The first call returns a legal study plan: Intro before everything, Compilers dead last because it needs both Algorithms and OS. The second call is the important one. Once we make Intro require Compilers — which requires Algorithms, which requires DataStruct, which requires Intro — there is a cycle, and no ordering can satisfy it. The algorithm returns None. That is not a failure; it is the exact error your build tool throws when two packages depend on each other, or your spreadsheet flags a circular reference. Topological sort's ability to say "impossible, and here's why" is why it sits at the heart of make, npm, pip, and every CI pipeline.

Intro DataStr OS Algos DBs Compilers IntroDataStrOSAlgosDBsCompilers flatten the arrows into one legal order →
Topological sort — a tangle of "before/after" arrows collapses into a single line where every prerequisite lands to the left of what needs it. Add one arrow that points backward and the line becomes impossible: that is a dependency cycle.
Wait —
BFS and DFS treat every edge as one hop. But the road home isn't "one hop" — one road is a two-minute side street, another is a forty-minute highway. If the ripple counts hops, how does it ever find the fastest route rather than the one with the fewest turns?

Dijkstra — shortest paths when the edges have a price

The moment edges carry weights, BFS breaks. Its whole guarantee rested on "one hop = one unit", so the first arrival was the nearest. With weights, a path of three cheap edges can beat a path of one expensive edge, and the ripple — which counts hops — walks right past the better route. We need a search that expands by total distance so far, not by hop count. That search is Dijkstra's algorithm.

The idea is greedy (chapter 8): keep a frontier of "reached but not yet finalised" nodes, each tagged with the cheapest total distance found to it so far, and always finalise the globally cheapest one next. Because all weights are positive, once a node is the cheapest on the frontier, nothing discovered later can undercut it — so its distance is locked. To repeatedly pull out the current minimum, Dijkstra leans on a priority queue, a.k.a. a min-heap: treat it as a black box (its internals are a later volume) that hands back the smallest item in O(log n). Watch it route a commute where minutes, not turns, are the currency:

python
import heapq
def dijkstra(graph, start):
    dist = {start: 0}
    pq = [(0, start)]                 # min-heap of (distance_so_far, node)
    settled = {}
    while pq:
        d, u = heapq.heappop(pq)      # the globally CHEAPEST open node
        if u in settled: continue     # already finalised -> skip stale copy
        settled[u] = d                # lock it in: this is its shortest distance
        for v, w in graph[u]:         # relax each outgoing edge
            nd = d + w
            if v not in dist or nd < dist[v]:
                dist[v] = nd
                heapq.heappush(pq, (nd, v))
    return settled

print(dijkstra(roads, "Home"))
# -> {'Home': 0, 'B': 1, 'A': 3, 'C': 4, 'Work': 7}

Line by line: dist holds the best-known tentative distance to each node; pq is the min-heap frontier, seeded with the start at distance 0. Each loop pops the cheapest open node — heappop is the black-box min — and, if it hasn't already been finalised, settles it: its distance is now final and correct. Then it relaxes every outgoing edge: "could I reach v more cheaply by going through u?" If yes, it records the shorter distance and pushes v back onto the heap. The gem is in the output. The direct road Home→A costs 4, but Dijkstra reports A's distance as 3 — because the detour Home→B→A is only 1+2. The greedy frontier found the sneaky cheaper route the eye would skip, and Work comes out at 7 (Home→B→A→C→Work).

4 ✗ (skipped) 1213 Homed=0 Bd=1 Ad=3 Cd=4 Workd=7
Dijkstra's answer, frozen — each node's final shortest distance in green, and the tree of cheapest routes. The eye-catching bit: the direct Home→A road costs 4, but A settles at 3 via the two-hop detour through B, so the direct edge is never used.
InteractiveAdd traffic to one road — watch the route re-decide itself
4
While the direct road costs 3 or less it stays on the route; push it to 4 or more and Dijkstra quietly detours through B, capping the trip at 7 minutes. Your GPS does exactly this, live, when traffic changes.

How costly is all this? Count the work. Every node is settled once, and each settling relaxes its outgoing edges — so across the whole run we touch each edge once, doing an O(log V) heap operation per touch. That is O(E log V) for the edges, plus O(V log V) for the pops, giving Dijkstra's classic O((V + E) log V). On a continent-sized road graph that is the difference between a route in milliseconds and one that never returns.

🛰️
From Dijkstra to your GPS: A*
Pure Dijkstra explores in all directions equally — a growing blob. Your GPS uses A* ("A-star"), which is Dijkstra plus a hint: prefer nodes that are also geographically closer to the destination (straight-line distance as a guess). Same greedy skeleton, same priority queue — just a smarter ordering key that aims the search at the goal instead of spilling everywhere. That one tweak is why routing a cross-country trip is instant.
🛑
Negative weights break Dijkstra — silently
Dijkstra assumes every edge weight is ≥ 0. Feed it a negative edge and it does not crash — it confidently returns a wrong shortest distance, because its "once settled, never cheaper" bet no longer holds. If your weights can go negative (refunds, energy gained, arbitrage), reach for Bellman–Ford instead. A wrong answer that looks right is the most dangerous kind.
The deeper cut — why greedy is allowed to be right, and where it isn't

Dijkstra's correctness rests entirely on one assumption: no negative edge weights. The argument is a clean proof-by-contradiction. When we settle a node u as the cheapest on the frontier at distance d, suppose some cheaper path to u existed. That path must leave the already-settled region through some other frontier node x. But x's tentative distance is ≥ d (we picked u as the minimum), and every remaining edge only adds non-negative weight — so the path through x costs ≥ d too. Contradiction: no cheaper path can exist, so locking u at d is safe. The instant a weight can be negative, that "only adds" step fails — a later negative edge could rescue a path you already dismissed — and Dijkstra can return wrong answers. That is exactly the crack the Bellman–Ford algorithm fills, at the cost of an extra factor of V. Negative weights are not exotic: think currency arbitrage, where an "edge" can pay you.

BFS, DFS, Dijkstra — three questions, three tools, one structure. The last step is the one that changes you as an engineer: learning to see the graph that was hiding inside the problem all along. →

The move that makes you dangerous — model it as a graph first

Step back and look at what just happened across this chapter. A friendship network, a maze, a course catalogue, and a road map are, on the surface, four unrelated things. We solved all four with the same three functions, unchanged, because underneath they were the same object: nodes and edges. That is the real lesson of this chapter, and it is worth more than any single algorithm.

The transferable move — the one the top 1% make almost reflexively — is this: when a new problem lands, before you write a line, ask "what are the nodes, and what are the edges?" The answer is often startling. States of a puzzle are nodes; legal moves are edges; "solve the puzzle" becomes "find a path" — and BFS solves a sliding-tile puzzle for free. Words are nodes; "differ by one letter" is an edge; word-ladder puzzles and spell-check suggestions become shortest paths. Jobs and machines are nodes; "can run on" is an edge; scheduling becomes a matching problem. Half of algorithm design is not inventing a clever method — it is recognising the graph that was there the whole time, at which point a solved, off-the-shelf algorithm takes over.

roads / GPSsocialbuild depsthe web …all the same shape: nodes + edges learn the shape once, solve every domain
One structure under everything — the surface stories differ, but strip the story away and each is dots and lines. The engineer's edge is learning to strip the story on sight.
🧠
The everyday transfer
This isn't only for code. Planning a trip with connecting flights? Graph. Untangling who-reports-to-whom in a reorg? Graph. Figuring out the order to renovate a house — wiring before drywall, floors before furniture? That's a topological sort you do in your head. Once you own "nodes and edges", you start noticing them in meetings, spreadsheets, and to-do lists — and you reason about them with the same clarity you now bring to code.
🏛️
Where these run in the wild — the greatest-hits reprise
GPS & ride-hailing: Dijkstra/A* on road graphs, live. Google: PageRank ran on the web-link graph; the crawler is a BFS/DFS of hyperlinks. Social: friend suggestions and "degrees of separation" are BFS. Dev tools: git's history is a commit graph, and every make/npm/pip/CI build is a topological sort with cycle detection. Compilers: register allocation is graph colouring. Networks: the internet routes packets with shortest-path algorithms on the router graph. You have been surrounded by this chapter your entire computing life.

One human made a piece of this feel inevitable. Edsger Dijkstra later recounted that he worked his shortest-path method out in about twenty minutes in 1956 — in his head, sitting at a café, with no paper and no computer. The lesson isn't the biography; it's the way of thinking it reveals. He didn't picture streets. He pictured the pure structure — nodes, edges, a growing frontier of "cheapest settled so far" — and reasoned about that. When you strip a problem down to its skeleton like that, the answer often stands up on its own. That stripping-down is the skill this whole chapter was really teaching.

We just found the shortest path through a graph in polynomial time — fast, reliable, done. So here is a question that sounds almost identical: instead of the shortest path from Home to Work, find the shortest tour that visits every city and returns — the Travelling Salesman. Change that one word, "path" to "tour", and every fast algorithm we own detonates: the cost curve leaps off the polynomial track and onto the red O(2ⁿ) explosion, where a few dozen cities would outlast the universe.

We found the shortest path in polynomial time — fast, reliable, done. The next chapter switches tools entirely: strings & number theory, the crypto-and-quant layer. How Ctrl-F, git diff and DNA aligners find a pattern without re-reading what they already know — and the one impossibly one-sided calculation that hides behind every padlock icon in your browser. →

, and what the best engineers do when they hit that wall. →

14Strings & number theory — the crypto and quant layer

Two toolkits that punch far above their weight. String search is how spell-check, git diff, DNA aligners and Ctrl-F find a needle in a haystack — and the trick is refusing to re-read what you already know. Number theory is the quiet machinery under every padlock icon in your browser: primes, remainders, and one impossibly one-sided calculation. After this chapter you'll derive why the naive scan is slow and how to fix it, sieve every prime up to a million by pure elimination, raise a number to the millionth power in about twenty multiplications, and see exactly why a TLS handshake is safe.

The naive scan re-reads what it already knows

You want to find a short pattern inside a long text. The obvious method: line the pattern up at position 0, compare character by character; on a mismatch, shift the pattern one step right and start comparing again from scratch. It works. Let's count what it costs — because counting operations, not guessing, is the whole game (chapter 0).

Let the text be length n and the pattern length m. At each of the n − m + 1 starting positions we might compare up to m characters. Multiply: the worst case is (n − m + 1) · m comparisons — that's O(n·m). The friendly case looks fine — searching "cat" in "the cat sat on the mat" takes just 22 comparisons, because most alignments fail on the very first letter. The trap is text that almost matches everywhere.

python
def naive_search(text, pat):
    n, m = len(text), len(pat)
    comps = 0
    for i in range(n - m + 1):        # every start position
        j = 0
        while j < m:                  # compare pattern left to right
            comps += 1
            if text[i+j] != pat[j]:
                break                 # mismatch: give up, shift by 1
            j += 1
    return comps

print(naive_search("aaaaaaaaaaaaaaaaaaaab", "aaaab"))  # 85
print(naive_search("the cat sat on the mat", "cat"))    # 22

Line 4 walks every start position; line 6 compares until a mismatch. The pathological string "a"×20 + "b" against "aaaab" costs 85 comparisons (verified) — at each of 17 positions it matches four as, then fails on the b, and throws all four matches away. Push it to a 10,001-character run with a length-100 pattern and the count explodes to 990,200. The waste has a name, and naming it is the engineer's move (chapter 4): we keep re-reading text characters we already looked at.

text: a a a a a a b shift 0 a a a aa 4 matches, then ✗ — throw them away shift 1 a a a aa re-reads the SAME a's again ✗ shift 2 a a a aa …and again. This is the wasted work. Each green run was already known — the naive scan forgets it and re-reads.
Fig — After a mismatch the naive scan shifts by one and re-compares characters it just matched. Those repeated green reads are the O(n·m) blow-up.

If we already matched four as, we know the next window starts with three as. Why look again →

KMP: never re-examine a matched character

Here is the insight that Knuth, Morris and Pratt turned into an algorithm around 1970 — and you can feel it without the machinery. When the pattern aaaab matches four as and fails on the b, those four matched characters are not garbage — they are information. They tell you the text here is aaaa…, so the next plausible alignment already has three as satisfied. There is no reason to walk the text pointer backwards. Ever.

KMP precomputes, from the pattern alone, a small table: "if I fail after matching k characters, how many are still guaranteed to match at the next useful shift?" That table lets the text pointer march forward and never retreat. Each text character is looked at essentially once, so the whole search is O(n + m) — the n to scan the text plus the m to build the table. Linear. The nested loop collapses into a single pass.

python
# same haystack and needle as before, counting TEXT-character reads
naive : 85 reads     KMP : 21 reads     (text length = 21)
naive : 990,200      KMP : 10,001       (text length = 10,001)

# the pattern's "memory" table (how much prefix survives a mismatch)
lps("aaaab") == [0, 1, 2, 3, 0]
lps("abcab") == [0, 0, 0, 1, 2]

Those numbers are measured, not asserted. KMP reads each of the 21 text characters exactly once; the naive scan read 85. On the big string it is 10,001 versus 990,200 — a gap that widens forever as the text grows. The lps table is the pattern's self-knowledge: for aaaab, after matching aaaa we can keep 3 characters on the next shift; that [0,1,2,3,0] is the "don't look back" rule, baked in ahead of time.

text pointer only ever moves →, never ← a a a a a a a b mismatch here naive: shift +1, re-read matched chars KMP: shift by the table amount, keep the guaranteed prefix, text pointer stays put
Fig — On a mismatch, naive nudges the pattern one step and re-reads; KMP jumps the pattern by the table amount and keeps the text pointer moving forward. One pass, O(n + m).
InteractiveGrow the haystack — watch the two curves separate
naive O(n·m) KMP O(n) pattern fixed at m = 5, worst-case text ("aaaa…ab")
21
Naive work grows with n·m; KMP work grows with n. Same answer, wildly different bill.
Wait —
KMP builds a whole table just for the pattern. Is there a way to skip that and still slide over the text in one pass — using the hashing trick from chapter 6?

Rabin-Karp: slide a hash over the text

Different idea, same enemy (re-work). Instead of comparing characters, fingerprint them. Turn every length-m chunk of text into a single number — a hash (chapter 6) — and turn the pattern into a number too. If two numbers differ, the chunks differ; you skip the comparison entirely. The catch: computing a fresh hash for every window would itself be O(m) per window, O(n·m) total — no win. The move that saves it is a rolling hash: when the window slides one step, don't recompute — update. Subtract the character leaving the left, shift, add the character entering the right. That's O(1) per slide, O(n) for the whole text.

Think of the window's hash as a number written in base 256 (one digit per byte). Sliding right is exactly like turning 734 into 345: drop the leading digit's place value, multiply by the base, add the new trailing digit — all modulo a big prime so the number stays small.

python
BASE, MOD = 256, 1_000_000_007
high = pow(BASE, m-1, MOD)             # place value of the leftmost char
h = hash_of(text[:m])                  # hash of the first window
# slide from window i to i+1 in O(1):
h = ((h - ord(text[i])*high) * BASE + ord(text[i+m])) % MOD
#     - drop the leaving char --  -shift-  - add the entering char -

Ran on "the cat sat on the mat" searching "cat": the pattern hashes to 6513012, the window slides 19 times, and — this is the honesty check — every single rolling update was compared against a hash computed from scratch and matched exactly. The match is reported at index 4, the same spot the naive scan found. Where a hash collides (two different chunks, same fingerprint), you fall back to a direct character check, so a collision costs a little time but never a wrong answer.

t h e c a t s a t window i → hash H window i+1 in O(1) − (leaving char × baseᵐ⁻¹) × base (shift everyone up) + (entering char) all mod a big prime → number stays small
Fig — The window's hash is a base-256 number; sliding it right is drop-a-digit, shift, add-a-digit — three arithmetic ops, independent of the window length.
↺ The thing people get backwards
People assume a hash collision ruins Rabin-Karp. It doesn't. A matching fingerprint is only a candidate — you confirm it with a real character comparison before declaring a hit. Collisions cost occasional wasted checks, never correctness. The fingerprint is a fast filter, not the verdict.
🌍
Where you meet this
Rolling hashes power rsync and Dropbox-style deduplication (fingerprint file blocks to find what changed), plagiarism and near-duplicate detectors, and content-addressed storage. The same "slide a summary, update in O(1)" idea is the sliding window of chapter 7 — hashing just makes the summary a single number.

String search leaned on hashing and modular arithmetic — mod a big prime. That "mod" is the doorway to number theory, and it starts with the oldest algorithm still in daily use →

Euclid and clock arithmetic

Modular arithmetic is clock math: on a 12-hour clock, 10 + 5 = 3, because you wrap around after 12. Write it 15 mod 12 = 3. That wrap-around is the entire foundation of what follows — remainders, not full numbers, are what cryptography computes with, because they stay small and they hide information.

The greatest common divisor (GCD) of two numbers is the largest number dividing both. The naive way — test every candidate down from the smaller number — is O(min(a,b)). Euclid's insight, over two thousand years old and still unbeaten, is one line: any divisor of a and b also divides their remainder a mod b. So replace the bigger number with that remainder and repeat; the numbers shrink fast, and when one hits zero the other is your answer. You stepped this in trace T33 — here's why it's fast.

python
def gcd(a, b):
    while b:                 # until nothing is left over
        a, b = b, a % b      # shrink: replace (a,b) with (b, remainder)
    return a

gcd(1071, 462)      # 21, reached in 3 steps
gcd(832040, 514229) # 1,  worst case: 28 steps (consecutive Fibonacci numbers)

Line 3 is the whole algorithm: each step throws away the larger number entirely and keeps only the remainder. How fast do the numbers shrink? Each two steps at least halves the larger value, so the step count is O(log(min(a,b))) — logarithmic, like binary search (chapter 1). The measured worst case is telling: feed it two consecutive Fibonacci numbers and every step peels off exactly one — gcd(832040, 514229) takes 28 steps. Fibonacci is the slowest possible input, and even that is tiny. Python's built-in math.gcd runs this same idea.

Euclid: keep the remainder, drop the rest (1071, 462) (462, 147) (147, 21) (21, 0) → gcd = 21 1071 mod 462 = 147 462 mod 147 = 21 147 mod 21 = 0 12 3 6 9 15 mod 12 = 3
Fig — Euclid shrinks a pair to its GCD in a logarithmic number of remainder steps; modular arithmetic is the clock that wraps every result back into a small range.

Remainders let us tame huge numbers. The next tool uses nothing but a wrap-around and a crossing-out pencil to find every prime there is →

The Sieve of Eratosthenes — primes from pure elimination

A prime is a number with no divisors but 1 and itself — the atoms of arithmetic, and the raw material of crypto. To find every prime up to n, you could test each number for primality one at a time. Eratosthenes, a librarian in ancient Alexandria, had a lazier and faster idea: don't hunt for primes — eliminate non-primes. Write every number from 2 to n. Take 2, keep it, and cross out every multiple of 2. Move to the next number still standing (3), keep it, cross out every multiple of 3. The next survivor (5), same. Whatever is never crossed out is prime, because a composite number is by definition a multiple of some smaller prime.

python
def sieve(n):
    is_prime = [True] * (n + 1)
    is_prime[0] = is_prime[1] = False
    p = 2
    while p * p <= n:                 # stop at √n — see below
        if is_prime[p]:
            for k in range(p*p, n+1, p):  # cross out p², p²+p, p²+2p, …
                is_prime[k] = False
        p += 1
    return [i for i in range(n+1) if is_prime[i]]

sieve(30)   # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

Two small optimizations carry real weight. Line 5 stops at √n: any composite ≤ n has a factor no larger than its square root, so once you've sieved with primes up to √n, everything left is prime. Line 7 starts crossing at , not 2p: smaller multiples of p (like 2p, 3p) were already crossed by smaller primes. The cost is startling — O(n log log n), which grows so slowly that log log n is under 4 for any n you'll ever sieve. Measured: π(1,000,000) = 78,498 primes, found in one sweep.

why start crossing at p², and why stop at √n (example n = 30, √30 ≈ 5.5) p = 2crosses 4, 6, 8, 10 … (starts at 2² = 4) p = 3crosses 9, 12, 15 … (starts at 3² = 9; 6 already gone) p = 5crosses 25, 30 (starts at 5² = 25) p = 77² = 49 > 30 → STOP. Everything still standing is prime. √n boundary
Fig — Each prime p begins striking at (everything below was struck by a smaller prime), and the whole sweep ends once p² > n. Those two cuts are what make the sieve near-linear.
InteractiveSlide n — watch the primes survive the cull
prime (survives) ×2 ×3 ×5 ×7+
60
Every composite is crossed by its smallest prime factor. What's left uncrossed is, by definition, prime.
The deeper cut — why log log n?
Crossing out every multiple of a prime p costs about n/p writes. Summing over the primes up to n gives n · (1/2 + 1/3 + 1/5 + 1/7 + …). That sum of reciprocals of the primes is a famous result — it grows like ln ln n. So total work ≈ n · ln ln n, i.e. O(n log log n). The log log n factor is why the sieve feels essentially linear: for n = 10⁹, ln ln n ≈ 3.03.
Wait —
crypto needs primes with hundreds of digits. You can't sieve up to a 300-digit number. So how do we ever compute with numbers that big — like raising one to the power of another?

Fast modular exponentiation — the engine of crypto

Cryptography constantly computes a^b mod m where a, b, and m are numbers hundreds of digits long. Compute a^b the obvious way — multiply a by itself b times — and you'd need b − 1 multiplications, and the intermediate number would have billions of digits. Both are fatal. Two ideas fix it. First, reduce mod m after every multiply, so the number never grows past m. Second, square, don't multiply one-by-one — the divide-and-conquer trick from chapter 5. To get a^13, write 13 in binary as 1101: a^13 = a^8 · a^4 · a^1. You get a, a², a⁴, a⁸ by repeated squaring — one multiply each — and combine the ones whose bit is set.

python
def modpow(a, b, m):
    result = 1
    a %= m
    while b > 0:
        if b & 1:                     # this bit of the exponent is set
            result = (result * a) % m # …so fold a's current power in
        a = (a * a) % m               # square: a → a² → a⁴ → a⁸ …
        b >>= 1                        # drop that bit
    return result

modpow(7, 13, 11)          # 2       — matches Python's built-in pow(7,13,11)
modpow(2, 1_000_000, 1_000_000_007)   # done in ~20 squarings, not a million

The loop runs once per bit of the exponent, so it does about log₂(b) squarings — O(log b). Measured: 7^13 mod 11 takes 4 squarings and 3 multiplies (7 operations) instead of 12, and every result matches Python's built-in pow(a, b, m), which is this algorithm. The showstopper: 2^1,000,000 mod (10⁹+7) — a naive approach wants 999,999 multiplications; this does 20 squarings and 7 multiplies. Twenty-seven operations to raise 2 to the millionth power. That single collapse from a million to twenty is what makes public-key cryptography physically possible.

13 in binary = 1101 → a¹³ = a⁸ · a⁴ · a¹ a a⁴ a⁸ square square square 1101 ← bits of 13 (from a⁸ down to a¹) green bit set → multiply this power into the result result = a⁸ · a⁴ · a¹ = a¹³ (4 squarings, 3 multiplies)
Fig — Repeated squaring builds a, a², a⁴, a⁸ in four steps; the binary digits of the exponent say which of those powers to multiply together. That's why the work is O(log b), not O(b).
InteractiveCrank the exponent — count the squarings
multiplications to compute a^b mod m naive (b−1): 0 fast (≈log₂b): 0 exponent b in binary: 1
13
One step per bit. Doubling b adds a single squaring — that's what O(log b) feels like.

Fast modular power plus big primes is almost the whole recipe for the padlock in your address bar. Time to assemble it →

RSA: why big primes keep your secrets

Here's the asymmetry everything rests on. Multiplying two large primes is trivial — p × q = n, instant. Going backwards — given only n, recover p and q — has no known fast method; the best algorithms take astronomically long for numbers a few hundred digits wide. A one-way street: easy forward, effectively impossible back. That gap is a trapdoor, and RSA is built on it. First you need big primes, and here trial division dies: checking a 19-digit number like 2⁶¹−1 took about 70 seconds on this machine, while the probabilistic Miller-Rabin test — which asks a few clever "if this were prime, this equation would hold" questions using fast modular power — answered in roughly a ten-thousandth of a second (a gap of order a half-million-fold here; timings vary by machine). Miller-Rabin says "almost certainly prime," and you can drive the error probability below the chance of a cosmic ray flipping your answer. You'll meet the randomness behind it in chapter 15.

python
p, q = 61, 53                 # (real RSA uses ~300-digit primes)
n   = p * q                   # 3233   — public
phi = (p-1) * (q-1)           # 3120   — secret, needs p and q
e   = 17                      # public exponent
d   = pow(e, -1, phi)         # 2753   — private key (modular inverse of e)

c = pow(65, e, n)             # encrypt message 65 → 2790   (public key)
m = pow(c, d, n)              # decrypt 2790      → 65     (private key)

Line by line: n = 3233 is public and e = 17 is public — together they're the padlock anyone can snap shut. The private key d = 2753 is the modular inverse of e (the number that undoes it, mod φ), and computing φ = 3120 requires knowing p and q. Encrypting is one fast modular power: 65^17 mod 3233 = 2790. Decrypting is another: 2790^2753 mod 3233 = 65 — the original, back exactly (verified). An attacker sees n, e, and the ciphertext 2790, but to get d they must factor n — and that's the wall. Every ingredient is from this chapter: big primes (Miller-Rabin), the encrypt/decrypt operations (fast modular power), and the key (modular inverse via Euclid).

p = 61 q = 53 multiply easy → n = 3233 ← factor back: infeasible (no known fast algorithm) PUBLIC: n, e🔒 anyone can encrypt PRIVATE: d🔑 only you decrypt
Fig — Two primes multiply forward in an instant; the product resists factoring back. That one-way trapdoor splits into a public lock everyone can use and a private key only you hold.

Myth

Encryption is secure because the algorithm is a closely-guarded secret.

Reality

RSA's algorithm is fully public and printed in textbooks. Only the key — the two primes — is secret. Security lives in the hardness of factoring, not in hiding the method (Kerckhoffs's principle).
🌍
Where you meet this
Every https:// and the padlock in your browser: the TLS handshake uses public-key crypto (RSA or its elliptic-curve cousins) to agree on a secret key. Every Bitcoin and blockchain signature, SSH logins, signed software updates, encrypted messaging — all standing on big primes and fast modular power. The quant/CS cousin of this arithmetic shows up in hashing, pseudo-random generators (a linear congruential generator is just x → (a·x + c) mod m), and competitive programming, where modular exponentiation and sieves are everyday reflexes.
⚠️
Hard, not impossible
Factoring is believed hard — it has never been proven so, and a large enough quantum computer running Shor's algorithm would factor n efficiently and break RSA. "Secure" here means "no one has found a fast way yet," which is why the field is already moving to post-quantum schemes. Honest engineering names its assumptions.
🧠
The 1% mental move
Both halves of this chapter are the same reflex: look for the asymmetry. In string search, matching is one-directional information — a matched prefix constrains the future, so never look back. In crypto, multiplication is one-directional work — easy forward, brutal backward, so build your secret on the return trip. When you meet a new problem, ask: is there a direction that's cheap and a reverse that's expensive? Can I reuse what I've already computed instead of redoing it? Both KMP and RSA were invented by people who refused to pay twice for the same information.

Miller-Rabin only worked by flipping a coin — it trusts randomness to be almost-certainly right, fast. That's not a hack; it's a whole design philosophy. Chapter 15 makes randomness your armor: a random pivot that defeats any adversary, and estimating answers you can't compute by throwing darts →

15Randomization & the probabilistic mind

Every chapter so far made choices deterministic — the pivot, the hash, the next node. Now flip one coin. This chapter shows why deliberately adding randomness makes an algorithm faster, safer, and able to answer questions no exact method can touch. After it you'll be able to armor a sort against its own worst case, compute a number like π by throwing darts, price a financial option by simulating the future, and draw a perfectly fair sample from a stream far too big to store.

Every fixed plan has a nemesis

A deterministic algorithm always makes the same choice on the same input. Usually that's a virtue. But it hides a nasty consequence: the algorithm has a fixed worst case — one specific arrangement of input that drives it to its slowest possible behavior. And if someone can read your code — it's open-source, or a public API, or a well-known library — they can construct that exact arrangement and feed it to you on purpose.

Quicksort is the classic victim. Tell it "always pick the first element as the pivot," then hand it an already-sorted list. Every partition peels off just one element, the recursion sinks n levels deep, and the tidy O(n log n) collapses into O(n²). (You stepped quicksort's partition in trace T28 — here we attack it.) That's not a thought experiment; it's a denial-of-service. Put a naive quicksort behind a web endpoint and an attacker who sends sorted data can pin your CPU with a tiny request.

The escape is not a cleverer fixed rule — the adversary just studies the new rule and defeats it too. The escape is to stop being predictable. If the algorithm flips a coin to make its choices, there is no single input that is reliably its worst case, because the worst case now depends on coins the attacker cannot see.

Deterministic — predictable pivot = first element adversary reads the rule… feeds a sorted list O(n²) — every time Randomized — unaimable pivot = 🎲 random adversary reads the rule… and has nothing to aim at O(n log n) — expected
Fig — Determinism is a fixed target. The attacker who knows your rule can always find your worst input — unless your choices depend on coins they can't predict.
🚫
Algorithmic-complexity attacks are real
Sorted-input quicksort, hash flooding (chapter 6), and catastrophic regex backtracking (chapter 10) are all the same exploit: force a deterministic algorithm into its worst case on purpose. Randomization is a standard defense against every one of them.

So how much does a single coin flip actually buy? Let's stop asserting and start counting →

Randomness is armor — the randomized pivot

Count the fixed-pivot disaster first. Partitioning a block of m elements costs m−1 comparisons. If every pivot is the extreme value — exactly what a sorted list guarantees — the block sizes march down n−1, n−2, …, 1, and the total is (n−1)+(n−2)+…+1 = n(n−1)/2. That's the textbook O(n²), and it is not approximate: I ran a comparison-counting quicksort on a sorted list of n = 2000, and the fixed-pivot version reported 1,999,000 comparisons — precisely n(n−1)/2, to the comparison.

Now make the pivot random. Each pivot is equally likely to be any rank, so partitions are balanced on average, and the expected number of comparisons works out to about 2n ln n (≈ 1.39·n·log₂n) — squarely the O(n log n) class. Same sorted input, random pivot: about 24,600 comparisons, averaged over twenty runs. Roughly 80× fewer — on the very input that murdered the deterministic version.

The deep move: we didn't change the input, we changed who chooses. The chance that a random shuffle happens to land on any one specific bad arrangement is 1/n!. For a mere 20 items that's 1 in 2.4×10¹⁸; for a shuffled deck of 52 cards it's 1 in 8×10⁶⁷ — more arrangements than there are atoms in the galaxy. The worst case didn't vanish. It became something you will not witness before the heat death of the universe.

python
def quicksort_count(a, pivot="first"):
    a = list(a); comps = 0; stack = [(0, len(a)-1)]
    while stack:
        lo, hi = stack.pop()
        if lo >= hi: continue
        if pivot == "random":                 # ← the one line that armors it
            p = random.randint(lo, hi); a[p], a[hi] = a[hi], a[p]
        pv = a[hi]; i = lo
        for j in range(lo, hi):
            comps += 1                         # count every key comparison
            if a[j] < pv:
                a[i], a[j] = a[j], a[i]; i += 1
        a[i], a[hi] = a[hi], a[i]
        stack.append((lo, i-1)); stack.append((i+1, hi))
    return comps
# sorted input, n=2000:  fixed → 1,999,000   random → ~24,600  (≈80× fewer)

Line by line: we sort a copy with an explicit stack instead of recursion, so the sorted worst case can't overflow Python's call stack. The if pivot == "random" line swaps a randomly chosen element into the pivot slot — that's the entire defense. The inner for loop is the partition, and comps += 1 tallies each key comparison so we measure cost directly instead of trusting a formula. Everything else is ordinary Lomuto partitioning (stepped in T28).

fixed pivot, sorted input depth = n n(n−1)/2 compares random pivot depth ≈ log₂ n ≈ 2n ln n compares Same elements. The pivot choice alone decides whether the tree is a stick or a bush.
Fig — The cost of quicksort is the shape of its recursion tree. A fixed pivot on sorted data grows a one-sided stick (n levels); a random pivot grows a balanced bush (log n levels).
InteractiveSlide n — watch the worst case pull away from the expected one
fixed pivot 0 random pivot 0 bar length is log-scaled; the numbers are the real comparison counts
800
The gap between the two bars is the adversary's power — and randomizing the pivot gives it away.
The deeper cut — where 2n ln n comes from

Number the sorted values 1..n. Two values of rank i and j (with i<j) are compared only if one of them is chosen as a pivot before any value ranked strictly between them is — because that in-between pivot would split i and j into different partitions, and they'd never meet again. Among the j−i+1 values in that rank window, each is equally likely to be picked first, so the probability that it's i or j is 2/(j−i+1). Sum that expected indicator over all pairs and you get Σ 2/(j−i+1) ≈ 2(n+1)H_n − 4n ≈ 2n ln n, where H_n is the harmonic number. My measured 24,593 for n=2000 sits right on that curve (the leading 2n ln n ≈ 30,400 minus the O(n) correction). No worst-case input exists because the cost is an average over the coins, not over the data.

↺ The thing people get backwards
Most people assume a random pivot makes quicksort faster on average than a good deterministic pivot. It doesn't — on random data, a plain median-of-three does just as well. What randomization buys is not speed, it's robustness: it strips the adversary of the power to choose your input. You trade a deterministic guarantee for a probabilistic one that no input can break. Randomness here is armor, not a turbocharger.
🌍
Where you meet this
Python's own sort is Timsort (chapter 11), not quicksort — but C-library qsort implementations, many database sort routines, and countless others randomize the pivot for exactly this reason. Python also randomizes string hashing per process (PYTHONHASHSEED) to defeat hash-flooding DoS, and "power of two random choices" load balancers spread traffic evenly by picking two servers at random and taking the emptier — randomness as armor, everywhere.

Armor is one use of a coin. The other is stranger: using randomness to compute a number you can't otherwise reach

Compute by throwing darts — Monte Carlo

Now turn the idea inside out. Instead of using randomness to protect an exact algorithm, use it to estimate an answer that's hard to compute directly. The technique is called Monte Carlo — after the casino — and its motto is: to learn about a system, sample random outcomes from it and count.

The cleanest demonstration computes π. Draw a 1×1 square, and inside it the quarter of a circle of radius 1 centered at one corner. The square's area is 1; the quarter-circle's area is (1/4)·π·r² = π/4. Throw darts uniformly at random into the square. The fraction that land inside the quarter circle must, on average, equal the ratio of the areas — π/4. So π ≈ 4 × (darts inside) / (total darts). A dart at (x, y) is inside exactly when x² + y² ≤ 1 — one multiply, one compare, no trigonometry, and the symbol π appears nowhere in the code. You measure it by counting.

python
def estimate_pi(n):
    inside = 0
    for _ in range(n):
        x, y = random.random(), random.random()   # a dart in the unit square
        if x*x + y*y <= 1.0:                        # inside the quarter circle?
            inside += 1
    return 4 * inside / n                           # fraction inside ≈ π/4

#      n         estimate      error
#     100        3.120000     0.021593
#    1000        3.128000     0.013593
#   10000        3.126000     0.015593   ← note: WORSE than n=1000!
#  100000        3.137280     0.004313
# 1000000        3.140592     0.001001
# 10000000       3.142310     0.000717

Four lines do it. random.random() draws a coordinate in [0,1), so (x, y) is a uniform dart. The one comparison x*x + y*y <= 1.0 asks "is it inside?", inside tallies the hits, and 4 * inside / n turns the ratio into π. The table is a real run — and look closely: at n = 10,000 the estimate was actually worse than at n = 1,000. That's not a bug. A single random run can get lucky or unlucky. What improves reliably is never one run — it's the typical error, and pinning that down is the next section.

inside outside π ≈ 4 · (green / total) area ratio = π/4
Fig — No formula for π anywhere — just darts and a count. The geometry does the math: the fraction landing in the quarter circle is π/4.
InteractiveThrow more darts — watch π assemble itself out of noise
darts thrown 500 estimate of π 3.1416 error vs true π 0.0000
500
You just computed π with no formula — only counting. More darts, tighter answer (drag it back and forth: the noise is real).
Wait —
a million darts only bought me three correct digits. How many would I need for six?

The √N law — and pricing the future

Here's the law that governs every Monte Carlo estimate, and it's worth burning into memory: the typical error shrinks like 1/√N, where N is the number of samples. I measured it directly. Run the π estimator 200 times at N = 100 and the estimates scatter with a standard deviation of about 0.165. Quadruple to N = 400 and the scatter halves to 0.081. Quadruple again to 1600 → 0.039; again to 6400 → 0.020. Every ×4 in samples halves the error — the fingerprint of 1/√N.

Flip that around and it stings: to win one more correct decimal digit — 10× smaller error — you need 100× more samples. Monte Carlo hands you the first digits cheaply and charges brutally for the last ones. Why 1/√N? Each sample is an independent draw, and averaging N independent draws is exactly the setup where the spread of the average narrows as 1/√N — the same statistics that make a poll of 1,000 people accurate to a few percent, not a few thousandths. It isn't special to π; it's the signature of averaging independent randomness.

10²1 digit 10⁴2 digits 10⁶3 digits 10⁸4 digits 10¹⁰5 digits 10¹²6 digits bar height is log-scaled — each step right is a 100× taller sample budget
Fig — The cruel arithmetic of 1/√N: every extra correct digit multiplies the required samples by 100. Three digits is a million darts; six digits is a trillion.
InteractiveSlide the sample budget — feel the diminishing returns
samples N (log scale) → typical error → N = 10,000 typical error ≈ 0.016 one more digit → ×100 samples
10,000
The curve is 1.6/√N (the constant measured from the real π run) — steep at first, then agonizingly flat.

And this is exactly why Wall Street runs on Monte Carlo. The fair price of a financial option is an expected value — the average payoff across every possible future path of the underlying stock, discounted back to today. For the simplest options there's a closed-form answer (the Black–Scholes formula). For the messy real ones — path-dependent, many assets, exotic payoffs — the integral has no formula at all. So you don't solve it; you sample it: simulate thousands of random futures, compute the payoff in each, and average. I priced a European call both ways. Black–Scholes says 10.4506. Monte Carlo, averaging simulated futures: 1,000 paths → 11.02, 100,000 paths → 10.49, a million paths → 10.4458 — the √N law crawling toward the true price. Risk desks simulate millions of paths every night to answer "how much could we lose tomorrow?" That is expected-value thinking made computational: when you cannot reason about the average, sample it.

python
def mc_call(n, S0, K, r, sigma, T):
    drift = (r - 0.5*sigma**2)*T
    vol   = sigma*(T**0.5)
    total = 0.0
    for _ in range(n):
        z  = random.gauss(0, 1)               # one random shock
        ST = S0*math.exp(drift + vol*z)        # one simulated future price
        total += max(ST - K, 0.0)              # the call's payoff on that path
    return math.exp(-r*T) * total/n            # discounted average payoff
# Black–Scholes = 10.4506;  mc_call(1_000_000) → 10.4458

The loop is the whole idea: random.gauss(0, 1) draws one random "shock", S0*exp(drift + vol·z) turns it into one possible price at expiry, and max(ST − K, 0) is what the call pays on that future. Average over a million futures, discount to today, and you've evaluated an integral no one wrote down — by sampling it.

✗ The myth

"More samples always means a better answer, so if it's not accurate enough, just crank N higher."

✓ The reality

Error falls only as 1/√N — the tenth digit would cost 10¹⁸ samples. Past a point you stop brute-forcing and switch to variance reduction (antithetic variates, control variates, low-discrepancy sequences), which shrinks the constant in front of the 1/√N instead.

🌍
Where you meet this
Every path-traced movie frame and every game's global illumination is Monte Carlo integration over light rays — the grainy "noise" in a half-rendered image is the 1/√N error, and it clears as more rays are cast. The method was born at Los Alamos in the 1940s for neutron diffusion; today it powers physics simulation, A/B testing, Bayesian inference (MCMC), and — everywhere — finance.
⚙️
Why the machine loves it
Monte Carlo is embarrassingly parallel: every sample is independent, so you split N draws across cores — or thousands of GPU lanes — with zero locking or coordination (recall the GIL tradeoffs from Volume 2). That's why render farms and risk grids throw enormous core counts at it, and the 1/√N error falls in wall-clock time roughly in proportion to the hardware you own.

Quicksort's coins and π's darts gamble in opposite directions. Naming that difference is the whole taxonomy of randomized algorithms →

Two ways to gamble — Las Vegas vs Monte Carlo

Look at what the coins actually put at risk. Randomized quicksort always returns a correctly sorted list — the coins only affect how long it takes; a bad streak makes it slower, never wrong. The π estimator is the mirror image: it always finishes in exactly N steps, but the answer is only approximate — a bad streak makes it wrong-ish, never slow. Those are the two families of randomized algorithm, and knowing which one you're holding tells you exactly what can go wrong.

Las VegasMonte Carlo
Answeralways correct ✓probably correct / approximate
Running timerandom (usually fast)fixed & bounded
You bet…timecorrectness
Examplesrandomized quicksort, quickselectπ by darts, option pricing, Miller–Rabin
Las Vegas answer: LOCKED ✓ time: 🎲 varies keep rolling until right — correctness is never at risk Monte Carlo answer: 🎲 varies time: LOCKED stop on a clock — accept a tiny chance of error
Fig — Each family locks one thing and lets the coin move the other. Las Vegas bets time to guarantee the answer; Monte Carlo bets the answer to guarantee the time.

The beautiful bridge between them: if you can check an answer cheaply, you can convert Monte Carlo into Las Vegas. Guess with a fast randomized method, verify the guess; if it's wrong, guess again. The verify step turns "probably right" into "definitely right, eventually" — you've traded a small error probability for a small extra runtime.

The deeper cut — tunable doubt (Miller–Rabin)

Chapter 14's Miller–Rabin primality test is Monte Carlo with a dial. For a composite number, a single random "witness" exposes it as composite with probability at least 3/4 — so a prime-looking number could be a fraud with probability at most 1/4 after one test. Run k independent tests and the chance of being fooled every time drops to at most (1/4)ᵏ = 4⁻ᵏ. At k = 30 that's under 10⁻¹⁸ — you're likelier to be struck by a cosmic-ray bit-flip than to certify a composite as prime. You don't wait for certainty; you buy as much of it as you want, one cheap random test at a time.

Wait —
can I take a perfectly fair random sample from a stream when I don't even know how long it is — and can't store it?

A fair sample from an endless stream — reservoir sampling

The problem sounds impossible. You're watching a firehose — every tweet, every log line, every sensor reading — and you want to keep a uniformly random sample of, say, one item, such that when the stream finally ends, every item that ever flowed past had an equal chance of being the one you kept. The catch: you don't know how long the stream is (it could be billions of items, or never end), you get exactly one pass, and you have O(1) memory — room for the one item, nothing more.

The solution is one line of genius. Reservoir sampling: keep the first item. When the i-th item arrives, replace whatever you're holding with it with probability exactly 1/i. That's the entire algorithm — and notice that n, the stream's length, never appears.

python
def reservoir_one(stream):
    kept = None
    for i, item in enumerate(stream, start=1):   # i = 1, 2, 3, ...
        if random.random() < 1.0 / i:            # newest barges in w.p. 1/i
            kept = item
    return kept
# length-10 stream, 1,000,000 trials — how often each item was the survivor:
#   item 0: 10.00%   item 1: 10.04%   item 2:  9.98%   item 3:  9.99%
#   item 4:  9.97%   item 5: 10.00%   item 6: 10.06%   item 7:  9.98%  ...
# every item ≈ 1/10, dead fair — with room for exactly one item in memory

The loop reads one item at a time. At position i, random.random() < 1.0/i is true with probability 1/i — so item 1 is kept for sure, item 2 has a 1-in-2 shot at bumping it, item 3 a 1-in-3 shot, and so on. The comment is a real million-trial run: every one of the ten items ended up the survivor almost exactly a tenth of the time, deviating by at most 0.06 percentage points. Perfectly fair, in one pass, in constant memory.

the stream flows past → #11/1 #21/2 #31/3 #41/4 #i1/i reservoir holds ONE item (1/i)·(i/(i+1))·((i+1)/(i+2))···((n−1)/n) = 1/n the product telescopes — every numerator cancels the previous denominator
Fig — Why it's fair: an item kept at step i must then survive every later replacement. Multiply the survival chances and the whole product collapses to exactly 1/n — the same for the first item and the last.

Here's the magic, worked out. Consider item i. It gets kept at step i with probability 1/i. To still be the survivor at the end, it must then not be replaced by items i+1, i+2, …, n. Item j replaces with probability 1/j, so it fails to replace with probability (j−1)/j. Multiply the whole chain of survival:

(1/i) · (i/(i+1)) · ((i+1)/(i+2)) · … · ((n−1)/n) = 1/n

and it telescopes — every numerator cancels the denominator before it — leaving exactly 1/n. Every item, whether it arrived first or billionth, ends with probability 1/n. The math is fair by construction, and n is never needed.

InteractiveWalk down the stream — watch the newcomer's odds shrink
item #i arrives and barges into the reservoir with probability 1/i: 100% i = 1 → 1/1 = 100.0% …and after the algebra, every one of the i items seen so far is holding an equal 1/i share. each item so far: 1/1 = 100.0%
1
The newcomer's odds fall as 1/i — and that's exactly what keeps the whole sample fair.
🌍
Where you meet this
"A random sample of all tweets," Spark's and BigQuery's TABLESAMPLE, random log sampling for observability dashboards, and picking a random line from a file too big for RAM — all reservoir sampling. Any time a system shows you "a random selection" from something it can't hold all at once, this one-pass 1/i trick is very likely running underneath.
The deeper cut — keeping k items (Algorithm R)

For a sample of k items, fill the reservoir with the first k. Then for the i-th item (0-indexed, i ≥ k), pick a random slot j in 0..i; if j < k, overwrite res[j] with the new item. The same telescoping argument gives every item a final probability of exactly k/n. I ran it — k = 5 from a stream of 100, 200,000 trials — and every item appeared as a chosen one about 10,000 times out of 200,000, i.e. k/n = 5% each, flat across all 100. One pass, O(k) memory, provably uniform.

Step back and see the shape of this whole chapter. Randomness bought three different things: armor (no adversary can aim at a random pivot), computation (sample the darts, the futures, the light rays — and average your way to an answer no formula gives), and fairness under ignorance (a uniform sample without ever knowing how much there is). The transferable move — the one the 1% run on reflex — is this: when being predictable is your weakness, add a coin; when the exact answer is out of reach, estimate it by trying many random cases and averaging; and when you want a property like fairness, work backwards from the property to the rule that produces it. Stanislaw Ulam invented Monte Carlo not by out-computing the neutron equations but by asking a humbler question — recovering from illness and playing solitaire, he wondered about his odds of winning, and realized that instead of grinding through the combinatorics he could just deal many random hands and count. That question — "what if I just try it thousands of times and count?" — is available to you on every hard problem you'll ever meet.

Six chapters of patterns; one volume of thinking. Next we assemble them into a single operating system for the algorithmic mind — how to look at any new problem and know, in seconds, which technique it wants. Chapter 16 is the difference between the 99% and the 1%, and it closes the volume →

16How to think like the 1%

This chapter teaches no new algorithm. It teaches the thing underneath all of them — the operating system for an algorithmic mind, the small set of reflexes that turn fifteen chapters of techniques into one way of seeing. The gap between a programmer whose code melts under load and one whose code scales to a billion users is not a bigger memory or a faster brain. It is a loop, run so many times it became instinct. By the end of this chapter you will be able to sit in front of a problem you have never seen, and know exactly what to ask, what to reach for, and how to tell whether your fix actually worked — the whole volume, folded into a way of thinking you can carry anywhere.

Derive it — never memorize it

The single worst way to learn this material is to memorize a table of "sorting is n log n, hashing is O(1)." Memorized facts rot, and they teach you nothing about the problem in front of you that isn't already in the table. The 1% do the opposite: they derive the complexity on the spot, by counting the work as the input grows — exactly the move you built in Chapter 1. The growth zoo isn't a list to recite; it's a lens you look through. And there's a shortcut that makes the derivation almost mechanical: count the operations at some size n, then again at 2n, and look at the ratio.

python
def quad(n):                 # a full nested loop
    ops = 0
    for i in range(n):
        for j in range(n):
            ops += 1
    return ops

for n in (1000, 2000, 4000, 8000):
    print(n, quad(n))
# 1000 1000000   |  double n -> 4x the work
# 2000 4000000   |  ratio 4  ->  the exponent is 2
# 4000 16000000  |  ratio 4  ->  O(n squared)
# 8000 64000000  |  ratio 4

Read it line by line. quad just tallies how many times the inner body runs. We call it at four sizes, each double the last, and print the count. The counts we actually got were 1,000,000 then 4,000,000 then 16,000,000 then 64,000,000 — every time we doubled n, the work went up four-fold. That ratio is the answer: doubling the input and getting 4× the work means cost ∝ , because 4 = 2². You never had to remember that nested loops are quadratic — you watched the exponent fall out of the ratio. Run the same probe on six different shapes and the ratios sort themselves into the whole zoo:

double n → measure how the work multiplies → read the class ratio ≈ 1O(1) — work never grows ratio ≈ 1.1O(log n) — one extra step per doubling ratio ≈ 2O(n) — work tracks the input ratio ≈ 2.2O(n log n) — a hair above linear ratio ≈ 4O(n²) = 2²; ratio 8 → O(n³) = 2³
Fig — The doubling-ratio decoder. The multiplier when you double n is 2 raised to the exponent — so the ratio hands you the exponent directly. No table required.
InteractiveDerive it — slide through snippets, read the exponent off the ratio
snippet: full nested for i in range(n): for j in range(n): ops += 1 bar height = log-scaled op count at n = 1k · 2k · 4k · 8k work multiplier when n doubles ×4.0 → derived class O(n²) 4 = 2² → exponent 2
O(n²)
You never recite a rule. You double the input, watch the multiplier, and the exponent is just its logarithm, base 2.
The deeper cut — why the ratio is exactly 2 to the power of the exponent
If the true cost is c·n^k for some constant c and exponent k, then doubling the input gives c·(2n)^k = c·2^k·n^k, so the new cost divided by the old is exactly 2^k — the constant c cancels, which is why this trick sees through machine-dependent constants (Chapter 1's whole reason for dropping them). Take the base-2 logarithm of the measured ratio and you get k back: log₂(4)=2, log₂(8)=3. Logarithmic cost is the interesting exception: doubling n adds a fixed number of steps rather than multiplying, so the ratio drifts toward 1 from above (10→11→12→13 in our run) instead of settling on a clean power. That drift-to-one is the fingerprint of a log.

So you can name any single function's growth on sight. But real work isn't "name this loop" — it's "I have a problem; which tool do I even reach for?" That needs a different reflex →

The toolkit is a flowchart, not a menu

Beginners keep algorithms in a list and scan it hopefully. Experts keep them in a decision tree keyed on the signature of the problem — a handful of tell-tale features that each point straight at a technique. Learn the triggers, not just the tools. Here is the whole volume compressed into that tree: read the problem, spot the signature, follow the arrow.

a new problem — ask… sorted / monotonic answer?→ Binary search · ch12 "have I seen this before?"→ Hashing · ch6 overlapping subproblems?→ Dynamic prog. · ch9 pairs / subarray / in-place?→ Two pointers · ch7 choices, build & abandon?→ Backtracking · ch10 network / dependencies?→ Graphs · ch13 safe best-local move?→ Greedy (prove it!) · ch8 no exact answer / too big?→ Randomize · ch15 None of these? → brute force it (ch4), then hunt the wasted work and re-run the tree.
Fig — The pattern toolkit as a decision tree. You don't search the menu; you match the signature. Each arrow is a chapter of this volume.
InteractiveWhich technique? Flip the switches that describe your problem
flip a switch below to light its technique
This is the flowchart, made of switches. Real problems trip several at once — that's the moment to ask which signature is strongest.
↺ The thing people get backwards
People think mastery means memorizing more algorithms. It's the reverse: mastery is memorizing more triggers — the signatures that tell you which of a small set of algorithms applies. There are only a dozen or so core techniques in this whole volume. The 1% don't know more of them than you; they recognize the situation that calls for one faster, because they've filed each algorithm under "when do I reach for this?" instead of only "how does this work?". Index your tools by their trigger, not their mechanism.
🌍
Where you meet this
Every system you touch is this tree, already followed. Your phone's contacts open instantly because someone saw "look this up by name" and reached for hashing (ch6). GPS reroutes around traffic because someone saw "shortest path in a network" and reached for a graph algorithm — Dijkstra (ch13). git bisect finds the commit that broke the build in a handful of steps because someone saw "monotonic: good before, broken after" and reached for binary search (ch12). git diff, spell-check, and DNA alignment all run edit-distance DP (ch9) because someone saw "overlapping subproblems." The signature was the whole insight; the code was downstream.

The tree tells you what to reach for. But what do you do before you know the answer — when nothing in the tree obviously fits? You run the loop →

The loop that cracks a problem you've never seen

Here is the method that survives contact with the unknown — the four-beat cycle from Chapter 4, now as your default operating procedure. One: solve it the dumbest way that works — brute force, no cleverness, just a correct answer. Two: measure the baseline — count its operations, name its Big-O. Three: ask the one question that unlocks everything — where is the wasted work? Look for the thing you compute again and again, the search you redo, the pair you re-examine. Four: kill that waste with a technique from the tree, then re-check the complexity to confirm the curve actually dropped. Then loop.

1 · bruteforce 2 · measurebaseline 3 · where'sthe waste? 4 · apply +re-check still wasteful? loop again — each turn drops the curve
Fig — The loop is the whole game. It doesn't require you to know the answer up front — it manufactures the answer, one removed inefficiency at a time.

Watch it turn on a problem you've already met. Two-sum: find two numbers in a list that add to a target. Beat one, brute force: check every pair — two nested loops, and the doubling probe from earlier would report ratio 4, so it's O(n²). Beat three, the question: what work repeats? For each number x you re-scan the whole list looking for target − x — you keep asking "is this value present?" over and over. Beat four: that exact phrase — "have I seen the value I need?" — is a trigger in the tree, and it points at hashing (ch6). Store each number in a set as you pass it; the lookup becomes O(1); the whole thing collapses to O(n). One pass through the loop turned a quadratic into a linear. You didn't need to be clever — you needed to be systematic. (Step it live in trace T30.)

Solve, then optimize — in that order, always
The brute-force answer is never wasted. It pins down what "correct" even means, it gives you the baseline curve to beat, and often it ships — because at your real n it may already be fast enough (Chapter 2: respect the constant, and never optimize a curve that isn't your bottleneck). Premature optimization skips beats one and two and lands you with fast code that solves the wrong problem.

The loop drops your Big-O. But two programs with the same Big-O can still finish minutes apart. To win there, you have to think about the metal →

Respect the constant — machine sympathy

Big-O is necessary, not sufficient. It deliberately throws away the constant factor — and once you've picked the right curve, that discarded constant is the entire remaining game. Two algorithms at the same O(n) can differ by 5× or 50× depending on how kindly they treat the machine from Volume 1: whether the data they walk is already in the CPU's fast cache, whether the branches they take are predictable, whether the work can spread across cores. Here are two pairs, each with identical Big-O, timed on this machine:

python
data = list(range(5_000_000))

# both O(n): sum by hand vs. the built-in
def manual():
    t = 0
    for x in data: t += x
    return t

def sum_data(): return sum(data)
from timeit import Timer
def best(f): return min(Timer(f).repeat(repeat=5, number=1)) * 1e3   # ms, best of 5

print("manual loop:", round(best(manual), 1), "ms")   # ~113 ms (same Big-O)
print("builtin sum:", round(best(sum_data), 1), "ms")  # ~16 ms  (~7x faster)

Both loops touch all five million items exactly once — flatly, provably O(n); the doubling probe gives ratio 2 for each. Yet on this run the hand-written loop took about 113 ms and the built-in sum() about 16 ms — roughly 7× apart at the same complexity (wall-clock on one machine; your numbers will differ, but the ordering won't). The gap is pure constant: the interpreter re-dispatches Python bytecode on every pass of the hand loop, while sum() runs the same additions in a tight C loop with far less overhead per element. Same curve, very different constant. The second pair shows the memory hierarchy directly:

python
grid = [[1]*2000 for _ in range(2000)]   # 4,000,000 cells

def row_major():           # walk each row fully before the next
    s = 0
    for r in range(2000):
        row = grid[r]
        for c in range(2000): s += row[c]
    return s
# row-major: 131 ms   |   col-major (swap the loops): 200 ms  -> ~1.5x, same O(R·C)

Both nested loops visit all four million cells — identical O(R·C). But row_major walks memory the way it's laid out: it grabs one row (a contiguous run of references) and marches straight along it, so the values it needs next are already sitting in cache. Swap the loops to go column-first and every single step jumps to a different row-list, defeating the cache and costing about 1.5× more here (131 ms vs 200 ms) for the very same additions. In pure Python the interpreter dilutes the effect; in the C or Rust underneath your libraries the same swap can cost 5–10×. The Big-O saw none of this — the machine saw all of it.

input size n → c·n (big constant) c′·n (cache-friendly) both are O(n) — same shape, the constant sets the height.
Fig — Big-O picks the shape; the constant picks the height. Once your curve is right, machine sympathy is where the last order of magnitude hides.

Myth

"Same Big-O means the same speed — the constant doesn't matter."

Reality

The constant is exactly what separates two O(n) programs that finish 7× apart. Big-O tells you which one wins eventually, as n → ∞. At the n you actually have, a cache-friendly, branch-predictable, vectorizable constant is often the whole ballgame. Get the curve right first — then fight for the constant.

That's the engineering. But the deepest payoff of this whole volume is that the reflexes work off the computer entirely — on your errands, your bugs, your decisions →

Run the loop on your life

The reason these ideas feel powerful is that they were never really about code. They're about structure, and structure is everywhere. Once the reflexes are in, you start applying them without noticing — which is the real "you use this every day" moment.

python
import math
# BINARY-SEARCH your debugging: a bug appeared somewhere in your commit history.
# "good before, broken after" is monotonic -> bisect it, don't read every commit.
for commits in (100, 1000, 100_000, 1_000_000):
    print(commits, "commits ->", math.ceil(math.log2(commits)), "checks")
# 100 -> 7   |   1000 -> 10   |   100000 -> 17   |   1000000 -> 20

That's git bisect, and it's Chapter 12 wearing overalls. A million commits hide a bug; reading them one by one is O(n) — a million checkouts. But "works before the bad commit, fails after" is monotonic, and monotonic is a trigger for binary search: test the middle, throw away half, repeat. We computed it — 20 checks, not a million. That's O(log n) applied to your own workflow, and it's why "bisect it" is the fastest debugging advice anyone will ever give you. The other techniques transfer just as cleanly:

python
# EXPECTED-VALUE your risks (quant thinking for life): train vs. drive.
p_delay, delay = 0.10, 40         # 10% chance the train loses 40 minutes
train = 25 + p_delay * delay      # base 25 min + expected delay
drive = 35                        # flat, "safe"
print(round(train,1), "vs", drive)   # 29.0 vs 35  -> take the "risky" train

The train feels risky because of that 40-minute tail. But expected value — probability × cost — puts the delay at just 0.10 × 40 = 4 minutes, so the train averages 29 minutes against the drive's 35. The "safe" choice was slower. That's the quant's core move (Chapter 15): don't react to the scary outcome, weight it by its probability. And the rest fold in the same way — greedy your errands (visit the nearest stop next, ch8), hash your memory (write it down so recall is O(1) instead of re-deriving it, ch6), and DP your decisions (never re-solve a problem you've already solved — cache the answer, ch9).

1,000,000 commits — "good ✓ … somewhere it breaks ✗" test middle → keep this half the bad commit each test halves what's left → ~20 tests total, not 1,000,000
Fig — git bisect is binary search on your own history. The same halving that finds a name in a phone book finds the commit that broke the build.
🧠
The transfer is the point
You already binary-search a phone book and expected-value a bet without naming them. Naming them is the upgrade: once "monotonic → halve it" and "scary tail → weight by probability" are words in your head, you reach for them deliberately, on problems where the structure was hidden. That's what it means to think algorithmically about things that aren't algorithms.

So how do you drill these until they're automatic — until you see in growth curves and triggers without trying? →

The gap is a loop, run relentlessly

Here is the honest, slightly deflating, deeply encouraging truth this volume has been building toward: the distance between the 99% and the 1% is not talent. It is not a gift you were or weren't born with. It is this handful of reflexes, practiced until they became how you see — and reflexes are trainable. Five drills turn the ideas in this book into instinct:

  • Derive the complexity of everything you read. Every function in every codebase, every snippet in every article — before you move on, count its work and name its Big-O. Make it a tic. This is how "what's the growth?" becomes automatic.
  • Re-derive one classic a day, from scratch. Binary search, merge sort, the two-sum trick — rebuild one each morning without looking. You'll discover how much you'd only memorized, and each rebuild files it deeper as a method, not a fact.
  • Explain it out loud. If you can't say why quicksort is n log n on average to an imaginary beginner, you don't own it yet. Teaching is the fastest debugger for your own understanding.
  • Always solve, then optimize. Get a correct brute-force answer first — every single time — then run the loop. Cleverness before correctness is how you ship fast, wrong code.
  • Ask the two questions, on everything. What is the input size? Where is the wasted work? Those two questions, asked reflexively, are the bulk of what separates the engineer from the coder.
what's n? where's the waste? ask them once → a chore ask them 10,000 times → instinct
Fig — Two questions, asked until they disappear into reflex. The 1% aren't asking harder questions — they've asked these so often they no longer notice they're asking.
💡
Why this compounds
Every problem you meet is training data for the tree. Solve one with hashing and the next "have I seen this?" jumps out faster. The reflexes reinforce each other: better complexity-reading makes "where's the waste?" sharper, which makes the right trigger fire sooner, which teaches you the technique deeper. This is a positive feedback loop pointed at your own judgment. Run it for a year and you won't recognize how you used to think.

That is the whole message of Volume 3, and it's smaller than it looked from the outside. Count the work. Watch the growth. Match the signature to the tool. Brute-force, find the waste, delete it, re-check. Respect the constant. Then run that loop on everything you touch — code and otherwise — until it stops being something you do and becomes something you are. That's the 1%. Not a different kind of mind. The same mind, running a better loop, relentlessly.

Where the series goes next. You now think about cost like an engineer. The next volume gives you the vocabulary of structure to act on it: the 13 must-know data structures — dynamic arrays, linked lists, stacks and queues, hash maps, binary search trees, heaps, tries, graphs, and the rest — each one a deliberate trade of space for time, and each the reason some operation in this volume was O(1) instead of O(n). You've learned to read the price. Next, you learn to build the machines that set it. →