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

55The matrix — a grid in a straight line of memory

In Chapter 54 the hash map handed us a value straight from its key: no order, no geometry, just one hop. Here we want the opposite thing, a shape — rows and columns, like a spreadsheet, a chessboard, or the pixels of a photo. But here is the catch the whole chapter turns on: the machine underneath has no grid. Volume 1 drew RAM as a single line of numbered boxes, one address after the next, and that is genuinely all there is. So a matrix is a quiet trick that folds a two-dimensional idea flat onto one-dimensional memory. The one question worth chasing is this: where, in that single line, does cell (i, j) actually live? Answer that and the rest falls out. By the end you will compute any cell's address in your head, feel why summing a grid row-by-row beats column-by-column on identical work, and know exactly when a real contiguous block crushes Python's list-of-lists.

★ YOU ALREADY RUN THIS · seat-K14your ticket was never a description of a seat — it was two instructions
The lights are already down and your ticket says K14. You do not wander the aisles looking for it. You walk up counting rows in the dark — A, B, C — until you reach K, then you sidestep along that row counting to 14, and you sit. Notice what you never needed to know: the size of the cinema. K14 in a twenty-row house and K14 in a sixty-row house cost you exactly the same two moves — walk past ten full rows, then step fourteen seats in. And notice the one thing the whole trick leans on. Every row holds the same number of seats. Take that away and counting rows tells you nothing.
K is the eleventh letter, so walk past ten full rowsi * ncols · the row jump — and ncols is the row width, never the row count
then step fourteen seats along that row+ j · the column offset. Together: index = i*ncols + j
you never counted the whole cinemaO(1) — the seat is computed, never searched; any house size, the same two moves
every row holds the same number of seatsthe rectangle is what makes the arithmetic legal — a jagged grid destroys it
you read a page line by line, never down a columnrow-major order — the direction the grid was written down is the direction it is cheap to read
pin it: K14 was never a description of where you sit — it was two instructions: skip ten rows, then step fourteen. Every grid your machine touches is read exactly that way.
iolinked · chapter 55 — the checkpoints5 steps
$ sections covered in The matrix — a grid in a straight line of memory
01A grid is a story we tell; memory is a line
02The one formula: base + (i·ncols + j)·slot
03Row by row is a sprint; column by column is a stumble
04Python's grid isn't a grid: list-of-lists vs one true block
05The 1% move: pick the layout that matches your walk

01A grid is a story we tell; memory is a line

Let's start by drawing a 3×4 matrix. Your eye sees a rectangle: three rows stacked, four columns side by side. The CPU sees no such thing, because its memory is exactly what Volume 1 gave us. It is a single sequence of addressed slots, box 0, box 1, box 2, marching off in one direction only, with no "up," no "down," and no second axis anywhere. So to store a grid at all, the machine has to flatten it, unrolling the rectangle into one long strip.

There are two honest ways to unroll it, and the near-universal choice is row-major order. You lay down all of row 0 end to end, then immediately after it all of row 1, then row 2, so the rows are concatenated into one contiguous run. The other way, column-major, lays down whole columns first, and Fortran, MATLAB, and Julia use it, so it will matter later. Row-major is how C arranges a grid, and therefore NumPy, and therefore almost every image and tensor you touch. The rectangle in your head is a line in the RAM.

Make the fold concrete before the formula arrives. Take that 3×4 grid and label its cells a through l, reading left to right, top to bottom: [a,b,c,d] on top, [e,f,g,h] in the middle, [i,j,k,l] at the bottom. Row-major writes them into memory in exactly that reading order, twelve slots with no gaps: a b c d e f g h i j k l. Cell b and cell c were side by side in the grid, and they stay side by side in the line. But a and e sat one directly above the other, and now four slots separate them. That single gap is the whole story of this chapter. Going sideways is cheap. Going down costs a full row.

Two names for the same fold
Row-major is also called C order (because the C language does it), and it's exactly the addressed line Volume 1 drew — just with a rule for which value goes in each box. NumPy calls it order='C'; column-major is order='F' (Fortran). When a library asks "C or F?", it's asking which way to unroll your grid onto the one-dimensional line.
how we think — a 3×4 grid 0,0 0,1 0,2 0,3 1,0 1,1 1,2 1,3 2,0 2,1 2,2 2,3 flatten row-major ↓ how RAM holds it — one contiguous strip → 0,0 0,1 0,2 0,3 1,0 1,1 1,2 1,3 2,0 2,1 2,2 2,3 b+0+1+2+3 +4+5+6+7 +8+9+10+11 row 0 row 1 row 2
Fig — The fold. A 3×4 grid becomes twelve consecutive slots: row 0 first, then row 1 butted right against it, then row 2. The "grid" was never real to the machine — only this contiguous line is.

If the whole grid is one flat line, then cell (i, j) lands at some exact position in it. Which one? There's a single formula — and it's the reason indexing a matrix is instant →

02The one formula: base + (i·ncols + j)·slot

Look at the strip again and reason it out. To reach the start of row i, you must step past every row before it. Each of those rows is exactly ncols slots wide, and there are i of them. So i × ncols slots lie behind you. Now walk j slots into row i to reach the column you want. The cell's position in the flat line is its linear index, and it works out to:

index = i × ncols + j  →  address = base + (i × ncols + j) × itemsize

Here base is the RAM address of cell (0,0), and itemsize is the bytes per slot (8 for a 64-bit number). That is one multiply, one add, a fixed amount of arithmetic no matter how vast the grid. This is why reading matrix[i][j] is O(1). The machine never searches for a cell. It computes where the cell must be and fetches it directly. That is exactly what array indexing did back when we built the array, now with the 2D→1D twist baked into the offset. And notice what does the work: ncols, the width. The formula only holds because every row is the same width, so the grid is rectangular. Store a jagged grid and the trick collapses.

Feel why that rectangle is non-negotiable. Suppose row 0 held 4 cells but row 1 held only 2. The formula still wants to compute i × ncols to skip past whole rows, but which ncols? There is no single width to multiply by, so the jump lands somewhere meaningless. A grid earns its O(1) indexing precisely because every row is the same width. The moment the widths differ, the machine must store where each row begins and look it up, so the free arithmetic is gone and you are back to a jagged pile of separate lists.

