python/volume-3/chapter-44ch 44 / 64 · 12 min

44Graphs — the universal model

In Chapter 43 we searched a line of data — binary search halving a sorted array, quickselect plucking the median from a billion numbers. But most of the world isn't a line; it's a web. Think of roads, friendships, web links, task lists, molecules, or the moves in a chess game. Draw a dot for each thing and a line for each connection, and every one of them turns into the same shape: a graph. Here's the plan. We build that shape from zero, look at the two ways a machine actually stores it in memory, then pick up the three tools that answer almost any question you can ask of it — BFS, the ripple; DFS, the plunge; and Dijkstra, the weighted route. All the way through, we keep coming back to the one question that quietly turns a hard problem easy — what are the nodes, and what are the edges? By the end you'll ask it on sight. You'll model a maze, a course catalogue, and a road map as the very same object, and watch half the difficulty disappear the moment you name it.

Dots and lines — the most general structure there is Two ways to write a graph down — and the tradeoff that decides everything BFS — the ripple that finds the shortest path DFS — the plunge, and the one-line secret it shares with BFS
scroll to begin

01Dots and lines — the most general structure there is

Let's start with the definition, because it's tiny. A graph is just two sets: a set of nodes (also called vertices) — the things — and a set of edges — the connections between pairs of things. That's all there is to it. And that emptiness is exactly where the power comes from. Because we said nothing about what the things are, anything can be a node.

See how far that reaches. Cities are nodes and roads are edges. People are nodes and friendships are edges. Web pages are nodes and hyperlinks are edges. Tasks are nodes and "must-happen-before" is an edge. Atoms are nodes and chemical bonds are edges. The valid states of a Rubik's cube are nodes and a single twist is an edge. Every one of these is literally a graph — not "like" a graph, not "modelled as" a graph. Same structure, same algorithms, one skill.

Edges come in two kinds, and the difference runs through the whole chapter. An undirected edge is a two-way street: if Ada is friends with Bex, Bex is friends with Ada. A directed edge points one way: page A links to page B, but B need not link back, and "socks before shoes" does not mean "shoes before socks". An edge can also carry a weight — a number attached to the connection, like the minutes to drive that road, the cost of that flight, or the strength of that bond. No weight means every edge counts the same. Weights are where Dijkstra will earn its keep.

A B C D undirected edge directed edge (B→C) 7 weight node (vertex)
The whole vocabulary in one picture — nodes (the things), an undirected edge (two-way), a directed edge (one-way, drawn with an arrow), and a weight (a number on the connection). Master these four words and you can describe roads, friend networks, and build systems in the same breath.
Where you meet this — everywhere, all day
Your GPS routing home is a shortest path on a road graph. Google ranked the 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 graph of commits. Your package manager refuses to install two libraries that need conflicting versions by reasoning over a dependency graph. A compiler decides which variables share a CPU register by colouring a conflict graph. Each of those is a headline product — and each is a graph algorithm from this chapter under the hood.

We have the picture. But a machine cannot store a picture — it stores bytes. So the first real question is brutally practical: how do you write a graph down? →

02Two ways to write a graph down — and the tradeoff that decides everything

There are two honest ways to hand a graph to a computer. Choosing between them is the first engineering decision of every graph problem.

First, the adjacency list: for each node, store a list of its neighbours. Ada → [Bex, Cy]; Bex → [Ada, Dot, Eli]; and so on. In Python this is just a dictionary of lists — a hash table (Volume 1) whose values are dynamic arrays. To find Ada's neighbours you jump straight to her list. To store the whole graph you spend one slot per node plus one slot per edge-endpoint. Count it: V nodes and E edges cost V + 2E slots for an undirected graph, since each edge appears at both of its ends. That is O(V + E) space — you pay only for connections that actually exist.

Now the other way, the adjacency matrix: a V×V grid of 0s and 1s, where cell [i][j] = 1 means "there is an edge from i to j". Want to know if Ada and Fin are directly connected? Read one cell — O(1), instant, with no list to scan. But the grid has V² cells whether or not the edges exist, so it costs O(V²) space no matter how few edges there are. The matrix reserves a box for every possible connection in the universe. The list writes down only the ones that are real.

