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

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

In Chapter 32 we sorted algorithms into a zoo of growth classes — O(1), O(log n), O(n), O(n²) — and learned to name any loop's curve in time. But time is only half the bill, and this chapter prices the other half. That other half is space: the extra memory a program burns while it works. Then we drop straight through the abstraction to the metal, where a genuinely unsettling fact is waiting. Two algorithms with the identical Big-O can run a hundred times apart in real seconds. The reason has nothing to do with the exponent on n. The whole way through, we keep asking the one question that actually matters. If the operation count is the same, what else is the machine charging me for? By the end you'll read the space cost of any routine at a glance, and you'll spend memory to buy speed on purpose. You'll even predict when a "slower-looking" layout will crush a "faster" one, because you'll finally see the constant that Big-O throws away.

★ YOU ALREADY RUN THIS · the memory hierarchyYour desk, the shelf, the library
Look at where things actually are around you right now. The page you are working on is under your hand. The two books you keep glancing at are on the desk. The rest of that shelf is behind you, one chair-swivel away. And the volume you need twice a year sits in a library across town, so you plan a trip for it. Nobody taught you that layout either. You put what you're using where reaching costs nothing, and you accept a long walk for the thing you rarely want. Now catch the second habit: you work down a stack top to bottom. You do not fetch page 400, then page 12, then page 700.
the page under your handregisters · L1 — already in reach
the shelf behind youRAM · ~100 ns, a real stall
the library across townSSD / disk — plan the trip
working down the stack in orderspatial locality · the 64-byte cache line
hopping 400, then 12, then 700random access — every look misses
pin it: you have been managing a cache your whole life — and where the data sits decides the race long after the operation counts have tied.
iolinked · chapter 33 — the checkpoints5 steps
$ sections covered in Space, and the memory hierarchy — why equal Big-O runs 100× apart
01Space is a bill too — count the extra memory
02The trade that runs this whole volume: buy time with space
03Not all memory is one place — the hierarchy
04Two O(n) loops, one many times slower — the access pattern decides
05The 1% move: respect the constant, spend space on purpose

01Space is a bill too — count the extra memory

Let's start by pricing the other half of the bill. Space complexity is the same idea as time complexity, just pointed at memory instead of the clock: how much extra storage does an algorithm need as the input size n grows? Watch that word extra — it carries the whole idea. We don't count the input itself; it has to exist. We count only the scratch space the algorithm allocates on top of it. And here's the hook: two routines that hand back the byte-for-byte same answer can sit in completely different space classes.

Let's make that word "extra" concrete before we price anything. Say you hand a routine a playlist of 10,000 songs and ask for it reversed. The playlist itself is not part of the bill, because it existed before the routine ran. What we bill is everything the routine creates while it works: a loop counter here, a whole duplicate list there. Python lets us watch this directly with sys.getsizeof(), which reports the bytes an object occupies. So our method all chapter is simple and honest. We run the routine, measure what appeared, and ask how that number grows as n grows. If the scratch stays fixed while n climbs, the routine is frugal. If the scratch grows in step with n, we're paying rent on a second copy.

The cleanest split in space cost is in-place versus copying, and it's worth feeling the difference. An in-place routine rearranges the data it was handed, right where it sits. It allocates only a fixed handful of variables, a loop counter and a temp, no matter how big n gets. That's O(1) extra space, which we also call constant space. A copying routine builds a whole new structure the size of the input, so its bill is O(n) extra space. Now watch the exact same "reverse the playlist" idea land in both classes:

space.pypython
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 in from the ends toward the middle, swapping references as they meet. The list object's size stays byte-for-byte unchanged: we measured 80056 bytes before and after, zero extra. Line 7, [::-1], allocates a second list of the same length instead — 80056 more bytes, and that bill scales with n. Same output, different space class, and the built-in pair makes the same choice explicit. list.sort() sorts in place and returns None, so it costs O(1) extra. sorted() hands back a new list, which is O(n) extra: we measured a 100,000-element sorted() result at 800056 bytes, a full second copy.

And that number 80056 is not a mystery, so let's take it apart. A Python list doesn't hold your values directly — it holds references, one 8-byte pointer per element. Ten thousand elements times 8 bytes is 80,000 bytes of pointers. The remaining 56 bytes are the list object's own header, the fixed bookkeeping every list carries. Add them and you get 80,000 + 56 = 80056, exactly what we measured. Run the same arithmetic on the 100,000-element sorted() copy: 100,000 × 8 + 56 = 800056. When a measurement decomposes cleanly like this, you know you're seeing the real machine, and that's a habit worth keeping: predict the number, then measure it.

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

02The 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, and the search becomes an instant jump. You spend O(n) space, and in return you buy back a factor of n in time. This one trade is the engine under two techniques you'll meet all volume: hashing (ch37) and memoization (ch40). It's worth seeing it raw, right now.

Let's feel the trade on four numbers before we name the problem. Say I read you 3, 9, 12, 7 and ask: do any two of them add to 16? The re-scanning way answers by checking every pair, and with four numbers that's six checks. The remembering way makes one pass and keeps a running memory. At 3 you'd need a 13 to finish, and you haven't seen one, so you remember the 3. At 9 you'd need 7, not seen yet either, and at 12 you'd need 4, same story. Then comes 7, you'd need 16 − 7 = 9, and 9 is sitting right there in your memory. Done in four steps, with no backward scanning at all. That little running memory is about to become a dict.

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

tradeoff.pypython
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, and that restart is where the comes from. The hashed version replaces that scan with the in seen test on line 11. The price is seen itself, a dict that grows to n entries. And the payoff was not subtle. On our machine (timings are hardware-dependent), the brute force took ≈8,793 ms while the hashed version took ≈2.0 ms. That's about a 4,300× speedup, bought with a dict of ~590 KB. You handed the machine half a megabyte and it handed back nearly four-thousandfold. Here is that trade drawn as a curve, where 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 →

03Not all memory is one place — the hierarchy

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

It's fair to ask why we don't just build all memory as fast as L1 and skip the pyramid. Two hard physical walls say no, and the first is cost and density. Cache is built from SRAM, which spends about six transistors holding each single bit. Main memory is DRAM, which gets away with one transistor and a tiny capacitor per bit. SRAM is blazing but bulky and expensive, so you get megabytes of it, not gigabytes. The second wall is distance: signals cannot outrun the speed of light, and at 3 GHz one clock cycle lasts a third of a nanosecond. In that time, light itself covers only about 10 cm. Memory that answers within a cycle must sit physically next to the core, and next to the core is small real estate. The pyramid isn't a design fashion — it's physics sending an invoice.

The latencies of these levels span a range that's honestly hard to believe, so let's make it visceral. These are typical order-of-magnitude figures for a modern machine, not measured on yours, because real numbers vary by hardware. Nanoseconds mean nothing to human intuition, so we scale every level into human time instead. Every figure below is stretched by one rule — pretend an L1 hit takes 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 h 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.
InteractiveOne byte, please — and the CPU just… waits
the CPU needs one value — how long until it arrives, and what does it forfeit waiting? RAM (DRAM) REAL LATENCY ~100 ns AT HUMAN SCALE · L1 HIT = 1 SECOND 1.7 minutes ≈ a walk to the kitchen WHILE IT WAITS, THE CPU COULD HAVE RUN… 330 instructions — then idled them all away. L1 → RAM is the cliff: 3 → 330 instructions forfeited — a 100× fall, for one wrong guess about where the byte lived.
RAM
Drag from registers down to disk. Watch the "instructions forfeited" counter — that is the true price of a miss: a single trip to RAM stalls the core for ~330 instructions it could have finished; a disk seek, 33 million. Fast code is the art of keeping data high on this ladder.

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.

That bet has a name: locality, and it comes in two flavours. Temporal locality is the bet that data you touched a moment ago, you'll touch again soon, so the cache holds on to recent arrivals. Spatial locality is the bet that after address 1000 you'll want 1001, so memory never hands over a lone byte. It delivers a whole 64-byte cache line, the neighbours riding along for free. Both bets are statements about your code, not about the hardware. A loop that sums a list front to back wins both bets on every single step. Code that hops to a random address each step loses both, every time. The hardware never changes its strategy — which means the difference between winning and losing lives entirely in how you arrange and walk your data. Hold that thought through the next section.

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 →

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

Now the payoff of the pyramid: Big-O counts operations, and it 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 can do the exact same number of accesses, same Big-O, and still run wildly apart. It happens when one touches memory in an order the caches love and the other in an order they hate. Big-O drops that hidden multiplier on the floor and calls it the constant factor, and the memory access pattern is what sets it.

Picture two ways to hold a million numbers, starting with a contiguous array that lays them end to end in one solid 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. Better still, the prefetcher sees your straight march and has the next line ready before you ask. Now take 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, which means a fresh cache miss per node. Seven-eighths of every fetched line is wasted, and the prefetcher is 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 claim, because claims are cheap — we timed it. First we run the same math in two layouts, so the layout is the only thing that differs. One version sums ten million integers held in a Python list, a block of references pointing off to separately-boxed int objects. The other sums the same values as a NumPy array, the raw int64s packed contiguously. Same additions, same O(n):

locality.pypython
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)

The two runs did the same operation count, yet they landed ~8.6× apart (hardware-dependent). The array wins because it's a tight contiguous block that the caches and prefetcher love. The list scatters its int objects across the heap, which means 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. Then we proved the effect is the access order and nothing else. We walked the identical data twice, once in sequential order and once through a random permutation, so the reads were the same and only the pattern differed. 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.

Those memory numbers deserve a quick unpacking, because "boxed" is doing all the work. In Python, the number 5 is not 8 bytes sitting in a slot. It's a full object on the heap, with a type pointer and a reference count wrapped around the actual digits, about 28 bytes for a small int. The list stores an 8-byte reference to that box, so each number really costs 8 + 28 = 36 bytes, split across two places in memory. Ten million of those is 360,000,000 bytes, and there's the ~360 MB. The NumPy array throws the boxes away and keeps just the raw 8-byte int64s, so ten million × 8 = 80 MB in one block. And the ratio checks out: 36 / 8 = 4.5, exactly the memory saving we measured. When the arithmetic and the measurement agree like that, you can trust you've understood the layout.

SYNTAX · walking a grid the way it is actually storedrow-major iteration, what it really buys you in CPython, and where the cache line genuinely decides
-- ROW-MAJOR. walk the rows; inside a row, walk the items. for row in grid: one row = one contiguous block of references for x in row: neighbours, in the order they are stored total += x -- THE SAME ORDER, but paying two subscripts on every element for r in range(rows): for c in range(cols): total += grid[r][c] -- fetch grid[r], THEN fetch [c]. every time. -- COLUMN-MAJOR. the same n reads, every step jumping a whole row. for c in range(cols): for r in range(rows): total += grid[r][c] -- stride = one row, so no line is reused -- WHERE LOCALITY REALLY DECIDES: leave the boxes behind import array buf = array.array('q', values) -- stdlib. raw int64s, packed end to end. import numpy as np m = np.arange(rows * cols).reshape(rows, cols) m.sum(axis=1) -- compiled loop, stride 1, nothing unboxed
for row in gridAsks the list for its next reference. No index arithmetic and no second subscript, per element.
grid[r][c]Two BINARY_SUBSCR bytecodes: fetch the row object, then fetch the item. Measured at 1.66× the cost.
what the ORDER alone buysColumn order against row order, both index-hopping: only ×1.23 here. Python's boxes dilute it.
why so smallA list holds pointers, and each int is a 28-byte object elsewhere. The line has already missed.
array.array('q')Stdlib, nothing to install. Raw signed 64-bit ints packed contiguously, eight of them to a cache line.
NumPyThe same packing plus a compiled loop. m.sum(axis=1) beat the pure-Python row walk by 132× here.
the honest headlineUnboxed, 2,000,000 additions either way: contiguous 0.370 ms, one value per line 4.262 ms. ×11.5.
the ruleWalk data the way it is laid out; and if that walk is hot, lay it out unboxed first.
you type
# ---------- grid_walk.py, 1000 x 1000 = 1,000,000 ints ----------
$ python grid_walk.py

