Pattern 05 of 20

Breadth-First Search

Explore level by level with a queue - the go-to for shortest paths in unweighted graphs and grids.

What breadth-first search is

BFS explores a graph in expanding rings: everything at distance 1 from the start, then everything at distance 2, and so on. The machinery is a queue plus a visited set - pop a node, push its unvisited neighbors, repeat. Because nodes are discovered in order of distance, the first time BFS reaches a node is via a shortest path (in edges).

That single property drives almost every interview use: shortest path in an unweighted graph or grid, minimum number of moves or transformations, and anything phrased 'fewest steps to reach X'. Level-order tree traversal is the same algorithm with no visited set needed.

How to recognize it

Signals: 'shortest', 'minimum number of steps/moves/mutations', 'nearest', or 'level by level'. The state space doesn't have to look like a graph - words one letter apart, lock dials one turn apart, or grid cells one move apart are all edges. If every move costs the same, BFS gives the optimal answer; the moment moves have different costs, you need Dijkstra instead.

The contrast to internalize: DFS answers 'does a path exist / enumerate everything', BFS answers 'what is the closest'. Choosing DFS for a shortest-path question is the classic wrong turn, because DFS finds a path, not the shortest one.

The level-by-level template

Most problems need not just visiting order but the distance itself. Snapshot the queue length at the top of each round: exactly that many nodes form the current level, so process them in an inner loop and increment the level counter after. Marking visited at enqueue time (not dequeue time) is what keeps duplicate states out of the queue.

pseudocode
queue = [start]; visited = {start}; level = 0
while queue not empty:
    for i in 1 .. len(queue):        # exactly one level
        node = queue.pop_front()
        if node is target: return level
        for next in neighbors(node):
            if next not in visited:
                visited.add(next)     # mark at enqueue time
                queue.push_back(next)
    level += 1

Multi-source BFS

When the question asks how far everything is from the nearest of several sources - rotting oranges infecting neighbors, distance-to-nearest-zero in a matrix - seed the queue with all sources at level 0 and run the same loop. The flood expands from every source simultaneously, and each cell is first reached by its nearest source. Simulating one source at a time is the O(n^2) trap; multi-source is one pass.

Common pitfalls

Marking visited at dequeue instead of enqueue - the same node enters the queue many times and the complexity quietly explodes.

Using BFS on weighted edges: 'fewest edges' is not 'lowest cost'. Different costs mean Dijkstra (or 0-1 BFS with a deque for weights of 0 and 1).

Forgetting the level-size snapshot and reading len(queue) inside the loop while pushing to it - levels bleed together and the distance is wrong.

Grid BFS: not validating bounds before reading neighbors, or re-allocating the four-direction array in the innermost loop of a hot path.

Tree level-order: no visited set is needed - adding one is harmless but signals you're pattern-matching without understanding why (trees have no cross edges to revisit).

Worked examples

1. Binary tree level order traversalmedium

Given the root of a binary tree, return its node values level by level as a list of lists.

  1. The output shape - one list per level - dictates level-by-level processing, which is the queue-snapshot template.
  2. Push the root; each round, record len(queue) as the size of the current level and pop exactly that many nodes, collecting values and pushing children.
  3. Append the collected values as one level's list; the loop naturally ends when a round adds no children.
  4. O(n) time and O(w) space where w is the widest level - the queue never holds more than two adjacent levels.
pseudocode
result = []
queue = [root] if root else []
while queue not empty:
    level = []
    for i in 1 .. len(queue):
        node = queue.pop_front()
        level.append(node.val)
        if node.left: queue.push_back(node.left)
        if node.right: queue.push_back(node.right)
    result.append(level)
return result

Remember: Snapshotting the queue length at the top of each round is what turns 'a queue of nodes' into 'a sequence of levels'.

2. Rotting orangesmedium

In a grid of empty cells, fresh oranges, and rotten oranges, rot spreads to adjacent fresh oranges each minute. Return the minutes until no fresh orange remains, or -1 if some never rot.

  1. Rot spreads from all rotten oranges simultaneously - that word 'simultaneously' is the multi-source BFS signal.
  2. Seed the queue with every initially rotten orange (all at minute 0) and count the fresh ones.
  3. Run level-by-level BFS: each level is one minute; rotting a fresh neighbor decrements the fresh count and enqueues it.
  4. Answer: the number of levels that actually rotted something; if fresh count is still positive at the end, return -1. One pass, O(rows x cols).
