Index/Graphs

SponsorGitHub
Coding6 min

Graphs

Nodes and edges, and two traversals that answer most questions about them. The hard part is usually recognising that a problem is a graph at all.

What they’re really testing: Modelling — deciding what a node is and what an edge means — and then picking BFS, DFS or Dijkstra for the right reason.

  • expanding
  • queued
  • done
pythonruns with the steps
1def bfs(graph, source):
2 dist = {source: 0}
3 queue = deque([source])
4 while queue:
5 node = queue.popleft()
6 for nxt in graph[node]:
7 if nxt not in dist:
8 dist[nxt] = dist[node] + 1
9 queue.append(nxt)
10 return dist
01 / 11The graphdiscovered 0 / 6in queue 0

Six nodes, seven undirected edges, no weights. Stored as an adjacency list — a map from each node to its neighbours — which is what makes visiting every edge cheap.

Cost

OperationTimeSpaceWhy
BFS / DFS (adjacency list)O(V + E)O(V)Every node is visited once and every edge inspected once from each end. The visited set is what keeps it linear on cyclic graphs.
BFS / DFS (adjacency matrix)O(V²)—Finding a node's neighbours means scanning a whole row. Use a matrix only for dense graphs or O(1) edge lookups.
Dijkstra with a binary heapO((V + E) log V)O(V)BFS with a priority queue, for non-negative weights. Negative edges break the argument and need Bellman-Ford at O(V·E).
Topological sortO(V + E)—Kahn's algorithm, or DFS postorder reversed. Also the cycle test for a directed graph: fewer than V nodes emitted means a cycle.

Reach for it when

  • Anything with relationships — followers, dependencies, routes, prerequisites, state transitions.
  • "Fewest steps" or "shortest path" in an unweighted setting, which is BFS exactly.
  • Grids. A grid is a graph where each cell has up to four neighbours, and most maze, island and flood-fill problems are graph traversals in disguise.
  • Cycle detection, dependency ordering, build systems, course schedules.
  • Connected components — islands, friend circles, clustering by reachability.

Modelling comes first

Most graph questions do not say "graph". They say courses with prerequisites, words one edit apart, a grid of land and water, packages that depend on packages. The work is deciding what a node is and what an edge means; once that is settled, the algorithm is usually BFS or DFS off the shelf.

Two representations, and the choice is about density:

python
graph = {"A": ["B", "C"], "B": ["D"], "C": ["D", "E"]}   # adjacency list — default
matrix = [[0, 1, 1], [0, 0, 1], [0, 0, 0]]                # adjacency matrix — dense, O(1) edge test

A list is O(V + E) space and makes traversal linear. A matrix is O(V²) space and only pays off when the graph is dense or you need to ask "is there an edge between these two" constantly.

The two traversals are one algorithm

python
def traverse(graph, source):
    seen = {source}
    frontier = deque([source])           # stack -> DFS, queue -> BFS
    while frontier:
        node = frontier.popleft()        # .pop() for DFS
        for nxt in graph[node]:
            if nxt not in seen:
                seen.add(nxt)            # mark on discovery, not on expansion
                frontier.append(nxt)

Swap the container and the order of exploration changes completely; everything else is identical. Two details in that loop carry all the weight:

  • seen is checked before queuing. Without it, a cycle makes the loop run forever.
  • Nodes are marked when discovered, not when expanded. Mark on expansion and a node can sit in the queue several times over.

BFS and shortest paths

Because BFS drains the queue in distance order, nodes come out in layers: everything one edge away, then everything two edges away. The first time a node is discovered is therefore along a path with the fewest edges — the guarantee that makes BFS the answer to "minimum number of steps".

python
def shortest_path(graph, source, target):
    parent = {source: None}
    queue = deque([source])
    while queue:
        node = queue.popleft()
        if node == target:
            break
        for nxt in graph[node]:
            if nxt not in parent:
                parent[nxt] = node        # remember how we got here
                queue.append(nxt)
    path, node = [], target               # walk the parents backwards
    while node is not None:
        path.append(node)
        node = parent.get(node)
    return path[::-1]

Storing a parent alongside each node is what turns "how far" into "which route". The moment edges carry different weights, this argument collapses and you need Dijkstra — the same loop with a heap as the frontier.

DFS and structure

Depth-first is the tool for questions about structure rather than distance: cycles, components, ordering, articulation points. The recursive form is short enough to write under pressure, but remember the stack depth is the path length — an explicit stack is safer on large graphs.

Topological order, via Kahn's algorithm:

python
def topo(graph, indegree):
    queue = deque(n for n in graph if indegree[n] == 0)
    order = []
    while queue:
        node = queue.popleft()
        order.append(node)
        for nxt in graph[node]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                queue.append(nxt)
    return order if len(order) == len(graph) else []    # short = cycle

That last line is two answers in one: the ordering when it exists, and cycle detection when it does not.

Which algorithm

QuestionAlgorithm
Fewest edges to reach a nodeBFS
Cheapest route, non-negative weightsDijkstra
Cheapest route, negative weights allowedBellman-Ford
Is there a cycle / valid orderingDFS colours, or Kahn
How many connected piecesDFS or BFS from every unvisited node
Grid, maze, islands, flood fillBFS or DFS over cells

What to say in the interview

State the model first — "nodes are cells, edges are the four neighbours" — then name the traversal and why. Give the cost as O(V + E) and say what V and E are in this problem, because for a grid that is rows × cols and four times that. If the question mentions weights, say why BFS is no longer sufficient before reaching for a heap.

Where candidates slip

  • Marking visited on dequeue instead of on enqueue. The same node then enters the queue several times before it is expanded, and the work explodes on dense graphs.
  • Using DFS for a shortest path. Depth-first finds a path and will happily return a long one; only BFS gives the fewest edges.
  • Running Dijkstra with negative weights. The settled-node argument fails, and the result is silently wrong rather than obviously broken.
  • Forgetting that an undirected edge goes in both adjacency lists. Half a graph is a maddening bug to find later.
  • Ignoring disconnected components. A single traversal from one source only reaches its component — counting islands means looping over every unvisited node.

Possible follow-up questions

4 questions·try answering before you reveal