60The graph — the structure that models everything
In Chapter 59 we built the trie — a tree whose very shape is the letters of a word. Every structure in this volume so far has had a fixed skeleton. A line, a grid, a tree that only ever branches downward. The graph throws that skeleton away. It keeps the barest idea of structure: a set of nodes and a set of edges between them. That minimalism is exactly why it swallows everything. Maps, friendships, web links, git history, the tasks a build must run in order are not like graphs, they are graphs. Vol 3 already walked one, running BFS and DFS on it. Here we build the thing itself, from memory up. We chase the question those walks quietly assumed an answer to — how does a web of relationships actually sit inside a machine whose memory is one flat line of bytes? By the end we'll take a real problem and name its nodes and edges. Then we pick one of two storage layouts, the move that separates fluent from lost, and it decides whether the thing fits in RAM at all.
Before the definition, look at something you touched today. Your phone's map app found a route because intersections are nodes and roads are edges. Your group chat is people as nodes and "can message" as edges. When pip installs a package, it reads a graph of "needs this first" and works out a safe order. None of these were designed to look alike. But the machine stores and walks them with one structure and one set of algorithms, and that shared machinery is the payoff we're chasing.
graph[u] — the adjacency list. One hash jump, and that city's neighbours are just there: the cost is its own degree, never VM[i][j] — one multiply-add, one read, O(1). Worth V² cells only when you ask it relentlessly, or when nearly every pair really does fly01Two sets — and suddenly everything is one shape
Let's start with the definition, because it's almost insultingly small. A graph is a set of nodes — also called vertices, the things — and a set of edges, the connections between pairs of things. That's the whole contract. The definition says nothing about what the things are, so anything can be a node. A city, a person, a web page, a commit, a chemical, a state of a game. Watch what that buys you. Cities-and-roads, people-and-friendships, pages-and-links, tasks-and-"must-come-before" are not like graphs, they are graphs: same structure, same code.
Edges come in two flavours you'll keep meeting. An undirected edge is a two-way street, like Ada ⇄ Bex: if she's your friend, then you're hers too. A directed edge points only one way, so page A → page B, and "socks before shoes" is never "shoes before socks." An edge may also carry a weight, a number on the connection: minutes of drive, cost of a flight, or strength of a bond. Four words — node, edge, direction, weight — let you describe a road atlas, a social network, and a compiler's build order in one breath. The vocabulary was Vol 3's job, and here we ask the brutally practical question it skipped past. A machine can't store a drawing; it can only store bytes. So how does a graph actually sit in RAM?
So picture a tiny road map: Oslo, Bergen, and Trondheim, with roads Oslo–Bergen and Oslo–Trondheim. The machine doesn't care where you drew the dots or how long you made the lines. All it needs to answer later is one question: who is connected to whom. That is adjacency — the list of each node's direct neighbours — and it's the only thing worth storing. Throw the drawing away and keep the neighbours, and you've turned a picture into something a flat line of bytes can hold.
There are exactly two honest ways to write adjacency down, and they sit at opposite ends of a memory tradeoff. The first spends nothing on connections that don't exist. →
02The adjacency list — pay only for the edges that exist
Let's build the thrifty one first. The adjacency list is the layout in the figure above: for each node, keep a list of its neighbours. In Python that's a dict-of-lists. It's a hash table (the dict, from Vol 1) whose values are dynamic arrays (the array we built earlier this volume). To find node 2's neighbours you hash the key 2, land on its slot, and read the list. The neighbours are right there. To store the whole graph you spend one dict entry per node, plus one reference slot for every edge-endpoint. Count it: V nodes and E undirected edges cost V + 2E slots, because each edge is written at both of its ends. That's O(V + E) space — you pay only for connections that are real.
Let's make V + 2E concrete with four friends: Ada, Bex, Cy, and Dot. Say the friendships are Ada–Bex, Ada–Cy, Bex–Cy, and Cy–Dot, so V = 4 and E = 4. Now write each neighbour list: Ada → [Bex, Cy], Bex → [Ada, Cy], Cy → [Ada, Bex, Dot], Dot → [Cy]. Count the references: 2 + 2 + 3 + 1 = 8, which is exactly 2E. Add the four dict entries and the graph costs 12 slots, and every one of them stands for a friendship that really exists.
One honest footnote to V + 2E: that's the undirected bill. A directed edge is written at only one end — its source — so it costs a single slot, not two. Turn those same four friendships into one-way "follows" and the endpoint count drops to V + E, which is 4 + 4 = 8 slots instead of 12. The rule of thumb is simple: undirected doubles, directed doesn't. The layout itself doesn't care which — it just stores whatever neighbours you hand it.
import sys
graph = { # a hash table (Vol1) whose values are dynamic arrays
0: [1, 2],
1: [0, 2],
2: [0, 1, 3],
3: [2],
}
sys.getsizeof(graph) # -> 224 the dict itself: 4 keys in a hash table
sys.getsizeof(graph[2]) # -> 88 node 2's list: 56-B header + 8 B per ref slotLine by line: graph is the entire structure, a dict whose four keys are the nodes and whose four values are neighbour lists. getsizeof(graph) returns 224 bytes. That's the hash table's own machinery for four keys (Vol 1's sparse-table overhead), and crucially it does not include the lists. getsizeof measures one object, not what it points at. getsizeof(graph[2]) is 88 bytes: a list's ~56-byte header plus 8 bytes for each neighbour reference. A real run measured a marginal 47 B per undirected edge once list over-allocation is amortised in. The headline: nothing here scales with V². It scales with the edges you actually have.
u you walk graph[u] — a list whose length is exactly u's degree (its neighbour count). You never touch the other V−1 nodes. That's the property BFS and DFS (Vol 3) lean on: their whole cost is "visit each node, scan its neighbour list once" = O(V + E). The adjacency list is what makes that sum small.So the list is thrifty. Why would anyone ever choose the other layout — the one that reserves a box for every possible connection in the universe? Because it buys something the list can't. →
03The adjacency matrix — a box for every possible edge
Now the other layout, the extravagant one. The adjacency matrix is a V×V grid of 0s and 1s, where cell [i][j] = 1 means "there's an edge from i to j." Memory is one-dimensional (Vol 1), so this grid is flattened row-major, exactly like the matrix chapter earlier this volume. Row 0's V cells come first, then row 1's V cells, laid end to end. Cell (i, j) lives at base + (i·V + j)·slot. That flat formula is the whole point. To ask "is i linked to j?" the machine does one multiply-add and reads one cell — O(1), no list to scan. The adjacency list makes you walk a neighbour list to answer that (O(degree)). The matrix answers it in a single indexed jump.
A quick word on degree, since it's what that cost is measured in: a node's degree is just how many neighbours it has. In our four-friend graph Cy has degree 3 and Dot has degree 1. Scanning a neighbour list is O(degree) because you touch each of those neighbours exactly once. The matrix skips the scan entirely — it jumps straight to cell (i, j) and reads the answer in one step.
Let's plug a real coordinate into base + (i·V + j)·slot. Take our six-node graph and ask "is node 2 linked to node 4?" Here i = 2, j = 4, and V = 6, so the offset is 2·6 + 4 = 16 cells past the start. You can check that by hand: row 0 fills cells 0–5, row 1 fills 6–11, row 2 fills 12–17, so cell (2, 4) sits at 12 + 4 = 16. One multiply, one add, one read — that's the O(1) the matrix is buying you.
The catch is the flip side of the same coin. The grid has V² cells whether or not the edges exist, so it costs O(V²) space always, sparse or not. And in pure Python a "cell" isn't a bit. A list-of-lists stores an 8-byte reference per cell (measured: 8.06 B/cell). A true packed matrix (NumPy uint8, or a stdlib bytearray) drops that to 1 byte, and a bit-packed one to 0.125 B/cell. But even at one bit, V² grows quadratically and swallows the list long before your data does.
import sys
V = 300 # so 44,850 pairs are even possible
def mbytes(M): return sys.getsizeof(M) + sum(sys.getsizeof(r) for r in M)
def gbytes(g): return sys.getsizeof(g) + sum(sys.getsizeof(v) for v in g.values())
def both(edges): # the SAME edges, stored two ways
M = [[0] * V for _ in range(V)]
g = {n: [] for n in range(V)}
for u, v in edges:
M[u][v] = M[v][u] = 1
g[u].append(v); g[v].append(u)
return mbytes(M), gbytes(g)
ring = [(i, (i + 1) % V) for i in range(V)] # every node, degree 2
full = [(i, j) for i in range(V) for j in range(i + 1, V)] # every pair connected
for label, e in (("0", []), ("300", ring), ("44,850", full)):
m, d = both(e)
print("%6s edges matrix %7d B dict %7d B" % (label, m, d))0 edges matrix 739320 B dict 26104 B 300 edges matrix 739320 B dict 35704 B 44,850 edges matrix 739320 B dict 765304 B
bytearray or uint8 grid shrinks the constant to 1 B a cell and a bit-packed one to an eighth of that, and every one of them is still density-blind — V² cells, edges or no edges. Second, read the last row against the density slider waiting in section 04, because the two are counting different things and both are honest. By slot count a complete graph is an exact tie — V + 2E = 300 + 89,700 = 90,000 = V² — which is precisely what that slider reports when you push it to complete. In real Python bytes the dict is heavier anyway, 765,304 against 739,320, and every one of those 25,984 excess bytes is overhead the slot count cannot see: each neighbour list over-allocates to 308 slots to hold 299, 2,520 B against the matrix row's exact [0] * 300 at 2,456 B, which is 19,200 B across 300 rows — and the hash table itself costs 9,304 B where a plain outer list costs 2,520, which is the other 6,784. So the matrix's fixed price was never extravagance. It is a bet on density, and only a near-complete graph ever collects on it.[[0]*V for …] matrix is 8× heavier than it looksnumpy.zeros((V,V), dtype=np.uint8) (1 B/cell, contiguous) or a bytearray. The nested-list "matrix" is fine for a diagram; it's a trap at scale.Here are the two layouts of the same six-node graph, side by side, so flip the toggle and watch the memory story change as you do. The list writes down only the edges that exist, growing one honest step at a time as connections appear. The matrix reserves all V² boxes the instant you create it, whether or not a single one of those edges is ever filled in.
For six nodes the gap is 20 versus 36 — a shrug. But watch what happens to that gap as the graph grows and stays sparse, the way every real graph does. →
04Sparse or dense — the choice that decides whether it fits in RAM
This isn't an academic preference; it's the difference between fitting in memory and not. Real graphs are overwhelmingly sparse: a person has hundreds of friends, not millions; a web page links to dozens of pages, not billions; a road touches a handful of intersections. For sparse graphs, V+2E and V² live in different universes. Take a million users, each with about ten friends, and just count the storage:
V, deg = 1_000_000, 10
E = V * deg // 2 # 5,000,000 undirected edges
cells = V * V # 1,000,000,000,000 (a trillion)
slots = V + 2 * E # 11,000,000 (eleven million)
cells // slots # -> 90909 the matrix is ~90,909x bigger
cells // 8 # -> 125,000,000,000 bytes = 125 GB at 1 bit/cellLine by line: V and deg fix the scale, and E halves the endpoint count because each friendship is one shared edge. So cells is V², a trillion boxes, while slots is only V+2E, or eleven million. That puts the ratio at 90,909×. And cells // 8 is the kindest possible matrix, one bit per cell, yet it still needs 125 GB (≈116 GiB) just to record who-knows-whom. The adjacency list of the same graph is about 88 MB at 8 bytes a slot, or 44 MB packed. One representation needs a data-centre, the other fits in a laptop. Same graph.
Slide the edge count on a fixed 16-node graph and watch the two costs pull apart. The matrix line never moves, because it reserved V² up front. The list rises one honest step per edge. They only meet at the far right, where the graph is complete and every pair is connected. That's the one regime where the matrix's fixed price is finally fair.
How many edges does "complete" actually mean? Every node connects to every other, so a V-node graph tops out at V(V−1)/2 undirected edges. For our 16-node graph that's 16·15/2 = 120 edges — the far-right end of the slider, and no further. A graph is dense when its real edge count sits near that ceiling, and sparse when it hugs the floor of a handful per node. That one ratio — edges over the V² the matrix reserves — is the whole basis for the choice coming next.
defaultdict(list) conjures an empty list the moment you index a missing node, so the build loop is two clean lines. graph.setdefault(u, []).append(v) does the same on a plain dict in one call. Build with the first, but query with .get — a defaultdict grows when you merely look.numpy.zeros((V,V), dtype=np.uint8) or a bytearray at 1 B a cell.INPUTimport sys
from collections import defaultdict
edges = [(0, 1), (0, 2), (1, 2), (2, 3), (2, 4)] # 5 nodes, 5 undirected edges
# 1 - the literal: you already know the whole graph
literal = {0: [1, 2], 1: [0, 2], 2: [0, 1, 3, 4], 3: [2], 4: [2]}
# 2 - from an edge list: the form data actually arrives in
g = defaultdict(list)
for u, v in edges:
g[u].append(v)
g[v].append(u) # BOTH ends - undirected
built = {k: g[k] for k in sorted(g)}
# 3 - the matrix alternative: a box for every possible pair
V = 5
M = [[0] * V for _ in range(V)] # NOT [[0]*V]*V - see the tripwires
for u, v in edges:
M[u][v] = M[v][u] = 1
print("literal == built from edges?", literal == built)
print("neighbours of 2 :", built[2], " (list: walk it, O(degree))")
print("is 2 linked to 4?", M[2][4], " (matrix: one multiply-add, O(1))")
print("is 0 linked to 4?", M[0][4], " (a miss costs exactly the same)")
print("degrees :", {n: len(built[n]) for n in built})
def total(dct): # the dict AND what it points at
return sys.getsizeof(dct) + sum(sys.getsizeof(v) for v in dct.values())
def matbytes(m):
return sys.getsizeof(m) + sum(sys.getsizeof(row) for row in m)
print("\n5-node graph, measured:")
print(" adjacency dict :", total(built), "bytes")
print(" matrix (lists) :", matbytes(M), "bytes")
# now the real question: 1000 nodes, 3000 edges - a sparse graph, like every real one
import random
random.seed(7)
N, E = 1000, 3000
big = defaultdict(list)
seen = set()
while len(seen) < E:
a, b = random.randrange(N), random.randrange(N)
if a != b and (a, b) not in seen and (b, a) not in seen:
seen.add((a, b))
big[a].append(b)
big[b].append(a)
for i in range(N):
big[i] # touch every node so isolated ones exist
big = dict(big)
BM = [[0] * N for _ in range(N)]
for a, b in seen:
BM[a][b] = BM[b][a] = 1
ad, mb = total(big), matbytes(BM)
print("\n1000 nodes / 3000 edges:")
print(" adjacency dict : %10d bytes" % ad)
print(" matrix (lists) : %10d bytes" % mb)
print(" matrix / dict : %10.1fx" % (mb / ad))
print(" endpoints stored:", sum(len(v) for v in big.values()), "= 2E")
print(" matrix cells :", N * N, "of which", 2 * E, "are 1 -> %.2f%% used" % (100 * 2 * E / (N * N)))OUTPUTliteral == built from edges? True
neighbours of 2 : [0, 1, 3, 4] (list: walk it, O(degree))
is 2 linked to 4? 1 (matrix: one multiply-add, O(1))
is 0 linked to 4? 0 (a miss costs exactly the same)
degrees : {0: 2, 1: 2, 2: 4, 3: 1, 4: 1}
5-node graph, measured:
adjacency dict : 664 bytes
matrix (lists) : 600 bytes
1000 nodes / 3000 edges:
adjacency dict : 157688 bytes
matrix (lists) : 8064856 bytes
matrix / dict : 51.1x
endpoints stored: 6000 = 2E
matrix cells : 1000000 of which 6000 are 1 -> 0.60% used- Forget the second append and the graph goes silently one-way. Building the same five edges with only
graph[u].append(v)gave{0: [1, 2], 1: [2], 2: [3, 4]}— 5 endpoints instead of 10. Node 3's neighbour list came back[], so it looked like an isolated node, and a BFS from 3 reached[3]where the correct graph reached[0, 1, 2, 3, 4]. Nothing raised. The cheap check is the count: an undirected build must end withsum(len(v) for v in graph.values()) == 2 * len(edges). [[0]*V]*Vmakes one row, V times over. Measured: afterM[0][1] = 1the shared version read[[0,1,0,0], [0,1,0,0], [0,1,0,0], [0,1,0,0]]— every row is the same object (M[0] is M[1]→True). And on size: a 1000×1000 list-of-lists measured 8,064,856 bytes, a flat 8.06 B per cell. Scale that to V = 10,000 and the grid is 100,000,000 cells — about 0.8 GB as Python lists, and still 100 MB packed down to one byte a cell. The same 10,000-node graph with 30,000 edges, built as an adjacency dict and measured the same way: 1,502,128 bytes, 1.5 MB. Same graph, and the grid is not slower — it is a different order of machine.- A list of neighbours believes whatever you hand it. Feeding the same friendship three times plus a self-loop produced
{0: [1, 1, 1], 1: [0, 0, 0], 2: [2, 2]}: node 0 reports degree 3 from one real friendship, and node 2 reports degree 2 from a loop back to itself, written at both of its identical “ends”. Every degree count, every traversal, every average is now wrong. Adefaultdict(set)with anif u != vguard returned{0: [1], 1: [0]}— deduped, self-loop refused.
Myth
At first glance the matrix looks like the "real" or default representation — a clean square with O(1) lookups and none of those messy little lists. So the instinct is to reach for it first, every single time.
Reality
But the list is the default, because real graphs are sparse and O(V²) memory explodes long before your edges do. Choose the matrix only when the graph is genuinely dense. Or choose it when you ask "is i linked to j?" so relentlessly that O(1) is worth the quadratic space. That means small V, or algorithms built on the matrix's algebra.
You now know which to store. But there's a second, quieter reason the list usually wins that has nothing to do with byte counts — and one clever repacking that makes it win even harder. This is where the metal speaks. →
05How the metal feels a graph — pointer-chasing, and the CSR trick
Two structures with the same Big-O can run at wildly different real speeds. The reason is that the CPU reads memory in cache lines, not bytes (Vol 3). A contiguous scan streams: the prefetcher sees the pattern and pulls the next line before you ask. A jump to an unrelated address stalls, and the core waits ~100 cycles for RAM. Look back at Fig 1. Our dict-of-lists puts every neighbour list at a scattered heap address. Walking node u's neighbours means following a reference off the dict to wherever that list happens to live — a cache miss. And if the neighbour data itself is objects, that's another miss per element. It's the pointer-chasing of the linked list wearing a graph's clothes: correct O(V+E), but stall-prone.
Here's the human insight that fixes it, the same "a tree can live in a flat array" move you saw the heap make earlier this volume. Take the adjacency lists and concatenate them all into one flat array, indices. Then keep a second small array, indptr, whose entry i says where node i's neighbours begin. Node i's neighbours are then the contiguous slice indices[indptr[i] : indptr[i+1]]. No dict, no scattered lists, just two straight runs of memory. This is CSR (compressed sparse row), and it's how NetworkX-scale libraries, SciPy, and GPU graph engines actually store graphs.
There's an honest cost to CSR, and it's worth naming out loud. Because every node's neighbours live in one packed run, inserting an edge means shifting everything after it along — CSR is built to be read, not edited. So the usual move is to build the graph in the mutable dict-of-lists, then freeze it into CSR once for the fast traversals. That read-many, write-once bet is exactly why static graph engines love CSR and interactive, edge-adding ones reach for the dict.
Run our four friends through CSR to see the two arrays fall out. Number them Ada = 0, Bex = 1, Cy = 2, Dot = 3, and lay every neighbour list end to end: indices = [1, 2, 0, 2, 0, 1, 3, 2]. Now indptr just marks where each node's run starts: [0, 2, 4, 7, 8]. To read Cy's neighbours you slice indices[indptr[2] : indptr[3]], which is indices[4:7] = [0, 1, 3] — Ada, Bex, Dot, exactly right. Two flat arrays, no pointer-chasing, and the neighbours of any node are one contiguous slice away.
indptr marks where each node's slice starts. Same O(V+E) information, but two contiguous blocks the CPU can stream — and, measured, ~11× smaller than the dict-of-lists.from array import array # packed 4-byte ints, not boxed objects
indptr = array('i', [0]) # where each node's neighbours begin
indices = array('i') # every neighbour, concatenated
for u in range(V):
for nb in adjlist[u]:
indices.append(nb)
indptr.append(len(indices)) # ...node u's slice ends here
# node u's neighbours, no dict, no scattered list:
indices[indptr[u] : indptr[u+1]] # a contiguous sliceLine by line: indptr and indices are array('i'), packed 4-byte integers laid contiguously (the array again), not Python objects with 28-byte headers. The loop walks the dict-of-lists once, appending each neighbour to the flat indices and recording the running boundary in indptr. The last line is the payoff: to iterate node u's neighbours you take one contiguous slice. For the 1000-node graph this whole structure measured 28,816 bytes against the dict-of-lists' 308,888, about 11× smaller. And it streams through cache instead of chasing pointers.
The deeper cut — four ways to store a graph, and when each is right
Beyond the two headline layouts there's a small zoo, each a different bet on your access pattern:
- Dict-of-lists / dict-of-sets — the flexible default. Sets give O(1) "is i linked to j?" and O(degree) iteration, at the cost of the hash overhead per neighbour. Best when the graph changes (edges added/removed at runtime).
- Adjacency matrix — dense graphs, tiny V, or when the algorithm is algebraic: raising a boolean matrix to the k-th power counts length-k paths, and matrix multiply gives reachability. The math only exists in this layout.
- CSR (compressed sparse row) — the read-mostly workhorse. Smallest and fastest to traverse, but rebuilding it to add one edge is O(V+E), so you freeze the graph first. This is what serious graph analytics and GPU kernels run on.
- Edge list — just a flat list of
(u, v, weight)triples. Useless for "who are u's neighbours?", perfect for algorithms that only ever iterate all edges — Kruskal's MST sorts exactly this list (Vol 3), and it's the natural on-disk / CSV form.
The move isn't memorising these four options. It's reading the algorithm's dominant question first, then picking the one layout that answers that question in O(1). Get the question right and the structure very nearly picks itself.
Layout settled, the metal appeased — now the reason any of this exists: asking the graph questions. And it turns out you've been living inside the answers all day. →
06Asking the graph questions — and where you already live inside one
Once the graph is in memory, every question is a walk over it. The two fundamental walks were Vol 3's subject, so here's a sentence each. BFS — breadth-first — spreads like a ripple in a pond using a queue. It visits everything one hop away, then two, then three, so its first arrival at a node is the shortest unweighted path. DFS — depth-first — plunges down one path to the end using a stack, often the call stack via recursion, backing up only at dead ends. It's the tool for cycles, connectivity, and ordering. Both are O(V + E), precisely because the adjacency list lets each of them "scan every node's neighbour list once." The structure and the traversal cost are the same fact seen twice. Add weights and BFS's equal-hop assumption breaks. Dijkstra (Vol 3) patches it with a heap.
Why does the queue guarantee the shortest path? Picture starting a rumour at Ada. On round one you tell her direct friends — everyone one hop away. Only when that whole round is done do you move on to their friends, two hops out. Because the queue empties in arrival order, you always finish the closer people before touching any farther one. So the first time the rumour reaches Cy, it arrived by the fewest possible hops. That shortest unweighted path isn't a bonus you compute — it falls straight out of the order the queue enforces.
A graph whose directed edges never loop back is a DAG, a directed acyclic graph, and it's quietly one of the most useful shapes in computing. "Must-happen-before" relations are DAGs, and a DFS over one yields a topological order: a linear sequence respecting every dependency. That single idea is your build system deciding compile order. It's your package manager installing libraries before the things that need them. And it's a spreadsheet recalculating cells in the right sequence.
Why must the graph be acyclic for this to work at all? Suppose it weren't — say A must come before B, B before C, and C before A. No ordering on earth satisfies all three, because whichever task you place first is already waiting on another one. A cycle is just a set of tasks each waiting on the next, forever. That's precisely the circular import or dependency deadlock your build tools shout about — the same impossibility, seen from the graph's side.
Make that topological order concrete with breakfast. To eat toast you must slice the bread before you toast it, and toast it before you butter it — three tasks with two "before" edges. Run a DFS and it hands back the order slice → toast → butter, every arrow pointing forward. Swap in "compile utils.c before main.c before linking" and it's the exact same graph. That's why one algorithm drives your build system, your installer, and your spreadsheet alike.
Which brings us to the 1% move. "Good at data structures" is not knowing graph trivia. It's the reflex, on a brand-new problem, to ask "what are the nodes, what are the edges?" and then "do I need every pairwise lookup (matrix) or just each node's neighbours (list)?" Answer those two and half the problem dissolves: the model tells you it's a graph, and the access pattern tells you how to lay it out. The graph is the most general container in this volume because it makes the fewest assumptions. And that generality is exactly why recognising one is the highest-leverage pattern-match you can train.
1 is land and 0 is water — the same shape a tile-map room-detector, a microscope colony counter and a wafer-defect scanner all work on. How many islands are there? An island is a maximal blob of land joined edge-to-edge — up, down, left, right — never diagonally. Two labels before we start, because both turn out to decide something. First, nobody handed you a graph: there is no dict of neighbours anywhere in this problem, no edge list, nothing but a grid of characters. Second, the thing wanted is a count, and it is wanted once — the raster is not going to keep changing under you. Now rewind to just before you read section 06 and watch somebody arrive, because here the route matters more than the destination: the first turn is a genuinely good one, and it is still the wrong call.r±1 and c±1 — so the count ought to be readable off a single sweep, no structure required. And there is a rule that very nearly works: a new island starts wherever a land cell has water above it and water to its left. That cell must be the island's top-left corner, so count the corners and you have counted the islands. One pass, O(rows × cols), allocates nothing, no queue, no recursion, no visited set to get wrong. And it is genuinely correct on a great many fields: every rectangle, every staircase running down-and-right, every single-cell speck, every one of the small hand-drawn examples you would sanity-check it against. It is also roughly what your eye does when it skims a map, which is why it arrives feeling like sight rather than like a guess. Do not skip past that. The instinct is local, and locality is the right instinct on a grid — neighbours are arithmetic, the sweep is row-major, and the whole field streams through cache in one pass, which is the layout chapter 55 spent itself on. It fails for a reason that has nothing to do with grids at all.(0, 2), (2, 1) and (4, 0), and at each of those the only two cells the rule is allowed to consult — the one above, the one to the left — really are water. The rule is not wrong about what it can see. It is wrong about what it cannot: a corner is a local fact, and an island is a global one. Nor does the error wash out at scale, which is the comfortable thing one hopes for. On a 600×600 random field holding 162,475 land cells the corner rule reports 49,113 blobs where the flood below finds 31,515 — 1.6× over — and that ratio is not a constant you could divide back out. It is a tally of how often the shapes happened to double back on themselves, which is a property of the picture, not of the algorithm. And no patch is coming. Widen the test to eight neighbours, to a 5×5 window, to any fixed radius you care to name, and the same defeat is always available, because two cells can belong to one island by way of a path that is arbitrarily long — so no bounded look-around can decide which island a cell is on. The instinct is not underpowered. It is asking a question that has no local answer.graph[u] is not stored, it is computed — (r−1, c), (r+1, c), (r, c−1), (r, c+1), kept if they land in bounds. That deserves a byte count, because it is this chapter's own arithmetic pointing the other way: materialise those neighbour lists for a 300×300 grid and the dict-of-lists measures 13,162,960 bytes to describe a field that itself weighs 739,320 — 17.8×, spent to store what four additions hand you for free. And 17.8× is the kind reading: that is the shallow getsizeof idiom of section 02, the one that measures an object and not what it points at, so it counts none of the 448,800 (r, c) tuples hanging off those lists. Add them and the real bill is 38,295,760 bytes — 51.8× the field. V + 2E is entirely real here. You simply never pay it.Then the move, and it is one sentence long: sink each island as you count it. Sweep the grid; the first land cell you meet starts a flood; the flood spreads to every land cell it can reach and turns each one into water on the way. When the queue drains, exactly one island has been erased from the map and the counter goes up by one. Carry on sweeping, and every land cell you meet after that is guaranteed to sit on an island you have never touched — because the ones you touched are not land any more. Each flood erases exactly one island, so the answer is the number of floods. Nothing is being counted cleverly. The counting was made trivial by destroying the thing counted, which is why the whole solution is a loop with a counter in it. Two details fall out for free. The sunk grid is the visited set, so there is no
seen to allocate and none to forget; and cells are sunk the moment they are queued, not when they are popped, which is exactly the discipline the BFS stepper above proved is what keeps a walk on a cyclic graph terminating at all. One honest cost, since the technique's whole trick is vandalism: it eats your input, so the program below floods a copy — and if the raster is large enough that a copy hurts, you are back to a separate seen set. And you have run this before, with a mouse: it is flood fill wearing a different costume — there the flood painted red and you kept the region, here it paints water and you keep the count.from collections import deque
grid = [list(r) for r in ("00111001", # 1 = land, 0 = water
"00100000",
"01100110",
"01000110",
"11000000",
"10000010",
"11111000")]
R, C = len(grid), len(grid[0])
# --- FIRST INSTINCT: a new island starts where land has water above AND left --
def by_corners(g):
return [(r, c) for r in range(R) for c in range(C)
if g[r][c] == "1"
and (r == 0 or g[r - 1][c] == "0")
and (c == 0 or g[r][c - 1] == "0")]
# --- THE TELL: an island IS a connected component. Sink it as you count it. ---
def by_flood(g):
g = [row[:] for row in g] # a copy -- the grid IS the visited set
floods, popped, sizes = 0, 0, []
for r in range(R):
for c in range(C):
if g[r][c] == "0":
continue
floods += 1 # one flood erases exactly one island
g[r][c] = "0"
q, size = deque([(r, c)]), 0
while q:
y, x = q.popleft(); popped += 1; size += 1
for ny, nx in ((y-1, x), (y+1, x), (y, x-1), (y, x+1)):
if 0 <= ny < R and 0 <= nx < C and g[ny][nx] == "1":
g[ny][nx] = "0" # sink it the moment you queue it
q.append((ny, nx))
sizes.append(size)
return floods, popped, sorted(sizes, reverse=True)
# --- THE REFEREE: union-find over the same grid (chapter 61) ------------------
parent = {}
def find(a):
while parent[a] != a:
parent[a] = parent[parent[a]] # halve the path as you climb
a = parent[a]
return a
land = [(r, c) for r in range(R) for c in range(C) if grid[r][c] == "1"]
for cell in land:
parent[cell] = cell
for r, c in land: # join each land cell to N and W only
for ny, nx in ((r-1, c), (r, c-1)):
if ny >= 0 and nx >= 0 and grid[ny][nx] == "1":
ra, rb = find((r, c)), find((ny, nx))
if ra != rb:
parent[ra] = rb
hits = by_corners(grid)
n, popped, sizes = by_flood(grid)
blame = {}
for h in hits:
blame[find(h)] = blame.get(find(h), 0) + 1
for row in grid:
print(" " + " ".join("#" if ch == "1" else "." for ch in row))
print()
print("FIRST INSTINCT water above and left ->", len(hits), "islands", hits)
print("THE TELL count the floods ->", n, "islands, sizes", sizes)
print("REFEREE union-find (ch 61) ->", len({find(c) for c in land}), "islands")
print()
print("cells dequeued :", popped, "= one per land cell -> O(rows x cols)")
print("worst miscount : one island of", max(sizes), "cells, counted",
max(blame.values()), "times"). . # # # . . # . . # . . . . . . # # . . # # . . # . . . # # . # # . . . . . . # . . . . . # . # # # # # . . . FIRST INSTINCT water above and left -> 6 islands [(0, 2), (0, 7), (2, 1), (2, 5), (4, 0), (5, 6)] THE TELL count the floods -> 4 islands, sizes [15, 4, 1, 1] REFEREE union-find (ch 61) -> 4 islands cells dequeued : 21 = one per land cell -> O(rows x cols) worst miscount : one island of 15 cells, counted 3 times
{person: [people they can relay to]}. Then answer the question you have genuinely asked yourself: who could get a message to whom in two hops or fewer?Two moves, and neither one is a graph algorithm. The build is
itertools.combinations(roster, 2) over each chat, writing every pair at both ends — a chat of four people is six relay links, not four. Use defaultdict(set) so the friend who shares three chats with you is stored once, not three times. The answer is then read off the dict, not walked: one hop is graph[me]; two hops is the union of graph[f] for every f in that first set, minus the people already one hop away and minus yourself.The check. Print
sum(len(v) for v in graph.values()) and confirm it is even — it is 2E, so an odd number means you wrote an edge at only one end. Then look at the DM-only person: they should have degree 1 and still reach almost everybody in two hops, because you are their hub. And find the person your DM-only friend cannot reach in two — that gap is the shape of your own network, printed.show the solution
from itertools import combinations
from collections import defaultdict
# your four group chats, plus the one person who only ever DMs you
chats = {
"flat 3B": ["me", "Sam", "Bea"],
"5-a-side": ["me", "Sam", "Tom", "Ravi"],
"cousins": ["me", "Meera", "Ravi"],
"book club": ["Bea", "Meera", "Nell"],
}
dms = [("me", "Anna")] # Anna is in none of the chats
def build(chats, dms):
"""Everyone sharing a chat can relay to everyone else in it."""
g = defaultdict(set)
for roster in chats.values():
for a, b in combinations(roster, 2):
g[a].add(b)
g[b].add(a)
for a, b in dms:
g[a].add(b)
g[b].add(a)
return {p: sorted(g[p]) for p in sorted(g)}
graph = build(chats, dms)
print("the address book - who can I reach in ONE hop:")
for person, nbrs in graph.items():
print(f" {person:6} -> {nbrs}")
print("\nendpoints stored:", sum(len(v) for v in graph.values()),
"= 2E, so E =", sum(len(v) for v in graph.values()) // 2, "real relay links")
print("degrees :", {p: len(n) for p, n in graph.items()})
def within_two(graph, me):
"""Read the answer straight off the dict - one hop, then their hops."""
one = set(graph[me])
two = set()
for friend in one:
two |= set(graph[friend])
two -= one | {me} # already direct, or myself
return sorted(one), sorted(two)
for who in ("me", "Anna", "Nell"):
direct, second = within_two(graph, who)
print(f"\n{who}:")
print(f" 1 hop ({len(direct)}): {direct}")
print(f" 2 hops ({len(second)}): {second}")
print(f" unreachable in <=2: {sorted(set(graph) - set(direct) - set(second) - {who})}")
# ---------------- OUTPUT, verbatim (CPython 3.12.7) ----------------
# the address book - who can I reach in ONE hop:
# Anna -> ['me']
# Bea -> ['Meera', 'Nell', 'Sam', 'me']
# Meera -> ['Bea', 'Nell', 'Ravi', 'me']
# Nell -> ['Bea', 'Meera']
# Ravi -> ['Meera', 'Sam', 'Tom', 'me']
# Sam -> ['Bea', 'Ravi', 'Tom', 'me']
# Tom -> ['Ravi', 'Sam', 'me']
# me -> ['Anna', 'Bea', 'Meera', 'Ravi', 'Sam', 'Tom']
#
# endpoints stored: 28 = 2E, so E = 14 real relay links
# degrees : {'Anna': 1, 'Bea': 4, 'Meera': 4, 'Nell': 2, 'Ravi': 4, 'Sam': 4, 'Tom': 3, 'me': 6}
#
# me:
# 1 hop (6): ['Anna', 'Bea', 'Meera', 'Ravi', 'Sam', 'Tom']
# 2 hops (1): ['Nell']
# unreachable in <=2: []
#
# Anna:
# 1 hop (1): ['me']
# 2 hops (5): ['Bea', 'Meera', 'Ravi', 'Sam', 'Tom']
# unreachable in <=2: ['Nell']
#
# Nell:
# 1 hop (2): ['Bea', 'Meera']
# 2 hops (3): ['Ravi', 'Sam', 'me']
# unreachable in <=2: ['Anna', 'Tom']We can now store any web of relationships and walk it. But one specific question — "are these two nodes in the same connected blob?" — comes up so often, and can be answered so much faster than a fresh traversal each time, that it earned its own razor-sharp structure. Next: Union-Find, a forest of parent-pointers that answers "connected?" in almost constant time. →
- A graph is two sets and nothing else — nodes and edges — and because a machine holds bytes rather than pictures, it throws the drawing away and keeps only who is adjacent to whom: two diagrams laid out completely differently are the same graph when their adjacency matches, which is why cities-and-roads, people-and-friendships, pages-and-links and tasks-and-must-come-before all run on one structure and one set of algorithms.
- The adjacency list — a dict of lists, no import, no class — costs V + 2E slots undirected and V + E directed, because an undirected edge is one fact written at two addresses, and it answers the question every traversal asks a million times, who are u's neighbours?, with one hash jump and an O(degree) read that never touches the other V−1 nodes — which is the whole reason BFS and DFS come out at O(V + E).
- The adjacency matrix buys something the list genuinely cannot — is i linked to j? in one multiply-add and one read, O(1), off a flat row-major strip — at a price that is V² whether the edges exist or not: measured, 300 nodes cost 739,320 bytes holding all 44,850 edges and the identical 739,320 holding none, so the bill is deaf to density and choosing the grid is a bet on density, one that only pays near the V(V−1)/2 ceiling.
- Same Big-O, different machines: a dict of lists scatters every neighbour list at an unrelated heap address, so walking one is pointer-chasing and a stall per hop, while CSR concatenates every list into one flat
indicesarray and keeps anindptrof where each node's slice begins — two contiguous runs a prefetcher can stream, the same information at O(V + E) — which is what SciPy and the GPU graph engines actually hold, and which is built to be read, not edited. - Once it is in memory every question is a walk: BFS ripples out of a queue, so a node's first arrival is along a shortest unweighted path; DFS plunges down a stack and hands back cycles, connectivity and the topological order of a DAG that your build system, your installer and your spreadsheet are already running — and the 1% move is not knowing any of that, it is asking, on a problem that arrived looking like anything else, what are the nodes and what are the edges, then which question dominates, because the first answer says it is a graph and the second picks the layout.
defaultdict(set) when you want that O(1) edge test without the quadratic space and can spare edge order; and CSR when the graph is large, static and read constantly. Chapter 62 turns that ladder into a single decision map.graph[u] is one hash-and-jump, defaultdict(list) is the build loop, and .get rather than [] is what stops a query from silently growing the structure it is querying. Chapter 49 gave you the dynamic array, and every neighbour list is one, over-allocating as it grows, which is where the marginal 47 B an undirected edge really costs comes from — and array('i') is what makes CSR's two runs packed 4-byte integers instead of Python objects. Chapter 55 gave you the matrix and the line base + (i·V + j)·slot, which is the entire O(1) edge test, and the warning that [[0]*V]*V builds one row V times. Chapters 52 and 53 gave you the queue and the deque that BFS's frontier is, and chapter 51 the stack DFS rides — usually the call stack, which is the same thing wearing a function's name. Chapter 58's heap is what patches BFS when the edges start carrying weights and equal hops stop meaning equal cost. Volume 1 supplied the ground: memory is one flat line of bytes, a name is an 8-byte reference, and row-major flattening is why a 2-D grid is a 1-D strip. And volume 3 gave you both walks in full — chapter 44 owns the traversal this chapter only stores for — along with the cache line that explains why two layouts with identical Big-O do not run at identical speed.A graph is the moment you stop storing things and start storing the relationships between them — so here we build the web from bare primitives, walk it two ways, and watch that one small shape quietly turn into maps, dependencies, and the route home.