◈ python mapVol 3 · Ch 44/47
Volume 3 Python, from the metal up · chapter 44

44Graphs — the universal model

In Chapter 43 we searched a line of data. Binary search halved a sorted array, and quickselect plucked 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, and the moves in a chess game. Draw a dot for each thing and a line for each connection, and every one of them collapses into a single shape: a graph. Here's the plan. We build that shape from zero, then look at the two ways a machine actually stores it in memory. After that we 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. The whole way through, we keep circling the one question that quietly turns a hard problem easy: what are the nodes, and what are the edges? By the end you'll reach for that question on sight. You'll model a maze, a course catalogue, and a road map as the very same object, and watch half the difficulty dissolve the moment you name it.

iolinked · chapter 44 — the checkpoints6 steps
$ sections covered in Graphs — the universal model
01Dots and lines — the most general structure there is
02Two ways to write a graph down — and the tradeoff that decides everything
03BFS — the ripple that finds the shortest path
04DFS — the plunge, and the one-line secret it shares with BFS
05Dijkstra — shortest paths when the edges have a price
06The move that makes you dangerous — model it as a graph first

01Dots and lines — the most general structure there is

The definition is almost insultingly small. A graph is nothing but two sets bundled together. One set holds the nodes — also called vertices — which stand for the things themselves. The other holds the edges, one for each pair of things that are joined. That is the entire object. And its emptiness is precisely where the power comes from. We never said what the things are, so anything at all can be a node.

Watch 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 flavours, and the distinction runs through the whole chapter. An undirected edge is a two-way street. If Ada is friends with Bex, then Bex is friends with Ada. A directed edge points one way. Page A links to page B, but B need not link back. In the same spirit, "socks before shoes" does not mean "shoes before socks". An edge can also carry a weight, a number attached to the connection. That number might be the minutes to drive a road, the cost of a flight, or the strength of a bond. No weight means every edge is worth the same. Weights are where Dijkstra will earn its keep.

★ YOU ALREADY RUN THIS · bfs-vs-dfsthe thing you told one person
You said it to one person — Tuesday, the colleague at the next desk, half a sentence, nothing careful about it. Wednesday two of her team repeat it back to you, slightly wrong. Friday it arrives from a floor you never visit. You can walk it backwards without trying — him, then her, then your neighbour, then you — and there is the shape of the week: everyone one desk away heard first, then their people, then theirs, and nobody was told twice. That is one way news moves through a building. The other is the friend who calls and pulls a single thread all the way down — who, then why, then what happened after — and only when it dead-ends does she back up and ask about anyone else.
the people you would tell directlygraph[you] — one adjacency list
everyone one desk away, then their people, then theirsBFS — finish ring d before ring d+1 (popleft)
“who did you hear it from?”parent[v] = u — one back-pointer rebuilds the whole chain
you don't re-tell someone who already knowsthe seen set — drop it and one cycle loops forever
the friend who pulls one thread to its end firstDFS — pop() instead of popleft()
pin it: Telling everyone you know before anyone they know is BFS. Chasing one thread to its end before backing up is DFS. Same news, same people — the only difference is which end of the line you take the next person from.
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 over to a computer, and the choice genuinely matters. Picking between the two is the first engineering decision in every graph problem you meet.

First, the adjacency list. For each node, you 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 up. V nodes and E edges cost V + 2E slots for an undirected graph, because each edge shows up at both of its ends. That is O(V + E) space, and you pay only for connections that actually exist.

The second layout is the adjacency matrix: a square grid, V rows by V columns, holding 0s and 1s. A cell [i][j] = 1 declares an edge from i to j. To ask whether two people are directly joined, you read that one cell — a flat O(1) answer, with nothing to scan. What you pay for that speed is room. All V² cells sit there whether their edge exists or not, so the grid always costs O(V²) regardless of how sparse the graph is. The list records only the edges that are real; the matrix keeps a slot ready for every edge that could ever be. (The data-structures volume takes this comparison apart in full.)

