◈ python mapVol 4 · Ch 49/62
Volume 4 Python, from the metal up · chapter 49

49The array — the block everything is built on

In Chapter 48 we said a data structure is two things welded together. It is a way of arranging data in memory, plus the operations that arrangement makes cheap. Now we cash that in on the first arrangement there is, the one every other structure in this volume is negotiated from: the array, a contiguous block of equal-size slots laid end to end in RAM with no gaps. Here's the plan. First we set that block down in memory and watch the machine reach any element with a single multiply-add. Then we feel the price the same tight packing charges the instant you insert in the middle. The whole way through we keep asking the one question that decides everything. What does the layout force to be cheap, and what does it force to be expensive? By the end you'll see why indexing is O(1) and a middle insert is O(n). These are not rules to memorise but facts the layout leaves no choice about. You'll see why Python's list grows in sudden jumps, and why a list of a million integers costs 36 MB while the same numbers packed cost 8. And you'll see when the array is the container to reach for. Which, more often than you'd guess, it is.

iolinked · chapter 49 — the checkpoints7 steps
$ sections covered in The array — the block everything is built on
01One block, equal slots, no gaps
02Why indexing is O(1): one multiply-add
03The price of the packing: the shift
04Static vs dynamic: how a fixed block grows
05References vs values: why a list weighs 4× an array
06Why the metal loves a block: cache locality
07The 1% move: the array is the default, and the foundation

01One block, equal slots, no gaps

Let's start from the metal. Back in Volume 1 we drew RAM as one long row of numbered boxes, where each box holds one byte and carries an address, its number in the row. An array is the simplest way to lay a sequence into that row: you pick a slot size, then set the elements down in consecutive slots starting at some address. That starting address is the array's base, and the slot size, how many bytes each element occupies, is what we call the stride. Now watch what is not there. Nothing marks where one element ends and the next begins, except the plain fact that they are all the same width. So the machine can always find element i by counting, skipping i strides from the base to stand right on it.

The word doing all the work is contiguous, meaning "touching, in one unbroken run." The slots are neighbours in physical memory. There is no header between them, no pointer from one to the next, no bookkeeping of any kind. That absence is the whole trick. Everything good and bad about the array flows straight out of it.

★ YOU ALREADY RUN THIS · the-pill-organiseryou open THU without counting from Monday — and you have done it half-asleep
You keep a seven-day pill organiser on the kitchen counter. Seven identical compartments in a row, lids printed MON to SUN. It's Thursday, so your hand goes straight to the fourth lid — you don't count from Monday, you don't lift Tuesday's to check. The label is the position, and the position is the box. Then the pharmacy adds a mid-week dose and you want a compartment between WED and THU. There isn't one. To make room, every compartment after it would have to shuffle along — and the box only holds seven anyway. Instant to open, immovable to change. You bought one and sold the other on the day you chose that box.
reach for THU without counting from MONarr[3] · base + 3×stride · O(1)
every compartment the exact same sizeone fixed stride — why the arithmetic works at all
squeezing a dose in between WED and THUinsert(i) shifts the n − i tail · O(n)
the box holds seven, and that is thatfixed capacity — a static array
buying a bigger box and refilling it oncelist's resize + copy · amortized O(1)
pin it: an array never looks for THU — it computes where THU already is, and that calculation costs the same at slot 3 or slot 3 million.
one contiguous block · equal 8-byte slots · consecutive addresses index address 0 1 2 3 4 5 200 247 245 269 210 258 1000 1008 1016 1024 1032 1040 +8 bytes (stride) ↑ base Addresses shown in decimal for clarity — the real ones look like the 2,606,972,461,072 we measured below.
Fig — A six-element array of track lengths. Same-width slots at consecutive addresses; the index is not stored anywhere — it is computed from base and stride.
Two numbers define an array
A base address (where slot 0 sits) and a stride (bytes per slot). Everything else — every element's location — is arithmetic on those two. Hold that thought: it's the reason the next section is titled "one multiply-add."

If the position of element i is nowhere written down, how does the machine find it instantly? It doesn't look. It calculates →

02Why indexing is O(1): one multiply-add

Here is the array's superpower, and it is pure arithmetic: to find element i, the machine computes its address directly, with no lookup and no search along the way:

address(i) = base + i × stride