⚠ MOST BEGINNERS THINK…my grid is rectangular — I built it that way
Section 02 has just made the rectangle the price of admission, and the natural next thought is that Python collects that price at the door. You built the grid with a comprehension, every row came out four long, and the object can tell you its own dimensions on request: len(m) is the row count, len(m[0]) is the width. Every observation propping this up is true, which is why nobody arrives here carelessly. So the belief is that a list of lists is a matrix, and a matrix carries a shape the language keeps for you — if a row ever lost a cell, something would say so. Give one row two fewer cells and ask the grid three ordinary questions.
TYPE THIS — 10 SECONDS
m = [[1, 2, 3, 4], [5, 6], [7, 8, 9, 10]]      # a "3 x 4 grid" -- row 1 lost two cells
print("shape:", len(m), "x", len(m[0]))
print("columns:", list(zip(*m)))
print("column sums:", [sum(col) for col in zip(*m)])
print("cells that vanished:", 3 * 4 - sum(len(r) for r in m))
shape: 3 x 4
columns: [(1, 5, 7), (2, 6, 8)]
column sums: [13, 16]
cells that vanished: 2
A list of lists is a list of independent rows that merely happen to agree on length: the rectangle is a promise your code keeps, not a rule the language enforces, and when the promise lapses nothing raises — every tool that spans the rows then picks its own answer in silence: len(m[0]) trusts row zero, zip stops at the shortest row, and plain indexing trusts whichever row you happened to land on. Read the three answers in order, because each one fails differently. Line 1 reports a confident 3 x 4, and it is not lying so much as answering a question you did not ask: len(m[0]) is row zero's length, and there is no ncols anywhere in the object to compare it against. Line 2 asks for the columns and gets two instead of four — zip stops at its shortest input, so columns 2 and 3 did not come back empty, they were never produced. Line 3 then sums those two survivors and hands you numbers that look exactly like an answer. No error, no warning, no missing-value marker. Two cells simply left the building.

Three labelled refinements, so none of this reads as a half-truth. First, it is not that Python never complains — it complains late, and from the wrong place. m[1][3] does raise, IndexError: list index out of range, but that is a message about row 1 arriving long after row 1 stopped being four wide, and it points at the reader rather than at whatever shortened the row. Worse, the toolbelt in section 04 will note that a negative index never raises; on a ragged grid that warning grows a second edge, because m[1][-1] hands back 6 — column 1 wearing column 3's name. Second, a real matrix type does collect the price at the door. On NumPy 1.26.4, np.array(m) refuses outright with ValueError: setting an array element with a sequence. The requested array has an inhomogeneous shape after 1 dimensions. The detected shape was (3,) + inhomogeneous part. That is the whole difference between a convention and a contract, and it is the same difference section 02 described from the machine's side: a contiguous block cannot be built at all unless the widths agree, so the layout enforces what the list of lists only hopes for. Third, ragged nesting is not a bug in general. A list of rows of different lengths is a perfectly good structure — the lines of a file, the children of each node, the readings from each sensor — and if that is what you have, itertools.zip_longest(*m, fillvalue=None) gives you all four columns with the holes made visible: [(1, 5, 7), (2, 6, 8), (3, None, 9), (4, None, 10)]. It only becomes a lie when you call it a matrix and hand it to code that multiplies i by a width.

Let's plug real numbers in and watch it fire. Stay with the 3×4 grid, so ncols is 4, and ask for cell (1,2): row 1, column 2, which is the value g from a moment ago. First step past the rows behind it. There is one full row above, so that is 1 × 4 = 4 slots. Then walk 2 more slots into row 1. You land at slot 4 + 2 = 6. Count it off on the strip a b c d e f g, and g really is the seventh box, index 6. No scanning, no searching. One multiply, one add, and you are standing on the cell.

Now stretch it so the formula has to work a little harder. Picture a 5×8 grid, five rows by eight columns, and reach for cell (3,6). Step past the three full rows behind it, which is 3 × 8 = 24 slots. Then walk 6 more into row 3, landing at 24 + 6 = 30. If each cell is an 8-byte number, that is a byte offset of 30 × 8 = 240 from base. One multiply, one add, one shift, and the grid could be a million rows deep without changing that count.

We didn't take this on faith. NumPy lays a matrix out as exactly this flat block, so we can ask the machine for each cell's real address and check it against the formula:

address.pypython
import numpy as np
M = np.arange(20).reshape(4, 5)      # a 4×5 grid, so ncols = 5
base = M.ctypes.data                 # RAM address of cell (0,0)

for (i, j) in [(0,0), (0,3), (1,0), (2,4), (3,4)]:
    offset = (M[i, j:j+1].ctypes.data - base) // M.itemsize
    print((i, j), "formula i*ncols+j =", i*5 + j, " measured offset =", offset)

print("strides (bytes):", M.strides)      # (20, 4)  ·  one row = 5×4 bytes
print("C_CONTIGUOUS:", M.flags['C_CONTIGUOUS'])   # True

Line 2 builds the grid and reshapes it row-major. Line 3 grabs the base address. The loop asks NumPy for the true byte address of each cell, subtracts base, and divides by the slot size to get the offset in elements. Every single one matched the formula exactly: (0,0)→0, (0,3)→3, (1,0)→5, (2,4)→14, (3,4)→19. The last line prints the strides, (20, 4), the machine's own words for the same fact. Stepping one column over moves 4 bytes, one slot to the neighbour. Stepping one row down moves 20 bytes, a whole ncols × itemsize leap. Hold onto that asymmetry. The next section is entirely about it.

0 1 2 3 4 5 6 7 8 9 10 11 skip i=2 full rows → i·ncols = 2·4 = 8 + j = 3 index(2,3) = i·ncols + j = 8 + 3 = 11
Fig — Reaching cell (2,3): skip two full rows of width 4 (that's the i·ncols jump), then step 3 into the row. Landing slot: 11. No search — pure arithmetic.
InteractivePick a cell — watch its flat address compute
4×5 grid — ncols = 5 flat memory — 20 contiguous slots index = i·5 + j = 0
(0,0)
The 2D address (i,j) and the 1D slot i·ncols+j are the same place, wearing two costumes.
The off-by-one that isn't the one you think
The formula uses ncols — the number of columns, the row width — not the number of rows. Reach for nrows by reflex and every address is wrong the instant the grid isn't square. The mnemonic: you multiply i by "how far it is to the next row," and one row is ncols slots long.

One multiply, one add, and you're at any cell. So why can two loops that touch every cell — same count, same Big-O — finish many times apart? Because of which direction you walk the line →

03Row by row is a sprint; column by column is a stumble

Here is the payoff, and it is the whole Big-O lesson from Volume 3 made physical. Summing an n×n grid touches cells no matter which way you loop, so the operation count, the Big-O, is identical. But the machine does not charge by the operation — it charges by the cache line. Recall the metal: every miss to RAM hauls in a whole 64-byte line, betting you will want the neighbours next, and a prefetcher races ahead when it spots a straight march. A matrix is laid out so that row neighbours are memory neighbours, while column neighbours sit ncols slots apart.

Put a number on that 64-byte line, because the next paragraph leans on it. Our cells are 64-bit integers, so each one weighs 8 bytes, and 64 ÷ 8 = 8. Every trip to RAM therefore drags back eight numbers at once, whether you asked for eight or just one. Read them in the order they arrived and all eight get used before the next trip. Read one, jump far away, read one again, and you paid for eight but kept one. Seven of every eight numbers hauled in and thrown straight back out. That ratio, not the operation count, is what the timings below are really measuring.

So walk it row by row, with the inner loop running along j, and you stream straight down the contiguous strip. One miss pulls in eight useful numbers, and the prefetcher sees your line and readies the next. Walk it column by column, with the inner loop running down i, and every single step jumps a full ncols × itemsize stride to a different cache line. That means a fresh miss per element, seven-eighths of every fetched line thrown away, and the prefetcher left blind. Same reads. Wildly different metal.

row-major walk — inner loop along j 01234567891011 contiguous — 1 miss per cache line, then free hits; prefetcher happy. column-major walk — inner loop down i 01234567891011 visits slots 0, 4, 8, then 1, 5, 9 … each jump = a new line = a fresh miss. measured, pure-Python 2000×2000: row-major 354 ms · column-major 480 ms → 1.36× (interpreter dilutes it) measured, NumPy strided read 4096×4096: contiguous 41 ms · strided 250 ms → ~6× on the same bytes
Fig — Same cells, two directions. Row-major glides along the line the caches love; column-major leaps a full row every step, paying a miss each time. The exponent ties; the constant decides.

We measured both. In pure Python, a nested loop over a 2000×2000 list-of-lists does four million additions either way. Row-major ran ≈354 ms and column-major ≈480 ms, about 1.36× slower. Timings are hardware-dependent, and here the interpreter's own overhead swamps most of the cache cost. To see the effect undiluted, strip the interpreter away and move the same bytes through NumPy's C loop. Reading a 4096×4096 block in memory order took ≈41 ms. Reading it strided down the columns took ≈250 ms, about for the identical data. Same work. The only thing that changed was the direction of the walk.

Put it in nanoseconds to feel the height difference. That 4096×4096 block holds 4096² = 16,777,216 cells, about 16.8 million. Reading it in memory order in ≈41 ms works out to roughly 2.4 ns per cell. Reading it strided in ≈250 ms is about 14.9 ns per cell — the very same cell, visited the same number of times, costing six times as long. The curve keeps its shape, but column-major draws it far taller.

Sit with what those two numbers mean, because it dents a comfortable belief. Big-O said both walks are O(n²), and that is true: the exponent, how the cost grows as the grid gets bigger, is identical. But Big-O deliberately hides the constant factor sitting out front. Here that hidden constant reached a full once the interpreter was out of the way. Both loops touch every cell the same number of times and grow at the same rate. One simply spends several times longer per cell, because it keeps throwing fetched cache lines away. Same shape of curve, very different height. That gap is exactly where careful data-structure work earns its keep.

You might expect exactly then, since we keep one number in every eight. Real hardware never quite hits that ceiling. The prefetcher still salvages a little even on the strided walk, some of the data already sits in a larger outer cache, and the row-major run itself is not perfectly free. So the clean 8:1 ratio erodes to the ≈6× we actually measured. The lesson survives untouched: most of every fetched line is hauled in and wasted, and you pay for it in real wall-clock time.

Wait —
the exact same four million additions, and one ordering runs several times slower purely because of where the numbers sit? What is the machine actually being charged for, if not the arithmetic?
rowcol.pypython
M = [[1]*2000 for _ in range(2000)]      # a 2000×2000 nested list

def row_major():                          # inner loop runs ALONG a row
    t = 0
    for i in range(2000):
        row = M[i]                        # one row object, then stream it
        for j in range(2000):
            t += row[j]                   # memory neighbours — cache-friendly
    return t

def col_major():                          # inner loop runs DOWN a column
    t = 0
    for j in range(2000):
        for i in range(2000):
            t += M[i][j]                  # jumps between scattered rows — hostile
    return t
# measured best-of-5: row_major ≈ 354 ms · col_major ≈ 480 ms  (≈1.36×)

The two functions compute the identical sum. The only difference is the loop nesting. In row_major the inner loop fixes i and sweeps j, so line 9 marches along one contiguous row after hoisting it into row. In col_major the inner loop fixes j and sweeps i, so line 16 re-indexes a different row object on every step, chasing pointers all over the heap. Swapping two for lines is the whole change — and it's free speed.

InteractiveToggle the walk — watch the cache misses pile up
4×4 grid · flat strip = four 4-slot cache lines · step through the visiting order flat memory, grouped by cache line: cache lines crossed: 0 mode: row-major
Row-major crosses a line every 4th step; column-major crosses one almost every step — 4 misses versus 16, on identical work.
↺ The thing people get backwards
People think the two nested loops are "the same loop, just written differently" — same variables, same sum, same O(n²). They are not the same to the machine. Loop order is a memory-access decision, not a style choice. Fixing the row in the outer loop and sweeping the column inner (for row-major storage) can be several times faster than the reverse, for free, on code that looks byte-identical to a reviewer. This one habit — match your loop nesting to your storage order — is why numerical libraries obsess over it and why the same matrix multiply can run 10× apart depending on which index moves fastest.

The pure-Python numbers were oddly muted — only 1.36×. That's a clue: a Python "matrix" isn't really a contiguous block at all. Which raises the real question — where does a list-of-lists live in memory? →

04Python's grid isn't a grid: list-of-lists vs one true block

When you write [[0]*cols for _ in range(rows)], you do not get one flat block. You get what Volume 1 would predict. An outer list holds rows eight-byte references, each pointing off to a separate row-list object allocated somewhere else on the heap. Each of those row-lists is itself a contiguous run of references, pointing to separate integer objects, scattered again. That is three levels of indirection, and the rows can sit anywhere. So "moving to the next row" is a pointer-chase to an unrelated address. That is precisely why our column-major stumble was only 1.36×. The layout is already so scattered that walking it "the right way" barely helps.

Walk that chain once by hand to feel the cost. To read cell grid[7][3], the machine first reads slot 7 of the outer list, which is not a number but an address, so it goes there. That lands on row 7's own list, where it reads slot 3, again an address, so it goes there too. Only now, at a third and unrelated spot in memory, does it finally find the actual integer. Three hops for one value, and the second and third can be anywhere the allocator happened to have room. The prefetcher, which only rewards a straight march through memory, has nothing to lock onto. Every cell is a small treasure hunt.

Tally what that chain costs against a real block. Reading one cell of the list-of-lists can trigger three separate cache misses (the outer slot, the row-list, then the integer), each potentially a fresh 64-byte line dragged in for a single 8-byte value. A contiguous NumPy row pays one miss and then rides that line for the next eight cells for free. Same logical grid, same (i, j) lookups, but one layout asks the memory system for several times the work. The structure you never see is the one quietly setting the bill.

NumPy does the opposite. An np.zeros((1000,1000)) is one contiguous block of raw values, with no per-cell object and no per-row indirection, just a tiny header describing the shape and strides. Let's weigh both:

nested.pypython
import sys, numpy as np
r = c = 1000

nested = [[0]*c for _ in range(r)]                 # list of 1000 row-lists
struct = sys.getsizeof(nested) + sum(sys.getsizeof(row) for row in nested)
print("nested list, structure only:", struct, "B")   # 8,064,856 B ≈ 8.06 MB

block = np.zeros((r, c), dtype=np.int64)           # one contiguous block
print("numpy block:", block.nbytes, "B")              # 8,000,000 B  (+128 B header)

# the catch: those shared 0s are ONE cached int. Give each cell a real value:
big = [[i*c + j for j in range(c)] for i in range(r)]  # 1,000,000 distinct ints
print("with distinct values: ~", (struct + r*c*28)//10**6, "MB vs numpy 8 MB")

Line 4 sums the outer list's header plus all 1000 row-lists: 8,064,856 bytes, about 8.06 MB. And that is only the structure, because every cell is the integer 0, which Python caches as a single shared object (Vol 1). The NumPy block on line 7 is 8,000,000 bytes of raw int64 plus a 128-byte header. That is essentially the same here, since an 8-byte reference and an 8-byte integer weigh the same. The trap springs on line 12. The moment the cells hold distinct values, each becomes a real ~28-byte integer object on the heap. The nested version balloons to ~36 MB while NumPy stays flat at 8 MB, about 4.5× heavier. And every one of those objects is a separate cache-missing pointer-chase. NumPy stores numbers. A list-of-lists stores directions to numbers.

THE STDLIB TOOLBELT · the grid, both waysnested lists for readable code, one flat list for the machine's own layout
W, H = 4, 3 # WIDTH first: W is ncols, the row width grid = [[0] * W for _ in range(H)] # H INDEPENDENT rows -- the only safe build grid[r][c] = v # two hops: outer slot, then row slot flat = [0] * (W * H) # one list -- the machine's own layout flat[r * W + c] = v # section 02's formula, typed out r, c = divmod(index, W) # ... and the same formula, run backwards flat[r*W : (r+1)*W] # a whole ROW -- contiguous, cheap flat[c :: W] # a whole COLUMN -- one full stride per step list(zip(*grid)) # transpose: rows become columns
[[0] * W for _ in range(H)]The comprehension runs [0] * W H separate times, so you get H distinct row objects. The first tripwire measures it: distinct row objects came out as 3 for this build and 1 for the starred shortcut. This is the only safe way to build a grid.
r * W + cSection 02's formula with names you can type. W is ncols — the row width. Reach for the row count here and every address is wrong the instant the grid stops being square.
divmod(index, W)The inverse, and it is one call. Slot 6 in a 4-wide grid gives (1, 2), and 1*4 + 2 is 6 again. Handy whenever you have a flat position and need the (row, col) a human can read.
flat[c::W]The column, straight out of the flat list — a stride of W. That stride is precisely the leap that costs you a fresh cache line on every step in section 03: the slice syntax is short, the walk underneath is not.
list(zip(*grid))Transpose in one call — it builds new tuple rows holding the very same cell objects, never copies of them. Proved with id() in the exercise below.
a negative index does not raisegrid[-1][0] quietly hands you the last row. So a bounds check on a board or a flood fill must be written if 0 <= r < H and 0 <= c < W — a try/except IndexError catches the overshoot and sails straight past the wrap.
INPUTW, H = 4, 3                                  # 4 columns wide, 3 rows tall

# A. the nested grid - build it with a COMPREHENSION, never with *
grid = [[0] * W for _ in range(H)]           # H independent row-lists
grid[1][2] = 7
print("grid       :", grid)
print("grid[1][2] :", grid[1][2])

# B. the flat grid - one list, and the formula from section 02
flat = [0] * (W * H)
flat[1 * W + 2] = 7                          # r*W + c
print("flat       :", flat)
print("flat[1*W+2]:", flat[1 * W + 2])
print("same cell? ", grid[1][2] == flat[1 * W + 2])

# the formula, and its inverse
r, c = divmod(6, W)                          # slot 6 -> which (row, col)?
print("divmod(6, W) ->", (r, c), "and back:", r * W + c)

# rows and columns out of the flat list
print("row 1      :", flat[1 * W:(1 + 1) * W])
print("column 2   :", flat[2::W])

# transpose without touching a cell twice
print("zip rows   :", list(zip(*grid)))
OUTPUTgrid       : [[0, 0, 0, 0], [0, 0, 7, 0], [0, 0, 0, 0]]
grid[1][2] : 7
flat       : [0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0]
flat[1*W+2]: 7
same cell?  True
divmod(6, W) -> (1, 2) and back: 6
row 1      : [0, 0, 7, 0]
column 2   : [0, 7, 0]
zip rows   : [(0, 0, 0), (0, 0, 0), (0, 7, 0), (0, 0, 0)]
TRIPWIRES
  • [[0] * W] * H builds one row and H references to it. Setting bad[1][2] = 7 gave [[0, 0, 7, 0], [0, 0, 7, 0], [0, 0, 7, 0]] — a whole column changed — while the comprehension version gave the single 7 you asked for. The proof is id: distinct row objects came out as 1 for the starred version and 3 for the comprehension. * repeats the reference, exactly as Volume 1 said it would; it was never a copy.
  • There is no column slice, and the thing that looks like one lies to you. grid[:][1] returned [4, 5, 6] — that is row 1, because grid[:] copies the outer list and [1] then indexes it. No error, no warning, just the wrong axis. The real column is [row[1] for row in grid][2, 5, 8], or flat[1::3] on the flat version → the same [2, 5, 8].
  • Loop order is a memory decision, and you can measure it in pure Python. Summing a 1000×1000 nested grid, best of 7 on this machine: row-wise 31.3 ms against column-wise 46.8 ms — 1.50× for the identical four million additions (it wandered between 1.4× and 1.6× across runs). Modest, and honestly so: as section 04 explains, a list-of-lists is already so scattered that walking it “correctly” can only recover a little. On a real contiguous block the same choice was worth about 6×.
list of lists — scattered, indirect outer list row 0 · • • • row 1 · • • • row 2 · • • • scattered int objects every arrow = a possible cache miss NumPy — one contiguous block 128 B header raw int64 values, back to back row 0 | row 1 | row 2 — no pointers, no gaps one miss streams 8 neighbours into cache 1000×1000 int64: block = 8.0 MB flat nested (distinct) ≈ 36 MB
Fig — Same 1000×1000 grid, two memory realities. Left: pointers to scattered rows to scattered ints — indirect and cache-hostile. Right: one packed block — this is why NumPy is fast and lean.
InteractiveCount the objects — a Python grid explodes; NumPy stays at one
one grid, two layouts — how many separate objects must the CPU chase to read it all? 1000 × 1000 grid PYTHON list-of-lists · SEPARATE OBJECTS 1,001,001 ≈ 1,001,001 index cards, each holding the address of the next number NUMPY block · SEPARATE OBJECTS 1 one contiguous block — zero indirection EVERY EXTRA OBJECT IS A POINTER-CHASE THE CACHE CAN MISS · log scale, each step ≈ 10× ▲ NumPy = 1 list-of-lists: 1,001,001 scattered objects · NumPy: 1 block. Memory 36.1 MB vs 8.0 MB — 4.5× heavier. same numbers, either way — one layout hands them to the cache in a row; the other flings them across the heap.
1000 × 1000
Drag from a tiny 8×8 board up to 2000×2000. The Python count is n² + n + 1 — one int object per cell (once the values are distinct, so they leave CPython's small-int cache), one list per row, one outer list — every one a separate address the prefetcher can't predict. NumPy never moves off 1. That gap is the whole reason a real matrix is one contiguous block.

Myth

A Python list of lists is a matrix — a real 2D block of numbers you can crunch efficiently.

Reality

It's an outer array of pointers to scattered row-arrays of pointers to scattered ints. Fine for a small board; for real numeric work it's slow and heavy. A true matrix is one contiguous block — that's what NumPy, images, and tensors give you.
Where you meet this
You use matrices constantly without naming them. A photo is a matrix of pixels — a 1080p RGB frame is a (1080, 1920, 3) contiguous uint8 block, exactly 6.22 MB (we measured it), which is why cropping or brightening it is just arithmetic on offsets. A spreadsheet, a game board (chess, Go, Minesweeper), the adjacency matrix of a graph (the graph chapter, a few chapters on), and every layer of a neural network — its weights are a matrix, and training is matmul (Vol 3) run billions of times. NumPy, PyTorch, and TensorFlow all keep tensors as one contiguous block for precisely the cache reasons above.
The deeper cut — strides, and why A.T is instant but A.T.copy() is slow

A NumPy array is really a block plus a recipe: the raw bytes, plus strides that say how many bytes to jump per axis. Reshaping or transposing usually does not touch a single value. It just hands back a new view with rearranged strides over the same block. That is why A.T (transpose) is O(1) and free. It swaps the two strides, and now "reading a row" secretly reads down a column of the original. The bill comes due when you force it back to contiguous with A.T.copy(). That physically gathers the strided data into a fresh block, and we measured it at ~6× the cost of a normal copy, the exact cache penalty from earlier. The view is a promise. The .copy() is when memory pays for it.

Make the transpose trick concrete with the numbers we already have. That 4×5 block reported strides (20, 4): step one row and move 20 bytes, step one column and move 4. Transpose it and NumPy simply swaps them to (4, 20). Not one value moved. Now asking for row 0 of A.T steps by 20 bytes each time, which is precisely walking down column 0 of the original block. The data never changed shape in memory — only the recipe for reading it did. That is why transposing a billion-element array can return in an eyeblink: it rewrites two small numbers, not a billion big ones.

So the matrix is a deal: one rigid contiguous block, unbeatable when you touch cells by (i,j) and walk them in storage order. The last move is knowing when that deal is the right one to strike →

05The 1% move: pick the layout that matches your walk

Here's the human insight worth stealing: someone had to notice that a two-dimensional grid doesn't need two dimensions. Flatten it once, agree on row-major, and a single multiply-add reaches any cell forever. That is the whole invention. Trade the intuitive rectangle for a formula, and indexing becomes free. The row-major-versus-column-major choice was not obvious either — it is a genuine historical fork. C chose row-major, Fortran chose column-major, and MATLAB and Julia inherited Fortran's, so neither is "right." What is wrong is code that fights its own storage order, a column-sweeping loop over a row-major block, or vice versa, quietly paying the cache tax on every step.

This fork is not a museum piece; it bites in real code. Load an image with a C-based library and you get a row-major block. Hand those same bytes to a Fortran-descended routine that assumes column-major, and unless someone converts, it reads your rows as columns and quietly returns a transposed, wrong answer. Nothing crashes. NumPy even lets you ask for either layout explicitly, with order='C' or order='F'. The bytes can look identical on disk. The agreement about how to fold them back into a grid is the thing you have to get right, and getting it wrong fails silently.

You do not have to guess which fold you are holding. Ask the array: A.flags['C_CONTIGUOUS'] is True for a row-major block and A.flags['F_CONTIGUOUS'] is True for a column-major one. A fresh transpose flips which flag is set without moving a byte, which is the strides trick showing through. When a routine hands back a transposed, wrong answer, this one-line check is usually how you catch it before the bytes ever mislead you.

So the transferable move, the thing that is being good at data structures: know your access pattern before you pick your layout. Ask what you'll do most. Do you index arbitrary cells by (i, j) and sweep in a predictable direction? A matrix is the answer — dense, contiguous, O(1) access, and near-free to stream if your loop nesting agrees with the storage order. Will you mostly walk columns? Store column-major, or transpose once up front and walk rows. The matrix isn't fast in the abstract; it's fast when your walk and its fold point the same way.

WATCH AN EXPERT THINKis this number anywhere in a million-cell grid?
A large read-only grid of integers sits in memory — a tiled elevation map, a price ladder, a sorted index of event ids; the story does not change the shape — and one question has to be answered over and over: is this value in the grid, and if it is, which cell? Two facts ship with the grid, and they are the entire specification. Every row ascends from left to right. And every row starts above the previous row's last value. One label before we begin, so nothing here reads as more than it is: if you are allowed to build a companion structure that scales with the cell count, this problem stops being interesting — a set of all m·n values answers in one hop and chapter 54 explains exactly why. The version worth thinking about is the one where the grid is all you get: it is large, it is read-only, and you may not keep any companion structure that scales with the cell count. Something the size of the row count is still inside the rules, which will matter shortly. So rewind to before you read this chapter and watch somebody arrive, because the route matters more than the destination here — the turn everybody takes is a reasonable one.
FIRST INSTINCT
Two axes, so two searches. First find the row that could hold the value: walk down the rows until you meet the one whose last value is not below the target. Then binary search inside that row. It is correct on the first attempt and it stays correct, which is exactly what will later make its dumber cousin a good referee. Then you sharpen it, and the sharpening is real work rather than a straw man. The row-finding walk is itself a search over a sorted sequence, because the rows' first values ascend too — that is the second given fact, restated. So bisect them: i = bisect_right(firsts, target) - 1 picks out the band in O(log m), bisect_left(grid[i], target) finds the cell in O(log n), and the whole thing is logarithmic in both directions. On paper it is finished.
WHY IT STALLS
firsts is a column. This chapter has already established that a list of lists has no column object, and here that fact arrives as a bill: [row[0] for row in grid] sweeps every row header, rebuilt on every single query, so an O(log m) bisect is riding on an O(m) prelude. Worse, that cost tracks something which is not a property of the data at all. The run below folds the same one million cells three ways and times 20,000 lookups against each. At 100 × 10,000 the instinct actually wins, 2.3 us against 2.8 us, because a hundred row heads is nothing and the flat search does more probes than that. At 1,000 × 1,000 it is 17.7 us against 3.0 us. At 10,000 × 100 it is 830.0 us against 11.5 us, about seventy times apart. Identical values, identical answers, and the only thing that moved was how somebody chose to fold them. You can rescue the asymptotics by computing firsts once and keeping it — that is a legitimate answer, not a mistake — but you have then bought a second structure that must stay in step with the grid, and you are still holding two searches, two different boundary rules, and a guard for the target that sits below every row.
THE TELL
Read the two given facts again, together instead of one at a time. Every row ascends. Every row begins above the last one's end. Lay the rows down in row-major order — which is the order they are already sitting in — and those two facts say one thing between them: the grid is already a single sorted line of m·n values, and the rectangle is only packaging. That is not a new idea to invent. It is section 01's fold, read as a claim about order instead of a claim about addresses, and the packaging comes off with the formula this chapter has been holding the whole time. So stop searching in two dimensions. Binary search the flat index from 0 to m*n - 1, and let divmod(mid, ncols) turn each probe back into the (i, j) that the list of lists actually needs. One search, one boundary rule, no column and no cached companion structure — and the only shape fact the search ever consults is ncols, the width, exactly as section 02 warned. The cost is O(log(m·n)), which is log m + log n written once rather than twice: the sharpened instinct's best case, reached in a single pass, and reached without caring which way the million cells were folded.
from bisect import bisect_left, bisect_right
import math, random, time

def flat_search(grid, target):
    """ONE binary search, straight down the flat line. divmod unfolds each probe."""
    ncols = len(grid[0])
    lo, hi = 0, len(grid) * ncols - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        i, j = divmod(mid, ncols)              # section 02's formula, run backwards
        v = grid[i][j]
        if v == target:
            return (i, j)
        if v < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return None

def two_phase(grid, target):
    """The instinct, sharpened: find the band, then binary search inside it."""
    firsts = [row[0] for row in grid]          # a COLUMN -- a full sweep, every call
    i = bisect_right(firsts, target) - 1
    if i < 0:
        return None
    row = grid[i]
    j = bisect_left(row, target)
    return (i, j) if j < len(row) and row[j] == target else None

def brute(grid, target):                       # the referee, written before we got clever
    for i, row in enumerate(grid):
        for j, v in enumerate(row):
            if v == target:
                return (i, j)
    return None

def build(m, n):                               # gaps of 3, so absent targets are real
    vals = [5 + 3 * k for k in range(m * n)]
    return [vals[i * n:(i + 1) * n] for i in range(m)], vals

def best_us(fn, grid, queries, rounds=3):
    runs = []
    for _ in range(rounds):
        t0 = time.perf_counter()
        for t in queries:
            fn(grid, t)
        runs.append(time.perf_counter() - t0)
    return min(runs) * 1e6 / len(queries)

# --- 1. the referee, exhaustive on a small grid -----------------------------
small, vals = build(60, 40)
seen = set(vals)
absent = [t for t in range(vals[-1] + 6) if t not in seen]
ok_p = all(flat_search(small, t) == two_phase(small, t) == brute(small, t) for t in vals)
ok_a = all(flat_search(small, t) is None and two_phase(small, t) is None for t in absent)
print(f"referee  60x40 | {len(vals):,} values that ARE there: flat == two-phase == brute -> {ok_p}")
print(f"               | {len(absent):,} values that are NOT: both return None           -> {ok_a}")

# --- 2. the precondition, stated and checked -------------------------------
grid, vals = build(1000, 1000)
chains = all(grid[i][-1] < grid[i + 1][0] for i in range(len(grid) - 1))
print(f"\nprecondition  every row ascends AND each row starts above the last one's end: {chains}")
print(f"              so, read row-major, this grid IS one ascending line of {len(vals):,} slots")

# --- 3. the same 1,000,000 cells, folded three ways -------------------------
random.seed(55)
print(f"\n20,000 lookups, best of 3 -- the SAME 1,000,000 cells, folded three ways")
print(f"  {'shape':>15}  {'two-phase':>10}  {'flat':>9}   cells touched: two-phase vs flat")
for m, n in ((100, 10_000), (1000, 1000), (10_000, 100)):
    g, v = build(m, n)
    q = [random.choice(v) for _ in range(20_000)]
    a = best_us(two_phase, g, q)
    b = best_us(flat_search, g, q)
    same = all(two_phase(g, t) == flat_search(g, t) for t in q)
    touch = f"{m:,} heads + ~{math.ceil(math.log2(n))}"
    print(f"  {f'{m:,} x {n:,}':>15}  {a:>8,.1f}us  {b:>7,.1f}us   "
          f"{touch:<20} vs ~{math.ceil(math.log2(m*n))}   agree {same}")

# --- 4. the near-miss: rows sorted, columns sorted, rows do NOT chain -------
SM = SN = 300
stair = [[1000 * j + 7 * i for j in range(SN)] for i in range(SM)]
rows_ok = all(all(r[k] < r[k + 1] for k in range(SN - 1)) for r in stair)
cols_ok = all(stair[i][j] < stair[i + 1][j] for i in range(SM - 1) for j in range(SN))
chains = all(stair[i][-1] < stair[i + 1][0] for i in range(SM - 1))

def staircase(grid, target):
    i, j = 0, len(grid[0]) - 1                 # start at the top-RIGHT corner
    while i < len(grid) and j >= 0:
        v = grid[i][j]
        if v == target:
            return (i, j)
        if v > target:
            j -= 1                             # that whole column is too big
        else:
            i += 1                             # that whole row is too small
    return None

random.seed(7)
here = [random.choice(random.choice(stair)) for _ in range(1000)]
missed = sum(1 for t in here if flat_search(stair, t) is None)
found = sum(1 for t in here if staircase(stair, t) == brute(stair, t))
print(f"\nnear-miss  300x300: rows ascend {rows_ok}, columns ascend {cols_ok},"
      f" rows chain {chains}")
print(f"           of 1,000 values that ARE in it, the flat search said NOT FOUND for {missed:,}")
print(f"           the staircase walk from the top-right corner found {found:,} of 1,000")
referee  60x40 | 2,400 values that ARE there: flat == two-phase == brute -> True
               | 4,808 values that are NOT: both return None           -> True

precondition  every row ascends AND each row starts above the last one's end: True
              so, read row-major, this grid IS one ascending line of 1,000,000 slots

20,000 lookups, best of 3 -- the SAME 1,000,000 cells, folded three ways
            shape   two-phase       flat   cells touched: two-phase vs flat
     100 x 10,000       2.3us      2.8us   100 heads + ~14      vs ~20   agree True
    1,000 x 1,000      17.7us      3.0us   1,000 heads + ~10    vs ~20   agree True
     10,000 x 100     830.0us     11.5us   10,000 heads + ~7    vs ~20   agree True

near-miss  300x300: rows ascend True, columns ascend True, rows chain False
           of 1,000 values that ARE in it, the flat search said NOT FOUND for 997
           the staircase walk from the top-right corner found 1,000 of 1,000
trigger: reach for the flat index the moment a structure's storage order already is the order you want to search — then divmod is the entire translation. Look for the shape in the specification rather than in the data: “sorted, and every row picks up where the last one left off”, “a paged log where the pages are in time order”, “a tiled index written out tile by tile”. Every one of them is a sorted line that somebody folded for storage, and every one of them tempts you into searching the folds instead of the line. Then learn the near miss, because a tell that fires on everything is not a tell. Take a grid whose rows ascend and whose columns ascend, but whose rows do not chain — row 1 may start below row 0's end. Every local ordering you can see still holds, the global one does not, and the flat line is no longer sorted. The run builds exactly that grid, 300 × 300, and asks the flat search for 1,000 values that are genuinely in it: it reported not found for 997 of them, instantly, with a confident None and no error attached. That grid wants a different walk. Start at the top-right corner, where the two comparisons disagree usefully — too big retires an entire column, too small retires an entire row — and you land on the answer in O(m + n); it found all 1,000. Two grids that look alike on the page, two different structures underneath. The last habit is worth as much as the first: keep the stupid version and let it referee. brute is two nested loops and it was written before you got clever, so it cannot share your clever mistake — it confirmed the fast search on all 2,400 values present in the small grid and on all 4,808 values that were absent. And when you quote timings, quote the shape beside them: microseconds move with the machine and wander a few percent between runs, but a cost that tracks the row count instead of the cell count does not.
NOW TURN THE GRIDrotate 90° in one line — then prove with id() what that line actually built
The drill. Rotate a grid 90° clockwise. The famous one-liner is [list(t) for t in zip(*m[::-1])], and typing it is the easy part. Before you run it, work out on paper why reversing the rows and then transposing adds up to a clockwise turn. Draw the 3×3 and follow one corner cell around.

Then the question that actually teaches you something. What did zip build? Are the cells copied into the new grid, or merely pointed at again? Do not answer by eye — answer with is and id(). And build your test grid out of strings made at run time, not small integers: CPython caches the little ints, so a grid of zeros and ones would hand you a comforting True that proves nothing.

Two more things to establish while you are in there. What type is each row of zip(*m) before you wrap it in list? And does four consecutive rotations return you to the original grid?

The bonus, for a square grid only: transpose it in place by swapping m[i][j] with m[j][i] — and get the loop bounds right, because touching every pair twice swaps them straight back. Then prove no new list objects were made at all: same outer id, same row ids, start to finish.
show the solution
# Build cells as runtime-made objects so identity is meaningful
# (small ints are cached by CPython, which would fake the result).
W = H = 3
m = [[f"r{i}c{j}" + "" for j in range(W)] for i in range(H)]
for row in m:
    print(row)

def rotate90(m):
    """Clockwise. Reverse the ROWS, then transpose - that is all."""
    return [list(t) for t in zip(*m[::-1])]

rot = rotate90(m)
print("rotated 90 clockwise:")
for row in rot:
    print(" ", row)

# WHAT zip(*m) ACTUALLY BUILT - proved with id()
print()
print("zip(*m) type of a row:", type(list(zip(*m))[0]).__name__)
print("m[2][0] is rot[0][0]:", m[2][0] is rot[0][0])       # SAME cell object
print("m[2] is rot[0]      :", m[2] is rot[0])             # NEW row container
print("distinct row objects:", len({id(r) for r in rot}) == H)
print("cells re-referenced, not copied:",
      all(rot[i][j] is m[H - 1 - j][i] for i in range(H) for j in range(W)))
print()
print("four turns returns the original values:",
      rotate90(rotate90(rotate90(rotate90(m)))) == m)

# ['r0c0', 'r0c1', 'r0c2']
# ['r1c0', 'r1c1', 'r1c2']
# ['r2c0', 'r2c1', 'r2c2']
# rotated 90 clockwise:
#   ['r2c0', 'r1c0', 'r0c0']
#   ['r2c1', 'r1c1', 'r0c1']
#   ['r2c2', 'r1c2', 'r0c2']
#
# zip(*m) type of a row: tuple
# m[2][0] is rot[0][0]: True
# m[2] is rot[0]      : False
# distinct row objects: True
# cells re-referenced, not copied: True
#
# four turns returns the original values: True

# THE IN-PLACE TRANSPOSE, for a SQUARE grid - no new objects at all.
sq = [[f"{i}{j}" + "" for j in range(3)] for i in range(3)]
before = (id(sq), [id(r) for r in sq])
for i in range(3):
    for j in range(i + 1, 3):                # upper triangle ONLY
        sq[i][j], sq[j][i] = sq[j][i], sq[i][j]
after = (id(sq), [id(r) for r in sq])
for row in sq:
    print(" ", row)
print("same outer list:", before[0] == after[0], "| same row objects:", before[1] == after[1])

#   ['00', '10', '20']
#   ['01', '11', '21']
#   ['02', '12', '22']
# same outer list: True | same row objects: True

# WHY REVERSE-THEN-TRANSPOSE IS A CLOCKWISE TURN. Follow the top-left cell,
# r0c0. A clockwise quarter-turn must carry it to the top-RIGHT. m[::-1]
# flips the rows top-to-bottom, so r0c0 drops to the bottom-left. Transpose
# then reflects across the main diagonal, and the bottom-left corner lands
# on the top-right. Two reflections make a rotation - which is also why
# reversing the COLUMNS instead (zip(*m) then reverse each row) turns it
# anticlockwise. One reflection is a mirror; two are a turn.
#
# WHAT zip GAVE BACK, EXACTLY. Each row of zip(*m) is a TUPLE, not a list,
# which is why the comprehension wraps it - drop the list() and you get a
# grid you cannot assign into. And the cells were never copied: m[2][0] is
# rot[0][0] came back True, and the all() check confirmed it for every one
# of the nine. So the rotation wrote 9 references and 3 new row containers.
# It did NOT touch the strings themselves. On a million-cell grid that is a
# million pointer writes and zero cell copies - which is exactly why NumPy
# can go one better and rotate by rewriting two stride numbers, moving
# nothing at all (the deeper cut in section 04).
#
# THE CATCH THAT FOLLOWS FROM IT. Because the cells are shared, a rotation
# of a grid of MUTABLE cells gives you two grids pointing at one set of
# objects - mutate a cell through one and the other sees it. Fine for
# strings and numbers, which cannot change. A real trap for a board of
# lists or dataclasses, and the fix is the same as always: copy the cells
# yourself if you meant a copy.
#
# AND THE BOUNDS ON THE IN-PLACE VERSION. The inner loop starts at i + 1,
# so it visits each unordered pair once. Start it at 0 and you swap every
# pair twice, which returns the grid unchanged - a bug that looks like
# "the transpose did nothing" rather than like an error. Note also that
# this only works on a SQUARE grid: a 3x5 transpose is 5x3, a different
# shape, so there is no in-place version to write.

One case the closing figure hints at deserves a sentence of its own: what if the grid is mostly empty? Picture a 100,000 × 100,000 matrix of web links, where almost every cell is 0 and only a handful per row are ever set. A dense block would demand ten billion slots, nearly all of them storing nothing. Here the honest layout is a sparse matrix: keep only the non-zero cells, each tagged with its own (i, j), and let every absent cell mean zero by default. You give up the free O(1) address arithmetic in exchange for a lookup, but you store thousands of numbers instead of billions. Same rule as ever. The access pattern, and now the fill, decides the layout.

how will youtouch the grid? dense · index (i,j), sweepin storage order mostly walk columnsof a big grid mostly empty(sparse, all zeros) row-major block,inner loop = fast axis column-major, ortranspose once hash of (i,j) coords— not a dense block
Fig — Choosing on sight. The layout falls out of the access pattern: sweep a full grid in order → dense row-major; sweep columns → flip the storage; mostly empty → don't store a grid at all. Picking this well is the skill.
When the matrix is the wrong deal
A matrix pays for every cell, present or not. If your grid is sparse — mostly zeros, like a web's link graph or a giant recommendation table — a dense block wastes almost all its memory storing nothing. That's a different structure entirely: a hash map of only the non-zero (i,j) coordinates, or a compressed sparse format. Reach for the contiguous block when the grid is full; reach for a keyed structure (the hash map we built last chapter) when it's mostly empty.
The reflex to carry forward
Whenever you write nested loops over a grid, pause and ask: which index moves fastest, and does that match how the data is stored? Make the innermost loop run along the contiguous axis. It's the cheapest performance win in all of numerical computing — free, invisible in the Big-O, and worth up to an order of magnitude on the metal.

Everything so far has lived on a line, whether contiguous or chained: the array, the linked list, the stack, the queue, and now the matrix. The matrix was the boldest bet on the line. It crams a whole second dimension into it and pays with rigid, dense storage. The next family gives that up entirely. It scatters its data across the heap as nodes joined by pointers. In exchange for abandoning the contiguous block, it buys something the line never could: the power to halve the search space at every single step.

The next chapter opens the hierarchy family with the binary tree — a structure with no straight line at all, where each node points to two children, and that simple branching is the seed of logarithmic search. →

SAY IT BACKthe chapter in five breaths
  1. A grid is a story we tell about a line — memory has exactly one axis, so the rectangle has to be unrolled, and row-major unrolls it by laying row 0 down and butting row 1 hard against it, which is why two cells side by side stay side by side while two cells stacked one above the other end up a full row apart.
  2. Any cell is one multiply and one add away: index = i·ncols + j, and divmod(index, ncols) runs the same fold backwards — the machine never searches for a cell, it computes where the cell must be and fetches it, and that fixed arithmetic on a grid of any size is what O(1) access actually means.
  3. The number in that formula is the row width, ncols, never the row count — and the formula is legal only because every row is the same width, so the rectangle is not a tidiness rule but the very thing that makes the address arithmetic free; go jagged and there is no single width to multiply by, so the machine has to store and look up where each row begins.
  4. Two loops over the very same cells can cost wildly different amounts, because the machine charges by the cache line, not by the operation — walking a 2000² list of lists row-wise against column-wise measured 354 ms against 480 ms, only 1.36×, because the interpreter's own overhead swamps the cache cost; on a 4096² contiguous block with the interpreter stripped away it was 41 ms against 250 ms, about for identical work with an identical Big-O.
  5. A Python list of lists stores directions to numbers, not numbers — an outer list of references to scattered row-lists of references to scattered ints, three levels of indirection and a pointer-chase per row — which is why a 1000×1000 grid of distinct values weighs about 36 MB against NumPy's flat 8 MB, and why real numeric work reaches for one contiguous block plus a small stride recipe.
reach for this when… every cell is going to hold something, you address cells by position rather than by name, and you can say out loud which index moves fastest. All three clauses are load-bearing, and each one has a near neighbour that wants a different structure. If most cells will stay empty, a dense block pays for every slot anyway — a 100,000 × 100,000 board is ten billion of them, nearly all storing nothing — that is a sparse grid, and it belongs in a hash map keyed by (i, j), which is chapter 54's structure doing this chapter's job. If you reach cells by a name rather than a coordinate, you never wanted two axes at all; you wanted that same hash map. If the rows will not stay the same length, you do not have a matrix, you have a list of rows — say so in the code, because the language will not say it for you. And if you cannot name the fastest-moving index, you are not ready to choose the layout: that answer is what decides row-major against column-major, and choosing it wrong is the quiet tax the last three sections were about.
You already owned the pieces: chapter 49 gave you the array — a contiguous block whose cells are reached by computing an address rather than walking to it — and a matrix is that same trick asked to carry a second dimension, with i·ncols smuggling the extra axis into the offset. Volume 1 supplied the ground it stands on twice over: RAM as one line of numbered boxes, which is why the rectangle must be folded at all, and the 64-byte cache line, which is why the direction of the fold decides the speed. Volume 3 gave you Big-O and, more usefully here, taught you that it deliberately hides a constant — this chapter is where that hidden constant grew to on code a reviewer would call identical. And chapter 54 gave you the hash map, which turns out to be the honest answer whenever the grid is mostly empty.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 55, in working code

The rectangle in your head was never real to the machine — so let's fold it into one straight line ourselves, reach any cell with a single multiply-add, and feel exactly why the direction of your walk decides the speed.

The fold — a rectangle becomes one line
Memory only marches in one direction, so a grid has to be unrolled. We flatten it by hand in row-major order, compare that to the column-major fold, and rebuild the rectangle from the strip — proving the grid was only ever a story we tell about a line.
The one formula — reach any cell for free
index = i·ncols + j is the whole trick: one multiply, one add, and you're at any cell without ever searching. We read a cell, write a cell, and run the formula backwards with divmod.
The walk decides — same cells, different metal
Summing a grid touches n² cells whichever way you loop, so the Big-O ties. But the machine charges by the cache line, not the operation. We prove the totals match, count the cache-line crossings each direction pays, and transpose the grid so a column-walk becomes a friendly row-walk.
Python's grid isn't a grid — and real matrix moves
A list-of-lists is pointers to scattered rows, not one true block — which is why the *3 shortcut aliases every row, and why real numeric work reaches for a contiguous layout. We spring the aliasing trap, multiply two matrices from primitives, and store a mostly-empty grid the smarter way.
end of chapter 55 · five sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked