python/volume-3/chapter-33ch 33 / 64 · 9 min

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. Now we add the other half: space, the extra memory a program burns. Then we drop through the abstraction to the metal, where an unsettling fact is waiting. Two algorithms with the identical Big-O can run a hundred times apart in real seconds, and the reason has nothing to do with the exponent on n. All the way through, we keep asking the one question that 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, spend memory to buy speed on purpose, and predict when a "slower-looking" layout will beat a "faster" one — because you'll finally see the constant that Big-O throws away.

Space is a bill too — count the extra memory The trade that runs this whole volume: buy time with space Not all memory is one place — the hierarchy Two O(n) loops, one many times slower — the access pattern decides
scroll to begin

01Space is a bill too — count the extra memory

Let's price 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, since it has to exist. We count only the scratch space the algorithm allocates on top of it. Here's the hook: two routines that hand back the byte-for-byte 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 handed. It allocates only a fixed handful of variables — a loop counter, a temp — no matter how big n gets. 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 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 from the ends toward the middle, swapping references. The list object's size is byte-for-byte unchanged — we measured 80056 before and after, so zero extra. Line 7, [::-1], allocates a second list of the same length80056 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), while 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

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 so the search becomes an instant jump. You spend O(n) space, and you buy back a factor of n in time. This trade is the engine under two techniques you'll meet all volume — hashing (ch37) and memoization (ch40) — 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. Here's the trade: keep a dict of numbers you've already seen, and for each new number just ask "is target − x in there?" — an O(1) question. That's 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:

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 — 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 and 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, 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. That's 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. 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 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. Imagine an L1 hit took 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.
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. So 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 →

04Two 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. It comes down to whether 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. That means a fresh cache miss per node, seven-eighths of every fetched line wasted, and 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. We sum ten million integers as a Python list (a block of references pointing off to separately-boxed int objects), then 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)

Same operation count, yet ~8.6× apart (hardware-dependent). 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. 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, with the same reads and only the pattern different. 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. So 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 just 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 →

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

The growth zoo 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 (ch37, ch40). 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 (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 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 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. 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)). 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 and 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 by marching 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.com
Written by Ajai Raj