That is one multiply and one add: the CPU forms the address and reads the slot, with no scanning, no comparing, and no walking from the front. It does not matter whether i is 0 or 9,999,999, because the same two operations land on the exact byte either way. That is what O(1), or constant time, really means, since the cost does not grow with the size of the collection. This is the payoff Volume 1 promised when it called a Python list "a contiguous array of references." Indexing is a jump, not a search.

Notice what that formula quietly depends on: every slot being the same width. That is the price the array charges for its speed, because a fixed width is what lets the stride be a single constant number. Picture the alternative, where the elements had different sizes. To reach slot 100 you would first have to add up the lengths of all 99 slots ahead of it, walking the whole run just to learn where the next one begins. The moment the widths vary, base + i × stride stops working and the O(1) jump is gone. So the uniform slot is not a small detail. It is the whole bargain that turns position into arithmetic.

Let's make that concrete with real numbers. Say our array of track lengths starts at base address 1000, and each slot is 8 bytes wide. Slot 0 sits at 1000, because 1000 + 0 × 8 is just 1000. Slot 1 sits at 1000 + 1 × 8 = 1008, and slot 3 sits at 1000 + 3 × 8 = 1024. Notice we jumped straight to slot 3 without ever looking at slots 1 or 2. That is the whole point. The machine does one multiply and one add, then reads. It would do the exact same two operations to reach slot 3 or slot three million.

We can watch the formula on real memory. The array module hands us a block of packed 8-byte slots and will report the true base address of slot 0:

index.pypython
import array
A = array.array('q', [10, 20, 30, 40, 50])   # 'q' = signed 8-byte slots
base, length = A.buffer_info()                # the REAL address of slot 0
for i in range(length):
    print(i, base + i * A.itemsize)           # itemsize == stride == 8
# 0 2606972461072
# 1 2606972461080   <- exactly 8 bytes on from slot 0
# 2 2606972461088
# 3 2606972461096
# 4 2606972461104

Line 3 asks the array for its base address, an actual location in this process's RAM. Line 5 applies the formula, so slot i sits at base + i × 8. The printed addresses climb by exactly 8 each step, with no gaps. That proves the slots are contiguous, and that reaching any one is a single multiply-add. The machine never touched slots 0–3 to find slot 4. It computed straight to it.

And it's flat in the clock, not just in theory. Watch what happens when we time index access on lists of wildly different sizes (machine-dependent numbers, but the shape is the point):

index.pypython
import timeit
for n in (1_000, 100_000, 10_000_000):
    L = list(range(n))
    t = timeit.timeit(f"L[{n//2}]", globals={"L": L}, number=2_000_000)
    print(n, round(t/2e6*1e9, 1), "ns")
# 1000       13.9 ns
# 100000     13.7 ns
# 10000000   14.4 ns   <- a 10,000x bigger list, same access time

Reaching the middle of a thousand-element list and the middle of a ten-million-element list both cost about 14 nanoseconds on our machine. The list grew ten-thousand-fold; the access time didn't budge. That is O(1) you can see on a stopwatch.

Sit with how strange that is for a moment. The collection got ten thousand times bigger, and the machine did not work one bit harder to reach into it. That is the array's first gift, and it falls out of nothing but the fixed stride and a little arithmetic. But every gift in this chapter has a matching bill. The same tight packing that makes a read a single jump is about to make a middle insert an expensive shuffle, and for the exact same reason: no gaps, ever.

Myth

A bigger array takes longer to reach a deep element — element one-million must be "further in," so it costs more to get to.

Reality

Distance is free. base + i × stride is one multiply-add whatever i is; we measured ~14 ns to index the middle of both a 1,000- and a 10,000,000-element list. The array doesn't travel to the element — it computes where it already is.
The dark side of raw arithmetic
Because base + i × stride is just arithmetic, nothing stops the machine computing the address for an i past the end — it will happily read or write a neighbour's memory. That is the buffer overflow, the single most exploited bug class in computing history (the Morris worm, Heartbleed, countless CVEs). Low-level languages like C leave the door open. Python bolts it shut: every index is bounds-checked, and an out-of-range i raises IndexError instead of corrupting memory — a small tax on every access, paid for safety.
InteractiveSlide the index — watch the address compute, the slot light up
0 1 2 3 4 5 6 7 1000 1008 1016 1024 1032 1040 1048 1056 address = 1000 + 0 × 8 = 1000 one multiply, one add → the CPU jumps straight to the slot. no searching.
0
The cost never changes with i. That flatness is what O(1) looks like.

Instant reads sound like a free lunch. They aren't — the same tight packing that makes index a jump makes the middle a wall →

03The price of the packing: the shift

The array's contiguity is a promise: no gaps, ever. Reads love that promise. Inserts hate it. Suppose you want to slip a new track into the middle of the playlist, at position i. There is no empty slot waiting there — slot i is occupied by its neighbour, and its neighbour by its neighbour, all the way to the end. To open a hole at i without breaking the "no gaps" promise, every element from i onward must slide one slot to the right. That's n − i elements moved for a single insertion. Delete in the middle and it's the same in reverse: everything after the hole shifts left to close it.

Count it on a real playlist. Say you have 6 tracks in slots 0 through 5, and you want the new track at position 2. Slots 2, 3, 4, and 5 each have to slide one to the right, which is n − i = 6 − 2 = 4 moves before you can write the newcomer into slot 2. Insert near the front and you move almost the whole array. Insert at the very back and you move nothing. The cost is not fixed. It is exactly however many elements sit to the right of the cut.

Deletion is the same story told backwards, and it is worth counting once. Take those same 6 tracks in slots 0 through 5, and pull the one at position 2 out. Now there is a hole at slot 2, and the array's no-gaps promise forbids it. So slots 3, 4, and 5 each slide one to the left to close it, which is n − i − 1 = 6 − 2 − 1 = 3 moves. An insert opens a hole and a delete closes one, but either way the machine pays for every element sitting past the cut.

So insertion and deletion are O(n), because the cost grows with how much sits after the cut. Insert at the very end (i = n) and nothing shifts at all, which is cheap. Insert at the front (i = 0) and all n elements have to move, which is the worst case. The measurement is unambiguous, and it shows that inserting at the front of lists ten times larger costs ten times more:

cost.pypython
import timeit
for n in (10_000, 100_000, 1_000_000):
    L = list(range(n))
    t = timeit.timeit("L.insert(0, 99)", globals={"L": L}, number=1000)
    print(n, round(t/1000*1e6, 1), "us per front-insert")
# 10000        1.7 us
# 100000      17.1 us    <- 10x the elements, ~10x the shift
# 1000000    185.0 us    <- and again: linear, O(n)

Each L.insert(0, 99) forces CPython to shove every existing element up one slot before writing the new value into slot 0. As n goes 10k → 100k → 1M, the time goes 1.7 → 17 → 185 microseconds. That is a clean straight line, and there's the O(n), lived. Appending to the end of these same lists, by contrast, runs in tens of nanoseconds. That is thousands of times faster, because it shifts nothing.

insert at i = 2 → elements 2..5 shift right (n − i = 4 moves) 200 247 245 269 210 258 200 247 NEW 245 269 210 258 before after
Fig — To keep the "no gaps" promise, an insert at i must move the n − i tail elements over. Cheap reads, expensive middle edits — two faces of one coin.
InteractivePick where to insert — count how many elements have to move
insert at i = 0 → shifts = 8 − 0 = 8 elements move front insert = worst case: the whole array slides.
0
Blue stays put; amber must move. Drag i toward the end and the cost melts to zero — that's why append is the cheap edit.
Read-heavy: yes. Middle-churn: no.
If your workload indexes, scans, and appends, the array is close to unbeatable. If it constantly inserts and removes in the middle, every edit drags a tail of memory and the O(n) tax compounds — that's the exact pain the next chapter's linked list was invented to remove.

The array so far has a fatal-sounding flaw: a fixed size. Lay six slots and you've committed to six. So how does list — which you .append to forever — live on top of it? →

04Static vs dynamic: how a fixed block grows

A raw array is static. Its length is fixed the moment it's allocated, because the slots after it may belong to something else. Python's list is a dynamic array, the same contiguous block wrapped in a trick that lets it grow. The trick is overallocation. The list quietly asks for more slots than it currently needs, and keeps the spare capacity in reserve. Appending just writes into the next spare slot and bumps a length counter, which is O(1), no copying. Only when the spare runs out does the list allocate a bigger block, copy everything across, and free the old one.

Why hand out spare slots at all? Picture the lazy alternative. The list grows by exactly one slot on every append, so every single append has to copy the whole block into a new, one-bigger block. Building a list of 1000 elements that way copies 1 + 2 + 3 + … + 1000 elements in total, which is 1000 × 1001 / 2 = 500500 copies for 1000 appends. That is O(n²), and it would make a growing list crawl. Overallocation is the fix: hand out a batch of spare slots, and most appends become a free write with no copy at all.

