Search Tech Journey

Find topics, journeys and posts

6-month learning plan30 / 130
back to blog
pythonbeginner 50m read

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.

🧩DSAM03 · Data Structures & Algorithms· Session 030 of 130 90 min

🎯 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’.

You will be able to
  • 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

A graph is a subway map
🌍 Real world

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.

💻 Code world

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

Learn these four and you can walk into any graph problem
  • 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

  1. 1736
    Euler and the Seven Bridges of Königsberg
    Leonhard Euler proves you can't cross all seven bridges without repeats — the birth of graph theory.
  2. 1959
    Dijkstra's shortest path
    Edsger Dijkstra invents his algorithm on a napkin in ~20 minutes while shopping for coffee. Still runs your Google Maps queries.
  3. 1972
    Tarjan's DFS SCC algorithm
    Robert Tarjan formalises DFS as a general-purpose graph-analysis tool. Wins the Turing Award later.
  4. 1996
    PageRank
    Larry Page + Sergey Brin: rank web pages by their position in the graph of links. Google is born.
  5. 2003
    Facebook 2004 — social graph
    The ‘friend graph’ becomes the defining data model of Web 2.0. Every recommendation engine since is a graph algorithm.
  6. 2013
    Graph databases mature
    Neo4j, TigerGraph, Amazon Neptune. Fraud detection, knowledge graphs, and recommendation move to native graph storage.

(b) Visual walkthrough · 15 min

Three representations, one graph

Adjacency list

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
Adjacency matrix

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)
Edge list

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
NetworkX (Python)

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

11
Precondition: DAG

Directed AND acyclic. If there's a cycle, no valid order exists.

22
Compute in-degrees

For each node, count incoming edges.

33
Queue all zero-in-degree nodes

These have no unmet dependencies — safe to process first.

44
Pop a node, add to output, decrement each successor's in-degree

When a successor's in-degree hits 0, enqueue it.

55
Repeat

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 in the wild
‘Find shortest number of hops’ — friends-of-friends suggestions, web crawler frontier, ARP path in networks, tree level-order (S028).
unweighted
DFS in the wild
‘Can I reach X’ — connected components, deadlock detection in databases, garbage collector marking, maze solvers.
connectivity
Dijkstra in the wild
‘Fastest path with costs’ — Google Maps, Uber ETAs, BGP routing across ISP networks, telecom least-cost routing.
weighted
Topological sort in the wild
‘Do things in the right order’ — Make/Bazel build order, npm/pip dependency install, university course prerequisites, Airflow DAG execution.
DAG

Common misconception
✗ What most people think

"BFS finds the shortest path. So if I need a shortest path, I use BFS."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 distance
From first principles
Start with the question

Why 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?

  1. 1
    BFS 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
  2. 2
    A 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
  3. 3
    Therefore 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
  4. 4
    So 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
  5. 5
    DFS 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

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.

Mental modelRipple vs. thread

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.
🔔 Fires when you see

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.

The tradeoff

You must explore a large graph and you can hold only part of it in memory. BFS or DFS?

BFS
+ you gain shortest hop-count for free; finds nearby answers fast, so it wins when the target is close to the source; never gets lost down an infinite branch
− you pay memory is the frontier width, which on a branching factor b at depth d is O(b^d) — this is the one that actually OOMs in production
pick when the answer is expected to be shallow, or you genuinely need the shortest path — social-graph degrees, blast radius within N hops
DFS
+ you gain memory is only O(depth), which is dramatically smaller on wide graphs; natural fit for recursion and for post-order work like topological sort and cycle detection
− you pay no distance guarantee; can wander deep into a useless branch for a long time; on a cyclic or effectively infinite graph without a visited set it never terminates
pick when you need to visit everything anyway (so order does not matter), or the graph is wide and shallow answers are not expected — crawlers, dependency resolution, component labelling
Iterative deepening
+ you gain DFS memory with BFS ordering guarantees — it finds the shallowest answer while holding only O(depth)
− you pay re-explores the upper levels on every iteration; the overhead is a constant factor on a branching graph but is genuinely wasteful when branching is near 1
pick when you need shortest-path semantics but BFS's frontier will not fit — the standard answer in game search and large-state-space exploration
What a senior engineer actually does

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

