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.
i * ncols · the row jump — and ncols is the row width, never the row count+ j · the column offset. Together: index = i*ncols + j01A 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.
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.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.
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.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
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:
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']) # TrueLine 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.
(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.(i,j) and the 1D slot i·ncols+j are the same place, wearing two costumes.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 n² 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 n² reads. Wildly different metal.
n² 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 6× 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 6× 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 8× 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.
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.
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:
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.
[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.(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.id() in the exercise below.grid[-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)][[0] * W] * Hbuilds one row and H references to it. Settingbad[1][2] = 7gave[[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 isid: 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, becausegrid[:]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], orflat[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×.
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.(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.
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.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.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.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
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.id() what that line actually built[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.
(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.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. →
- 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.
- Any cell is one multiply and one add away:
index = i·ncols + j, anddivmod(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. - 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. - 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 6× for identical work with an identical Big-O.
- 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.
(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.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 6× 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.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.