the graph 0 1 2 3 adjacency LIST — O(V+E) 0 → [1, 2] 1 → [0, 2] 2 → [0, 1, 3] 3 → [2] only real edges stored adjacency MATRIX — O(V²) 0 1 2 3 0123 0 1 1 0 1 0 1 0 1 1 0 1 0 0 1 0 a box for every possible edge
One graph, two encodings — the list stores only the four edges that exist; the matrix reserves all 16 cells whether or not they hold an edge. The matrix trades memory for an instant "is i linked to j?" lookup — a good deal only when the graph is dense.

That difference is not academic. It is the difference between fitting in memory and not. Real graphs are sparse: a person has hundreds of friends, not millions, and a web page links to dozens of pages, not billions. Here is the gap, computed for a million users each with about ten friends:

represent.pypython
V = 1_000_000        # a million users
avg_deg = 10         # ~10 friends each
E = V * avg_deg // 2 # undirected edges  -> 5,000,000

matrix_cells = V * V           # 1,000,000,000,000  (a trillion)
list_slots   = V + 2 * E       #        11,000,000  (eleven million)
print(matrix_cells // list_slots, "x bigger")   # -> 90909 x bigger

Line by line: V and avg_deg set the scale. E works out the edge count, halved because each friendship is one edge shared by two people. matrix_cells is V² — a trillion boxes. list_slots is V + 2E — eleven million. So the matrix is ~90,909× larger. Even packed down to a single bit per cell, that trillion-cell matrix is 125 GB just to record who-knows-whom, while the list fits in a few hundred megabytes. For a sparse graph, the matrix is a catastrophe you can measure.

InteractiveSlide the edges — watch the matrix waste, and the list stay honest
adjacency MATRIX (8×8 = 64 cells, always) adjacency LIST (V + 2E slots) 8 slots
6
The matrix reserves all 64 boxes no matter what. The list only ever holds V + 2E. They tie only when the graph is nearly complete — which real graphs never are.

Myth

The matrix is the "real" representation — it's a clean square grid, so it must be the simple, correct default.

Reality

The list is the default. Real graphs are sparse, and the matrix's O(V²) memory explodes long before your data does. Reach for a matrix only when the graph is genuinely dense, or when you need O(1) "is there an edge?" checks so often that the memory is worth it.

Now the graph is in memory. The real magic starts: asking it questions. The first and most human question — "what's the shortest way from here to there?" — is answered by dropping a stone in a pond. →

03BFS — the ripple that finds the shortest path

Drop a stone in still water and a ring spreads outward. Every point the ring touches at the same instant is the same distance from the splash. Breadth-first search (BFS) is exactly that ring, run on a graph. Start at a node, visit all its neighbours (distance 1), then their unvisited neighbours (distance 2), then theirs (distance 3), rippling outward one layer at a time until the whole graph is soaked.

The engine that keeps the ripple a ring is a queue — the first-in-first-out line from Volume 1. You add newly-discovered nodes to the back and always take from the front. Because the front always holds the earliest-discovered (nearest) node, you finish an entire distance-layer before the next one begins. Here it is running on a tiny friendship network:

bfs.pypython
from collections import deque
def bfs(graph, start):
    dist = {start: 0}                 # how many hops from start
    q = deque([start])                # the ripple's frontier (a queue)
    while q:
        u = q.popleft()               # take the NEAREST unfinished node
        for v in graph[u]:            # look at each neighbour
            if v not in dist:         # first time we reach v...
                dist[v] = dist[u] + 1 # ...so this IS its shortest distance
                q.append(v)           # add it to the back of the line
    return dist

print(bfs(friends, "Ada"))
# -> {'Ada': 0, 'Bex': 1, 'Cy': 1, 'Dot': 2, 'Eli': 2, 'Fin': 3}

Line by line: dist does double duty as "have I seen this node?" and "how far is it?". q starts holding only the source. Each loop takes the front node u — guaranteed to be the nearest unfinished one — and scans its neighbours. The load-bearing line is if v not in dist. The first time BFS reaches a node is along the shortest possible route, because any shorter route would have arrived in an earlier ring. So dist[v] = dist[u] + 1 is not a guess. It is the final answer, set once and never revised. The run confirms it: Ada→Fin is 3 hops, and no path is shorter.

Why BFS = shortest path (in an unweighted graph)
A ring cannot skip. To reach a node at distance 3, the ripple had to pass through a node at distance 2 the moment before. So the first arrival is always the shortest — no back-tracking, no second-guessing. This only holds when every edge counts the same (one hop = one unit). Add weights and the ripple lies; that is precisely the crack Dijkstra fills, two sections down.
Ada Bex Cy Dot Eli Fin hop 0hop 1hop 2hop 3
The ripple, frozen — BFS from Ada assigns every node its hop-distance in expanding rings: 0, then 1 (Bex, Cy), then 2 (Dot, Eli), then 3 (Fin). The colours are the distance. The shortest Ada→Fin route is simply "walk back down the ring numbers".
The "six degrees of separation" is BFS
The famous claim that any two people are linked by a short chain of acquaintances is a statement about distances in the friendship graph. When a social network actually measures it, they run BFS. A 2016 Facebook study reported that the average distance between two of its users was about 3.5 hops — the world is a shallower ripple than folklore says.
InteractiveGive everyone the same number of friends — and watch the ripple swallow the Earth
each hop, the BFS ripple multiplies its reach by your friend-count — how few rings until it has met everyone alive? THE RIPPLE TOUCHES ALL 8 BILLION PEOPLE ON EARTH IN… 7 hops from you with 30 friends each, the whole planet is 7 handshakes away every hop multiplies the crowd ×30 — 7 rings, and there is no stranger left on Earth.
30
This is the pure tree model — it assumes every friend brings all-new faces, so the hop-count swings hard with friend-count. Real networks overlap (your friends know each other), so each hop meets fewer new people than a clean ×b — which is why you can't just multiply it out by hand. But the exponential is real: with realistic friend-counts a 2016 Facebook study measured an average distance of ~3.5 hops between any two of its users. A handful of BFS layers, and no stranger is left on Earth.

Now stop reading and run the ripple yourself. Below, BFS spreads across a grid with walls. Press Step and watch the wavefront fan out ring by ring, colouring each cell by its distance, until it reaches the goal — and the shortest path falls out. Then flip to DFS and watch the very same search drop the rings and plunge into one corridor instead.

InteractiveStep the search — ripple (BFS) vs plunge (DFS) on the same maze
green = start · amber = goal · dark = wall · fill = order/distance
ring 0 · 1 cell
BFS finishes a whole ring before the next — so it reaches the goal in the fewest possible steps (14 here). DFS commits to one path and only backs up when it dead-ends.

You just watched BFS spread and DFS plunge on the same maze. That "plunge" is not a bug — it is a second superpower, and it happens to be one line of code away. →

04DFS — the plunge, and the one-line secret it shares with BFS

Depth-first search (DFS) picks a direction and commits. From a node it walks to a neighbour, then a neighbour of that, going as deep as it can before it hits a dead end and backs up to try the last unexplored turn. Where BFS fans out in careful rings, DFS dives to the bottom first. Its natural engine is the stack — last-in-first-out — which is also why DFS is usually written as a plain recursive function: the call stack from Volume 1 is the stack.

Here is the insight that makes graph search click, and it is one of the most satisfying in the whole volume. BFS and DFS are the same algorithm. The only difference is which end of the frontier you take the next node from. Take from the front (a queue) and you get rings — BFS. Take from the back (a stack) and you get a plunge — DFS. Just one line changes:

dfs.pypython
def traverse(graph, start, use_stack):
    seen = {start}; frontier = deque([start]); order = []
    while frontier:
        u = frontier.pop() if use_stack else frontier.popleft()   # <-- the ONLY difference
        order.append(u)
        for v in graph[u]:
            if v not in seen:
                seen.add(v); frontier.append(v)
    return order

print(traverse(friends, "Ada", False))  # queue -> ['Ada','Bex','Cy','Dot','Eli','Fin']
print(traverse(friends, "Ada", True))   # stack -> ['Ada','Cy','Eli','Fin','Dot','Bex']

Line by line: the setup is identical for both. The whole personality of the search lives in one expression — frontier.pop() (take from the back, LIFO, a stack) versus frontier.popleft() (take from the front, FIFO, a queue). Swap only that, and the same code produces two completely different explorations. The queue visits Ada's world layer by layer. The stack shoots straight down one chain to Fin before it ever finishes with Bex. That is the deep unity: search is just "keep a frontier and choose whom to expand next", so the data structure is the strategy.

The seen set is not optional
Both searches carry a set of already-visited nodes (seen here, dist in BFS). Delete it and, the instant the graph has a cycle, the search walks A→B→A→B forever — a hang, not a crash, which is worse. A tree has no cycles, so on trees you can skip the set; on a general graph, never. This one missing line is among the most common real-world graph bugs.
↺ The thing people get backwards
DFS is not "a worse BFS that fails to find shortest paths". It is a different tool for different jobs. DFS is how you detect a cycle (if your plunge meets a node still on the current path, there's a loop), how you find connected components (start a fresh DFS from every unvisited node; each run scoops up one island), and how you compute a topological sort. You would never reach for BFS for those. Asking "which is better, BFS or DFS?" is like asking "which is better, a ruler or a compass?" — they answer different questions.

The crown jewel of DFS is topological sort: given tasks with "must-come-before" dependencies (a directed graph), produce an order in which every task appears after everything it depends on. Course prerequisites, spreadsheet cell recalculation, and — most famously — build systems and package managers all live or die by it. Here it is on a course catalogue:

dfs.pypython
# prereqs: a course maps to the courses it requires first
print(toposort(prereqs))
# -> ['Intro', 'DataStruct', 'OS', 'Algorithms', 'Databases', 'Compilers']

prereqs["Intro"] = ["Compilers"]   # make Intro depend on Compilers -> a loop
print(toposort(prereqs))           # -> None   (no valid order exists)

The first call returns a legal study plan: Intro before everything, Compilers dead last because it needs both Algorithms and OS. The second call is the important one. Once we make Intro require Compilers — which requires Algorithms, which requires DataStruct, which requires Intro — there is a cycle, and no ordering can satisfy it. The algorithm returns None. That is not a failure. It is the exact error your build tool throws when two packages depend on each other, or your spreadsheet flags a circular reference. This ability to say "impossible, and here's why" is why topological sort sits at the heart of make, npm, pip, and every CI pipeline.

Intro DataStr OS Algos DBs Compilers IntroDataStrOSAlgosDBsCompilers flatten the arrows into one legal order →
Topological sort — a tangle of "before/after" arrows collapses into a single line where every prerequisite lands to the left of what needs it. Add one arrow that points backward and the line becomes impossible: that is a dependency cycle.
Wait —
BFS and DFS treat every edge as one hop. But the road home isn't "one hop" — one road is a two-minute side street, another is a forty-minute highway. If the ripple counts hops, how does it ever find the fastest route rather than the one with the fewest turns?

05Dijkstra — shortest paths when the edges have a price

The moment edges carry weights, BFS breaks. Its whole guarantee rested on "one hop = one unit", so the first arrival was the nearest. With weights, a path of three cheap edges can beat a path of one expensive edge. The ripple counts hops, so it walks right past the better route. We need a search that expands by total distance so far, not by hop count. That search is Dijkstra's algorithm.

The idea is greedy (chapter 39): keep a frontier of "reached but not yet finalised" nodes, each tagged with the cheapest total distance found to it so far, and always finalise the globally cheapest one next. Because all weights are positive, once a node is the cheapest on the frontier, nothing discovered later can undercut it, so its distance is locked. To keep pulling out the current minimum, Dijkstra leans on a priority queue, a.k.a. a min-heap. Treat it as a black box (its internals are a later volume) that hands back the smallest item in O(log n). Watch it route a commute where minutes, not turns, are the currency:

dijkstra.pypython
import heapq
def dijkstra(graph, start):
    dist = {start: 0}
    pq = [(0, start)]                 # min-heap of (distance_so_far, node)
    settled = {}
    while pq:
        d, u = heapq.heappop(pq)      # the globally CHEAPEST open node
        if u in settled: continue     # already finalised -> skip stale copy
        settled[u] = d                # lock it in: this is its shortest distance
        for v, w in graph[u]:         # relax each outgoing edge
            nd = d + w
            if v not in dist or nd < dist[v]:
                dist[v] = nd
                heapq.heappush(pq, (nd, v))
    return settled

print(dijkstra(roads, "Home"))
# -> {'Home': 0, 'B': 1, 'A': 3, 'C': 4, 'Work': 7}

Line by line: dist holds the best-known tentative distance to each node. pq is the min-heap frontier, seeded with the start at distance 0. Each loop pops the cheapest open node — heappop is the black-box min — and, if it hasn't already been finalised, settles it: its distance is now final and correct. Then it relaxes every outgoing edge, asking "could I reach v more cheaply by going through u?" If yes, it records the shorter distance and pushes v back onto the heap. The gem is in the output. The direct road Home→A costs 4, but Dijkstra reports A's distance as 3, because the detour Home→B→A is only 1+2. The greedy frontier found the sneaky cheaper route the eye would skip, and Work comes out at 7 (Home→B→A→C→Work).

4 ✗ (skipped) 1213 Homed=0 Bd=1 Ad=3 Cd=4 Workd=7
Dijkstra's answer, frozen — each node's final shortest distance in green, and the tree of cheapest routes. The eye-catching bit: the direct Home→A road costs 4, but A settles at 3 via the two-hop detour through B, so the direct edge is never used.
InteractiveAdd traffic to one road — watch the route re-decide itself
4
While the direct road costs 3 or less it stays on the route; push it to 4 or more and Dijkstra quietly detours through B, capping the trip at 7 minutes. Your GPS does exactly this, live, when traffic changes.

How costly is all this? Count the work. Every node is settled once, and each settling relaxes its outgoing edges. So across the whole run we touch each edge once, doing an O(log V) heap operation per touch. That is O(E log V) for the edges, plus O(V log V) for the pops, giving Dijkstra's classic O((V + E) log V). On a continent-sized road graph that is the difference between a route in milliseconds and one that never returns.

From Dijkstra to your GPS: A*
Pure Dijkstra explores in all directions equally — a growing blob. Your GPS uses A* ("A-star"), which is Dijkstra plus a hint: prefer nodes that are also geographically closer to the destination (straight-line distance as a guess). Same greedy skeleton, same priority queue — just a smarter ordering key that aims the search at the goal instead of spilling everywhere. That one tweak is why routing a cross-country trip is instant.
Negative weights break Dijkstra — silently
Dijkstra assumes every edge weight is ≥ 0. Feed it a negative edge and it does not crash — it confidently returns a wrong shortest distance, because its "once settled, never cheaper" bet no longer holds. If your weights can go negative (refunds, energy gained, arbitrage), reach for Bellman–Ford instead. A wrong answer that looks right is the most dangerous kind.
The deeper cut — why greedy is allowed to be right, and where it isn't

Dijkstra's correctness rests entirely on one assumption: no negative edge weights. The argument is a clean proof by contradiction. When we settle a node u as the cheapest on the frontier at distance d, suppose some cheaper path to u existed. That path must leave the already-settled region through some other frontier node x. But x's tentative distance is ≥ d, since we picked u as the minimum, and every remaining edge only adds non-negative weight. So the path through x costs ≥ d too. That is a contradiction: no cheaper path can exist, so locking u at d is safe. The instant a weight can be negative, that "only adds" step fails. A later negative edge could rescue a path you already dismissed, and Dijkstra can return wrong answers. That is exactly the crack the Bellman–Ford algorithm fills, at the cost of an extra factor of V. Negative weights are not exotic: think of currency arbitrage, where an "edge" can pay you.

BFS, DFS, Dijkstra — three questions, three tools, one structure. The last step is the one that changes you as an engineer: learning to see the graph that was hiding inside the problem all along. →

06The move that makes you dangerous — model it as a graph first

Step back and look at what just happened across this chapter. A friendship network, a maze, a course catalogue, and a road map look like four unrelated things. Yet we solved all four with the same three functions, unchanged, because underneath they were the same object: nodes and edges. That is the real lesson of this chapter, and it is worth more than any single algorithm.

The transferable move — the one the top 1% make almost reflexively — is this: when a new problem lands, before you write a line, ask "what are the nodes, and what are the edges?" The answer is often startling. States of a puzzle are nodes and legal moves are edges, so "solve the puzzle" becomes "find a path" — and BFS solves a sliding-tile puzzle for free. Words are nodes and "differ by one letter" is an edge, so word-ladder puzzles and spell-check suggestions become shortest paths. Jobs and machines are nodes and "can run on" is an edge, so scheduling becomes a matching problem. Half of algorithm design is not inventing a clever method. It is recognising the graph that was there the whole time, at which point a solved, off-the-shelf algorithm takes over.

roads / GPSsocialbuild depsthe web …all the same shape: nodes + edges learn the shape once, solve every domain
One structure under everything — the surface stories differ, but strip the story away and each is dots and lines. The engineer's edge is learning to strip the story on sight.
The everyday transfer
This isn't only for code. Planning a trip with connecting flights? Graph. Untangling who-reports-to-whom in a reorg? Graph. Figuring out the order to renovate a house — wiring before drywall, floors before furniture? That's a topological sort you do in your head. Once you own "nodes and edges", you start noticing them in meetings, spreadsheets, and to-do lists — and you reason about them with the same clarity you now bring to code.
Where these run in the wild — the greatest-hits reprise
GPS & ride-hailing: Dijkstra/A* on road graphs, live. Google: PageRank ran on the web-link graph; the crawler is a BFS/DFS of hyperlinks. Social: friend suggestions and "degrees of separation" are BFS. Dev tools: git's history is a commit graph, and every make/npm/pip/CI build is a topological sort with cycle detection. Compilers: register allocation is graph colouring. Networks: the internet routes packets with shortest-path algorithms on the router graph. You have been surrounded by this chapter your entire computing life.

One person made a piece of this feel inevitable. Edsger Dijkstra later recounted that he worked his shortest-path method out in about twenty minutes in 1956 — in his head, sitting at a café, with no paper and no computer. The lesson isn't the biography. It's the way of thinking it reveals. He didn't picture streets. He pictured the pure structure — nodes, edges, a growing frontier of "cheapest settled so far" — and reasoned about that. When you strip a problem down to its skeleton like that, the answer often stands up on its own. That stripping-down is the skill this whole chapter was really teaching.

We just found the shortest path through a graph in polynomial time — fast, reliable, done. So here is a question that sounds almost identical: instead of the shortest path from Home to Work, find the shortest tour that visits every city and returns — the Travelling Salesman. Change that one word, "path" to "tour", and every fast algorithm we own detonates: the cost curve leaps off the polynomial track and onto the red O(2ⁿ) explosion, where a few dozen cities would outlast the universe.

We found the shortest path in polynomial time — fast, reliable, done. The next chapter switches tools entirely: strings & number theory, the crypto-and-quant layer. How Ctrl-F, git diff and DNA aligners find a pattern without re-reading what they already know — and the one impossibly one-sided calculation that hides behind every padlock icon in your browser. →

, and what the best engineers do when they hit that wall. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 44, in working code

Graphs are just dots and lines. But once you can write them down and walk them, half the hard problems in computing turn out to be the same problem. So let's build the whole toolkit by hand.

Writing a graph down
Before you can ask a graph anything, you have to hand it to the machine. Here are the two honest encodings — the sparse list and the dense matrix — and the memory gap that decides between them.
BFS — the ripple
Drop a stone in a pond: the ring touches every point at its true hop-distance, nearest first. That ring, run on a graph with a queue, is breadth-first search — and it hands you shortest paths for free.
DFS — the plunge, and what it unlocks
Swap the queue for a stack and the very same code stops rippling and starts plunging — that's depth-first search. Its crown jewel is topological sort: flattening 'must-come-before' dependencies into a legal order.
Dijkstra — when edges have a price
BFS counts hops, but roads have lengths. The moment edges carry weights, you need Dijkstra: a priority queue that always expands the cheapest-known node next, and never has to revisit a settled one.
The move that makes you dangerous
The real payoff isn't any one algorithm — it's the reflex to SEE a graph where others see a puzzle. Turn word-transformations into nodes and edges, and shortest-path solves it untouched.
end of chapter 44 · six sections
iolinked.com
Written by Ajai Raj