build_graph
The default representation — dict[node → list[neighbor]]. Directed or undirected via a single flag.
representation
bfs_shortest
The canonical unweighted-shortest-path template. seen set prevents cycles; parent dict lets you reconstruct the path.
BFS
dfs_recursive vs dfs_iterative
Same traversal, two shapes. Recursive is short but hits Python's 1000-frame limit. Iterative uses an explicit stack, no depth limit.
DFS
connected_components
Loop over all nodes; if unvisited, DFS from it → that DFS returns one whole component. Union-find would also work.
connectivity
dijkstra
Heap-based, lazy-deletion. Copy-pasted from S029 to show how heap-of-frontier is the universal shape for weighted shortest path.
weighted
topological_sort
Kahn's algorithm — process nodes with no unmet dependencies first, decrement successors. If output size &lt; V, there was a cycle.
DAG
has_cycle_directed
Three-colour DFS: WHITE = unvisited, GRAY = currently on the stack, BLACK = fully processed. A back-edge to a GRAY node = cycle.
cycle
Try itImplement ‘number of islands’ (classic grid BFS/DFS)
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))   # 3

The insight: any grid problem where you ask ‘how many connected regions?’ or ‘shortest path in the grid?’ is a graph problem in disguise.

💡 Hint · Treat the 2D grid as an implicit graph — each ‘1’ cell has up to 4 neighbors (up/down/left/right). Loop over all cells; when you find an unvisited ‘1’, DFS/BFS to mark the whole island and increment the count.

(d) Production reality · 15 min

War story npm / pip · every package managerglobal daily use
🔥 What broke

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.

🧯 The fix
Modern package managers (npm 7's arborist, pip's 2020 resolver, cargo, poetry) frame the problem as a SAT-solver-adjacent graph traversal. They use topological sort + backtracking to find a valid installation order, and cycle detection to catch circular deps early.
🎓 Lesson to steal
Package management is one of the biggest applied graph problems in software. Learning topological sort and cycle detection isn't academic — it's what makes npm install possible.
Post-mortem
War story Facebook · early social graph· 2007millions of friend edges
🔥 What broke

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.

🧯 The fix
Facebook built TAO (The Associations and Objects) — a purpose-built graph store on top of MySQL + memcached, tuned for BFS from any node in under 10 ms. Later added ByteGraph-style optimisations. Today, every ‘people you may know’ suggestion is a bounded BFS on TAO.
🎓 Lesson to steal
When your data IS a graph, use graph algorithms and (eventually) a graph store. Trying to force graph queries into SQL joins usually costs you.
Post-mortem
War story Google Maps · every route requestbillions of routes per day
🔥 What broke
Naive Dijkstra on the entire road network of a country would take seconds per query — unacceptable for interactive maps.
🧯 The fix
Google Maps uses Contraction Hierarchies: preprocess the road graph offline, add ‘shortcut’ edges between important nodes. At query time, run bidirectional Dijkstra on the smaller, contracted graph. Query time drops from seconds to milliseconds.
🎓 Lesson to steal
Textbook Dijkstra is the starting point, not the endpoint. In production, preprocessing + specialised structures beat pure algorithms by orders of magnitude.
Post-mortem

Where this shows up in the rest of the plan

Graphs are the universal shape
S033 · Dynamic Programming
DP on a DAG of subproblems is DP. Longest path = topo-order DP.
S056 · Build Systems / CI
Every build system (Bazel, Nx, Turbo) is topological sort of a task DAG.
S073 · Concurrency
Deadlock detection = cycle detection in the ‘resource wait’ graph.
S085 · LLM Knowledge Graphs
RAG over knowledge graphs is BFS/DFS from a query node.
S102 · Distributed Consensus
Raft, Paxos — nodes gossip along a graph of peer connections.
S117 · Query Optimisers
SQL query plans are DAGs; the optimiser picks the best topological execution.

(e) Recall + stretch · 10 min

Recall — click to reveal · click to reveal
★ = stretch question

Explain-out-loud test

Teach these three, one minute each, no notes:

  1. What is a graph, and give one real-world example.
  2. When do you use BFS vs DFS?
  3. 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.