grid = [[r * 1000 + c for c in range(1000)] for r in range(1000)]

sum_rows_fast     # for row in grid: for x in row
sum_rows_indexed  # for r: for c: grid[r][c]      -- same order
sum_cols          # for c: for r: grid[r][c]      -- stride one row

# ---------- and the same question with the boxes removed ----------
big  = np.arange(16_000_000, dtype=np.int64)   # 128 MB
seq  = big[:2_000_000]     # 2,000,000 contiguous  -> 8 values per line
strd = big[::8]            # 2,000,000 strided     -> 1 value  per line
seq.sum(); strd.sum()      # identical addition count
you see
PURE PYTHON, 1000 x 1000
  sum_rows_fast       26.2 ms   x1.00
  sum_rows_indexed    43.5 ms   x1.66   <- subscripting alone
  sum_cols            53.4 ms   x2.04
  of the 2.04x column penalty, 1.66x is plain subscripting
  the access ORDER itself buys only    x1.23

NUMPY, same 1000 x 1000 int64 block
  m.sum(axis=1).sum()  0.198 ms   x132 faster than sum_rows_fast

UNBOXED, 2,000,000 additions each
  contiguous (8 values / 64-byte line)   0.370 ms
  stride 64B (1 value  / 64-byte line)   4.262 ms   x11.50

WHY PYTHON DILUTES IT
  sys.getsizeof(grid[0])     8856 bytes of POINTERS
  sys.getsizeof(grid[0][0])  28 bytes, and it lives elsewhere
  numpy row m[0].nbytes      8000 bytes, values in line
where beginners trip
  • Row-major beat column-major by 2.04× — but 1.66× of that was subscripting, not the cache. Split the gap before you claim it.
  • So the honest pure-Python cache win here is ×1.23, not the ×100 the latency table promises. The boxes eat the rest.
  • Take the boxes away and the same 2,000,000 additions split 0.370 ms against 4.262 — ×11.5, one value per line versus eight.
  • for row in grid is the fastest spelling and the clearest. This is one of the rare places where those agree.
  • array.array ships with Python. Reach for it before NumPy when you just need packed numbers, not maths.
  • A NumPy array of dtype=object throws the whole benefit away: you are back to a block of pointers.
  • Locality never changes the exponent. If your loop is O(n²), lay it out perfectly and it is still O(n²).
  • Measure on the machine that will run it. Cache sizes, line width and latency all differ, so this constant is local truth.
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×"

So are linked structures just wrong? No — they're right when you insert and delete in the middle constantly and rarely iterate, as in some LRU caches, certain allocators, and 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 the other work each iteration does. Arithmetic, loop control, and Python bytecode all take their own time, so 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. So the title's "100×" is the per-access truth, and the single-digit multiples are what survives once real work is mixed in. Both numbers are honest — they simply measure different things.

You might reasonably object that Python's interpreter is so slow that none of this metal-level detail matters. The 2.3× says otherwise. Even with bytecode dispatch soaking up cycles, the access pattern still showed through — the cache stall is big enough to survive the dilution. And the moment your hot loop moves into compiled code, the dilution vanishes and the pattern becomes the dominant cost. That compiled path is exactly what NumPy and sorted() hand you for free. So the practical rule for a Python engineer is simple. Keep bulk data in contiguous structures, let compiled loops walk them, and save pointer-heavy designs for the places their flexibility genuinely pays. You don't need to count nanoseconds to follow that rule — you just need to remember the pyramid is there.

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 →

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

The growth zoo of Chapter 32 gave you the lens to choose the lower growth curve. This chapter installs the reflex that separates engineers who "know Big-O" from engineers 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. 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 (ch37, ch40), and now you know its price. The 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 habit has a name worth loving — machine sympathy, writing code that flows the way the hardware wants to run.

