Cost
| Operation | Time | Space | Why |
|---|---|---|---|
| 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 heap | O((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 sort | O(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:
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 testA 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
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:
seenis 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".
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:
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 = cycleThat last line is two answers in one: the ordering when it exists, and cycle detection when it does not.
Which algorithm
| Question | Algorithm |
|---|---|
| Fewest edges to reach a node | BFS |
| Cheapest route, non-negative weights | Dijkstra |
| Cheapest route, negative weights allowed | Bellman-Ford |
| Is there a cycle / valid ordering | DFS colours, or Kahn |
| How many connected pieces | DFS or BFS from every unvisited node |
| Grid, maze, islands, flood fill | BFS 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.