01 Β· DSA β File 05
Graphs
Graphs are the most disguised interview topic. "Course prerequisites," "islands in a grid," "word ladder," "account merging," "network delay" β none of these say "graph," all of them are graphs. The skill isn't graph algorithms; it's recognizing graphs, then applying one of about five standard tools.
1What a graph is, and why it's everywhere
A graph is the most general "things and relationships" structure: nodes (vertices) connected by edges. Drop the tree's rules β no root, no parent/child, cycles allowed, disconnected pieces allowed β and you get a graph. (A tree is just a connected, acyclic graph. Linked lists too. You've been doing graphs all along.)
Three flavors change which algorithm you reach for:
One-way edges (service A calls B; task X before Y) vs mutual (friendship, network cables).
Edges with costs (latency, distance, price) vs plain connectivity. Weights are what push you from BFS to Dijkstra.
A directed acyclic graph (DAG) is the shape of every dependency system you've ever used.
docker-compose depends_on, Airflow DAGs (it's in the name), Makefile targets, package resolvers, Kubernetes ownerRefs, microservice call graphs, import graphs, workflow engines. Every "X depends on Y" system you operate is a DAG under the hood.
2Representations
Interview problems hand you graphs in three costumes.
Adjacency list β the default
node β list of neighbors. Space O(V + E). This is what you build from an edge list in the first 30 seconds of almost every graph problem:
from collections import defaultdict
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u) # drop this line for directed graphs
Edges [(0,1),(0,2),(1,2),(2,3)]
0 βββ 1
\ /
\ /
2 βββ 3
Adjacency list
0: [1, 2]
1: [0, 2]
2: [0, 1, 3]
3: [2]
Adjacency matrix
matrix[u][v] = 1/weight. O(1) edge lookup, but O(VΒ²) space β only for small dense graphs (or FloydβWarshall).
Implicit graph β the sneaky one
Nobody gives you edges; the rules generate neighbors. A grid cell's neighbors are its 4 adjacent cells. A word's neighbors are words one letter away. A state's neighbors are states one move away.
neighbors(state), you have a graph. That reframe is the key that unlocks half of Β§8's "secretly a graph" table.
3BFS and DFS β the only two ways to explore
Every graph traversal answers one question: what do I visit next? There are exactly two disciplined answers, and everything else is a variation.
- BFS: visit all nodes at distance 1, then distance 2, then 3β¦ β expand like a ripple. Uses a queue (FIFO: first discovered, first expanded).
- DFS: follow one path as deep as it goes, backtrack, try the next β probe like a maze-runner with a ball of string. Uses a stack (explicit, or the call stack).
BFS from A (ripple)
A layer 0
/ \
B C layer 1
/ \ \
D E F layer 2
order: A B C D E F
DFS from A (probe)
A
β go deep: AβBβD β¦
β dead end, backtrack
β to B, try E β¦
βΌ
order: A B D E C F
BFS β with the layer structure explicit
from collections import deque
def bfs(graph, start):
dist = {start: 0}
q = deque([start])
while q:
node = q.popleft()
for nb in graph[node]:
if nb not in dist: # dist doubles as visited
dist[nb] = dist[node] + 1
q.append(nb)
return dist
DFS β recursive and iterative
Recursive (default)
def dfs(graph, start, visited=None):
if visited is None:
visited = set()
visited.add(start)
for nb in graph[start]:
if nb not in visited:
dfs(graph, nb, visited)
return visited
Iterative (deep graphs / recursion limits)
def dfs_iter(graph, start):
visited, stack = set(), [start]
while stack:
node = stack.pop()
if node in visited:
continue
visited.add(node)
stack.extend(nb for nb in graph[node]
if nb not in visited)
return visited
DFS's superpowers: exhaustively exploring components, detecting cycles, topological ordering, and anything path-shaped/backtracking-shaped. It's usually less code, which matters under time pressure.
| You need⦠| Reach for | Why |
|---|---|---|
| Shortest / level / nearest anything (unweighted) | BFS | Layers = distances; first visit is shortest |
| "Explore everything," cycle facts, ordering, path enumeration | DFS | Postorder + recursion stack give you structure for free |
| Cost | O(V + E) | Both β each node and edge touched once; space O(V) |
4Connected components & grid problems (Number of Islands)
Component counting = "how many separate blobs": loop over all nodes; every time you find an unvisited one, that's a new component β flood it (BFS or DFS) to mark the whole blob.
Number of Islands is exactly this with an implicit grid graph β cells are nodes, edges connect adjacent land. Here's a grid with its three islands flood-filled in three colors:
| c0 | c1 | c2 | c3 | c4 | |
|---|---|---|---|---|---|
| r0 | 1 | 1 | 0 | 0 | 0 |
| r1 | 1 | 1 | 0 | 0 | 1 |
| r2 | 0 | 0 | 0 | 1 | 1 |
| r3 | 0 | 0 | 0 | 0 | 0 |
| r4 | 1 | 0 | 0 | 0 | 0 |
Three colors = three flood fills = 3 islands. Diagonals don't connect (only 4-adjacency), so the green and amber blobs stay separate.
def num_islands(grid):
if not grid: return 0
rows, cols = len(grid), len(grid[0])
count = 0
def sink(r, c): # DFS flood fill
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != "1":
return
grid[r][c] = "0" # mark visited by sinking
sink(r+1, c); sink(r-1, c); sink(r, c+1); sink(r, c-1)
for r in range(rows):
for c in range(cols):
if grid[r][c] == "1":
count += 1 # new island discovered
sink(r, c) # erase it entirely
return count
O(rows Γ cols): each cell visited O(1) times. The grid toolkit generalizes to Max Area of Island, Flood Fill, Surrounded Regions, Pacific-Atlantic (flood from the borders β a lovely inversion), and Rotting Oranges (multi-source BFS: seed the queue with all rotten oranges at t=0; layers = minutes).
Grid idiom worth pre-memorizing:
for dr, dc in ((1,0), (-1,0), (0,1), (0,-1)):
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols:
...
5Topological sort β ordering a DAG
The question it answers: given dependencies ("A before B"), produce a valid execution order β or report that none exists (a cycle). This is docker-compose startup order, DB migration ordering, build systems, Airflow scheduling. Interview costume: Course Schedule.
Kahn's algorithm (BFS flavor) β the one to know cold. Intuition: something with no prerequisites can go first. Do it, delete it, repeat. Track each node's in-degree (how many prerequisites still point at it); anything at 0 is ready.
indegree[nb] -= 1.from collections import deque, defaultdict
def topo_sort(num_nodes, edges): # edges: (u, v) = u before v
graph = defaultdict(list)
indegree = [0] * num_nodes
for u, v in edges:
graph[u].append(v)
indegree[v] += 1
q = deque(n for n in range(num_nodes) if indegree[n] == 0)
order = []
while q:
node = q.popleft()
order.append(node)
for nb in graph[node]:
indegree[nb] -= 1 # "delete" the edge
if indegree[nb] == 0: # all prereqs satisfied β ready
q.append(nb)
return order if len(order) == num_nodes else [] # short order β cycle!
DFS flavor: postorder DFS, then reverse β a node finishes only after everything it points to has finished. Know it exists; write Kahn's.
Cycle detection, summarized
Directed graph: Kahn's leftover-nodes check, or DFS with three colors:
- white β unvisited, never touched
- gray β in the current recursion stack, i.e. on the path you're standing on right now
- black β fully done, all descendants explored
Hitting a gray node = back edge = cycle. Gray is the crucial subtlety β hitting a black node is fine (just a shared dependency, like two services importing the same library).
Undirected graph: DFS; seeing a visited neighbor that isn't your immediate parent = cycle. Or union-find (Β§7): an edge joining two nodes already in the same set closes a cycle.
6Shortest paths with weights β Dijkstra
BFS's "first visit = shortest" breaks with weights: a 2-hop path of weight 1+1 beats a 1-hop path of weight 10, but BFS finds the 1-hop first.
Dijkstra's fix β greedy by total distance: always expand the frontier node with the smallest known total distance from the source. That's BFS where the queue becomes a min-heap keyed by distance.
u is popped, no future route can beat its distance β any other route must exit through some frontier node that's already β₯ as far, and (with non-negative weights) can only get longer from there. So u's distance is final. That non-negativity assumption is where the proof lives β negative edges break it (then: Bellman-Ford; mention, don't implement).
import heapq
def dijkstra(graph, src): # graph[u] = [(v, weight), ...]
dist = {src: 0}
pq = [(0, src)] # (distance-so-far, node)
while pq:
d, u = heapq.heappop(pq)
if d > dist.get(u, float('inf')):
continue # stale entry β already found better
for v, w in graph[u]:
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(pq, (nd, v)) # "lazy deletion": push, skip stale later
return dist
The if d > dist[u]: continue line is the idiomatic Python move β instead of decreasing a key inside the heap (which heapq can't do), push duplicates and skip outdated ones on pop. O((V + E) log V). Costume versions: Network Delay Time, Cheapest Flights (K stops β a twist), Path With Minimum Effort.
7Union-Find (Disjoint Set Union)
The question it answers: "are these two things in the same group?" under a stream of merges β without re-running traversals. Dynamic connectivity: accounts that share an email, network nodes as cables get added, Kruskal's MST, friend circles.
Each group is a tree; each node points at a parent; the root is the group's ID. find walks to the root; union links two roots.
find(D): walk to the root
find(D): D β B β A (root)
A
/ \
B C
β
D
union: point one root at the other
union(A-tree, C-tree):
point C's root at A
A
/|\
B C E
β
D
Two optimizations make it effectively O(1) per op (inverse-Ackermann, Ξ±(n) β€ 4 for any physical n):
class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path compression (halving)
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected (β this edge makes a cycle)
if self.rank[ra] < self.rank[rb]:
ra, rb = rb, ra
self.parent[rb] = ra # union by rank: shallow under deep
self.rank[ra] += self.rank[ra] == self.rank[rb]
return True
- Path compression: while finding the root, make nodes point closer to it β trees flatten themselves through use.
- Union by rank: attach the shorter tree under the taller β trees stay shallow.
When to prefer it over DFS: edges arrive over time / queries interleave with merges ("after adding each cable, are X and Y connected?"). For a static graph given up front, plain DFS components are simpler. union returning False doubles as undirected cycle detection (Redundant Connection).
8When interview problems are secretly graphs
The pattern-recognition section. If the problem contains states and legal moves between them, it's a graph search:
| Costume | The graph underneath | Tool |
|---|---|---|
| Course Schedule / build order / "can finish?" | courses = nodes, prereqs = directed edges | Topo sort (cycle β impossible) |
| Number of Islands / regions / provinces | grid cells or people = nodes, adjacency = edges | DFS/BFS flood, or union-find |
| Word Ladder ("hit"β"cog", one letter at a time) | words = nodes, one-letter-diff = edges | BFS (shortest transformation) |
| Clone Graph | (openly a graph) | DFS/BFS + oldβnew hashmap |
| Accounts Merge / synonymous items | shared attribute β same group | Union-find |
| Rotting Oranges / infection spread / "minutes untilβ¦" | multi-source ripple | Multi-source BFS |
| Currency exchange / evaluate division (a/b=2, b/c=3, a/c?) | variables = nodes, ratios = weighted edges | DFS/BFS multiplying weights |
| Locked rooms / keys / state puzzles (lock combos) | (state) = node, one action = edge | BFS over state space |
| "Minimum steps/moves/operations to reach X" | states + moves | BFS β "minimum steps" β shortest path |
| Alien Dictionary (deduce letter order) | letters = nodes, first-difference pairs = edges | Topo sort |
9Common interview questions
Q1. BFS vs DFS β when each?
Q2. Why does BFS give shortest paths in unweighted graphs?
Q3. Course Schedule ("can you finish?").
Q4. Detect cycle in a directed graph with DFS?
Q5. Why does Dijkstra fail with negative edges?
Q6. Union-find complexity?
Q7. Clone Graph.
Q8. Number of connected components given n and an edge list β two ways?
Q9. Word Ladder efficiently?
h*t patterns) precomputed in a dict to avoid O(nΒ²) pair comparison.Q10. Time/space of BFS/DFS?
10Self-test
Answer from memory, then open each spot-check.
1. Build an adjacency list from an edge list β directed and undirected variants. Space complexity?
defaultdict(list); undirected adds both directions; O(V+E).
2. Write BFS with distance tracking from memory. Where exactly does "visited" get marked, and what breaks if you mark it at pop-time instead?
At enqueue; marking at pop lets a node be enqueued multiple times (blow-up + distances stay correct only by luck). Try it in the stepper: DFS mode marks at pop, and you can watch duplicate stack entries get skipped.
3. Number of Islands: what marks a cell visited in the sinking version, and what's the total complexity?
Overwriting "1"β"0"; O(rowsΒ·cols).
4. Kahn's algorithm from memory. How do you detect a cycle from its output, and why does that work?
Order shorter than V β cycle; nodes in a cycle never reach indegree 0 β they're waiting on each other forever.
5. Directed-graph cycle detection with DFS: what do the three colors mean, and which transition signals a cycle?
White unvisited / gray on current path / black done; an edge to a gray node = back edge = cycle.
6. State Dijkstra's greedy invariant and the assumption that makes it sound. How does Python's heapq version handle "decrease-key"?
Popped-minimum's distance is final given non-negative weights; push duplicates, skip stale entries on pop (lazy deletion).
7. Union-find: what do path compression and union-by-rank each optimize, and what's the resulting amortized cost?
Compression flattens find paths; rank caps tree height; amortized Ξ±(n) β O(1).
8. "Minimum number of moves for a knight to reach (x, y)" β what's the graph, and what's the algorithm?
Implicit graph: squares as nodes, knight moves as edges; BFS ("minimum moves" = shortest path, unweighted).
9. Accounts Merge: why union-find over DFS here?
Groups form incrementally via shared emails; union-find merges as you scan without materializing an explicit graph you'd otherwise have to build for DFS.
10. Give the four-question recognition script for detecting a hidden graph problem.
States? Transitions? Shortest / ordering / groups / reachability? β pick BFS-Dijkstra / topo / DSU-components / DFS accordingly.