S030 · Graphs — Representation, BFS, DFS, Shortest Path
The most general data structure — and the algorithms that turn ‘find the shortest / cheapest / connected’ problems into 20-line functions.
🎯 Represent graphs three ways, traverse them with BFS / DFS / Dijkstra, and recognise where they secretly show up in every real system (route planners, social networks, dependency resolvers, package managers).
Why this session exists
Every interesting real-world problem is secretly a graph problem. Social networks, road maps, package dependencies, task schedulers, LLM knowledge graphs, git commits, database query planners — all graphs, all traversed with BFS or DFS. This session teaches the three representations, the four canonical algorithms (BFS, DFS, Dijkstra, topological sort), and the patterns that turn ‘this problem is complicated’ into ‘this problem is a graph, and I already know the algorithm’.
- Choose between adjacency list, adjacency matrix, and edge list based on graph density.
- Write BFS for shortest-path-in-unweighted-graph and DFS for connectivity from memory.
- Reuse S029's Dijkstra to solve weighted shortest path.
- Detect a cycle in a directed graph and compute a topological order.
- Recognise real problems (route planning, package resolvers, deadlock detection) as graph traversals.
Prerequisites
- S028 · Trees & BSTs — trees are acyclic graphs.
- S029 · Heaps & Priority Queues — used for Dijkstra.
- S026 · Stacks & Queues — BFS is a queue, iterative DFS is a stack.
(a) Intuition · 5 min
Stations are nodes. Tracks are edges. Some edges are directed (one-way tunnels), most are undirected (both ways). Some are weighted (this segment takes 4 minutes, that one 12). To go from your stop to a destination, you're asking a graph algorithm: ‘which sequence of edges gets me there — with the fewest stops (BFS) or the shortest time (Dijkstra)?’
Every ride-hailing app, every routing service, every ‘friends of friends’ suggestion — subway-map thinking behind different labels.
A graph G = (V, E) is a set of vertices and edges. Represent it with an adjacency list dict[node → list[neighbor]] and traversal algorithms become 15 lines of Python. BFS explores in expanding rings from the start; DFS goes deep down one path before backtracking. Both visit every reachable node exactly once → O(V + E).
Weighted edges add cost per hop → Dijkstra with a heap. Directed edges add ‘direction matters’ → topological sort. Cycles add complication → cycle detection with three-colour DFS.
The four graph algorithms that solve 80% of problems
- BFS — shortest edge-count path in unweighted graphs, level-order exploration.
- DFS — connectivity (‘can I reach A from B?’), cycle detection, topological sort, backtracking searches.
- Dijkstra — shortest path in graphs with non-negative edge weights (Google Maps, ride-hailing ETAs).
- Topological sort — dependency-ordered traversal of a DAG (build systems, task schedulers, package managers).
Timeline — the algorithms that built the modern internet
- 1736Euler and the Seven Bridges of KönigsbergLeonhard Euler proves you can't cross all seven bridges without repeats — the birth of graph theory.
- 1959Dijkstra's shortest pathEdsger Dijkstra invents his algorithm on a napkin in ~20 minutes while shopping for coffee. Still runs your Google Maps queries.
- 1972Tarjan's DFS SCC algorithmRobert Tarjan formalises DFS as a general-purpose graph-analysis tool. Wins the Turing Award later.
- 1996PageRankLarry Page + Sergey Brin: rank web pages by their position in the graph of links. Google is born.
- 2003Facebook 2004 — social graphThe ‘friend graph’ becomes the defining data model of Web 2.0. Every recommendation engine since is a graph algorithm.
- 2013Graph databases matureNeo4j, TigerGraph, Amazon Neptune. Fraud detection, knowledge graphs, and recommendation move to native graph storage.
(b) Visual walkthrough · 15 min
Three representations, one graph
dict[node] -> list of neighbors
- Space O(V + E) — best for sparse
- Iterate neighbors of u in O(deg(u))
- The default for real code
- Python: dict of sets or lists
V × V boolean/int matrix
- Space O(V²) — good only for dense graphs
- O(1) edge-existence check
- O(V) to list neighbors of a node — expensive
- Great for matrix-multiplication algorithms (path counts)
list of (u, v, weight)
- Space O(E) — most compact
- No fast neighbor lookup
- Used for Kruskal's MST + I/O
- Good for read-only analysis
Real-world default
- nx.Graph() / nx.DiGraph()
- Handles all reps under the hood
- Ships every classic algorithm
- Great for prototyping
BFS expands in rings; DFS drills deep
Topological sort — dependency ordering
Directed AND acyclic. If there's a cycle, no valid order exists.
For each node, count incoming edges.
These have no unmet dependencies — safe to process first.
When a successor's in-degree hits 0, enqueue it.
If output size < V at the end, there was a cycle → no valid topo order.
Where these algorithms show up in real systems
Graph algorithms as you'll actually use them
"BFS finds the shortest path. So if I need a shortest path, I use BFS."
BFS finds the path with the fewest edges. That is the shortest path only when every edge costs the same. Add weights and BFS is simply wrong — not slow, wrong — because a two-edge path can be cheaper than a one-edge path. That case needs Dijkstra, which is BFS with a priority queue instead of a FIFO queue.
Because the examples are always grids and mazes, where every step costs 1, so "fewest steps" and "shortest distance" coincide and the distinction is invisible. The moment edges carry latency, cost, or distance — which is every real network, road graph or pipeline DAG — they diverge, and BFS confidently returns a wrong answer with no error.
A weighted graph where the fewest-edges path is not the cheapest:
from collections import deque
import heapq
# A->C direct costs 10; A->B->C costs 2
W = {'A': [('B',1), ('C',10)], 'B': [('C',1)], 'C': []}
def bfs_hops(s, t):
q, seen = deque([(s, 0)]), {s}
while q:
n, d = q.popleft()
if n == t: return d
for m, _ in W[n]:
if m not in seen: seen.add(m); q.append((m, d+1))
def dijkstra(s, t):
pq, best = [(0, s)], {}
while pq:
d, n = heapq.heappop(pq)
if n in best: continue
best[n] = d
if n == t: return d
for m, w in W[n]:
if m not in best: heapq.heappush(pq, (d+w, m))
print(bfs_hops('A','C')) # 1 hop - but that hop costs 10
print(dijkstra('A','C')) # 2 - the actual shortest distanceWhy does BFS, and only BFS, produce shortest hop-counts? DFS visits every node too — why can it not just track depth and take the minimum?
- 1BFS removes nodes from a FIFO queue, so a node enqueued earlier is always dequeued earlier.forced by · that is the definition of FIFO — the container itself enforces the ordering
- 2A node at distance d is only ever enqueued by a node at distance d−1, so nodes enter the queue in non-decreasing distance order.forced by · distance increases by exactly 1 per enqueue, and the parent was itself dequeued in order
- 3Therefore the queue holds, at any moment, only nodes at distance d and d+1 — the frontier is a clean level boundary.forced by · non-decreasing insertion order plus uniform increment cannot produce a gap of 2
- 4So the first time BFS reaches a node, it reached it via a shortest path — any shorter path would have delivered it in an earlier level, and levels are processed in order.forced by · you cannot arrive early via a long route when all shorter routes were already fully expanded
- 5DFS has no such property: it commits to one branch to full depth, so it can reach a node by a long path first and only discover a short one much later, after marking it visited.forced by · a stack processes the most recent node, which carries no distance ordering at all
Therefore first-visit-is-shortest is a consequence of the container's ordering, not of anything in the traversal logic. DFS can be forced to find shortest paths only by abandoning the visited-set optimisation and exploring exponentially many paths.
And note what this predicts: generalise the container and you generalise the algorithm. Swap the FIFO for a min-heap keyed on accumulated cost and the identical argument gives Dijkstra — first pop is final. Add a heuristic to that key and you get A*. BFS, Dijkstra and A* are one algorithm with three priority functions.
BFS is a ripple: drop a stone at the source and the wavefront expands outward, touching everything at distance 1, then everything at distance 2. It knows exactly how far it has gone, and its memory cost is the circumference of the ripple.
DFS is a thread through a maze: follow one corridor as far as it goes, then back up to the last junction and try the next one. It has no idea how far it is from the start as the crow flies, and its memory cost is the length of the thread.
- Same code, different container. Queue → BFS, stack (or recursion) → DFS, priority queue → Dijkstra.
- BFS memory = width of the frontier (can explode on high-branching graphs). DFS memory = depth (can overflow the stack on long chains).
- Mark visited at enqueue time, not dequeue time, or the frontier fills with duplicates and BFS degenerates.
- Both are O(V+E) with an adjacency list. If yours is O(V²), you built an adjacency matrix or you are scanning a list for membership instead of using a set.
Fire this model the moment you see: "fewest steps / degrees of separation" · connected components · cycle detection · topological sort of a pipeline DAG · Airflow task dependency resolution · Spark's lineage graph · dependency install resolution · reachability or blast-radius questions over a lineage graph.
You must explore a large graph and you can hold only part of it in memory. BFS or DFS?
Decide by which resource kills you first. Wide graph → BFS's frontier is the risk, so DFS. Deep graph → DFS's stack is the risk, so BFS. Most real dependency and lineage graphs are wide and shallow, which is why DFS-based topological sort is the workhorse in orchestration systems.
The detail that actually bites: on any graph with cycles, the visited set is not an optimisation, it is a correctness requirement — and its memory is O(V) regardless of which traversal you chose. If V does not fit in memory, neither algorithm helps and you are now doing an external / distributed traversal, which is a different problem entirely.
(c) Hands-on · 25 min
Save as graphs.py. Zero deps beyond stdlib.
"""graphs.py — the interview canon: BFS, DFS, Dijkstra, topological sort.
Run: python graphs.py
"""
from __future__ import annotations
from collections import defaultdict, deque
import heapq
from typing import Optional
# ------------------------------------------------------------------
# 1) Adjacency-list graph builder — handles both directed + undirected
# ------------------------------------------------------------------
def build_graph(edges: list[tuple[int, int]], directed: bool = False) -> dict[int, list[int]]:
g: dict[int, list[int]] = defaultdict(list)
for u, v in edges:
g[u].append(v)
if not directed:
g[v].append(u)
return g
# ------------------------------------------------------------------
# 2) BFS — shortest edge-count path in an unweighted graph
# ------------------------------------------------------------------
def bfs_shortest(graph: dict[int, list[int]], start: int, goal: int) -> Optional[list[int]]:
"""Return the shortest path (as node list) from start to goal, or None."""
if start == goal:
return [start]
seen = {start}
parent: dict[int, int] = {start: -1}
q: deque[int] = deque([start])
while q:
u = q.popleft()
for v in graph.get(u, []):
if v in seen:
continue
seen.add(v)
parent[v] = u
if v == goal:
# Reconstruct path
path = [v]
while parent[path[-1]] != -1:
path.append(parent[path[-1]])
path.reverse()
return path
q.append(v)
return None
# ------------------------------------------------------------------
# 3) DFS — recursive and iterative flavors
# ------------------------------------------------------------------
def dfs_recursive(graph: dict[int, list[int]], start: int, seen: set[int] | None = None) -> list[int]:
seen = seen if seen is not None else set()
order: list[int] = []
seen.add(start)
order.append(start)
for v in graph.get(start, []):
if v not in seen:
order.extend(dfs_recursive(graph, v, seen))
return order
def dfs_iterative(graph: dict[int, list[int]], start: int) -> list[int]:
seen = {start}
stack = [start]
order: list[int] = []
while stack:
u = stack.pop()
order.append(u)
for v in graph.get(u, []):
if v not in seen:
seen.add(v)
stack.append(v)
return order
# ------------------------------------------------------------------
# 4) Connected components (undirected graph)
# ------------------------------------------------------------------
def connected_components(graph: dict[int, list[int]], nodes: list[int]) -> list[list[int]]:
seen: set[int] = set()
comps: list[list[int]] = []
for n in nodes:
if n in seen:
continue
comp = dfs_iterative(graph, n)
seen.update(comp)
comps.append(comp)
return comps
# ------------------------------------------------------------------
# 5) Dijkstra — shortest path with non-negative weights (S029 reuse)
# ------------------------------------------------------------------
def dijkstra(graph: dict[int, list[tuple[int, float]]], start: int) -> dict[int, float]:
"""graph: node -> list of (neighbor, weight). Returns dist[node]."""
dist: dict[int, float] = {start: 0.0}
heap: list[tuple[float, int]] = [(0.0, start)]
while heap:
d, u = heapq.heappop(heap)
if d > dist.get(u, float("inf")):
continue # stale entry
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float("inf")):
dist[v] = nd
heapq.heappush(heap, (nd, v))
return dist
# ------------------------------------------------------------------
# 6) Topological sort (Kahn's algorithm) — dependency order
# ------------------------------------------------------------------
def topological_sort(graph: dict[int, list[int]], nodes: list[int]) -> Optional[list[int]]:
"""Return a valid topological order for the DAG, or None if there's a cycle."""
indeg: dict[int, int] = {n: 0 for n in nodes}
for u in nodes:
for v in graph.get(u, []):
indeg[v] = indeg.get(v, 0) + 1
q: deque[int] = deque([n for n in nodes if indeg[n] == 0])
order: list[int] = []
while q:
u = q.popleft()
order.append(u)
for v in graph.get(u, []):
indeg[v] -= 1
if indeg[v] == 0:
q.append(v)
return order if len(order) == len(nodes) else None # None → cycle detected
# ------------------------------------------------------------------
# 7) Cycle detection in a directed graph (three-colour DFS)
# ------------------------------------------------------------------
def has_cycle_directed(graph: dict[int, list[int]], nodes: list[int]) -> bool:
WHITE, GRAY, BLACK = 0, 1, 2
color = {n: WHITE for n in nodes}
def visit(u: int) -> bool:
color[u] = GRAY
for v in graph.get(u, []):
if color.get(v, WHITE) == GRAY: # back-edge → cycle
return True
if color.get(v, WHITE) == WHITE and visit(v):
return True
color[u] = BLACK
return False
return any(color[n] == WHITE and visit(n) for n in nodes)
# ------------------------------------------------------------------
# Runner
# ------------------------------------------------------------------
if __name__ == "__main__":
# Undirected graph:
# 1 - 2 - 3
# | |
# 4 - 5 - 6
edges = [(1, 2), (2, 3), (1, 4), (4, 5), (5, 6), (3, 6)]
g = build_graph(edges)
print("BFS 1 -> 6:", bfs_shortest(g, 1, 6))
print("DFS from 1 (recursive):", dfs_recursive(g, 1))
print("DFS from 1 (iterative):", dfs_iterative(g, 1))
print("connected_components:", connected_components(g, [1, 2, 3, 4, 5, 6, 7, 8])) # 7 & 8 isolated
# Weighted directed graph for Dijkstra
weighted = {
1: [(2, 7.0), (3, 9.0), (6, 14.0)],
2: [(3, 10.0), (4, 15.0)],
3: [(4, 11.0), (6, 2.0)],
4: [(5, 6.0)],
5: [],
6: [(5, 9.0)],
}
print("Dijkstra from 1:", dijkstra(weighted, 1))
# DAG for topological sort — a tiny ‘build system’
dag = build_graph([("compile", "link"), ("test", "package"), ("link", "package"), ("bootstrap", "compile"), ("bootstrap", "test")], directed=True)
all_nodes = ["bootstrap", "compile", "link", "test", "package"]
print("topological_sort:", topological_sort(dag, all_nodes))
print("has_cycle (DAG):", has_cycle_directed(dag, all_nodes))
# Add a cycle: package -> bootstrap
dag2 = build_graph([("compile", "link"), ("test", "package"), ("link", "package"), ("bootstrap", "compile"), ("bootstrap", "test"), ("package", "bootstrap")], directed=True)
print("has_cycle (with cycle):", has_cycle_directed(dag2, all_nodes))
print("topological_sort (cyclic):", topological_sort(dag2, all_nodes))Anatomy of the script
What each function teaches
def num_islands(grid: list[list[str]]) -> int:
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
seen: set[tuple[int, int]] = set()
def dfs(r: int, c: int) -> None:
stack = [(r, c)]
while stack:
r, c = stack.pop()
if (r, c) in seen or r < 0 or c < 0 or r >= rows or c >= cols or grid[r][c] != "1":
continue
seen.add((r, c))
stack.extend([(r+1, c), (r-1, c), (r, c+1), (r, c-1)])
count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == "1" and (r, c) not in seen:
dfs(r, c)
count += 1
return count
grid = [
["1", "1", "0", "0", "0"],
["1", "1", "0", "0", "0"],
["0", "0", "1", "0", "0"],
["0", "0", "0", "1", "1"],
]
print(num_islands(grid)) # 3The insight: any grid problem where you ask ‘how many connected regions?’ or ‘shortest path in the grid?’ is a graph problem in disguise.
(d) Production reality · 15 min
You install a package. It requires two others. Those require five more. Those require twenty more, with version constraints that must all be satisfied. That's a graph problem — nodes are (package, version) pairs, edges are ‘requires’.
Naive resolution: brute force. Explodes exponentially. In 2016, npm had documented cases where installing a single package took an hour of resolver time.
npm install possible.Early Facebook stored friendships in a naive relational schema: a table with (user_a, user_b) rows. Computing ‘friends of friends’ for one user was a 2-hop JOIN — for a viral profile with 500 friends, that JOIN blew out the query planner.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three, one minute each, no notes:
- What is a graph, and give one real-world example.
- When do you use BFS vs DFS?
- What does topological sort do, and give one system that uses it.
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.