You can watch the reserve fill and refill for yourself, because sys.getsizeof reports the block's real byte size, and that size only changes when the underlying capacity does:

dynamic.pypython
import sys
L, size = [], sys.getsizeof([])          # empty list header = 56 bytes
for i in range(40):
    L.append(i)
    s = sys.getsizeof(L)
    if s != size:                        # only prints when it RESIZED
        print(len(L), s, "bytes  capacity", (s - 56)//8)
        size = s
# 1   88 bytes  capacity 4
# 5  120 bytes  capacity 8
# 9  184 bytes  capacity 16
# 17 248 bytes  capacity 24
# 25 312 bytes  capacity 32
# 33 376 bytes  capacity 40

The empty list is a 56-byte header with zero slots. The first append jumps it to capacity 4 (88 = 56 + 4×8). Appends 2, 3, 4 are then free, because they fill the reserve without changing the size. The 5th append exhausts capacity 4, so CPython grows to 8, copies the four elements over, and carries on. The pattern of capacities is 4, 8, 16, 24, 32, 40. It shows the block growing by a shrinking proportion each time, roughly 1.125×, not doubling. The gaps between resizes are why almost every append is cheap.

len = 5, but capacity = 8 → three spare slots absorb the next appends free 200 247 245 269 210 · · · used (len) spare capacity full → resize bigger block, copy all n
Fig — A dynamic array keeps spare slots so most appends are free writes. When the spare runs out it allocates a larger block and copies — rare, and paid back over many cheap appends.
NOW BUILD THE STATIC ARRAYa block that cannot grow, and that bills you out loud for every element it moves
The drill. Write a FixedArray(capacity) class over one plain list, and make it behave like the real, static block this section just described.

1. __init__ allocates [None] * capacity once and never resizes it again.
2. append(x) writes into the next free slot. Past capacity it must refuse — raise, don't grow, because a static block has nowhere to grow into.
3. insert(i, x) opens the hole the only way contiguity allows: back to front. Every element it physically moves adds one to a running self.shifts.
4. __getitem__ is one index into the block. No walk, no loop.

The check: with four items in a six-slot array, insert(0, x) must push shifts up by exactly 4, and the very next insert(len(A), y) must leave it untouched. Same method, same array — the bill is decided entirely by where you cut.
show the solution
# A static array: capacity fixed at birth, and it bills you for every shift.
class FixedArray:
    def __init__(self, capacity):
        self._slots = [None] * capacity   # the block: allocated once, never grows
        self._n = 0                       # how many slots are filled
        self.capacity = capacity
        self.shifts = 0                   # elements physically moved, ever

    def __len__(self): return self._n

    def append(self, x):
        if self._n == self.capacity:
            raise OverflowError(f"array is full ({self.capacity} slots) - a static block cannot grow")
        self._slots[self._n] = x          # write into the next free slot: no shift
        self._n += 1

    def insert(self, i, x):
        if self._n == self.capacity:
            raise OverflowError(f"array is full ({self.capacity} slots) - a static block cannot grow")
        for k in range(self._n, i, -1):   # BACK to front, or you smear the value
            self._slots[k] = self._slots[k - 1]
            self.shifts += 1              # <- the O(n) made visible
        self._slots[i] = x
        self._n += 1

    def __getitem__(self, i):             # O(1): one index into the block
        if not 0 <= i < self._n: raise IndexError("index out of range")
        return self._slots[i]

    def __repr__(self): return f"FixedArray({self._slots[:self._n]}, cap={self.capacity})"

A = FixedArray(6)
for t in (200, 247, 245, 269):
    A.append(t)
print(A, "shifts so far:", A.shifts)

A.insert(0, 111)          # worst case: everything moves
print(A, "shifts so far:", A.shifts)
A.insert(len(A), 999)     # best case: nothing moves
print(A, "shifts so far:", A.shifts)
print("A[2] ->", A[2], "  (one index, no walk)")
try:
    A.append(1)
except OverflowError as e:
    print("OverflowError:", e)

# FixedArray([200, 247, 245, 269], cap=6) shifts so far: 0
# FixedArray([111, 200, 247, 245, 269], cap=6) shifts so far: 4
# FixedArray([111, 200, 247, 245, 269, 999], cap=6) shifts so far: 4
# A[2] -> 247   (one index, no walk)
# OverflowError: array is full (6 slots) - a static block cannot grow

# READ THE COUNTER. Four elements sat past index 0, so inserting there moved
# four -- exactly n - i. Inserting at the END moved none, and the counter did
# not budge. One method, two prices, decided by nothing but where you cut.
# Then swap [None]*capacity for a real block and you have CPython's list --
# except CPython, instead of raising, allocates a bigger block and copies.
# That single difference is the whole of "static vs dynamic".

Doesn't that copy-everything resize make append secretly O(n)? Sometimes it does, because a single append that triggers a resize really does move all n elements. But that happens so rarely, only when capacity fills, that the average cost stays constant across all the appends. That is amortized O(1), the honest-average accounting we met back in Volume 3. Timing the whole build proves it, since the per-append cost stays flat even as the list grows 100×.

Here's why the average really does stay flat. Watch the copies as the list grows through capacities 4, 8, 16, 24, 32, 40. A resize copies only the elements already there, so growing to cap 8 copies 4, to cap 16 copies 8, to cap 24 copies 16, and to cap 32 copies 24. To reach 40 elements the resizes copy 4 + 8 + 16 + 24 + 32 = 84 elements in total, spread across 40 appends. That is about 2 extra moves per append, a small constant that does not grow as the list gets longer. Double the final size and that per-append number barely moves. That is amortized O(1) in plain arithmetic.

dynamic.pypython
# build a list of n by appending, then divide total time by n
100_000  appends:  ~28 ns each
1_000_000 appends:  ~38 ns each
10_000_000 appends: ~40 ns each     <- flat: amortized O(1) per append
InteractiveAppend one at a time — watch capacity jump, not creep
len = 0 · capacity = 0 · getsizeof = 56 bytes empty list: just the 56-byte header.
0
getsizeof holds steady, then leaps — every leap is a resize that copied all n. Between leaps, appends are free.
↺ The thing people get backwards
"Arrays are slow to insert, so avoid them." Half-true, and the half that's wrong costs people the fastest structure they have. Arrays are slow to insert in the middle. Appending to the end — which is what real code does the overwhelming majority of the time — is amortized O(1), and it streams through cache while it does it. The right reflex isn't "avoid arrays," it's "avoid middle edits on arrays." Reach for something else only when middle churn is your actual pattern.
The deeper cut — CPython's exact growth rule, and why not 2×

When a CPython list must grow, it doesn't double. The rule in list_resize is roughly new_allocated = newsize + (newsize >> 3) + 6, then rounded up. That works out to about 1.125× plus a constant, which is exactly the 4, 8, 16, 24, 32, 40 progression we measured. Why so modest? Doubling wastes up to half the block as permanent slack. A gentle 1.125× keeps memory tight while still leaving enough headroom that resizes become geometrically rarer as the list grows. So it preserves amortized O(1) without the memory bloat. It's a deliberately different trade from, say, C++'s std::vector, which typically doubles for faster growth and more slack. The header, by the way, is a fixed 56 bytes. That covers the PyListObject's refcount, type pointer, length, capacity, and a pointer to the slot block, which lives separately. getsizeof counts the header plus the slot block, but not the objects the slots point to. Which is the whole next section.

That last line hides the biggest surprise in this chapter. A Python list of a million numbers is far heavier than a million numbers — and the reason is what the slots actually hold →

05References vs values: why a list weighs 4× an array

Here's the twist that trips up almost everyone. A Python list's slots do not hold your numbers. They hold references — 8-byte addresses (Volume 1: a name is a reference) pointing to full int objects that live scattered elsewhere on the heap. Each of those integer objects carries the ~28-byte object header every Python object pays (refcount, type pointer, the digits). So one integer in a list costs you two things: 8 bytes in the contiguous slot block, plus ~28 bytes for the boxed object it points at. The block is tidy and contiguous; the values it references are strewn across memory.

Put numbers on that tax. A list holding three large integers like 1000000, 2000000, and 3000000 spends 3 × 8 = 24 bytes on references in the tidy block, plus 3 × 28 = 84 bytes on the three boxed int objects scattered on the heap. The values weigh more than three times the slots that point at them. One honest caveat: CPython caches the small integers from −5 to 256, so a list of tiny numbers shares those objects and pays the object tax only once. Use large or distinct values, as here, and every one is its own 28-byte allocation.

Why references at all, though, instead of the numbers themselves? It comes straight back to the uniform slot from the start of this chapter. A Python list can hold anything at once, so [42, "hello", 3.14] mixes an int, a string, and a float, and those three objects have wildly different sizes in memory. A slot of fixed width simply cannot store an object of unknown width. So it stores the one thing that is always the same size: an 8-byte address pointing at the real object elsewhere on the heap. The reference is the price the list pays for holding any type in every slot.

The array module offers the other layout, a packed array that stores the raw values directly, with no per-element object and no reference indirection in the way. The size difference is not subtle, and we measured it:

packing.pypython
import sys, array
n = 1_000_000
L = list(range(n))
A = array.array('q', range(n))            # packed 8-byte values

sys.getsizeof(L)                          # 8_000_056  → just the references
sum(sys.getsizeof(x) for x in set(L))     # 28_000_000 → the boxed ints behind them
sys.getsizeof(A)                          # 8_183_816  → raw values, no boxing

The list's slot block is ~8 MB, a clean million 8-byte references. But follow those references and you find a million 28-byte int objects, another ~28 MB. The list's true footprint is about 36 MB, roughly 36 bytes per element. The packed array holds the identical numbers in ~8.2 MB, about 8 bytes each. That is the same data in 4.4× less memory. The array pays no object tax and stores no pointers, just values back to back.

list — a contiguous block of references → scattered int objects 8 B each (references) int 200 · 28 B int 247 · 28 B int 245 · 28 B int 269 · 28 B ≈ 36 MB array('q') — raw values packed back to back, no objects, no pointers 200 247 245 269 210 8 B each · nothing else ≈ 8.2 MB
Fig — The list's slot block is contiguous, but its values are scattered 28-byte objects reached by pointer. The packed array stores the values themselves — 4.4× lighter for the same million numbers.
THE STDLIB TOOLBELT · list vs array.arraythe same thousand numbers, two blocks — one stores addresses, one stores the values themselves
from array import array L = [anything, anything, …] # slots hold 8-byte REFERENCES to boxed objects A = array(typecode, iterable) # slots hold the RAW VALUES, one C type, no boxing A.itemsize # bytes per slot — this IS the stride from section 02 A.buffer_info() # (base address, length) — the real block, in this process
listAny type in any slot, and it grows on its own. You pay ~8 B for the reference plus ~28 B for the boxed int it points at: ~36 bytes an element. Reach for it by default.
array(tc, it)ONE C type for the whole block, and the value sits in the slot. ~8 bytes an element, no object tax, no pointer to follow. Reach for it when the data is a million numbers of one kind.
'q' · 'Q'Signed / unsigned 8-byte integer — the typecode used all through this chapter. 'i' is 4 bytes, 'b' a single signed byte.
'd' · 'f'8-byte and 4-byte floats. Pick the smallest type that holds your range and the whole block shrinks with it — a choice a list never gives you.
A.itemsizeBytes per slot, reported by the object itself. This is the stride, and it is why base + i × itemsize lands on slot i exactly.
A.buffer_info()Returns (base_address, length). That block is real and contiguous, which is why A.tobytes() can hand it straight to a file, a socket, or NumPy with no conversion.
INPUTimport sys
from array import array
n = 1000
L = list(range(1000, 1000 + n))        # 1000 distinct ints, past the -5..256 cache
A = array('q', range(1000, 1000 + n))  # 'q' = signed 8-byte slots
box = sum(sys.getsizeof(x) for x in L)
print("list  block :", sys.getsizeof(L))
print("list  boxed :", box)
print("list  TOTAL :", sys.getsizeof(L) + box, "->", round((sys.getsizeof(L)+box)/n, 1), "B/elem")
print("array TOTAL :", sys.getsizeof(A), "->", round(sys.getsizeof(A)/n, 1), "B/elem")
print("itemsize    :", A.itemsize, "B == the stride")
OUTPUTlist  block : 8056
list  boxed : 28000
list  TOTAL : 36056 -> 36.1 B/elem
array TOTAL : 8320 -> 8.3 B/elem
itemsize    : 8 B == the stride
TRIPWIRES
  • L.insert(0, x) and L.pop(0) shift the entire tail on every single call — O(n), measured at 185 µs on a million-element list earlier in this chapter. Append at the end, or reach for collections.deque.
  • [[0]*3]*3 does not make three rows. It makes three references to one row, so grid[0][0] = 9 prints [[9,0,0],[9,0,0],[9,0,0]]. Write [[0]*3 for _ in range(3)] and you get [[9,0,0],[0,0,0],[0,0,0]].
  • An array is single-typed and says so out loud: array('q', [1, 2, 3.5]) raises TypeError: 'float' object cannot be interpreted as an integer. That rigidity is not a limitation bolted on — it is exactly what buys the fixed stride and the 8-byte packing.
Where you meet this
Every time you do numeric work at scale you're choosing packed over boxed. NumPy arrays, pandas columns, PyTorch / TensorFlow tensors, an image (a packed height × width × 3 byte array), an audio buffer, a database page, a network packet — all packed contiguous blocks, precisely to dodge the 4× object tax and to stay cache-friendly. Python's flexible boxed list is the right default for mixed, small collections; the moment it's a million homogeneous numbers, you pack.

Lighter is only half the win. The packed block is also the shape the CPU physically runs fastest over — and that's not a metaphor, it's the cache →

06Why the metal loves a block: cache locality

Big-O counts operations and treats every memory access as one unit. The hardware disagrees, and the array is where the disagreement pays off. Recall the memory hierarchy from Volume 3. An L1 cache hit costs ~1 ns, and a miss out to RAM costs ~100 ns. The CPU never fetches one byte. On every miss it hauls in the whole surrounding cache line, ~64 bytes. For a packed array of 8-byte values that's eight neighbours per fetch, so one miss buys seven free hits. Walk the block in order and the prefetcher, seeing the straight march, fetches the next line before you ask. A contiguous scan is, quite literally, the fastest thing a CPU does.

The flip side makes the point sharper. Imagine the same million values stored not in a block but as a chain, where each one carries the address of the next and they sit scattered anywhere on the heap. Now every hop is a leap to an unpredictable address, so the CPU almost always misses the cache and waits the full ~100 ns for RAM. Worse, the prefetcher sees no fixed stride to guess from, so it cannot run ahead the way it does on a block. Same number of elements, same O(n) walk, but the scattered layout gives the hardware nothing to hold onto.

To prove the speed comes from the layout and nothing else, we walk the same block twice: once in order, then once through a shuffled set of indices. The reads are identical and the count is identical, so the only thing that differs between the two walks is the address pattern:

cache.pypython
import timeit, random
n = 5_000_000
data = list(range(n))
seq  = list(range(n))                     # visit 0,1,2,... in order
rnd  = list(range(n)); random.shuffle(rnd)   # same indices, shuffled
def walk(order):
    s = 0
    for i in order: s += data[i]
    return s
# walk(seq): ~140 ms
# walk(rnd): ~1139 ms   → 8.1x slower, exact same reads

Both loops touch every element exactly once and add it up. They share the same n, the same O(n), and the same interpreter overhead per step. The only difference is the address pattern. seq marches through consecutive addresses, which is cache-friendly. rnd leaps to random ones, which is a miss almost every time. The result is that the ordered walk ran ~ faster on our machine. That gap is not in the operation count, since Big-O is identical. It's the constant the layout bakes in, and it's why the contiguous array beats a scattered structure with the same complexity.

Where does the ~8× come from? Do the back-of-envelope arithmetic. A 64-byte cache line holds 64 / 8 = 8 of our values, so the ordered walk pays one ~100 ns miss and then gets 7 near-free ~1 ns hits. That averages out to (100 + 7 × 1) / 8 ≈ 13 ns per element. The shuffled walk, by contrast, lands on a fresh line almost every read, so it pays close to the full ~100 ns each time. Divide the two and 100 / 13 lands right around the we measured, even though the operation count never changed. The only thing that moved was the layout.

one 64-byte cache-line fetch = eight packed values v0 v1 v2 v3 v4 v5 v6 v7 sequential: 1 miss → 7 free hits; prefetcher grabs the next line early. random order jumps to a fresh line almost every access → a miss each time, 7/8 of each fetch wasted. measured (5,000,000 reads, same indices, order only): sequential ≈ 140 ms random ≈ 1139 ms — 8.1× slower, same reads
Fig — Same reads, same O(n); only the access order differs. Contiguity lets one fetch serve eight elements and lets the prefetcher run ahead — the array's quiet, decisive advantage.
InteractiveGrow the array past cache — watch one read fall off the cliff
one random read — where the data lives decides its speed working-set size → L1 L2 L3 DRAM 4 KB32 KB1 MB8 MB256 MB time for that single read (linear scale, nanoseconds): 1 4 14 90 ns 1.1 ns the baseline — reads hit L1 at ~1 ns fits in L1 cache — the read is essentially free Same array, same random access, same O(1) — only cache residency changed.
16 KB
L1 is a pen on your desk (~1 ns); DRAM is mailing off for it (~90 ns). Drag past ~8 MB and every read falls off the cache cliff — that invisible line, not the Big-O, is what decides real-world speed.
The bet the hardware makes for you
Store data in a block and walk it in order, and the cache line plus the prefetcher turn RAM's ~100 ns into something close to L1's ~1 ns. The array is the structure that rewards that bet by construction — which is why, at equal Big-O, "just use an array" is so often the fastest answer. It's also why C++'s std::vector beats std::list at nearly everything, and why every high-performance numeric library is built on packed blocks.

Light, instant to index, cache-loving, cheap to append. It's no accident this is where every other structure begins →

07The 1% move: the array is the default, and the foundation

Being "good at data structures" is mostly knowing which one fits the access pattern. The array is the default, the one others must earn their way past. Here is its profile: O(1) indexing, O(1) amortized append, unbeatable cache behaviour, and minimal overhead of about 8 bytes per packed element against 36 for a boxed list. You pay for that with O(n) middle inserts/deletes and, when packed, a fixed element type. So the rule is sharp. If you access by position, iterate in order, and grow at the end, use an array. Reach for something else only when your dominant operation is middle churn, keyed lookup, or hierarchical order. The chapters ahead are exactly those "something elses."

Put the rule to work on one real decision. Say you are holding a million sensor readings that you build up once, then scan again and again to average and plot. You add only at the end, you read by position, and you walk them in order, three green lights, with never a middle insert in sight. That is an array, and specifically a packed one, sparing you the ~28-byte object tax a million times over. The moment the job changed to constant inserts in the middle, you would reach for something else. Matching the layout to the access pattern is the whole skill.

The human insight worth taking with you is the one that made all of it possible: turn position into arithmetic. Someone realised that if you fix the slot size, an element's location stops being something you store and search for. Instead it becomes something you compute, namely base + i × stride, one multiply-add. That single idea, address as a formula, is the seed of the whole field. A stack and a queue are arrays with a rule about which end you touch. A heap is a tree flattened into an array where a node's children are found by arithmetic (2i+1, 2i+2). A matrix is an array indexed by row × width + col. Even a hash table from Volume 1 is an array whose index you compute from the key. Learn to see the block under all of them, and the rest of this volume stops being thirteen unrelated tricks. It becomes variations on one move.

hash table heap matrix stack queue string one contiguous block · address = base + i × stride
Fig — Almost every structure in this volume is the array in disguise — a block, plus a rule for how to compute or interpret its indices. Learn the block and you've learned the foundation.

The everyday transfer is real too. A parking garage numbers its spaces so you find yours by arithmetic instead of wandering. A packed shelf shoves every book down when you wedge one into the middle. The array is the oldest idea in computing because it's the most human one. Put things in a row of equal boxes, and you'll always know where each one is.

Wait —
everything good about the array came from contiguity: pack the slots tight and position becomes arithmetic. But what if you gave that up on purpose — let each element live anywhere in memory, and store a pointer from each to the next? You'd lose the O(1) index. What could possibly be worth that?

The next chapter unbolts the block. The linked list throws away contiguity, scatters its elements as nodes joined by pointers, and buys back the one thing the array can't do cheaply — O(1) insertion in the middle — at the price of the multiply-add that made indexing free. Two structures, opposite deals with memory. →

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

The array is the oldest idea in computing and the quietest: put things in a row of equal boxes and you never search for one — you compute where it already is, and here are twelve tiny runnable proofs of that single move and everything it buys.

Position becomes arithmetic — O(1) indexing
Fix the slot size and an element's location stops being something you store and search for — it becomes something you compute: base + i × stride, one multiply-add, the same cost for slot 0 or slot nine-million.
The price of the packing — the shift
Contiguity is a promise of no gaps, and reads love it. Middle inserts and deletes hate it: to keep the promise, every element past the cut must slide, so both are O(n).
A fixed block that learns to grow
A raw array's length is frozen at birth. Python's list wraps it in overallocation — spare slots that make almost every append free, with rare copy-everything resizes that average out to amortized O(1).
Lighter, and the shape everything else is built on
Packed values dodge the 4× object tax a boxed list pays — and once you see position as arithmetic, the matrix and the heap turn out to be the same block wearing a different formula.
end of chapter 49 · seven sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked