python/volume-4/chapter-60ch 60 / 64 · 9 min

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 branches downward. The graph drops the skeleton entirely. It keeps only the barest idea of structure — a set of nodes and a set of edges between them. That minimalism is exactly why it fits so much: maps, friendships, web links, git history, and the tasks a build must run in order aren't 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, name its nodes and edges, and pick one of two storage layouts — the move that separates fluent from lost, because it decides whether the graph fits in RAM at all.

Two sets — and suddenly everything is one shape The adjacency list — pay only for the edges that exist The adjacency matrix — a box for every possible edge Sparse or dense — the choice that decides whether it fits in RAM
scroll to begin

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 (connections between pairs of things). That's the whole contract. Because the definition says nothing about what the things are, anything can be a node: a city, a person, a web page, a commit, a chemical, a state of a game. Here's 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 (Ada ⇄ Bex: if she's your friend, you're hers). A directed edge points one way (page A → page B; "socks before shoes" is not "shoes before socks"). An edge may also carry a weight — a number on the connection: minutes of drive, cost of a flight, strength of a bond. Four words — node, edge, direction, weight — and you can describe a road atlas, a social network, and a compiler's build order. The vocabulary was Vol 3's job. Here we ask the practical question it skipped: a machine can't store a drawing, it stores bytes. So how does a graph actually sit in RAM?

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 — 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 up: 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.

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). 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 is simple. 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, then row 1's V cells, laid end to end, with cell (i, j) living 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.

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.

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. Flip the toggle and watch the memory story change: the list writes down only the edges that exist; the matrix reserves all V² boxes the instant you create it.

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; E halves the endpoint count because each friendship is one shared edge. cells is V² — a trillion boxes. slots is V+2E — eleven million. The ratio is 90,909×. And cells // 8 is the kindest possible matrix — one bit per cell — still 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 — 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 (every pair connected) — the one regime where the matrix's fixed price is finally fair.

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

The matrix is the "real" or default representation — it's a clean square, O(1) lookups, no messy lists. Reach for it first.

Reality

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 when you do "is i linked to j?" so relentlessly that O(1) is worth the quadratic space — 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, because 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 — 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, 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, and 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 — 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.

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 four options; it's reading the algorithm's dominant question and picking the layout that answers it in O(1).

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, and the two fundamental walks were Vol 3's subject, so 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.

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, your package manager installing libraries before the things that need them, and a spreadsheet recalculating cells in the right sequence.

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.

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

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.com
Written by Ajai Raj