Let's make those two costs concrete before we scale them up. Take a tiny friendship graph with 4 people and 4 edges: Ada–Bex, Ada–Cy, Bex–Dot, and Cy–Dot. The adjacency list spends V + 2E = 4 + 8 = 12 slots, one per person plus two per edge, since each friendship is written down at both of its ends. The adjacency matrix spends V² = 16 cells, and only 8 of them ever hold a 1. On four nodes that gap is almost nothing. But V² grows like the square while V + 2E grows like a straight line. So the moment V climbs into the millions, the two costs stop being comparable at all.

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 gap is not a detail. It decides whether the graph fits in memory at all or simply does not. Real graphs are sparse. Your own contacts run to the hundreds, never the millions; a single web page points outward to a few dozen others, not to a few billion. Put concrete numbers on it, for a million users who each know roughly ten others:

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², which comes to a trillion boxes. list_slots is V + 2E, which comes to 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. 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 surely the "real" representation. It's a clean square grid, so it must be the simple, correct default to reach for.

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 sits the same distance from the splash. Breadth-first search (BFS) is exactly that ring, run on a graph. You start at a node and visit all its neighbours, which are distance 1. Then you visit their unvisited neighbours at distance 2, then theirs at distance 3. It ripples outward one layer at a time until the whole graph is soaked.

Trace it on a small network before we reach for code. Start at Ada, distance 0. Her direct friends Bex and Cy are the first ring, distance 1. Their new friends Dot and Eli form the next ring, distance 2. Finally Fin, reachable only through Dot or Eli, sits at distance 3. Notice that we never revisit Bex once she is placed, because she was already reached on an earlier, shorter ring. That refusal to revisit is the whole trick. The first time a node is touched is the last word on its distance.

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. Watch it run 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 doubles as "have I seen this node?" and "how far is it?". q starts out holding only the source. Each loop takes the front node u, which is 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, it arrives 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.

One honest gap before we move on. BFS as written hands you the distance to every node, but not the actual route to walk, and the distance is only half of what you usually want. The fix is one extra line. When you first reach v from u, record parent[v] = u, a single back-pointer to whoever discovered you. To rebuild the shortest Ada→Fin path, start at Fin and follow parents backward: Fin, then its parent, then its parent, until you land back at Ada, then reverse the list. Every shortest-path search in this chapter recovers its route the same way. Not by storing whole paths, but by remembering one step back.

SYNTAX · the graph idiom — a dict of lists, a deque, and one guardthe adjacency list is just a dict; collections.deque is the frontier; parent turns a distance into a route
from collections import deque, defaultdict graph = {node: [neighbour, …], …} -- that is the whole data structure graph[u].append(v); graph[v].append(u) -- both ends = UNDIRECTED. one end = DIRECTED def bfs(graph, src): dist = {src: 0} -- doubles as the seen-set. one dict, two jobs q = deque([src]) -- the frontier while q: u = q.popleft() -- FRONT -> rings -> shortest paths for v in graph.get(u, ()): -- .get so a dead end can't KeyError if v not in dist: -- FIRST arrival is the final answer dist[v] = dist[u] + 1 q.append(v) return dist u = q.pop() -- BACK -> a stack -> DFS. one method call is the entire difference parent[v] = u -- add this line and you get the route, not just its length
dequeO(1) at both ends. A plain list's pop(0) shifts every remaining element, so it is O(n) per pop — that one substitution quietly turns an O(V+E) search into O(V²).
popleft() vs pop()Front is FIFO is a queue is BFS. Back is LIFO is a stack is DFS. Everything else in the function is identical — the data structure is the strategy.
defaultdict(list)Builds the graph without an if key not in dance. But it is a loaded gun at read time — see the tripwires.
graph.get(u, ())The safe read on a plain dict: a node with no outgoing edges is normal, and it should not raise.
if v not in distThe load-bearing line. Mark a node the moment it enters the frontier, not when it leaves — otherwise the same node is queued once per edge into it.
parent = {src: None}One back-pointer per node, O(V) memory, and the whole route falls out by walking backwards and reversing. Storing a full path per node would be O(V²).
weighted edgesgraph[u] = [(v, w), …], and then the frontier must become a heapq keyed on total distance. A deque counts hops; hops are not minutes.
you type
$ python graph_idiom.py

from collections import deque, defaultdict

# ---------- 1. the graph IS a dict of lists ----------
edges = [("Ada", "Bex"), ("Ada", "Cy"), ("Bex", "Dot"),
         ("Bex", "Eli"), ("Cy", "Eli"), ("Dot", "Fin"), ("Eli", "Fin")]
g = defaultdict(list)
for u, v in edges:
    g[u].append(v)
    g[v].append(u)          # drop this line and the graph is DIRECTED
print(dict(g)["Ada"], len(g))

# ---------- 2. BFS: distances, in rings ----------
def bfs(g, src):
    dist = {src: 0}
    q = deque([src])
    while q:
        u = q.popleft()                   # FRONT -> rings -> BFS
        for v in g.get(u, ()):            # .get, so a leaf can't KeyError
            if v not in dist:             # first arrival IS the shortest
                dist[v] = dist[u] + 1
                q.append(v)
    return dist
print(bfs(g, "Ada"))

# ---------- 3. one extra line gives you the ROUTE ----------
def bfs_path(g, src, dst):
    parent = {src: None}
    q = deque([src])
    while q:
        u = q.popleft()
        if u == dst:
            break
        for v in g.get(u, ()):
            if v not in parent:
                parent[v] = u             # remember ONE step back
                q.append(v)
    if dst not in parent:
        return None
    path = []
    while dst is not None:                # walk the back-pointers home
        path.append(dst)
        dst = parent[dst]
    return path[::-1]
print(bfs_path(g, "Ada", "Fin"), bfs_path(g, "Ada", "Nobody"))

# ---------- 4. one character turns the ripple into a plunge ----------
def order(g, src, stack):
    seen, frontier, out = {src}, deque([src]), []
    while frontier:
        u = frontier.pop() if stack else frontier.popleft()
        out.append(u)
        for v in g.get(u, ()):
            if v not in seen:
                seen.add(v)
                frontier.append(v)
    return out
print(order(g, "Ada", False))
print(order(g, "Ada", True))

# ---------- 5. the tripwires, demonstrated ----------
d = defaultdict(list)
d["Ada"].append("Bex")
print(len(d), "Zoe" in d)
for v in d["Zoe"]:            # a READ on a defaultdict CREATES the key
    pass
print(len(d), "Zoe" in d)

plain = {"Ada": ["Bex"]}
try:
    plain["Bex"]
except KeyError as e:
    print("KeyError:", e)
you see
['Bex', 'Cy'] 6
{'Ada': 0, 'Bex': 1, 'Cy': 1, 'Dot': 2, 'Eli': 2, 'Fin': 3}
['Ada', 'Bex', 'Dot', 'Fin'] None
['Ada', 'Bex', 'Cy', 'Dot', 'Eli', 'Fin']
['Ada', 'Cy', 'Eli', 'Fin', 'Dot', 'Bex']
1 False
2 True
KeyError: 'Bex'
where beginners trip
  • Run 5 is the trap nobody warns you about: for v in d["Zoe"] is a read, and on a defaultdict a read inserts the key. The dict went from 1 entry to 2 while you were only looking. Traverse a graph that way and your node count grows as you walk it — build with defaultdict, then freeze it with dict(g) before you search.
  • bfs_path returned None for an unknown node rather than raising. Unreachable is an ordinary answer in a graph, not an error — decide which one you mean and say so in the return type.
  • list.pop(0) looks like deque.popleft() and costs O(n) instead of O(1). On a million-node graph that single character choice is the difference between seconds and hours.
  • Mark seen when a node is appended, not when it is popped. Mark it on pop and a node with five edges into it enters the frontier five times — still correct, but no longer O(V+E).
  • Delete the seen/dist guard entirely and a single cycle gives you an infinite loop, not a crash. A hang is the worst failure mode there is: nothing to read, nothing to catch.
  • Both ends or one end. Appending the edge to g[u] only makes it directed, and half the bugs in student graph code are one missing mirror-append.
  • BFS returns hops. If your edges carry minutes, prices, or bandwidth, hops are the wrong measurement and a deque cannot fix it — that is exactly the crack Dijkstra fills.
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 abandon 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 to a neighbour of that one. It keeps going as deep as it can, until 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, the last-in-first-out structure. That 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. That is BFS. Take from the back, a stack, and you get a plunge. That is DFS. Only 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. It's frontier.pop() (take from the back, LIFO, a stack) versus frontier.popleft() (take from the front, FIFO, a queue). Swap that one call and nothing else, and the same code produces two utterly 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", and the data structure is the strategy.

How much work is all this? Count it the honest way. Each node enters the frontier once and leaves once, so that is V pieces of work. And every time we pull a node, we scan its edges exactly once, which across the whole run touches each edge a fixed number of times, for E more. Add them, and both BFS and DFS run in O(V + E) time, the very same shape as the memory the adjacency list uses. That is as good as it can possibly get. You cannot answer a question about a graph without at least looking at the graph, and O(V + E) is the cost of looking at it once.

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. You are given tasks with "must-come-before" dependencies, which together form a directed graph. The job is to produce an order where every task appears after everything it depends on. Course prerequisites and spreadsheet cell recalculation both rely on it. So, most famously, do build systems and package managers. Run it on a course catalogue:

But how does a depth-first plunge produce a valid order? Here is the mechanism, and it is prettier than it sounds. Run DFS, and do not record a task when you first touch it. Record it at the moment you finish it, once every task it depends on has already been fully explored beneath it. A task can only finish after all of its prerequisites have finished, so the finish order naturally lists dependencies before dependents. Reverse that finish order and you have the answer, with every prerequisite now sitting to the left of the thing that needs it. The cycle case falls out for free too. If DFS ever walks back into a node it is still in the middle of exploring, that back-edge is the loop, and no valid order can exist.

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 comes before everything, and Compilers lands dead last because it needs both Algorithms and OS. The second call is the important one. Now we make Intro require Compilers, which requires Algorithms, which requires DataStruct, which requires Intro. That loop 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 always the nearest. With weights, a path of three cheap edges can beat a path of one expensive edge. The ripple only 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.

Put real numbers on that failure, because it is easy to nod past. Say Home connects straight to Work by one slow road worth 10 minutes, and also reaches Work through three quick streets worth 2 + 2 + 2 = 6 minutes. The quick route is plainly better, since 6 beats 10. But BFS counts hops, not minutes, so it sees a 1-hop path and a 3-hop path and declares the 1-hop road the winner. It reports 10 and never notices the 6. The ripple was measuring the wrong thing, and that single mismatch is the entire reason Dijkstra has to exist.

The idea is greedy (chapter 39). You keep a frontier of "reached but not yet finalised" nodes. Each one is tagged with the cheapest total distance found to it so far, and you 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, also known as a min-heap. Treat it as a black box, whose internals wait for 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, since heappop is the black-box min. If that node hasn't already been finalised, the loop settles it, meaning 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).

One line in that loop deserves a second look: the check for whether a node is already finalised. Why would a node ever come off the heap twice? Because relaxing pushes v every time we find a cheaper route to it, so the same node can sit in the heap two or three times, once per improvement, each copy tagged with a different tentative distance. The cheapest copy always surfaces first, since the heap hands back the minimum. We settle the node on that first, best copy. When a stale, more-expensive copy pops out later, the finalised-check throws it straight away. That is why we never bother to hunt down and delete the old copies. We just let them show up and ignore them.

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. Suppose we settle a node u as the cheapest on the frontier at distance d, and 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, because we picked u as the minimum. Every remaining edge only adds non-negative weight, so the path through x costs ≥ d too. That is the 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 either. Think of currency arbitrage, where an "edge" can actually pay you.

Watch that failure with three nodes, because the abstract proof lands harder once you have seen it break. Home reaches A directly for a cost of 2. Home also reaches C for 3, and there is an edge from C to A that costs −2. Dijkstra pops A at distance 2, declares it settled, and locks it forever. But the real cheapest route is Home→C→A, worth 3 + (−2) = 1, which beats the 2 it already committed to. Dijkstra walked away with the wrong answer and never looks back. That locked-forever promise is exactly what a negative edge betrays, and it is why you reach for Bellman–Ford instead.

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 are, on the surface, four unrelated things. 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.

There's a transferable move here, the one the top 1% make almost reflexively. 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. Make the states of a puzzle the nodes and the legal moves the edges. Now "solve the puzzle" becomes "find a path", and BFS solves a sliding-tile puzzle for free. Make words the nodes and "differ by one letter" the edge. Now word-ladder puzzles and spell-check suggestions become shortest paths. Make jobs and machines the nodes and "can run on" the edge, and 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.

NOW WRITE IT YOURSELFbuild the word graph, then climb it — cold to warm, one letter at a time
The model first, the code second. Take a list of four-letter words and declare the graph out loud before you type: the nodes are the words, and two words share an edge when they differ in exactly one letter. Write build(words) that returns that adjacency dict — every word a key, even the lonely ones — and print how many nodes and edges you got. Then the climb. Write ladder(g, src, dst): BFS from src, and the moment you first reach a word, record parent[word]. When you hit dst, walk the back-pointers home and reverse. Run it on cold→warm, cold→harm, cold→sore, and cold→band, printing each ladder and its length. Make sure an unreachable pair returns None rather than crashing. Now prove BFS is doing something DFS cannot. Write the same walk recursively — take the first unseen neighbour, commit, recurse — and run both on cold→sore. Print the two lengths side by side. Before you run it, write down your guess for the DFS length. Then answer this in one sentence: DFS found a real, valid ladder, so what exactly was wrong with it?
show the solution
from collections import deque, defaultdict

WORDS = ["cold", "cord", "card", "ward", "warm", "word", "word", "worm",
         "worn", "corn", "born", "barn", "bard", "hard", "harm", "form",
         "fort", "sort", "sore", "core", "bore", "band", "bend", "wand"]


def build(words):
    """Nodes are words; an edge joins two words differing in ONE letter."""
    words = sorted(set(words))
    g = defaultdict(list)
    for w in words:
        g[w] = []                                   # every word is a node
    for i, a in enumerate(words):
        for b in words[i + 1:]:
            if sum(x != y for x, y in zip(a, b)) == 1:
                g[a].append(b)
                g[b].append(a)
    return g


def ladder(g, src, dst):
    """BFS + one back-pointer per node = the SHORTEST ladder, not just its length."""
    if src not in g or dst not in g:
        return None
    parent = {src: None}
    q = deque([src])
    while q:
        u = q.popleft()
        if u == dst:
            break
        for v in g[u]:
            if v not in parent:
                parent[v] = u
                q.append(v)
    if dst not in parent:
        return None
    path, node = [], dst
    while node is not None:
        path.append(node)
        node = parent[node]
    return path[::-1]


def plunge(g, src, dst, seen=None):
    """The same walk with a stack (recursion) — finds A path, not THE shortest."""
    seen = seen or {src}
    if src == dst:
        return [src]
    for v in g[src]:
        if v not in seen:
            seen.add(v)
            sub = plunge(g, v, dst, seen)
            if sub:
                return [src] + sub
    return None


g = build(WORDS)
print(len(g), "words,", sum(len(v) for v in g.values()) // 2, "edges")
print("cold ->", g["cold"])

for a, b in [("cold", "warm"), ("cold", "harm"), ("cold", "sore"), ("cold", "band")]:
    p = ladder(g, a, b)
    print(f"{a} -> {b}: {p if p is None else ' > '.join(p)}"
          f"   ({'unreachable' if p is None else str(len(p) - 1) + ' steps'})")

short = ladder(g, "cold", "sore")
long_ = plunge(g, "cold", "sore")
print("BFS :", len(short) - 1, "steps  ", " > ".join(short))
print("DFS :", len(long_) - 1, "steps  ", " > ".join(long_))


# ---------- the actual run, python 3.12.7 ----------
#
# 23 words, 37 edges
# cold -> ['cord']
# cold -> warm: cold > cord > card > ward > warm   (4 steps)
# cold -> harm: cold > cord > card > hard > harm   (4 steps)
# cold -> sore: cold > cord > core > sore   (3 steps)
# cold -> band: cold > cord > card > bard > band   (4 steps)
# BFS : 3 steps   cold > cord > core > sore
# DFS : 14 steps   cold > cord > card > bard > band > wand > ward > hard > harm > warm > worm > form > fort > sort > sore
#
#
# ---------- reading it ----------
#
# 1. Nothing in ladder() knows what a word is. Feed the same function a
#    road map, a friend list, or a Rubik's cube's legal twists and it
#    returns shortest routes there too. The modelling -- "words are nodes,
#    one-letter-apart is an edge" -- was the entire insight. The algorithm
#    came off the shelf, untouched.
#
# 2. 24 words in, 23 nodes out: "word" appears twice in WORDS and set()
#    collapses it. A node is a THING, not an occurrence.
#
# 3. cold has exactly one neighbour. Every ladder in this graph therefore
#    starts cold > cord, and the search discovers that rather than being
#    told it. Degree-1 nodes are bottlenecks you can read straight off the
#    adjacency list before you search anything.
#
# 4. The headline: 3 steps against 14, on the same graph, from the same
#    start, to the same goal. DFS is not wrong -- every step of its
#    14-word chain is a legal one-letter change. It is answering a
#    different question. DFS asks "is there a path?" and commits to the
#    first neighbour it meets; BFS asks "what is the SHORTEST path?" and
#    the FIFO order is the only reason it can promise that. Choose the
#    frontier that matches the question, because both cost O(V + E) and
#    only one of them is the answer you wanted.
#
# 5. parent is O(V) memory and rebuilds any route on demand. Storing a
#    whole path on every node would be O(V**2) -- the same "remember one
#    step back" trade that Dijkstra and A* both live on.
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 human 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. He did it 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, which is nodes, edges, and a growing frontier of "cheapest settled so far", and he 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
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked