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:

Directed vs undirected

One-way edges (service A calls B; task X before Y) vs mutual (friendship, network cables).

Weighted vs unweighted

Edges with costs (latency, distance, price) vs plain connectivity. Weights are what push you from BFS to Dijkstra.

Cyclic vs acyclic

A directed acyclic graph (DAG) is the shape of every dependency system you've ever used.

Your production life is graphs. 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.

If you can write 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
The one non-negotiable rule: mark nodes visited, and mark them when discovered, not when processed. Cycles otherwise loop you forever; and marking late lets the same node enter a BFS queue twice.

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
The superpower: in an unweighted graph, BFS reaches every node via a shortest path β€” the first time you see a node is the earliest layer it can appear in, because layers are processed in increasing distance order. So: "shortest path, unweighted" β‡’ BFS, reflexively. (Word Ladder, minimum knight moves, maze escapes, "minimum steps to X".)

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 forWhy
Shortest / level / nearest anything (unweighted)BFSLayers = distances; first visit is shortest
"Explore everything," cycle facts, ordering, path enumerationDFSPostorder + recursion stack give you structure for free
CostO(V + E)Both β€” each node and edge touched once; space O(V)
Backend anchor: BFS is how you'd compute blast radius by hop count in a service mesh ("what breaks within 2 hops of this outage?"); DFS-based cycle detection is what your package manager and Terraform run before applying a dependency graph. GC mark-and-sweep is a graph traversal from roots.

Interactive: BFS vs DFS stepper

Same 6-node graph (A–B, A–C, B–D, C–D, C–E, D–F, E–F), same start, same neighbor lists. The only thing that changes between modes is which end of the frontier pops next. Step through both and watch the orders diverge.

A B C D E F

Queue (FIFO) β€” pops from the left

Visited

Traversal order: β€”

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:

c0c1c2c3c4
r011000
r111001
r200011
r300000
r410000

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.

A B C D E 0 0 2 1 2 badge = in-degree Β· green = 0 prereqs left = ready now
Kahn's intuition on A→C, B→C, B→D, C→E, D→E. A and B have in-degree 0, so they go first. Removing them decrements C and D; whoever hits 0 joins the queue. Every "delete" is just 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!
The final line is a free cycle detector: leftover nodes all have indegree > 0 β€” they're stuck waiting on each other, i.e., a deadlock. (This is literally how deadlock detection works: cycle in the waits-for graph.)

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.

Why the greedy step is safe: when the closest frontier node 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.

Backend anchor: OSPF routing is literally Dijkstra; "cheapest path" also models retry routing by latency, or minimizing egress cost across regions.

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:

CostumeThe graph underneathTool
Course Schedule / build order / "can finish?"courses = nodes, prereqs = directed edgesTopo sort (cycle ⇔ impossible)
Number of Islands / regions / provincesgrid cells or people = nodes, adjacency = edgesDFS/BFS flood, or union-find
Word Ladder ("hit"β†’"cog", one letter at a time)words = nodes, one-letter-diff = edgesBFS (shortest transformation)
Clone Graph(openly a graph)DFS/BFS + old→new hashmap
Accounts Merge / synonymous itemsshared attribute β‡’ same groupUnion-find
Rotting Oranges / infection spread / "minutes until…"multi-source rippleMulti-source BFS
Currency exchange / evaluate division (a/b=2, b/c=3, a/c?)variables = nodes, ratios = weighted edgesDFS/BFS multiplying weights
Locked rooms / keys / state puzzles (lock combos)(state) = node, one action = edgeBFS over state space
"Minimum steps/moves/operations to reach X"states + movesBFS β€” "minimum steps" β‰ˆ shortest path
Alien Dictionary (deduce letter order)letters = nodes, first-difference pairs = edgesTopo sort
The 20-second recognition script for any weird problem: What are the states? What are the transitions? Is a shortest anything asked (β†’ BFS / Dijkstra)? An ordering (β†’ topo sort)? Groups (β†’ union-find / components)? Everything reachable (β†’ DFS)? If states and transitions exist, you've escaped "weird puzzle" into "standard graph problem."

9Common interview questions

Q1. BFS vs DFS β€” when each?

BFS: shortest path (unweighted), level structure, nearest-first. DFS: full exploration, cycle detection, topo (postorder), backtracking, simpler code.

Q2. Why does BFS give shortest paths in unweighted graphs?

The queue processes nodes in non-decreasing distance order; first discovery happens at the minimum possible layer.

Q3. Course Schedule ("can you finish?").

Build the prereq DAG; run Kahn's; finishable ⇔ the topo order includes all nodes ⇔ no cycle.

Q4. Detect cycle in a directed graph with DFS?

Three colors; a gray-node hit (node still on the recursion stack) is a back edge = cycle. Black hits are fine β€” that's just a shared dependency.

Q5. Why does Dijkstra fail with negative edges?

The greedy finalization assumes paths only get longer as they extend; a negative edge can later improve a "finalized" node. Bellman-Ford handles it.

Q6. Union-find complexity?

Amortized Ξ±(n) per op with path compression + union by rank β€” effectively constant (Ξ±(n) ≀ 4 for any physically possible n).

Q7. Clone Graph.

Traverse with a hashmap original→copy; create the copy on first visit, wire neighbors via the map. The map is both visited-set and identity-preserver.

Q8. Number of connected components given n and an edge list β€” two ways?

(1) Build adjacency list, count DFS launches. (2) Union-find: start with n components, subtract 1 per successful union.

Q9. Word Ladder efficiently?

BFS over words; neighbor generation via wildcard buckets (h*t patterns) precomputed in a dict to avoid O(nΒ²) pair comparison.

Q10. Time/space of BFS/DFS?

O(V + E) time, O(V) space. For grids: O(rowsΒ·cols).

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.