DSA Graphs
Graph Traversal
Why does a queue give minimum hops while a stack does not? A chat hop-count (“friends within k steps”) and a maze solver both walk edges, but only one needs layer order.
expands a FIFO for . dives deep for structure and reachability. The is the contract that stops cycles and protects those guarantees.
Algorithm journey
Name the question
Min hops, any path, components, or finishing order?
Pick frontier DS
Queue for BFS layers; stack/recursion for DFS.
Usually mark when first discovered (enqueued / entered).
Whole graph?
Outer loop over all starts for disconnected inputs.
What actually happens
Naive idea: recurse through neighbors forever, or use DFS and hope the first path to the target is shortest. Cycles loop; DFS paths need not minimize edges.
Better idea: BFS processes vertices in nondecreasing hop distance. The first discovery of v uses the fewest edges. DFS marks vertices so each is expanded once and explores connectivity or edge classification instead.
The improved design still fails when edges have unequal weights (BFS is wrong), when you mark BFS only on dequeue (duplicate queue entries), or when a single start leaves other components untouched.
Invariant
BFS: when a vertex is first marked/enqueued, its hop distance from the source is final (minimum edges). DFS visit mark: a marked vertex will not be entered again as a fresh start; in three-color DFS, gray means on the recursion stack (back-edge ⇒ cycle in directed graphs).
How to choose BFS vs DFS
1. Minimum hops / layers? BFS (or multi-source BFS).
2. Any path, components, cycle structure, topo on DAGs? DFS is often simpler; use iterative DFS when depth can be V.
3. Weighted costs? Neither classic BFS nor DFS solves general shortest paths.
Quantitative check
Adjacency-list BFS/DFS: O(V + E) time, O(V) extra for visited plus frontier. At V = 10⁵, E = 10⁶ that is about a million edge scans: fine. Using list.pop(0) in Python for BFS can quietly become O(V²).
Cost model and visited discipline
Time & space
With an adjacency list, both BFS and DFS visit each vertex once and scan each edge once: O(V + E) time and O(V) extra space for the visited structure plus queue or stack.
Disconnected graphs
A single call from one start only reaches its connected component. For full coverage, loop over all vertices and start a traversal whenever visited is missing, this is how you count components or run flood fill on a grid.
In Python, prefer collections.deque with popleft() for BFS instead of list.pop(0), which is O(n) per dequeue and can accidentally turn a linear graph walk into O(V²).
Interactive traversal demo
Select BFS or DFS and click Run to see how they explore the graph differently.
Frontier and invariant trace
The same topology can produce different valid orders when neighbor order changes. What remains stable is the algorithmic invariant: FIFO layers for BFS and pending subtrees for DFS.
Interactive invariant lab
Graph state, not just graph shape
Switch semantics and algorithms, then step through the frontier, discovery state, and edge-level proof obligation.
Step 1 of 32
Seed the queue
Discover A at distance 0 before traversal begins.
- Frontier / path
- A
- Processed
- ∅
Invariant
Every discovered vertex is enqueued at most once; the FIFO queue is ordered by nondecreasing distance from A.
Breadth First Search
The ripple effect
BFS explores neighbors layer by layer. It starts at the root and visits all immediate neighbors before moving to the next level. Think of dropping a stone in water, the ripples expand outward evenly.
Data Structure: Queue (FIFO)
Best for: Shortest Path in unweighted graphs
Python
from collections import deque
def bfs(graph, start):
visited = {start} # mark when enqueued
queue = deque([start])
order = []
while queue:
vertex = queue.popleft()
order.append(vertex)
for neighbor in graph.get(vertex, ()):
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return orderJava
import java.util.*;
final class BfsTraversal {
static List<Integer> traverse(
List<List<Integer>> graph, int start) {
boolean[] visited = new boolean[graph.size()];
Deque<Integer> queue = new ArrayDeque<>();
List<Integer> order = new ArrayList<>();
visited[start] = true; // mark when enqueued
queue.addLast(start);
while (!queue.isEmpty()) {
int vertex = queue.removeFirst();
order.add(vertex);
for (int neighbor : graph.get(vertex)) {
if (!visited[neighbor]) {
visited[neighbor] = true;
queue.addLast(neighbor);
}
}
}
return order;
}
}Depth First Search
The maze-solver style
DFS goes as deep as possible down one path before backtracking. It's like solving a maze by following a wall until you hit a dead end, then going back.
Data structure: call stack (recursive) or explicit LIFO stack
Python
def dfs(graph, vertex, visited, order):
visited.add(vertex)
order.append(vertex)
for neighbor in graph.get(vertex, ()):
if neighbor not in visited:
dfs(graph, neighbor, visited, order)Java
import java.util.*;
final class RecursiveDfs {
static void traverse(
List<List<Integer>> graph,
int vertex,
boolean[] visited,
List<Integer> order) {
visited[vertex] = true;
order.add(vertex);
for (int neighbor : graph.get(vertex)) {
if (!visited[neighbor]) {
traverse(graph, neighbor, visited, order);
}
}
}
}Topological Sort (Kahn's Algorithm)
Kahn's algorithm uses in-degrees to order a directed graph. If fewer than n vertices are removed, the remaining subgraph contains a cycle.
Python
from collections import deque
def kahn_topo_sort(adj):
indegree = [0] * len(adj)
for neighbors in adj:
for v in neighbors:
indegree[v] += 1
queue = deque(v for v, degree in enumerate(indegree) if degree == 0)
order = []
while queue:
u = queue.popleft()
order.append(u)
for v in adj[u]:
indegree[v] -= 1
if indegree[v] == 0:
queue.append(v)
return order if len(order) == len(adj) else NoneJava
import java.util.*;
final class TopologicalSort {
static List<Integer> order(List<List<Integer>> adj) {
int n = adj.size();
int[] indegree = new int[n];
for (List<Integer> neighbors : adj) {
for (int v : neighbors) indegree[v]++;
}
Deque<Integer> queue = new ArrayDeque<>();
for (int v = 0; v < n; v++) {
if (indegree[v] == 0) queue.addLast(v);
}
List<Integer> order = new ArrayList<>(n);
while (!queue.isEmpty()) {
int u = queue.removeFirst();
order.add(u);
for (int v : adj.get(u)) {
if (--indegree[v] == 0) queue.addLast(v);
}
}
return order.size() == n ? order : List.of();
}
}Iterative DFS
Recursive DFS can hit stack limits for very deep graphs. The iterative version using an explicit stack is safer for production. Mark on push to prevent duplicate stack entries. Reverse-pushing gives the first listed neighbor priority among siblings, but an exact recursive order on a general graph requires stack frames that retain each vertex's next-neighbor index.
Python
def dfs_iterative(graph, start):
visited = {start}
stack = [start]
order = []
while stack:
vertex = stack.pop()
order.append(vertex)
# Reverse-push gives earlier neighbors priority.
for neighbor in reversed(graph.get(vertex, ())):
if neighbor not in visited:
visited.add(neighbor)
stack.append(neighbor)
return orderJava
import java.util.*;
final class IterativeDfs {
static List<Integer> traverse(List<List<Integer>> graph, int start) {
boolean[] visited = new boolean[graph.size()];
Deque<Integer> stack = new ArrayDeque<>();
List<Integer> order = new ArrayList<>();
visited[start] = true;
stack.push(start);
while (!stack.isEmpty()) {
int vertex = stack.pop();
order.add(vertex);
List<Integer> neighbors = graph.get(vertex);
for (int i = neighbors.size() - 1; i >= 0; i--) {
int neighbor = neighbors.get(i);
if (!visited[neighbor]) {
visited[neighbor] = true;
stack.push(neighbor);
}
}
}
return order;
}
}Traverse the whole graph, not one component
A start vertex reaches only its component. The outer loop below creates a traversal forest, counts components, and still visits isolated vertices whose adjacency lists are empty.
Python
def components(adj):
visited = set()
groups = []
for start in range(len(adj)):
if start in visited:
continue
group, stack = [], [start]
visited.add(start)
while stack:
u = stack.pop()
group.append(u)
for v in adj[u]:
if v not in visited:
visited.add(v)
stack.append(v)
groups.append(group)
return groupsJava
import java.util.*;
final class Components {
static List<List<Integer>> find(List<List<Integer>> adj) {
boolean[] visited = new boolean[adj.size()];
List<List<Integer>> groups = new ArrayList<>();
for (int start = 0; start < adj.size(); start++) {
if (visited[start]) continue;
List<Integer> group = new ArrayList<>();
Deque<Integer> stack = new ArrayDeque<>();
visited[start] = true;
stack.push(start);
while (!stack.isEmpty()) {
int u = stack.pop();
group.add(u);
for (int v : adj.get(u)) {
if (!visited[v]) {
visited[v] = true;
stack.push(v);
}
}
}
groups.add(group); // includes singleton isolated vertices
}
return groups;
}
}Why BFS gives minimum hop count
Initialization
dist[s] = 0; the queue initially contains only s. The invariant holds for layer zero.
Preservation
When a distance-d vertex is dequeued, every newly discovered neighbor receives d + 1 and joins behind all remaining distance-d vertices.
Conclusion
The first discovery of v uses the fewest possible edges. Marking on dequeue would allow duplicate enqueues and weakens this clean invariant.
Boundary: BFS minimizes number of edges, not total weight. Equal weights are fine; general non-negative weights require Dijkstra, 0/1 weights admit 0-1 BFS, and negative weights require a different argument.
Complexity depends on representation
| Traversal | Adjacency list | Adjacency matrix | Auxiliary space | Worst frontier |
|---|---|---|---|---|
| BFS | O(V + E) | O(V²) | O(V) | A wide layer can hold Θ(V) |
| Recursive DFS | O(V + E) | O(V²) | O(V) | Call depth can reach V |
| Iterative DFS | O(V + E) | O(V²) | O(V) | Explicit stack can hold Θ(V) |
For an undirected adjacency list, each logical edge is scanned twice; 2E is still O(E). In Java, recursive DFS over a long chain can throw StackOverflowError; prefer an explicit stack when depth is input-controlled.
Worked trace: one graph, two orders
BFS from A
- Q=[A]: discover A.
- Pop A; discover B, C → Q=[B,C].
- Pop B; discover D → Q=[C,D].
- Pop C; discover E → Q=[D,E].
- Later discover G then F → order A,B,C,D,E,G,F.
Iterative DFS from A
- S=[A]: discover A.
- Pop A; reverse-push C, B → top is B.
- Pop B; push D → follow one branch deeply.
- Pop D; pending discoveries depend on the mark-on-push policy.
- Order is deterministic only if adjacency order is defined.
What each traversal is good at
- BFS layers: store
dist[v]when enqueueing to get shortest hop-count from the source; also drives 0-1 BFS with a deque when every edge weight is 0 or 1. - DFS finishing times: Kosaraju / Tarjan SCC algorithms and topological sorting on DAGs are DFS-shaped.
- Edge classification (directed): tree, back, forward, cross edges, useful for cycle proofs and flow/cut intuition.
Signals and extensions
“Minimum number of operations”
BFS over implicit states; stop when the target is dequeued or first discovered under a consistent contract.
“All nodes at distance k”
BFS layers; process queue-size batches or store dist[].
“Does any path / region exist?”
BFS or DFS; choose iterative DFS when input depth can be large.
“Return the actual path”
Store parent[v] at first discovery, then reconstruct backward from target.
“Grid of cells”
Treat each cell as a vertex and legal moves as implicit edges; validate bounds before visited checks.
“Multiple nearest sources”
Seed one BFS queue with every source at distance zero. This is not repeated BFS.
Failure modes in real implementations
- Marking too late: marking BFS on dequeue or iterative DFS only on pop permits many duplicate frontier entries on converging graphs.
- Recursion controlled by input: a path graph has DFS depth V; use an explicit stack or enforce a safe bound.
- Mutating while traversing: concurrent edge updates can invalidate iteration; use an immutable snapshot, version, or documented consistency model.
- Unstable output: hash-map neighbor order makes traversal and tests nondeterministic; sort or use ordered adjacency when order is part of the API.
- Memory-heavy state: prefer primitive arrays over boxed sets when vertices are dense integers; reset with visit timestamps when many traversals reuse the same graph.
Crucial difference
BFS finds the shortest path in hops (number of edges) in an unweighted graph.
DFS does not guarantee the shortest path; it explores depth-first structure and reachability.
Exercises
- Draw a small graph where DFS from s reaches t in fewer recursive calls than BFS layers, yet BFS still reports a strictly smaller hop distance. Explain using the visit-mark timing.
- What breaks if BFS marks a vertex only when it is dequeued? Give a converging diamond graph and count duplicate queue entries.
- V = 10⁵ path graph. Recursive DFS depth? Which implementation would you ship, and what does the visit mark still guarantee?
Primary sources
- Cormen et al., Introduction to Algorithms (CLRS): BFS, DFS, edge classification, and the BFS shortest-path lemma.
- Sedgewick & Wayne: undirected/directed DFS applications (cycles, topo, CC).
- Skiena, The Algorithm Design Manual: war stories on BFS for unweighted distance.
Takeaway
BFS settles hop distance at first enqueue. DFS's visit mark prevents re-entry and reveals structure. Same O(V+E) scan, different guarantees.