Breadth-First Search
Explore a graph in waves with a queue to find the fewest-steps path from a start node, on adjacency lists and on grids. Traced, with JS and Python.
On this page
Drop a stone into a still pond. A ring spreads out, then a bigger one, then a bigger one. Every point the first ring touches is one unit from the stone, the second ring two units. The rings never reach a far point before a near one.
Breadth-first search (BFS) spreads through a graph the same way. Start at one node. Visit everything one edge away, then everything two edges away, and so on. Because it goes out in rings, the first time BFS reaches a node is by the shortest route. That is why you use it: in an unweighted graph, BFS finds the path with the fewest edges.
The idea: a queue keeps the rings in order
You need a way to finish ring 1 before starting ring 2. A queue does that for free. Take a node from the front, and put its new neighbours at the back. The neighbours wait behind everything that was already in line, which is everything closer to the start.
You also need to remember which nodes you have already found. As Graph Basics warned, a graph can have cycles, so without a memory you would walk around in circles. Keep a set of seen nodes, and add a node to it the moment it joins the queue.
- Put the start node in the queue. Mark it seen, at distance 0.
- Take the node at the front of the queue.
- For each neighbour not yet seen: mark it seen, give it distance (this node's distance + 1), and add it to the back.
- Repeat until the queue is empty.
Trace it on the bus map
Here is the bus map from Graph Basics again, starting from A, with neighbour lists in alphabetical order. Watch the queue and the d= labels.
queue = [start]; dist[start] = 0while queue is not empty: node = take from the front of queue for each neighbour next of node: if next not seen: dist[next] = dist[node] + 1 add next to the back of queue- queue0A
1Put A in the queue and mark it seen, at distance 0. Seen means "already in line or done", so nothing gets added twice.
- queue0B1C
- visited
- 1
2Take A from the front (d=0). New neighbours B and C join the back at d=1.
- queue0C1D
- visited
- 2
3Take B from the front (d=1). New neighbour D joins the back at d=2. A is already seen, so skip it.
- queue0D1E
- visited
- 3
4Take C from the front (d=1). New neighbour E joins the back at d=2. A and D are already seen, so skip them. D was reached through B at d=2, and going through C would also give 2, so nothing is lost.
- queue0E1F
- visited
- 4
5Take D from the front (d=2). New neighbour F joins the back at d=3. B and C are already seen, so skip them.
- queue0F
- visited
- 5
6Take E from the front (d=2). Both neighbours, C and F, are already seen. Nothing joins the queue.
- queue0G
- visited
- 6
7Take F from the front (d=3). New neighbour G joins the back at d=4. D and E are already seen, so skip them.
- queue
- visited
- 7
8Take G from the front (d=4). F is already seen, so skip it.
- queue
- visited
- 7
9Queue empty. Every stop has its fewest-stops distance. G is 4 stops from A: G came from F, F from D, D from B, B from A.
Look at the order nodes left the queue: A, B, C, D, E, F, G. That is distance 0, then 1, 1, then 2, 2, then 3, then 4. The queue never holds two nodes whose distances differ by more than 1, and the closer ones are always at the front. That is why the first distance BFS gives a node is final.
The code
Along with the distance, store each node's parent: the node it was discovered from. To recover the actual route, start at the goal and follow parents back to the start, then reverse.
// graph: adjacency list, e.g. { A: ["B", "C"], B: ["A", "D"], ... }
function bfs(graph, start) {
const dist = { [start]: 0 }; // also our "seen" set
const parent = { [start]: null };
const queue = [start];
let head = 0; // index of the front: O(1) dequeue
while (head < queue.length) {
const node = queue[head++];
for (const next of graph[node]) {
if (next in dist) continue; // already seen
dist[next] = dist[node] + 1;
parent[next] = node;
queue.push(next);
}
}
return { dist, parent };
}
function pathTo(parent, goal) {
if (!(goal in parent)) return null; // unreachable
const path = [];
for (let at = goal; at !== null; at = parent[at]) path.push(at);
return path.reverse();
}
// pathTo(bfs(graph, "A").parent, "G") → ["A", "B", "D", "F", "G"]from collections import deque
# graph: adjacency list, e.g. {"A": ["B", "C"], "B": ["A", "D"], ...}
def bfs(graph, start):
dist = {start: 0} # also our "seen" set
parent = {start: None}
queue = deque([start])
while queue:
node = queue.popleft()
for nxt in graph[node]:
if nxt in dist:
continue # already seen
dist[nxt] = dist[node] + 1
parent[nxt] = node
queue.append(nxt)
return dist, parent
def path_to(parent, goal):
if goal not in parent:
return None # unreachable
path = []
at = goal
while at is not None:
path.append(at)
at = parent[at]
return path[::-1]
# path_to(bfs(graph, "A")[1], "G") → ['A', 'B', 'D', 'F', 'G']A, C, E, F, G is also 4 stops. BFS returns whichever shortest path it met first, which depends on the order of the neighbour lists. Both are correct answers.
Each node joins the queue at most once, and each neighbour list is read once, so every edge is looked at twice in an undirected graph. The queue, dist and parent hold at most V entries.
BFS on a grid
The fewest moves through a maze is the same problem. As Graph Basics showed, each open cell is a node and its neighbours are the open cells up, down, left and right. Instead of a dist map, use a 2D array of the same size as the grid, filled with -1 for "not seen yet".
- steps from start
0 1 2 3 4 0 0 # 1 # 2 # # 3 # G - distance
- 0
- cells reached
- 1
1Start in the top-left corner at distance 0. Walls (#) are not part of the graph.
- steps from start
0 1 2 3 4 0 0 1 # 1 1 # 2 # # 3 # G - distance
- 1
- cells reached
- 3
2Wave 1: the open cells one step away. Right and down both work; up and left are outside the grid.
- steps from start
0 1 2 3 4 0 0 1 2 # 1 1 # 2 2 # # 3 # G - distance
- 2
- cells reached
- 5
3Wave 2. Each new cell gets its parent's distance plus 1. The wall in column 1 blocks the middle.
- steps from start
0 1 2 3 4 0 0 1 2 # 1 1 # 3 2 2 # # 3 3 # G - distance
- 3
- cells reached
- 7
4Wave 3. Two separate fronts move at the same pace: one along the top, one down the left side.
- steps from start
0 1 2 3 4 0 0 1 2 # 1 1 # 3 4 2 2 # 4 # 3 3 4 # G - distance
- 4
- cells reached
- 10
5Wave 4: (1, 3), (2, 2) and (3, 1). Two of the fronts are now one step from the same cell, (3, 2).
- steps from start
0 1 2 3 4 0 0 1 2 # 1 1 # 3 4 5 2 2 # 4 # 3 3 4 5 # G - distance
- 5
- cells reached
- 12
6Wave 5. Both (2, 2) and (3, 1) touch (3, 2). Whichever leaves the queue first claims it at distance 5; the other finds it already seen and skips it.
- steps from start
0 1 2 3 4 0 0 1 2 # 6 1 1 # 3 4 5 2 2 # 4 # 6 3 3 4 5 # G - distance
- 6
- cells reached
- 14
7Wave 6. The right-hand column fills in from (1, 4), going up and down at the same time.
- steps from start
0 1 2 3 4 0 0 1 2 # 6 1 1 # 3 4 5 2 2 # 4 # 6 3 3 4 5 # 7 - distance
- 7
- cells reached
- 15
8Wave 7 reaches the exit G. Because cells come out of the queue in distance order, no shorter route can exist.
- steps from start
0 1 2 3 4 0 0 1 2 # 6 1 1 # 3 4 5 2 2 # 4 # 6 3 3 4 5 # 7 - answer
- 7
- cells reached
- 15
9Answer: 7 steps. BFS took 15 cells out of the queue, each once, and looked at each cell's 4 sides once.
const DIRS = [[-1, 0], [1, 0], [0, -1], [0, 1]];
// grid: array of strings, "#" is a wall. Start top-left, exit bottom-right.
function stepsToExit(grid) {
const rows = grid.length, cols = grid[0].length;
const dist = grid.map((row) => new Array(row.length).fill(-1));
dist[0][0] = 0;
const queue = [[0, 0]];
let head = 0;
while (head < queue.length) {
const [r, c] = queue[head++];
if (r === rows - 1 && c === cols - 1) return dist[r][c];
for (const [dr, dc] of DIRS) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
if (grid[nr][nc] === "#" || dist[nr][nc] !== -1) continue;
dist[nr][nc] = dist[r][c] + 1;
queue.push([nr, nc]);
}
}
return -1; // the exit cannot be reached
}
stepsToExit(["...#.", ".#...", ".#.#.", "...#."]); // 7from collections import deque
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
# grid: list of strings, "#" is a wall. Start top-left, exit bottom-right.
def steps_to_exit(grid):
rows, cols = len(grid), len(grid[0])
dist = [[-1] * cols for _ in range(rows)]
dist[0][0] = 0
queue = deque([(0, 0)])
while queue:
r, c = queue.popleft()
if (r, c) == (rows - 1, cols - 1):
return dist[r][c]
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if not (0 <= nr < rows and 0 <= nc < cols):
continue
if grid[nr][nc] == "#" or dist[nr][nc] != -1:
continue
dist[nr][nc] = dist[r][c] + 1
queue.append((nr, nc))
return -1 # the exit cannot be reached
steps_to_exit(["...#.", ".#...", ".#.#.", "...#."]) # 7A grid with R rows and C columns has R × C nodes and at most 4 edges each, so this is O(R × C) time and space.
Common mistakes
Check yourself
Graph: { P: [Q, R], Q: [P, S], R: [P, S, T], S: [Q, R], T: [R] }. In what order does BFS from P take nodes out of the queue?
Why does BFS mark a node as seen when it is added to the queue, not when it is taken out?
A grid has 1,000 rows and 1,000 columns. Roughly how many cells can BFS take out of the queue in the worst case?
What to practise next
Depth-First Search walks the same bus map in a very different order: one branch as deep as it goes before backing up. It cannot find shortest paths, but it is the simpler tool for "is it connected?" and "how many separate pieces are there?".