Coding Interview Patterns

Course Content

Coding Interview Patterns

20 sections · 146 lessons

Graphs: The Core Idea


A graph problem is any problem about relationships between things, where the relationships — not the things — are what you reason over. People and friendships. Cities and flights. Courses and prerequisites. Cells of a map and the cells next to them. If the input is a list of pairs, you are probably holding a graph.

This is the largest section in the course, and it is worth the time. Graphs absorb more interview questions than any pattern except trees, and unlike trees they arrive in disguise: a grid of "1" and "0", a list of words, a set of email accounts.

Start with the simplest question: can I get from A to B? The naive answer is to try every path. In a graph of 12 nodes where every node connects to every other, there are about 9.9 million simple paths from A to B; with 15 nodes, about 17 billion. The repair is the idea this whole section rests on: visit each node once, and remember that you did. With a visited set, the same question costs O(V + E) — for 15 nodes and 100 edges, about 115 steps.

Five things that mean graphIs this a graph?Things and relationsCan I reach it?Fewest steps to thereA cycle or an orderingA grid of cells
A grid, a course list and a set of email accounts are all graphs; noticing that is most of the work.

The picture before the code

The chalk is the visited set. The list of junctions still to explore is the frontier. The only real choice is what kind of list the frontier is. Take the newest junction first (a stack) and you go deep down one corridor before trying others: that is depth-first search, DFS. Take the oldest first (a queue) and you spread out in rings — every junction one step away, then every junction two steps away: that is breadth-first search, BFS.

How to recognise it

Five signals in the problem statement:

  • Things with connections. The input is a list of pairs: [[0, 1], [1, 2]], [course, prerequisite], [from, to, time]. A list of pairs is an edge list, and an edge list is a graph.
  • Reachability. "Can I get from A to B?" "Which cells can reach the ocean?" "Is everyone connected?"
  • A grid with neighbours. This is the disguise that catches most people. Each cell is a node, joined to the cells above, below, left and right. Islands, mazes, flood fill, rotting fruit — graph problems wearing a matrix costume.
  • Dependencies and ordering. "In what order can I take these courses?" "Can this build finish?" That is topological sort, and it detects impossible requirements for free.
  • Grouping. "How many separate clusters?" "Which accounts belong to the same person?" That is connected components, or Union-Find.

The constraints confirm it. With V and E up to about 10^5, the intended cost is O(V + E) for a traversal or O(E log V) for weighted shortest paths. If your plan is worse than that, you either have a bug or the wrong algorithm.

Representing a graph

Interviewers rarely hand you a graph. They hand you an edge list like [[0, 1], [1, 2], [2, 0]]. It is compact but useless for walking: "who are node 1's neighbours?" means scanning every edge. So step one of nearly every graph problem is to build an adjacency list — for each node, the list of its neighbours.

Python
def build_graph(n: int, edges: list[list[int]], directed: bool = False) -> list[list[int]]:    """Turn an edge list into an adjacency list for nodes 0..n-1."""    graph: list[list[int]] = [[] for _ in range(n)]    for u, v in edges:        graph[u].append(v)        if not directed:            graph[v].append(u)                   # undirected: both directions    return graph

Building costs O(V + E) time and space. The if not directed line is the most consequential line in graph code: a friendship goes both ways, a prerequisite does not.

There are two other shapes. An adjacency matrix is a V × V grid where matrix[u][v] is 1 when the edge exists. A neighbour function builds nothing at all: it computes the neighbours when asked. A grid cell's neighbours are the four cells around it; a word's neighbours in a word ladder are the words one letter away.

Adjacency list01, 210, 32031space O(V + E) · list the neighbours of one node inO(degree)the default for almost every interview problemAdjacency matrix012301230110100110000100space O(V²) · "is there an edge?" in O(1), butlisting neighbours always costs O(V)worth it only when the graph is denseImplicit grid..#.#....no edges are stored at allthe neighbours of (r, c) are the four offsets,minus anything off the grid or blockedmost grid problems are graph problems indisguiseSame four-node graph, three representations. The choice is a space and access-pattern trade, not a correctness one.
The grid stores no edges at all — its neighbours are computed from the coordinates, which is why grid problems look different but are not.
adjacency listadjacency matrixneighbour function
spaceO(V + E)O(V²)O(1) — nothing stored
list the neighbours of uO(degree of u)O(V), scans a whole rowcost of computing them
"is there an edge u → v?"O(degree of u)O(1)depends
use foralmost everythingsmall, dense graphsgrids, puzzles, word ladders

Default to the adjacency list. Real graphs are sparse: a matrix for 10^5 nodes needs 10^10 cells, which does not fit in memory at all.

How it works: the visited invariant

Every traversal keeps one promise: a node enters the frontier at most once, because it is marked visited the moment it is discovered. From that promise, the cost follows. Each node is pushed and popped once: O(V). Each node's neighbour list is read once when it is popped, so the total reading is the sum of all list lengths: O(E) (2E for undirected graphs, since each edge is stored twice). Together, O(V + E).

BFS keeps a second promise. The queue always holds nodes in order of distance from the start, and never spans more than two neighbouring distances — all the nodes at distance d before any at distance d + 1. So the first time BFS reaches a node, it has reached it by a shortest path (counting edges). This is a guarantee, not a tendency. DFS gives no such guarantee: it may reach the target down a winding 40-edge corridor while a 3-edge path exists.

Deep or wide, and what it costs youDFS — a stack• Goes deep, backs up at dead ends• Natural to write as recursion• Finds a path, not the shortest oneBFS — a queue• Expands one ring of nodes at a time• Needs an explicit queue• First arrival is the shortest path
On unweighted edges BFS's first arrival is optimal; DFS's first arrival is merely first.
use BFS whenuse DFS when
you need the fewest steps (unweighted shortest path)any path will do (reachability, flood fill)
the answer is by levels: minutes, rounds, distancesyou need all paths, or cycle detection
the answer is probably near the startthe graph is deep and narrow

The templates

DFS, recursive and iterative. The recursive form is shortest to write. The iterative form avoids Python's recursion limit — about 1,000 frames by default, which a 5,000-node chain exceeds.

Python
def dfs_recursive(graph: list[list[int]], node: int, visited: set[int]) -> None:    """Visit everything reachable from node."""    visited.add(node)                            # mark on entry    for nxt in graph[node]:        if nxt not in visited:            dfs_recursive(graph, nxt, visited)def dfs_iterative(graph: list[list[int]], start: int) -> set[int]:    """Same reach as the recursive DFS, without the recursion limit."""    visited = {start}    stack = [start]    while stack:        node = stack.pop()        for nxt in graph[node]:            if nxt not in visited:                visited.add(nxt)                 # mark when pushed                stack.append(nxt)    return visited

BFS with distances. Swap the stack for a deque and pop from the left. Storing the distance in a dict does double duty: a node is visited exactly when it has a distance.

Python
from collections import dequedef bfs_distances(graph: list[list[int]], start: int) -> dict[int, int]:    """Fewest edges from start to every reachable node."""    dist = {start: 0}    queue = deque([start])    while queue:        node = queue.popleft()        for nxt in graph[node]:            if nxt not in dist:                  # dist doubles as the visited set                dist[nxt] = dist[node] + 1                queue.append(nxt)    return distdef count_components(n: int, edges: list[list[int]]) -> int:    """Outer loop: every node not yet seen starts a new component."""    graph = build_graph(n, edges)    visited: set[int] = set()    count = 0    for node in range(n):        if node not in visited:            count += 1            visited |= dfs_iterative(graph, node)    return count

Line by line, the parts that matter:

  • The mark comes with the discovery, not with the processing. In dfs_iterative and bfs_distances, a node is added to visited or dist in the same step that pushes it. That is what keeps each node in the frontier at most once.
  • count_components has an outer loop. One traversal only reaches one component. Looping over every node and starting a new traversal from each unvisited one covers them all — and counts them.
  • A grid needs only a neighbour function. Everything else stays the same:
Python
DIRECTIONS = ((-1, 0), (1, 0), (0, -1), (0, 1))def grid_neighbours(rows: int, cols: int, r: int, c: int):    """Yield the in-bounds up/down/left/right neighbours of (r, c)."""    for dr, dc in DIRECTIONS:        nr, nc = r + dr, c + dc        if 0 <= nr < rows and 0 <= nc < cols:            yield nr, nc

The forms it takes

The traversal barely changes across this section. What changes is the question and the bookkeeping:

problem shapetechniquelesson in this section
count connected regions in a gridDFS/BFS flood fill, with an outer loopNumber of Islands
copy a graph with cyclestraversal with an old-to-new mapClone Graph
spread from many starting points at oncemulti-source BFS, one level per time stepRotting Oranges
order tasks with prerequisites; detect cyclestopological sort (Kahn's algorithm)Course Schedule
edges arrive one by one; "already connected?"Union-FindRedundant Connection
shortest path with non-negative weightsDijkstra with a min-heapNetwork Delay Time
shortest path where the graph is never builtBFS over a neighbour functionWord Ladder

Complexity

algorithmtimespace
DFS or BFS on an adjacency listO(V + E)O(V)
DFS or BFS on an m × n gridO(m × n)O(m × n)
topological sort (Kahn)O(V + E)O(V + E)
Union-Find, E unionsO(E × α(V)), effectively O(E)O(V)
Dijkstra with a binary heapO(E log V)O(V + E)
Bellman-FordO(V × E)O(V)

For a grid, V = m × n and E is about 4 × m × n, so a traversal is O(m × n). A 1,000 × 1,000 grid is a million nodes; counting its islands took about half a second in Python.

Where it goes wrong

Three wrong answers, two timeoutsWrong answers• Directed edges added in both directions• Only one component ever traversed• Grid neighbours read out of boundsCorrect but far too slow• Visited marked at dequeue, not enqueue• So the same node is queued many times• Dijkstra where plain BFS would do
Marking visited as a node is enqueued is the one line that keeps the queue linear in size.

Five failure modes. The first gives right answers far too slowly; the others give wrong answers with no error.

  • Marking visited when a node is popped, not when it is pushed. Before its turn comes, a node can be pushed once for every edge that points at it. On a 1,000-node graph where every node connects to every other, BFS that marks on push makes 1,000 pushes. Marking on pop makes 499,501. If the code also forgets to skip an already-visited node when it pops it, every duplicate is expanded again: about 499 million neighbour checks instead of 999,000.
  • Grid bounds. In Python, grid[-1][0] does not raise — it reads the last row. A flood fill that skips the bounds check leaks from the top edge into the bottom, and the count is wrong with no error.
  • Edge direction. Adding the reverse edge to a prerequisite graph makes every dependency mutual, so every pair looks like a cycle. Leaving it out of a friendship graph makes half the graph unreachable. Read the statement for the word that describes the edge — "requires" is directed, "connected to" is not — and ask if it is unclear.
  • Only one component traversed. A traversal from node 0 reaches only node 0's component. Always test with an isolated node, one with no edges.
  • The wrong algorithm for the weights. BFS on a weighted graph returns the fewest edges, not the lowest cost. Dijkstra on a graph with negative weights returns a confident wrong answer. (The reverse, Dijkstra where every edge costs the same, is correct but pays a log factor for nothing.) Before writing shortest-path code, ask: are edges weighted, and can a weight be negative?

Check your understanding

0 of 3 answered

1.You need the fewest moves from a start cell to an exit in a maze grid where every move costs the same. Which traversal?

2.Why does BFS mark a node visited when it is pushed rather than when it is popped?

3.A problem gives n computers and a list of cable pairs, and asks how many separate networks there are. Which piece is essential?