One honest caveat before that reflex hardens. The memory you spend on a lookup table is not free-floating, because it competes for the same tiny caches as everything else you touch. A typical L1 holds a few tens of kilobytes, and an L3 tens of megabytes. Build a table far bigger than that and most lookups into it will miss to RAM, paying the very latency you were trying to dodge. Our two-sum dict still won hugely because, at ~590 KB, it sat comfortably inside the cache hierarchy, so its O(1) questions stayed genuinely cheap. So the trade is real, but it's priced in cache-sized coins. Spend space to save time, and then check where that space will actually live.

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 (ch32). 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 here is real. "Buy time with space" is just writing it down. You don't re-derive a friend's phone number every time, because you stored it once for O(1) recall. "Respect locality" is keeping what you're using within reach, like the tools on the bench instead of 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?" That question is the difference between code that looks fast on paper and code that is fast on the metal.

NOW WRITE IT YOURSELFone grid, two walks — then find out how much of the gap is really the cache
Build the grid and write the two walks. Make grid = [[r * 1000 + c for c in range(1000)] for r in range(1000)], a million integers in a thousand rows. Write sum_rows_fast(g) using for row in g: and then for x in row:, and write sum_cols(g) with the loops the other way round, for c in ...: outside and for r in ...: inside, reading g[r][c]. Assert that both return the same total before you time anything — a fast wrong answer is worthless. Then time both with min(timeit.repeat(..., number=1, repeat=7)) and report the ratio. Now the part that separates a claim from a measurement. Write a third function, sum_rows_indexed(g), that keeps the row order of the fast one but reads through g[r][c] like the slow one. It changes one thing only: how the element is fetched, not the order it is fetched in. Use it to split the column penalty into two parts, and state in numbers how much of the gap is plain subscripting and how much is the access order itself. Then answer the honest question. The chapter says a cache miss costs roughly a hundred times a hit. Your access-order number will be nothing like a hundred. Explain why, using sys.getsizeof(grid[0]) and sys.getsizeof(grid[0][0]) as your evidence — what is actually stored in a row, and where does the integer really live? Finally, make the effect big. Build big = np.arange(16_000_000, dtype=np.int64), then compare big[:2_000_000].sum() against big[::8].sum(). Both do exactly two million additions. Say why the second one is slower, in units of cache lines, and report the factor you measure. One hint and no more: 8 × 8 bytes is exactly one 64-byte cache line.
show the solution
# ---------- grid_walk.py ----------
import timeit, sys
import numpy as np

N = 1000
grid = [[r * N + c for c in range(N)] for r in range(N)]

def sum_rows_fast(g):
    total = 0
    for row in g:                 # one row = one contiguous block of references
        for x in row:             # neighbours, in stored order
            total += x
    return total

def sum_rows_indexed(g):
    total = 0
    for r in range(len(g)):
        for c in range(len(g[0])):
            total += g[r][c]      # SAME order, two subscripts per element
    return total

def sum_cols(g):
    total = 0
    for c in range(len(g[0])):
        for r in range(len(g)):
            total += g[r][c]      # every step jumps a whole row forward
    return total

assert sum_rows_fast(grid) == sum_rows_indexed(grid) == sum_cols(grid)
# all three agree: 499999500000

def ms(fn):
    return min(timeit.repeat(lambda: fn(grid), number=1, repeat=7)) * 1e3


# ---------- the three timings ----------
sum_rows_fast       26.2 ms   x1.00
sum_rows_indexed    43.5 ms   x1.66
sum_cols            53.4 ms   x2.04
  of the 2.04x column penalty, 1.66x is plain subscripting
  the access order itself buys only            x1.23

#   SPLITTING THE GAP
#     26.2 -&gt; 43.5   is the SUBSCRIPT tax. Same order, same cache
#                    behaviour; the only change is g[r][c] instead of
#                    iterating the row object. Two BINARY_SUBSCR
#                    bytecodes per element, a million times over.
#     43.5 -&gt; 53.4   is the ACCESS ORDER, and that is the cache bill:
#                    x1.23, not x100.


# ---------- why it is 1.23 and not 100 ----------
sys.getsizeof(grid[0])      -&gt; 8856 bytes    # 1000 POINTERS + header + slack
sys.getsizeof(grid[0][0])   -&gt; 28 bytes      # ONE boxed int, living elsewhere
m[0].nbytes                 -&gt; 8000 bytes    # numpy: 1000 raw int64s, in line

#   A "row" is not a row of numbers. It is a row of 8-byte POINTERS, and
#   each pointer aims at a separate 28-byte PyObject on the heap. So even
#   the row-major walk misses the cache on nearly every element -- it just
#   misses on the pointer fetch a little less often. Column order makes
#   the POINTER fetch scattered too, and that is the whole x1.23.
#
#   The 100x in the latency table is the per-access truth. What you
#   measure end-to-end is that stall diluted by bytecode dispatch,
#   refcount traffic, and unboxing. Both numbers are honest; they just
#   measure different things.


# ---------- now remove the boxes, and the line does the talking ----------
big  = np.arange(16_000_000, dtype=np.int64)   # 128 MB, far past any cache
seq  = big[:2_000_000]     # 2,000,000 values, contiguous
strd = big[::8]            # 2,000,000 values, 64 bytes apart

  contiguous (8 values / 64-byte line)   0.370 ms
  stride 64B (1 value  / 64-byte line)   4.262 ms   x11.50

#   Identical addition count. seq touches 16 MB and gets 8 useful int64s
#   out of every 64-byte line it fetches -- one miss, then seven free
#   hits, with the prefetcher running ahead of the loop. strd touches all
#   128 MB and gets exactly ONE useful value per line, so it pays 8x the
#   memory traffic and the prefetcher's straight-line bet is worthless.
#   Eight times the lines, and the measured factor is 11.5 -- the extra
#   is the prefetcher, which helps the contiguous walk and not the other.
#
#   For comparison: numpy m.sum(axis=1) on the SAME million ints the
#   Python grid held came in at 0.198 ms, or 132x sum_rows_fast. Same
#   O(n), same additions. Layout and a compiled loop, nothing else.


# ---------- the takeaway you can act on ----------
#   1. In pure Python, prefer `for row in grid` -- it is the fastest
#      spelling and the clearest one, and it costs you nothing.
#   2. Do not expect a 100x from access order alone in a list of lists.
#      Measure the split before you claim a cache win; ours was 1.23.
#   3. When the walk is hot and the data is numeric, unbox it. That is
#      where the cache line stops being a footnote and becomes the bill.
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 the next chapter: amortized analysis, the honest accounting that explains why a million appends stay flat despite the resize spikes. →

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

Big-O tells you how the work grows — but a program pays two more bills it never mentions, extra memory and the cache line, so let's price them out with code you can run and watch the "equal" algorithms split into fast and slow.

Space is a bill too — count the extra memory
Space complexity is time complexity pointed at memory: how much EXTRA storage grows with n. The cleanest split is in-place (a fixed handful of variables, O(1)) versus copying (a whole new structure, O(n)) — and the very same idea can land in either class.
Buy time with space — the trade that runs this whole volume
The most useful sentence in algorithm design: you can almost always trade memory for speed. When a routine keeps re-searching or recomputing, store the answer once and look it up forever. Spend O(n) space, buy back a factor of n in time — the engine under hashing and memoization.
Not all memory is one place — the hierarchy and the cache line
Big-O counts every memory access as one unit; the hardware does not. A CPU hauls in a 64-byte cache line (about 8 int64s) on every miss, betting you'll want the neighbours. Reward the bet — march straight through memory — and RAM feels like L1. Betray it and you pay ~100× per access.
Layout beats Big-O ties — the 1% reflex
Big-O is where you start, not where you stop. Once the complexity class is right, two more questions decide real speed: what am I storing, and how am I walking it? Contiguous, cache-friendly layouts win the tie — but only fix the constant AFTER the growth class, never before.
end of chapter 33 · five sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked