Topic 01

Modelling With Graphs

Graphs

A graph is a set of things and the connections between them. Once a problem is drawn that way, a whole toolbox applies to it unchanged: the search that finds a route between two cities also finds the order in which to build a project, and the list of services that go down when the database does. The algorithms in the rest of this chapter never ask what the things are. They ask only which things connect.

That makes the modelling step the one with the consequences. What counts as a node, what counts as an edge, which way the edge points, what its weight means and how the whole structure sits in memory are all decided before any algorithm runs, and each decision sets a cost the algorithm cannot undo.

Nodes, Edges, Direction and Weight

A node is one thing: a router, a package, a web page, a person. An edge connects two nodes. It can be two-way, like a cable or a friendship, where A connected to B means B connected to A. Or it can be one-way, like "package A depends on package B" or "page A links to page B", where the reverse is a different claim that may be false. An edge can also carry a weight, a number that says what crossing it costs: kilometres, milliseconds, money.

The figure draws the same five nodes three times. On the left the edges have no direction. In the middle each one is an arrow. On the right each carries a weight. Every algorithm in this chapter reads one of those three pictures, and feeding it the wrong one gives a confident, wrong answer.

The same five nodes, three different graphs
UndirectedABCDEa cable, a friendshipDirectedABCDEdepends on, links toWeighted42513ABCDEkm, ms, cost

Two familiar structures are special cases. A tree, from Chapter 6, is a connected graph with no cycles, so there is exactly one path between any two of its nodes. A linked list is a tree with a single branch. The graph in the figure is neither: D can be reached from A through B or through C, and that second route is what makes it a graph.

Graphs in Systems You Already Run

Routers and the links between them form a weighted graph, with latency or link cost as the weight. A project's dependencies form a directed graph, and it stops being a tree the moment two packages share a third. Calls between services form a directed graph whose reversed edges answer "who breaks if this fails". Hyperlinks form the directed graph a crawler walks. The objects inside a running program, each holding references to others, form the graph that Chapter 4's garbage collector traverses. Foreign keys between database tables form one too.

Naming the graph is the step that unlocks the rest of the chapter. "Which services fail if the auth service fails" becomes reachability over reversed edges. "In what order do we deploy" becomes a topological sort. "What is the cheapest path between two data centres" becomes a shortest-path problem. None of those questions needs new code once the nodes and edges are written down.

The Adjacency Matrix

The first way to store a graph is a table with one row and one column per node, holding a mark wherever an edge exists. Asking "is there an edge from A to B" is a single lookup. It pays in memory: the table has n² cells whatever the number of edges, and listing one node's neighbours means scanning its whole row of n cells, most of them empty.

For five nodes that is 25 cells, and nobody cares. For a million nodes it is a trillion cells, which comes to 125 gigabytes even packed at one bit per cell. A social network or a road map with a million nodes has perhaps ten edges per node, so almost every one of those trillion cells would hold a zero.

The directed graph as a matrix and as a list
Adjacency matrix: 5 × 5 = 25 cellsAdjacency list: 5 lists, 5 entriestofromAABBCCDDEE0110000010000100000100000ABCBDCDDEEemptyThe matrix stores every pair, edge or not. The list stores only the edges.

The Adjacency List

The second way stores, for each node, the list of its neighbours. Memory grows with the number of nodes plus the number of edges, not with the square of the nodes. Visiting a node's neighbours costs its degree, the number of edges it has, and a node with three neighbours costs three steps. Testing for one particular edge means searching that node's list, or keeping a set of neighbours per node to make the test constant time on average.

A million nodes with ten edges each is 10 million list entries, about 80 megabytes at 8 bytes per entry, against 125 gigabytes for the matrix. Nearly every graph a working engineer meets is sparse like this, with far fewer edges than n², so the adjacency list is the default and the matrix is kept for small or dense graphs.

The directed graph from the figures as an adjacency list
graph = {
    "A": ["B", "C"],
    "B": ["D"],
    "C": ["D"],
    "D": ["E"],
    "E": [],
}

In Python the adjacency list is usually a dictionary, as above: each key is a node and its value is the list of nodes it points to. A has two neighbours, B and C; B and C both point to D; D points to E; E points nowhere. Five nodes and five edges take five dictionary entries holding five neighbours in total.

Graphs That Are Never Stored

Some graphs are far too large to write down. The positions of a sudoku, the states of a sliding puzzle and the configurations a model checker explores are all nodes, and an edge joins two of them when one move turns the first into the second. Nobody stores those edges. A function computes a node's neighbours from a rule at the moment the search asks for them, and Chapter 9's backtracking search walks such a graph.

The traversals in the next topic work on these implicit graphs unchanged, because all they ever ask is "what are the neighbours of this node". The only thing kept in memory is the set of nodes already visited, which on a well-pruned search is a vanishing fraction of the whole graph.

What the Representation Costs

A graph built from Python objects that hold references to each other is natural to write and slow to walk. Following an edge is a pointer chase: the next node's address is known only after the current node has been read, so the processor cannot fetch ahead, and each hop is a likely cache miss of about 100 nanoseconds on Chapter 4's ladder. Chapter 5 found the same problem in linked lists. A million edges followed that way can spend a tenth of a second in misses alone.

The packed alternative stores the graph in two flat arrays. One holds every neighbour list back to back. The other holds offsets, one per node, saying where that node's run of neighbours starts. In the figure, A's neighbours sit at positions 0 and 1 of the neighbour array, because A's offset is 0 and B's is 2. A walk over this layout reads memory in order, the processor's prefetcher streams it at memory bandwidth, and large graphs that are read far more often than they change are laid out this way in graph libraries.

Pointers to chase, or two arrays to read in order
Objects and references: one pointer to chase per edgeABCDETwo flat arrays: every neighbour list stored back to backoffsets0A2B3C4D5E5endneighboursB0C1D2D3E4A's neighbours: positions 0 and 1node i runs from offsets[i] to offsets[i+1]

The choice follows the dominant operation, as Chapter 5 showed for containers. If the question is mostly "is there an edge", a matrix or a set per node answers it. If it is mostly "visit every neighbour", a list answers it, and a packed list answers it at the speed of the memory bus. The Big-O is the same for both lists; the cache lines are not.

Misconceptions
  • "Graph problems need a graph database." Most graphs a service meets fit in memory as adjacency lists, or already live as rows joined by foreign keys. A graph database is a storage choice for deep traversals over huge graphs, not a precondition for running a graph algorithm.
  • "The adjacency matrix is faster because an edge lookup is O(1)." That constant-time lookup is paid for with n² memory, and every traversal pays n steps per node to find its neighbours. For a sparse graph of a million nodes the matrix needs 125 gigabytes where the list needs about 80 megabytes.
  • "Dependencies form a tree." Two packages sharing a dependency make a graph with a shared node. A naive tree walk then counts or installs the shared package twice, and two different versions of it can be demanded at once.
  • "Direction does not matter, only the connection does." "A depends on B" does not make B depend on A. Dropping direction changes what is reachable, and turns the question "what breaks if B fails" into nonsense.
  • "Objects with references are the natural and fast representation." They are natural to write and slow to traverse. Each edge followed is a dependent memory load that the processor cannot start early, which is the linked-list problem from Chapter 5 at the scale of a whole graph.
Why It Matters
  • Name the nodes, the edges, their direction and their weight before choosing an algorithm. A wrong model makes a correct algorithm answer the wrong question.
  • Store sparse graphs as adjacency lists and keep the matrix for small or dense graphs. Memory then follows the edges, not the square of the nodes.
  • Generate edges on demand when the graph is a space of states. Store only what has been visited.
  • Pack a large, read-mostly graph into flat arrays before traversing it repeatedly. Cache lines, not Big-O, set its speed.
RelatedTrees the connected graphs with no cycles (Chapter 6)Edge tables a table of (from, to) rows; a join is one traversal stepCompressed sparse row the two-array layout, as sparse matrices name it

Knowledge Check

A graph has a million nodes and 5 million directed edges. Roughly what do a one-bit-per-cell matrix and an adjacency list at 8 bytes per entry cost?

  • About 5 megabytes for the matrix and 40 megabytes for the list
  • About 125 gigabytes for the matrix and 40 megabytes for the list
  • About 125 gigabytes for the matrix and 125 gigabytes for the list
  • About 1 gigabyte for the matrix and 8 megabytes for the list

A job asks "is there an edge from X to Y" millions of times on a dense graph of 2,000 nodes. Which layout fits best?

  • Node objects that hold references to their neighbour objects
  • Two packed arrays of neighbours and offsets, read in order
  • An adjacency matrix, since 2,000 squared bits is about 500 KB
  • Plain neighbour lists scanned from the start on every test

Why is a project's dependency structure a graph rather than a tree?

  • Because dependency edges carry version numbers as their weights
  • Because two packages can depend on the same third package
  • Because every dependency edge points in one direction only
  • Because projects have too many packages to fit in a tree

An on-call engineer wants the list of services that stop working if the auth service goes down. What is that question as a graph problem?

  • Everything reachable from auth once every call edge is reversed
  • Everything reachable from auth along the call edges as drawn
  • The cheapest weighted path from auth to the busiest service
  • The services that call auth directly, and no others at all

A solver explores the states of a puzzle, where one move turns a state into a neighbouring state. How should it hold the graph?

  • Build an adjacency list of every state before the search begins
  • Build an adjacency matrix over all states for fast edge tests
  • Load every state into a graph database and query it as needed
  • Compute neighbours from the move rule, storing only visited states

You got correct