Traversal Techniques
Java source ↗ Edit this article ↗
Jump to code:
Problem
There are two ways to explore a graph: breadth-first search (BFS) and depth-first search (DFS). This row of the sheets is the BFS one (the SDE sheet lists it simply as “BFS”); DFS has its own article, DFS.
You are given a graph as an adjacency list adj over vertices 0..V-1 (directed or undirected, it makes no difference to the algorithm) and a start vertex, usually 0. Return the vertices in the order BFS visits them: the start first, then its neighbours in the order they appear in adj[start], then their unvisited neighbours, and so on. Only vertices reachable from the start appear.
Constraints: 1 ≤ V, E ≤ 10^4.
Example 1
edges (undirected): 0-1, 0-2, 0-3, 2-4
adj = [[1,2,3], [0], [0,4], [0], [2]]
answer = [0, 1, 2, 3, 4] // 0, then its neighbours 1 2 3, then 4 (neighbour of 2)
Example 2
edges (undirected): 0-1, 0-2, 1-3, 2-3, 3-4, 4-5
adj = [[1,2], [0,3], [0,3], [1,2,4], [3,5], [4]]
answer = [0, 1, 2, 3, 4, 5] // 3 is reachable from both 1 and 2 but is listed once
BFS explores in layers: first everything at distance 0 (the start), then everything at distance 1, then distance 2. That layer order is what makes it the tool for shortest paths in unweighted graphs, and it comes from one data structure: a FIFO queue.
Approach 1: Mark on poll (works, but wasteful)
Intuition. Put the start in a queue. Repeatedly take the front vertex; if it has been processed before, ignore it; otherwise record it and append all of its unprocessed neighbours to the back. Because the queue is first-in-first-out, everything discovered at distance d comes out before anything discovered at distance d + 1.
This version marks a vertex as visited only when it comes out of the queue. It produces the correct order: the first copy of a vertex to leave the queue is the copy that entered first, and that is exactly when a standard BFS would have discovered it. But until it is polled, nothing stops other vertices from enqueueing it again. In example 2, vertex 3 is a neighbour of both 1 and 2, so it is enqueued twice and the second copy is thrown away later.
Algorithm.
- Enqueue
start. - While the queue is not empty: poll
u. Ifuis visited, continue. Otherwise mark it, append it to the answer, and enqueue every neighbour not yet visited.
static List<Integer> markOnPoll(List<List<Integer>> adj, int start) {
List<Integer> order = new ArrayList<>();
boolean[] visited = new boolean[adj.size()];
Deque<Integer> queue = new ArrayDeque<>();
queue.add(start);
while (!queue.isEmpty()) {
int u = queue.poll();
if (visited[u]) continue; // a stale duplicate: u was already processed
visited[u] = true;
order.add(u);
for (int v : adj.get(u)) {
if (!visited[v]) queue.add(v);
}
}
return order;
}
Complexity. Time O(V + E): each vertex is processed once and each edge pushes at most one entry. Space O(E): the queue can hold an entry per edge rather than per vertex. In a complete graph on V vertices that is about V^2 / 2 entries; the tests run a complete graph on 60 vertices to show that the answer is still right.
Approach 2: Optimal, mark on push
Intuition. Mark a vertex visited the moment it is discovered, that is, when it is pushed into the queue. Then the !visited[v] check stops every later attempt to enqueue it, so each vertex enters the queue exactly once and no stale entries ever appear. The order is identical to approach 1, and the queue never holds more than V entries.
Algorithm.
- Mark
startand enqueue it. - While the queue is not empty: poll
uand append it to the answer. - For each neighbour
vofuin list order: ifvis not visited, mark it and enqueue it.
static List<Integer> bfs(List<List<Integer>> adj, int start) {
List<Integer> order = new ArrayList<>();
boolean[] visited = new boolean[adj.size()];
Deque<Integer> queue = new ArrayDeque<>();
visited[start] = true;
queue.add(start);
while (!queue.isEmpty()) {
int u = queue.poll();
order.add(u);
for (int v : adj.get(u)) {
if (!visited[v]) {
visited[v] = true; // mark now so nobody else enqueues v again
queue.add(v);
}
}
}
return order;
}
Dry run on example 2:
| step | polled | neighbours scanned | newly enqueued | queue after | order |
|---|---|---|---|---|---|
| 1 | 0 | 1, 2 | 1, 2 | [1, 2] | 0 |
| 2 | 1 | 0, 3 | 3 | [2, 3] | 0 1 |
| 3 | 2 | 0, 3 | none (3 already marked) | [3] | 0 1 2 |
| 4 | 3 | 1, 2, 4 | 4 | [4] | 0 1 2 3 |
| 5 | 4 | 3, 5 | 5 | [5] | 0 1 2 3 4 |
| 6 | 5 | 4 | none | [] | 0 1 2 3 4 5 |
Step 3 is where the two approaches differ: approach 1 would have pushed a second copy of 3.
Complexity. Time O(V + E): every vertex is enqueued and polled once, and every adjacency list is scanned once (for an undirected graph the lists total 2E entries). Space O(V) for the visited array and the queue, plus the output.
Why BFS finds shortest paths. The queue always contains vertices from at most two consecutive layers, d followed by d + 1. A vertex is first discovered from a vertex one layer closer to the start, so the layer in which BFS first meets a vertex is its distance in edges. This is the property behind every “minimum number of steps” problem on unweighted graphs and grids.
Approach 3: The same BFS, one level at a time
Intuition. Many problems need to know where one layer ends and the next begins: the distance of each vertex, the minutes in Rotten Oranges, the words per step in Word Ladder. At the start of each round the queue contains exactly the current layer, so read its size once and poll exactly that many vertices. Everything they enqueue belongs to the next layer.
static List<List<Integer>> bfsLevels(List<List<Integer>> adj, int start) {
List<List<Integer>> levels = new ArrayList<>();
boolean[] visited = new boolean[adj.size()];
Deque<Integer> queue = new ArrayDeque<>();
visited[start] = true;
queue.add(start);
while (!queue.isEmpty()) {
int size = queue.size(); // exactly the vertices of the current level
List<Integer> level = new ArrayList<>();
for (int i = 0; i < size; i++) {
int u = queue.poll();
level.add(u);
for (int v : adj.get(u)) {
if (!visited[v]) {
visited[v] = true;
queue.add(v);
}
}
}
levels.add(level);
}
return levels;
}
For example 1 the layers are [[0], [1, 2, 3], [4]], and concatenating them gives the BFS order. The tests check both, and also check that the layers equal the expected distance groups. Time and space are the same as approach 2.
Disconnected graphs
A BFS from one start only reaches that start’s component. To visit every vertex, wrap the BFS in a loop over all vertices and restart it from each vertex that is still unvisited. This is the same outer loop used to count connected components.
static List<Integer> bfsAll(List<List<Integer>> adj) {
int n = adj.size();
List<Integer> order = new ArrayList<>();
boolean[] visited = new boolean[n];
Deque<Integer> queue = new ArrayDeque<>();
for (int s = 0; s < n; s++) {
if (visited[s]) continue;
visited[s] = true;
queue.add(s);
while (!queue.isEmpty()) {
int u = queue.poll();
order.add(u);
for (int v : adj.get(u)) {
if (!visited[v]) {
visited[v] = true;
queue.add(v);
}
}
}
}
return order;
}
For the graph 0-1, 2-3, 3-4, a BFS from 0 returns [0, 1], while bfsAll returns [0, 1, 2, 3, 4]. The total cost is still O(V + E), because the visited array is shared across restarts and nothing is processed twice.
BFS versus DFS
| BFS | DFS | |
|---|---|---|
| container | queue (FIFO) | stack or recursion (LIFO) |
| order | layer by layer | one branch as deep as possible, then backtrack |
| unweighted shortest paths | yes | no |
| typical uses | distances, minimum steps, multi-source spreading | cycles, components, topological order, backtracking |
| time / space | O(V + E) / O(V) |
O(V + E) / O(V) |
On example 1, BFS gives 0 1 2 3 4, while DFS goes deep into vertex 2 before trying 3 and gives 0 1 2 4 3.
Edge cases and pitfalls
- Mark when you push, not when you pop. Marking on poll is correct but lets the queue grow to
O(E); marking on push keeps it atO(V). - Mark the start vertex before the loop. Otherwise a neighbour can enqueue the start again and it appears twice in the output.
- Neighbour order is part of the answer. BFS visits neighbours in list order, so
adj[0] = [3, 1, 2]yields0 3 1 2. Do not sort the lists unless the problem says to. - Single vertex. The answer is just
[start]. - Directed graphs. Only arrows leaving a vertex are followed, so in
0->1, 0->2, 1->3, 3->0, 2->3, 4->0vertex 4 is unreachable from 0, while a BFS from 4 reaches everything. - Unboxing.
queue.poll()returns anInteger; assigning it tointis safe here only because the loop checks that the queue is not empty first.
Related problems
- DFS is the other traversal, with recursive and iterative versions.
- Connected Components wraps BFS in the outer loop to count components.
- Rotten Oranges is a level-by-level BFS that starts from many sources at once.
- Shortest path in undirected graph with unit weights turns BFS layers into distances.
- Cycle Detection in Undirected Graph (bfs) adds parent tracking to this BFS.