Purpose

Breadth first search explores the vertices of a graph in order of their distance from the starting vertex. This note gives the algorithm, its runtime analysis, and proofs of the two structural facts that make BFS useful: adjacent vertices sit within one level of each other, and the level of a vertex equals its shortest path distance from the start.

Core idea

A vertex is in one of three states during BFS:

  • Undiscovered: the vertex has not been seen yet.
  • Discovered: the vertex has been seen, but its neighbors have not been explored yet.
  • Explored: the vertex has been seen and its neighbors have been explored.

BFS holds discovered vertices in a queue, so vertices leave the queue in the same order they were discovered. Discovery order respects distance from the start, which is what the proofs below make precise.

BFS(G, s):
  mark all vertices as undiscovered
 
  mark s as discovered
  q = queue({s})
  while q is not empty:
    u = poll(q)
    for each edge (u, v) in G:
      if v is undiscovered:
        mark v as discovered
        add v to q
    mark u as explored

Analysis

The outer while loop runs once for each vertex in the graph, and the inner for loop runs once for each edge of the current vertex. The sum of all vertex degrees is twice the number of edges, so the total work is

Structure of the BFS tree

  1. visits a vertex if and only if there is a path from to .
  2. Edges into then-undiscovered vertices form a tree rooted at (the BFS spanning tree).
  3. Level of the tree contains exactly the vertices such that the shortest path from to has edges.
  4. All non-tree edges of connect vertices in the same level or adjacent levels.

A BFS from on a small graph shows all four facts at once. Solid edges are tree edges, dashed edges are the non-tree edges of , and every dashed edge stays within one level of itself:

flowchart TD
    subgraph L0["Level 0"]
        s((s))
    end
    subgraph L1["Level 1"]
        a((a))
        b((b))
    end
    subgraph L2["Level 2"]
        c((c))
        d((d))
        e((e))
    end
    s --- a
    s --- b
    a --- c
    a --- d
    b --- e
    a -.- b
    c -.- d
    b -.- d

    style s fill:#e3f2fd,stroke:#1565c0

Non-tree edges never skip a level

Fact 4 is the structural payoff of using a queue. If an edge spanned two or more levels, its deeper endpoint would have a shorter path than its level allows, contradicting the shortest-path claim below. The same-level case is exactly what the bipartiteness test looks for, since a same-level edge closes an odd cycle.

Difference in levels

Let be the level of vertex in the BFS tree.

Claim:

Proof: Suppose and . Without loss of generality, assume is explored before . Consider the iteration where we process .

Case 1: is still undiscovered. Since there is an edge between and , we discover while processing , so .

Case 2: is already discovered. Then is in the queue somewhere behind , and levels in the queue are non-decreasing, so . Every vertex still in the queue was discovered by a vertex of level at most , so .

In both cases .

Levels are shortest path distances

Claim: for every vertex reachable from , equals the length of the shortest path from to .

Proof: Let be the length of the shortest path from to .

: the tree path from to is a real path in with edges, and the shortest path is at least as short.

: let be a shortest path, so . Each consecutive pair is an edge, so the previous claim gives . Starting from and applying this along the path, .

Together, .

Implementation

from collections import deque
 
def bfs(graph, start):
    visited = set()
    queue = deque([start])
    visited.add(start)
    while queue:
        vertex = queue.popleft()
        print(vertex)
        for neighbor in graph[vertex]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)

Or a reusable level-order iterator over a graph. The visited set matters here: without it, any cycle makes the traversal loop forever.

from collections import deque
 
def level_order_traversal(graph, start):
    visited = {start}
    queue = deque([(start, 0)])
    while queue:
        vertex, level = queue.popleft()
        yield vertex, level
        for neighbor in graph[vertex]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append((neighbor, level + 1))