pseudocode
queue = all rotten cells; fresh = count of fresh; minutes = 0
while queue not empty and fresh > 0:
    for i in 1 .. len(queue):
        (r, c) = queue.pop_front()
        for (nr, nc) in 4-neighbors in bounds:
            if grid[nr][nc] == FRESH:
                grid[nr][nc] = ROTTEN; fresh -= 1
                queue.push_back((nr, nc))
    minutes += 1
return fresh == 0 ? minutes : -1

Remember: Several sources spreading at equal speed = one BFS seeded with all of them; every cell is reached first by its nearest source.

3. Word ladderhard

Transform beginWord into endWord one letter at a time, where every intermediate word must be in the given word list. Return the length of the shortest transformation sequence, or 0 if impossible.

  1. Nothing here says 'graph', but words are nodes and an edge joins words differing by one letter - and 'shortest sequence' on unit-cost edges means BFS.
  2. Generating neighbors by comparing all word pairs is O(n^2 * L); instead, for each dequeued word try all L positions x 25 letters and check membership in a set - O(25 x L) per word.
  3. Standard level-by-level BFS from beginWord, marking words visited as they enter the queue (removing them from the set works as the visited mark).
  4. Return level + 1 when endWord is generated. The transferable move: recast the domain as states-and-moves, then let vanilla BFS do the work.
pseudocode
words = set(wordList); if endWord not in words: return 0
queue = [beginWord]; steps = 1
while queue not empty:
    for i in 1 .. len(queue):
        word = queue.pop_front()
        if word == endWord: return steps
        for each position p, letter c:
            candidate = word with word[p] replaced by c
            if candidate in words:
                words.remove(candidate)   # visited
                queue.push_back(candidate)
    steps += 1
return 0

Remember: If states connect by unit-cost moves, it is a graph whether or not it looks like one - and 'shortest' hands the problem to BFS.

Check yourself

Try to answer before revealing - retrieving from memory is what makes it stick.

1Why does BFS find shortest paths in unweighted graphs?
  • Because the queue sorts nodes by distance
  • Because nodes are discovered in non-decreasing distance order, so the first arrival at any node used the fewest possible edges
  • Because BFS visits fewer nodes than DFS
  • It doesn't - you need Dijkstra for shortest paths

The queue processes all distance-k nodes before any distance-k+1 node, so a node's first discovery is via a minimum-edge path. Dijkstra is only needed once edges have unequal costs.

2When must you mark a node as visited?
  • When it is dequeued
  • When it is enqueued
  • After all its neighbors are processed
  • Only if the graph has cycles

Marking at enqueue time prevents the same node from being queued by several neighbors in the same wave. Marking at dequeue allows duplicate queue entries and can blow up time and memory.

3A grid asks for each cell's distance to the nearest of many zero-cells. What is the right approach?
  • Run BFS from every zero cell separately and take minimums
  • Run one BFS seeded with all zero cells at level 0
  • Run DFS from each non-zero cell
  • Sort cells by coordinates and sweep

Multi-source BFS floods from all sources at once; each cell is first reached by its nearest source, giving all distances in a single O(cells) pass instead of one BFS per source.

4Which of these problems is BFS the wrong tool for?
  • Fewest moves to solve a sliding puzzle
  • Cheapest flight route where flights have different prices
  • Minimum knight moves on a chessboard
  • Shortest path through a maze of open cells

Different prices means weighted edges - BFS's level order no longer matches cost order, so it can return a route with fewer hops but higher cost. That is Dijkstra territory.

5In the level-by-level template, why snapshot len(queue) before the inner loop?
  • To avoid an infinite loop
  • Because the queue length at the round's start is exactly the current level's node count, and pushes during the round belong to the next level
  • Because queues cannot be measured while being modified
  • To reserve memory in advance

Children pushed during a round are the next level. Freezing the count separates the two levels; reading the live length would mix them and corrupt any distance math.

Frequently asked questions

When do I choose BFS over DFS?

Choose BFS when the answer involves distance or 'fewest steps' on equal-cost moves, or when output is organized by levels. Choose DFS for existence, connectivity, exhaustive enumeration, or when the recursion structure mirrors the problem (trees, backtracking).

Does BFS work on weighted graphs?

Not for shortest paths. BFS minimizes edge count, not total weight. Use Dijkstra for non-negative weights, or the 0-1 BFS deque trick when weights are only 0 and 1.

How much memory does BFS use?

O(number of states) in the worst case for the visited set, and the queue can hold an entire level - which in wide graphs or grids may be a large fraction of all nodes. If memory is the constraint and the structure is a tree, iterative deepening DFS is the classic alternative.

Keep this pattern in memory

Reading it once won't stick. Sign in and CoderFlow will bring it back right before you'd forget it.

Start reviewing