Graph Traversals: BFS & DFS
Breadth-first and depth-first search as precise state machines: how FIFO creates shortest-edge layers, how a suspended stack creates nested DFS intervals, and which invariants make their many applications correct.
Graph traversal is controlled discovery. Both breadth-first search (BFS) and depth-first search (DFS) start from known vertices, expose outgoing edges, and prevent repeated discovery. One data-structure choice changes the shape of the entire computation: a FIFO queue makes BFS expand in distance layers, while a stack of suspended adjacency scans makes DFS produce nested subtrees and finish times.
The algorithms are valuable less as ways to “visit everything” than as proof frameworks. Their predecessor forests certify reachability; BFS layers certify minimum edge counts; DFS intervals expose cycles, ordering, and component structure.
The shared traversal contract #
A traversal must declare:
- a directed or undirected graph and the neighbor relation it follows;
- one source, several sources, or a whole-graph root order;
- when a vertex becomes discovered and when it becomes finished;
- which outputs are recorded: predecessors, distances, timestamps, edges, or callback events;
- whether adjacency may change during the run.
Use three semantic states:
- Undiscovered (white)
- No traversal path to the vertex has yet been recorded.
- Discovered (gray or active/frontier)
- A predecessor path is known, but some required processing remains.
- Finished (black)
- The algorithm has examined every relevant outgoing edge of the vertex.
The colors are conventional encodings; the state meanings are the invariant. A Boolean seen flag can be enough for reachability, but it cannot distinguish active from finished vertices when DFS cycle logic needs that distinction.
Whenever a previously undiscovered vertex is reached through edge , set . Every non-root parent edge is a real graph edge, so following parents produces a concrete path back to a root. These edges form a tree for one root, or a predecessor forest when traversal restarts in multiple components.
Why discovery is sound and complete #
Traversal discovery has two directions to prove:
- Soundness: every discovered vertex is reachable. The source is reachable by the empty path. Every later discovery extends an already certified path by one real edge.
- Completeness: every reachable vertex is eventually discovered. Take a source-to-target path and assume the first undiscovered vertex on it remains after termination. Its predecessor on the path was discovered, so its outgoing edge had to be examined, a contradiction.
The second argument relies on actually scanning every relevant edge of every discovered vertex. Early stopping or filtered neighbors changes the contract and therefore the reachable set being certified.
BFS: a FIFO frontier creates layers #
Standard BFS marks a vertex when it is enqueued, not when it is later removed:
for each vertex v:
state[v] = undiscovered
distance[v] = infinity
parent[v] = none
state[source] = discovered
distance[source] = 0
enqueue(source)
while queue is not empty:
u = dequeue()
for each v in out_neighbors(u):
if state[v] == undiscovered:
state[v] = discovered
distance[v] = distance[u] + 1
parent[v] = u
enqueue(v)
state[u] = finished
The queue contains discovered vertices whose adjacency scans are pending. Immediate marking reserves the vertex: the first edge to find it establishes its parent and distance, and no later edge enqueues it again.
The queue invariant #
At the start of each loop iteration:
- every queued vertex has a path of exactly its recorded distance;
- queued distances are nondecreasing from front to back;
- the queue contains at most two consecutive distance values, say and ;
- every vertex with distance below the front’s distance has been finished.
Why does FIFO preserve this? When a distance- vertex leaves the front, every newly discovered neighbor receives and is appended behind all vertices already queued. No operation can insert a smaller layer behind a larger one.
Why BFS finds minimum-edge paths #
For an unweighted graph, define as the minimum number of edges on a path from to , or infinity if none exists. BFS records a predecessor path of length , so
The opposite inequality follows by induction on true path length. Suppose every vertex at distance at most is discovered with its correct distance. Let be at distance , and let precede it on a shortest path. Then . BFS discovers and processes before any layer beyond ; examining either discovers with or finds that already received an equal-or-smaller distance. Therefore
Together the inequalities give equality.
The proof says exactly what BFS minimizes: edge count in the traversed graph. It does not minimize arbitrary weights, latency, risk, or any other edge attribute. Those require Shortest Paths.
Multiple shortest paths may exist. The distance is invariant, while the chosen parent depends on which equal-layer predecessor is scanned first.
A complete BFS trace #
Use this directed graph. The subgraphs show BFS distance layers from s; g is unreachable.
flowchart LR
subgraph L0["distance 0"]
s((s))
end
subgraph L1["distance 1"]
a((a))
b((b))
end
subgraph L2["distance 2"]
c((c))
d((d))
e((e))
end
subgraph L3["distance 3"]
f((f))
end
subgraph U["unreachable"]
g((g))
end
s --> a
s --> b
a --> c
a --> d
b --> d
b --> e
c --> f
d --> c
d --> f
e --> d
f --> b
Assume alphabetic adjacency order. The queue trace is:
| Removed | New discoveries | Queue after scan | Final distance |
|---|---|---|---|
s |
a, b |
[a,b] |
0 |
a |
c, d |
[b,c,d] |
1 |
b |
e (d already discovered) |
[c,d,e] |
1 |
c |
f |
[d,e,f] |
2 |
d |
none | [e,f] |
2 |
e |
none | [f] |
2 |
f |
none | [] |
3 |
One BFS tree has parents
Reversing some adjacency orders can make b the parent of d and d the parent of f, but the four distance layers remain the same.
Useful BFS variants #
- Multi-source BFS: enqueue every source initially at distance 0. The result gives distance to the nearest source in edge count.
- Early target stop: stop when the target is dequeued—or even when discovered if only its BFS distance/path is needed, because discovery fixes that distance.
- Depth limit: do not expand vertices at the limit; the result certifies reachability only within that radius.
- Reverse traversal: in a directed graph, follow incoming rather than outgoing edges, requiring an incoming index or a reversed graph view.
Changing the FIFO buffer changes the theorem. A stack gives depth-first behavior; a priority queue ordered by tentative cost leads toward Dijkstra’s algorithm. The buffer is not an incidental implementation detail.
DFS: suspended work creates depth #
Recursive DFS expresses its state directly in the call stack:
visit(u):
state[u] = active
discover_time[u] = ++time
for each v in out_neighbors(u):
if state[v] == undiscovered:
parent[v] = u
visit(v)
state[u] = finished
finish_time[u] = ++time
DFS follows an undiscovered neighbor immediately. It returns to only after that neighbor’s entire recursive subtree is finished, then resumes ’s adjacency scan where it stopped.
The decisive invariant is:
This is why “replace recursion with a stack” needs precision. An equivalent explicit implementation stores frames such as (u, next_neighbor_index). A simpler algorithm that pushes every neighbor as a bare vertex may still find all reachable vertices, but it can produce different parents, discovery/finish events, and edge classifications.
Exact iterative DFS #
- Mark the root active and push a frame whose neighbor iterator is at its beginning.
- Inspect the top frame:
- if another outgoing edge exists, advance the iterator;
- if its target is undiscovered, record the parent, mark it active, and push a new frame;
- otherwise classify/process the edge and keep the frame;
- if no neighbor remains, mark the top vertex finished, record its finish time, and pop.
The explicit stack uses the same worst-case depth as recursion but avoids dependence on the language’s call-stack limit.
DFS intervals and the parenthesis theorem #
Record an increasing timestamp on discovery and finish . A vertex is active exactly during the open interval between those events.
For any two vertices and in a DFS forest, their intervals are either disjoint or one contains the other:
or
or the symmetric containment. Partial overlap is impossible because a call cannot finish while a descendant frame remains above it.
Containment has structural meaning: is an ancestor of in the DFS tree exactly when ’s interval strictly contains ’s.
Run alphabetic DFS on the earlier graph, then restart at isolated g:
| Vertex | Parent | Discover | Finish | Interval relation |
|---|---|---|---|---|
s |
— | 1 | 14 | contains the reachable component |
a |
s |
2 | 13 | inside s |
c |
a |
3 | 12 | inside a |
f |
c |
4 | 11 | inside c |
b |
f |
5 | 10 | inside f |
d |
b |
6 | 7 | inside b |
e |
b |
8 | 9 | disjoint from d, inside b |
g |
— | 15 | 16 | separate tree |
The active path reaches s→a→c→f→b→d. Edge d→c then points to an active ancestor, certifying the directed cycle
Directed DFS edge classes #
When an edge is examined in a directed graph:
| Class | State/time relation | Meaning |
|---|---|---|
| Tree | v is undiscovered |
the edge first discovers v |
| Back | v is active |
points from a descendant to an ancestor; certifies a cycle |
| Forward | v is finished and a descendant of u |
non-tree shortcut down the DFS tree |
| Cross | v is finished and neither ancestor nor descendant |
connects already completed subtrees |
In the trace, a→d is forward because d became a descendant through a→c→f→b→d; e→d is cross because d finished before e was discovered.
For an undirected graph, each edge is commonly exposed from both endpoints. The incidence back to a vertex’s parent is not a new cycle edge. Cycle detection must ignore that parent twin (or distinguish edge IDs in a multigraph). Do not apply the directed gray-target rule without this adjustment.
From one source to a whole-graph forest #
A source traversal visits only its reachable set. To cover every vertex:
initialize all vertices as undiscovered
for each vertex r in a declared root order:
if r is undiscovered:
start a traversal at r
Each restart creates a tree root. In an undirected graph, these trees coincide with connected components. In a directed graph, they are traversal trees determined by root and adjacency order—not strongly connected components.
The order matters for the concrete forest but not for the fact that every vertex is eventually covered.
DFS as an algorithmic scaffold #
DFS exposes event points—discover vertex, examine edge, finish vertex—on which stronger algorithms build.
Directed cycle detection #
A directed graph has a cycle if and only if a complete DFS encounters a back edge to an active vertex. One direction was shown above. For the other, take the first vertex of any reachable cycle discovered; before it can finish, DFS follows the cycle through still-undiscovered vertices until an edge returns to an active vertex.
Topological ordering #
A topological order lists every directed edge with before . It exists exactly for a directed acyclic graph (DAG). In a DFS with no back edge, decreasing finish time is a topological order: for every edge , either DFS explores beneath , so , or is already finished, again giving .
If a back edge appears, reject the order and return a cycle witness. Merely reversing finish order without cycle detection does not turn a cyclic graph into a DAG.
Components and lowlink algorithms #
Repeated DFS directly finds undirected connected components. Strongly connected and biconnected components need additional stack/lowlink invariants. Tarjan’s 1972 work demonstrates how DFS structure supports linear-time algorithms for those problems, but their full mechanisms are distinct from basic traversal and should not be guessed from the DFS timestamps alone.
Complexity is representation-sensitive #
Assume an explicit adjacency representation where iterating all outgoing records of costs .
- each vertex changes from undiscovered once and enters the queue/stack at most once;
- each outgoing incidence is examined once in a directed graph;
- in a usual undirected layout, each edge contributes two incidences.
Therefore BFS and DFS take
time and auxiliary state for colors, parents, distances/timestamps, and frontier/stack.
Over an adjacency matrix, finding neighbors scans a -cell row. Repeating this for reached vertices costs even if the graph has only edges. The abstract algorithm is unchanged; the out_neighbors primitive is not. See Graph Representations for the full cost model.
DFS’s recursive call depth can be on a path. BFS’s queue can contain vertices on a wide layer. Both use linear worst-case auxiliary space, but their peak shapes differ.
Determinism and mutation #
Traversal order is not normally a graph invariant. It depends on:
- root order;
- order of outgoing adjacency;
- when state transitions occur;
- whether parallel edges are collapsed;
- callbacks or filters that alter which neighbors are considered.
If reproducible output matters, sort neighbors or use a representation with a documented stable order and include that cost in the contract.
Mutating adjacency during traversal invalidates ordinary proofs unless semantics are defined. Adding an edge after its source is finished may hide a newly reachable vertex; deleting one may invalidate an iterator or recorded parent. Safe choices are: forbid mutation, traverse a snapshot, or use a separately proven dynamic algorithm.
Failure diagnostics #
- Was a vertex marked at discovery or only after removal?
- Does the buffer obey FIFO for BFS, or preserve suspended frames for exact DFS?
- Is the traversal following outgoing, incoming, or undirected adjacency?
- Was the run single-source or restarted over all undiscovered vertices?
- Are distances measured in edge count, and are all edges equal-cost for that purpose?
- Does edge classification match directed/undirected and simple/multigraph semantics?
- Is a claimed order invariant under neighbor reordering, or merely one execution trace?
- Does the complexity include the representation’s neighbor-iteration cost?
- Could recursion depth overflow, or could graph mutation invalidate state?
Exercises #
- Run BFS from
son the example withs’s neighbors orderedb,aand every other list alphabetic. Give distances and parents. - Prove that every parent edge in a BFS tree goes from layer to layer .
- Give a graph where the BFS queue contains vertices at once and a graph where recursive DFS reaches depth .
- In the recorded DFS trace, classify
d→f,a→d,e→d, ands→b. - Explain why an undirected edge from a child back to its parent must not automatically be reported as a cycle.
- Produce a topological order for edges
p→q,p→r,q→s,r→s, then add one edge that makes an order impossible and give the cycle. - An adjacency matrix represents a graph with vertices, but a traversal reaches only of them. Give a precise row-scan bound in terms of and .
Solutions
- BFS discovers
b,aat distance 1. Processingbdiscoversd,e; processingathen discoversc. Processingddiscoversf. One parent map isb←s,a←s,d←b,e←b,c←a,f←d. Distances remains:0,a,b:1,c,d,e:2,f:3;gis unreachable. - When is first discovered from , BFS sets and
parent[v]=u. Immediate marking prevents any later parent replacement. Hence every recorded parent edge crosses exactly one successive layer. - A star directed outward from its center enqueues all leaves after scanning the center, so BFS width is . A directed path of vertices makes recursive DFS hold nested calls.
d→fis back becausefis an active ancestor.a→dis forward becausedis a finished descendant.e→dis cross becausedis finished in a sibling subtree.s→bis forward becausebbecame a descendant throughs→a→c→f→b.- In a symmetric adjacency layout, discovering child
vthrough undirected edge{u,v}meansvwill later see the same edge as an incidence to parentu. That observation merely retraces the tree edge. A cycle requires a distinct edge to another discovered vertex, or distinct parallel-edge identity in a multigraph. - One order is
p,q,r,s;p,r,q,salso works. Addings→pcreates cyclep→q→s→p(and alsop→r→s→p), so no topological order exists. - Initializing state for all vertices costs in the standard full-array implementation. Each of the reached vertices scans a row of length , so the adjacency work is ; total is , which is when .