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

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.

★ YOU ALREADY RUN THIS · the-route-mapthe map at the back of the in-flight magazine — and the two ways you read it
You are somewhere over the Tasman with the in-flight magazine open on your knees, at the back, where the route map lives. Arcs everywhere. You are not admiring it — you are working out whether you can get from Wellington to Reykjavík. Your eye does exactly one thing, over and over: it lands on a city and asks who does this one fly to, then lands on each of those and asks again. You never once checked whether Wellington flies to Cairo, or Lima, or the ninety other dots on the page. The airline could have printed a giant grid instead — every city crossed with every other, a tick in each box that has a flight. They printed the arcs, because almost every box would have been empty.
your eye lands on a city and asks who it flies tograph[u] — the adjacency list. One hash jump, and that city's neighbours are just there: the cost is its own degree, never V
you never checked Wellington–Cairo, or the other ninety dotsthe list stores V + 2E slots — one per flight that exists. Nothing is reserved for a route nobody flies
the one question the grid would answer better: is there a direct flight?M[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 fly
hubs: Dubai with a hundred arcs, your home airport with threedegree. The list hands each city exactly the slots it needs; the grid hands Dubai and your home airport the same V boxes each
finding the two-stop route is a different job from owning the maptraversal — chapter 44's rumour, spreading one hop at a time — walks this structure. Chapter 60 only builds the timetable it runs on
pin it: you never store the drawing — you store who touches whom. And in a world this sparse, the arcs that exist will always be cheaper than the boxes that might.
iolinked · chapter 60 — the checkpoints6 steps
$ sections covered in The graph — the structure that models everything
01Two sets — and suddenly everything is one shape
02The adjacency list — pay only for the edges that exist
03The adjacency matrix — a box for every possible edge
04Sparse or dense — the choice that decides whether it fits in RAM
05How the metal feels a graph — pointer-chasing, and the CSR trick
06Asking the graph questions — and where you already live inside one

01Two 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.

Direction changes the storage, not just the meaning
In an undirected graph every edge is written down twice — once at each endpoint (that's the 2E in V+2E), and the matrix comes out mirror-symmetric across its diagonal. In a directed graph you store each edge once, at its source, and the matrix is asymmetric. Same layouts, half or double the entries — worth checking which you have before you count bytes.
the graph (a drawing) 0 1 2 3 edges: 0-1, 0-2, 1-2, 2-3 dict — a hash table (Vol1) key 0 ref key 1 ref key 2 ref key 3 ref neighbour lists (scattered on the heap) @0x7a10 1 2 @0x3c88 0 2 @0x9d40 0 1 3 @0x1f08 2 each list is a contiguous run of 8-byte reference slots · the lists themselves live at unrelated addresses
Fig — a graph in RAM. You don't store the picture; you store adjacency. Here it's a dict (a hash table, Vol 1) mapping each node to a reference that points at a small list of that node's neighbours. The lists sit at scattered heap addresses — a detail that will cost us later.
↺ The thing people get backwards
People picture a graph as the drawing — dots and lines on a page — and assume storing it means storing coordinates. It doesn't. A machine stores only who is adjacent to whom; the geometry is thrown away. Two graphs drawn completely differently are the same graph if their adjacency is the same. Internalise that and the two representations below stop being arbitrary and start being obvious: they are just two ways to write down "who touches whom."

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.

adjlist.pypython
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 slot

Line 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 . It scales with the edges you actually have.

Why iterating a node's neighbours is O(degree), not O(V)
To loop over everyone adjacent to node 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.
adjacency list = 1 dict + V lists = V + 2E reference slots → O(V + E) dict (V keys) 64 B base + per-key slot O(V) + list[0] list[1] list[2] list[3] total slots across all lists = 2E (each edge sits at both ends)
Fig — the bill for an adjacency list. One dict costing O(V), plus V small lists whose slot-count sums to exactly 2E. Nothing reserves room for an edge that isn't there — the reason it's the default for the sparse graphs the real world is made of.

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 cellO(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.

⚠ MOST BEGINNERS THINK…an emptier graph must be a cheaper graph
The sentence just above is not in dispute — you have been told, in as many words, that the grid costs V² sparse or not. Being told is not the same as believing, and the belief that survives is the one two sections of honest arithmetic have been quietly reinforcing. Section 02 counted V + 2E slots and every single one of them stood for a friendship that really existed. The figure drew node 3 one box because node 3 had one neighbour. The stepper's meter ticked upward one edge at a time, and the invariant it kept restating was slots = 2 × edges processed. So the model that forms is the obvious one, and it forms without anyone deciding to hold it: a graph is made of connections, therefore the memory bill is made of connections too. Add an edge, pay a little more. Delete half the roads, get half the bytes back. Store a graph with no edges at all and pay almost nothing. None of that is careless — it is exactly, measurably right, for one of the two layouts. The belief simply travels one word too far, and the word is graph: it gets held about graphs when it is only ever true of a dict. So put the two layouts on the one axis that separates them, which is density, and hold everything else perfectly still. Fix V at 300, so that 44,850 pairs are even possible, and store the same 300 nodes three times over — with no edges, with a 300-edge ring, and with every possible pair joined. Weigh both layouts at each. One of the two numbers moves.
TYPE THIS — 10 SECONDS
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
Only the dict is buying edges — it went 26,104 → 35,704 → 765,304 bytes as the connections arrived — while the matrix's bill is density-independent: 739,320 bytes for the graph with 44,850 edges, and the very same 739,320 for the graph with none, because a cell is reserved for every possible pair the instant the grid is born and flipping one to 1 merely writes into a slot that was already paid for; the delta is not small, it is exactly zero, and it stays exactly zero at every V and every density you will ever hand it. Two honest edges before you file that away. First, this is a statement about allocation, not about compression: a packed 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.
A Python [[0]*V for …] matrix is 8× heavier than it looks
Writing an adjacency matrix as a nested Python list stores an 8-byte object reference in every cell — 64× more than the single bit the information needs. For a real dense grid reach for numpy.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.
adjacency matrix (V×V, here 4×4) j:0 1 2 3 0123 0 1 1 0 1 0 1 0 1 1 0 1 0 0 1 0 16 cells for 4 real edges …flattened row-major in memory (Vol1, d7) 0 1 1 0 1 0 1 0 1 1 0 1 0 0 1 0 row 0row 1row 2row 3 cell(i, j) at base + (i·V + j)·slot one multiply-add → one read → O(1) "is i linked to j?" per cell: 8.06 B (Python ref) · 1 B (bytearray/uint8) · 0.125 B (packed bit) but there are always V² of them — O(V²) no matter how few edges
Fig — the matrix, flat. The 2-D grid is a single contiguous strip in RAM; the index formula turns a coordinate into one address. That buys O(1) edge tests and cache-friendly row scans — at the fixed price of V² cells, most of them zero in any real graph.

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.

InteractiveSame graph, two layouts — toggle and watch the memory
V+2E = 20
Six nodes, seven edges. The list holds V+2E = 20 slots; the matrix reserves V² = 36 cells to record the same seven edges.

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 live in different universes. Take a million users, each with about ten friends, and just count the storage:

tradeoff.pypython
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/cell

Line 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.

The matrix doesn't get slow — it fails to exist
The usual "wrong structure" penalty is slowness. Here it's harder: a 125 GB allocation for a million-node social graph simply won't fit in a normal machine's RAM, so the program doesn't run at all. The adjacency list isn't just faster to build — for sparse data at scale it's the only one of the two that can be built.

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.

THE STDLIB TOOLBELT · there is no graph type — a dict of lists is onethree ways to build it, one line that decides whether it fits in RAM
graph = {node: [neighbours]} # that's the whole structure. no class, no import. # 1 - THE LITERAL, when you already know the shape graph = {0: [1, 2], 1: [0, 2], 2: [0, 1, 3, 4], 3: [2], 4: [2]} # 2 - FROM AN EDGE LIST, the form real data arrives in from collections import defaultdict graph = defaultdict(list) for u, v in edges: graph[u].append(v) graph[v].append(u) # BOTH ends - undirected. this line is the bug. # 3 - THE MATRIX ALTERNATIVE, a box for every possible pair M = [[0] * V for _ in range(V)] # NOT [[0]*V]*V - see the tripwires M[u][v] = M[v][u] = 1 M[2][4] # one multiply-add, one read - O(1)
graph[u]The entire read. One hash-and-jump into the dict (chapter 54), and you are holding that node's neighbour list — a dynamic array you can walk in O(degree). You never touch the other V−1 nodes, which is exactly why BFS and DFS come to O(V + E).
defaultdict(list) vs setdefaultdefaultdict(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.
graph[v].append(u)The second write. An undirected edge is one fact stored at two addresses, which is where V + 2E comes from. Directed graphs write it once, at the source. Leave this line out of an undirected build and nothing raises — you just get a graph that is quietly one-way.
[[0]*V for _ in range(V)]The only correct way to build the grid in pure Python. Every cell is an 8-byte reference, so the matrix costs V² slots the instant it is born. For a real dense grid reach for numpy.zeros((V,V), dtype=np.uint8) or a bytearray at 1 B a cell.
defaultdict(set)The variant that refuses duplicates and answers “is u linked to v?” in O(1) instead of O(degree) — the matrix's one advantage, bought without V². You pay the set's hash overhead per neighbour, and you lose edge order. Reach for it when edges arrive messy.
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
TRIPWIRES
  • 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 with sum(len(v) for v in graph.values()) == 2 * len(edges).
  • [[0]*V]*V makes one row, V times over. Measured: after M[0][1] = 1 the 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. A defaultdict(set) with an if u != v guard returned {0: [1], 1: [0]} — deduped, self-loop refused.
InteractiveSlide the density — watch V+2E and V² diverge
list · V + 2E matrix · V² (fixed) 32 256
16
V = 16, so V² = 256 always. The list ties the matrix only at E = 120 — a complete graph, where every one of the 120 possible edges exists. Real graphs live far to the left.

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.

CSR — the whole graph in two flat, contiguous arrays indptr 0 2 4 7 8 10 11 node0 node2 → [7,8) indices 1 2 0 2 0 1 3 2 4 3 5 indices[7:8) = node 2's neighbours: 0,1,3 measured: CSR 28,816 B vs dict-of-lists 308,888 B for the same 1000-node graph → ~11× smaller, and contiguous
Fig — CSR: the adjacency list, flattened. Every neighbour list is concatenated into one array; 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.
csr_a.pypython
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 slice

Line 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.

InteractiveSix degrees — how few BFS ripples reach every human alive
BFS ripple · friends of friends… people reached (log scale) 8B = everyone you
150
Each hop is one wave of BFS. This is the ideal tree — it assumes no two paths ever meet, so it counts d·(d−1)k−1 new people per hop, an upper bound on spread. Real friendships overlap heavily, so the frontier is smaller and the true count is a touch higher — yet still tiny: Milgram measured ~6, and Facebook's 1.6-billion-user graph measured 3.57. Drop to 2 friends and the graph degenerates into a near-chain — billions of hops, the same O(n) collapse a balanced structure avoids.

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.

A B C D E arrow = "before" topological order: A → B → C → D → E (every arrow points forward)
Fig — a DAG and its order. Directed, no cycles. A DFS produces a linear order in which every "before" edge points forward — the same computation behind git history, build graphs, and dependency resolution.
Where you meet this — you're inside a graph right now
Your GPS route home is a shortest path on a weighted road graph. Google ranked the early web by treating pages-and-links as one giant graph (PageRank). "People you may know" walks a friendship graph; git stores your history as a commit DAG; your package manager resolves versions over a dependency graph; a compiler assigns CPU registers by colouring a conflict graph; and every neural network is a computation graph whose edges carry the numbers backprop flows through. Each is a flagship system — and each is nodes, edges, and one of the two layouts from this chapter underneath.

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.

WATCH AN EXPERT THINKa raster of land and water, and the number no cell can see
A segmentation pass hands you a raster: a rectangular grid of cells where 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.
FIRST INSTINCT
Every cell already knows who its neighbours are — they are just 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.
WHY IT STALLS
Run it on the field in the program below and it answers 6. There are 4. And the error is not spread thinly — three of those six hits land on the same island: the 15-cell snake that begins at the top, winds down and to the left, and finishes along the bottom row. It re-enters fresh territory three separate times, at (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.
THE TELL
Read the specification back slowly: a maximal blob of land joined edge-to-edge. Strip the scenery and that sentence is the definition of a connected component — and counting connected components is one of the questions this chapter's runnable cards answer, on an adjacency dict, in a dozen lines. So this is not a new problem. It is the old problem with the dict deleted: the grid IS the graph. A cell is a node, and 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,32017.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
trigger: reach for flood-fill-as-a-counter when the question is “how many separate blobs” and adjacency is a rule rather than a stored list — grids, images, tile maps, pixel masks, anywhere a neighbour is arithmetic instead of a lookup. The signature of the move is the sink: you count floods rather than cells, and the structure you were about to build never gets built. The referee in that run is a deliberate spoiler for chapter 61 — union-find, run over the same grid, agreeing at 4 — and the line between the two is worth drawing now, because it is a line about time, not about answers. The flood is right for one count over a field that holds still: it touches each cell exactly once, 21 dequeues for 21 land cells, O(rows × cols), and it needs no structure to survive the call. Union-find is right when the land keeps arriving — cells added one at a time, blobs merging as they meet — because it answers “are these two in the same island?” after every arrival without re-flooding anything, which is the one thing the flood cannot do without starting over.
NOW WRITE DOWN YOUR OWN GROUP CHATSfour rosters in, one address book out — then read the two-hop answer straight off it
The drill. Write down the web you actually live in. Take your four group chats as rosters — a chat is a list of people, and everyone in a chat can relay a message to everyone else in it — plus one person who only ever DMs you and appears in no chat at all. Turn that into an adjacency dict: {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. →

SAY IT BACKthe chapter in five breaths
  1. 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.
  2. 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).
  3. 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.
  4. 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 indices array and keeps an indptr of 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.
  5. 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.
reach for this when… the data is relationships rather than records, and the question you keep asking is about reachability rather than about a single item. "Can I get from here to there", "what touches what", "what must happen first", "how many separate blobs" — those are graph questions no matter what the domain calls them, and the modelling step is free: name the nodes, name the edges, and half the problem dissolves. Then the neighbours, because several of them want your data instead. If you will only ever iterate all the edges and never ask for one node's neighbours, keep the raw edge list — it is leaner than either layout and it is the form the data arrives in. If the relation is strictly one-parent hierarchy that never loops, the trees of chapters 56–59 are cheaper and their invariants are far stronger; a graph gives you generality by giving up guarantees. If the only question is "are these two in the same blob?", asked repeatedly while edges keep arriving, that is chapter 61, not a fresh traversal each time. And if the graph is implicit — a grid, an image, a board position — build nothing at all: neighbours are arithmetic, and materialising them costs an order of magnitude more than the field they describe. Once you have decided it is a graph, the layout follows from one ratio, E over V²: the list by default, because the real world is sparse; the matrix when the graph is genuinely dense or when the O(1) edge test is the hot path and V is small; a 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.
You already owned the pieces: chapter 54 gave you the hash map, and here it stops being a lookup table and becomes the graph itself — 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.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 60, in working code

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.

Write the web down — nodes, edges, one shape
A graph is two sets: the nodes, and the edges between them. Here we turn a bare list of edges into the two honest layouts — a dict of neighbour lists and a V×V matrix — then read the degrees straight back out.
Two layouts, one memory bill
The list pays only for edges that exist; the matrix reserves all V² boxes the instant it's born. We count the slots, flatten the list into cache-friendly CSR, and keep the bare edge list for when all you ever do is scan.
Walk it — BFS ripples, DFS plunges
Every question is a walk. BFS spreads hop by hop from a queue and hands you shortest unweighted distances; DFS dives to the end down a stack, recursive or explicit. Both are O(V+E), because each just scans every neighbour list once.
The questions a graph answers
Counting connected blobs, ordering a DAG so every 'must-happen-before' points forward, and reconstructing the actual shortest path — the everyday work behind build systems, package managers, and the route home.
end of chapter 60 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked