Depth-First Search

Imagine you are dropped into a massive maze. You are told to visit every room, marking each hallway as you go. A natural strategy is to plunge down one corridor as far as possible. You only backtrack when you hit a dead end. You also backtrack upon reaching a room you have already seen. This is exactly the strategy of Depth-First Search (DFS). It is one of the two fundamental traversal algorithms for graphs, alongside Breadth-First Search. DFS matters because a huge fraction of graph problems reduce to one idea. That idea is "explore the graph systematically and record structure along the way." Compilers use DFS to detect cycles in dependency graphs. Build systems use it to order compilation steps. Web crawlers use it to explore link structure. Garbage collectors use it (in a stack-based form) to trace reachable objects from a root set. Puzzle solvers and game-tree engines use it to explore states depth-first before backtracking. In every one of these settings, the appeal of DFS is the same. It is simple to implement. It uses memory proportional only to the depth of recursion. Crucially, it produces a rich byproduct called the DFS forest. This encodes far more information than "which vertices are reachable." The big picture is this: DFS is not just one algorithm but a framework. By itself it computes reachability. But by adding timestamps and classifying edges as it explores, DFS becomes something more. It becomes the engine behind cycle detection, Topological Sort, and the discovery of strongly connected components. This article builds that engine from first principles. It covers how the recursion (or explicit stack) works. It explains why DFS always finishes in linear time. It shows what invariants make it correct. It also explains how to read the DFS forest it leaves behind. This article delivers a complete, verified treatment of DFS on both directed and undirected graphs. It includes precise pseudocode for the recursive and iterative forms. It includes a diagram of the resulting DFS forest. It provides a full complexity derivation and correctness proof via loop invariants. It includes a worked trace on a concrete six-vertex graph. Finally, it surveys the classic bugs that break naive implementations. These include infinite recursion on cycles, stack overflow on deep graphs, and mishandling of disconnected components.

Key takeaways
  • DFS explores a graph by going as deep as possible before backtracking, using recursion or an explicit stack.
  • It visits every vertex and edge exactly once (per direction), giving O(V + E) time using an Adjacency List representation.
  • Space is O(V) for the visited-set and recursion/call stack, plus O(V) if you store the DFS forest.
  • The traversal naturally produces a DFS forest: a collection of DFS trees whose parent pointers reveal structure.
  • Recording discovery/finish timestamps (foreshadowed here, detailed in DFS Edge Classification and Timestamps) upgrades plain DFS into a tool for cycle detection and ordering.
  • DFS underlies Topological Sort, strongly connected component algorithms, and reachability/connectivity tests.
The core insight

DFS visits a graph by committing fully to one path before backtracking, and this commit-and-backtrack discipline visits every vertex and edge exactly once, yielding linear-time exploration and a forest that encodes the graph's structure.

The idea

A graph G=(V,E)G = (V, E) can be represented as an Adjacency List or an Adjacency Matrix; DFS works with either, though the adjacency list gives the better running time. Depth-first search explores GG by starting at a vertex uu, marking it visited, and then recursively exploring one unvisited neighbor of uu at a time. When a call returns — because the current vertex has no more unvisited neighbors — the algorithm backtracks to the vertex that called it, and continues searching that vertex's remaining neighbors. This is precisely a preorder-style traversal of the recursion tree, generalized from trees to arbitrary graphs (which may contain cycles and multiple paths between vertices). Because a graph need not be connected, DFS is normally run as an outer loop over all vertices: for every vertex not yet visited, start a fresh DFS call from it. Each such call produces a DFS tree rooted at the starting vertex, consisting of the tree edges — the edges (u,v)(u, v) traversed when vv is first discovered from uu. The union of all DFS trees produced by the outer loop is the DFS forest. Every edge of GG that is not a tree edge is classified, in the fuller treatment of DFS, as a back, forward, or cross edge depending on the relationship between the endpoints in the forest — this classification is the subject of DFS Edge Classification and Timestamps, but even without timestamps, the forest itself already reveals connectivity structure: the number of DFS trees equals the number of connected components (undirected case) or gives partial information about reachability (directed case). The key mechanical device that makes DFS well-defined and efficient is a visited (or color) marker per vertex, checked before recursing. Without this marker, DFS would loop forever on any graph containing a cycle, since it would keep re-entering already-explored vertices. With it, each vertex is entered into the "currently being explored" state exactly once, guaranteeing termination and linear work.

How it works

DFS can be written recursively (using the language's call stack) or iteratively (using an explicit stack data structure). The recursive form is the clearest expression of the "commit fully, then backtrack" idea, so we present it first, then give the iterative variant that avoids recursion-depth limits.

Recursive DFS. Each vertex has a color: `WHITE` (undiscovered), `GRAY` (discovered, still being explored), or `BLACK` (finished, all neighbors explored). The outer procedure loops over all vertices so that disconnected components are all covered. ``` DFS(G): for each vertex u in G.V: color[u] = WHITE parent[u] = NIL time = 0 forest = [] # list of trees, one per component for each vertex u in G.V: if color[u] == WHITE: tree = new empty tree rooted at u DFS-VISIT(G, u, tree) forest.append(tree) return forest, parent DFS-VISIT(G, u, tree): color[u] = GRAY # u is discovered time = time + 1 discovery[u] = time tree.add(u) for each v in G.adj[u]: # examine every outgoing edge (u, v) if color[v] == WHITE: parent[v] = u tree.add_edge(u, v) # (u, v) becomes a tree edge DFS-VISIT(G, v, tree)

# else: v already discovered — edge is non-tree (back/forward/cross) color[u] = BLACK # u is finished time = time + 1 finish[u] = time ``` The `discovery` and `finish` timestamps are included here because they cost nothing extra and are exactly the hooks used by later algorithms (cycle detection, topological sort, strongly connected components); a plain reachability-only DFS can omit them and simply track `color`.

Iterative DFS (explicit stack). Recursion depth in the recursive version can reach O(V)O(V) in the worst case (a long path), which can overflow a language's call stack for very large graphs. An explicit-stack version avoids this: ``` DFS-ITERATIVE(G, s): for each vertex u in G.V: color[u] = WHITE stack = empty stack stack.push(s) while stack is not empty: u = stack.peek() if color[u] == WHITE: color[u] = GRAY # discover u advanced = false for each v in G.adj[u]: # find next unvisited neighbor if color[v] == WHITE: parent[v] = u stack.push(v) advanced = true break if not advanced: color[u] = BLACK # all neighbors explored: finish u stack.pop() ``` This iterative version simulates the same recursion tree: the explicit stack holds exactly the path from the current vertex back to the root, mirroring the call stack of the recursive version. Its subtlety is that `G.adj[u]` must be scanned incrementally (skipping already-tried neighbors) rather than restarted from scratch each time `u` is revisited, or the running time degrades; a standard fix is to store, per vertex, an iterator/index into its adjacency list. The figure below shows a directed graph and the DFS forest produced by running `DFS` starting the outer loop at vertex A (assuming adjacency lists list neighbors alphabetically).

Figure
A directed graph and the DFS forest it produces, with tree edges solid and a back edge dashed
Input graph G A B C D E F DFS forest (start A) A 1/12 B 2/11 C 3/4 D 5/10 F 6/9 E 7/8 solid green = tree edge dashed red = back edge (F→A)

Reading the figure: DFS starts at A (discovery time 1), goes to B, then to C (a dead end after one edge, so C finishes quickly at time 4), backtracks to B, descends to D, then to F, then to E (another dead end), and finally the edge F → A is discovered but A is already `GRAY`/finished-ancestor, so it is not a tree edge — it is a back edge, drawn dashed, indicating a cycle in the original graph.

Complexity & correctness

Time complexity. With an adjacency-list representation, `DFS-VISIT` is called exactly once per vertex, because the `color[u] == WHITE` check in the outer loop and in the neighbor loop guarantees a vertex is never entered twice. Inside each call, the `for each v in G.adj[u]` loop scans u's adjacency list once. Summing over all vertices, the total work done scanning adjacency lists is

∑u∈V∣adj[u]∣=Θ(E)\sum_{u \in V} |\text{adj}[u]| = \Theta(E)

(for a directed graph; for undirected graphs each edge appears in two adjacency lists, still Θ(E)\Theta(E) overall since constants don't affect asymptotic order). The bookkeeping per vertex (coloring, timestamping) is Θ(1)\Theta(1), contributing Θ(V)\Theta(V) total. Hence:

T(V,E)=Θ(V+E)T(V, E) = \Theta(V + E)

This is optimal in the sense that any algorithm must at least read the input graph, which already takes Ω(V+E)\Omega(V + E) time. If instead an Adjacency Matrix is used, checking a vertex's neighbors takes Θ(V)\Theta(V) time regardless of actual degree, giving Θ(V2)\Theta(V^2) total — a real cost, which is why adjacency lists are preferred for sparse graphs.

Space complexity. DFS uses Θ(V)\Theta(V) auxiliary space: the color array, the parent array, and (for the recursive version) the call stack, whose depth is bounded by the longest root-to-leaf path in the DFS tree, at most VV. The iterative version's explicit stack is likewise bounded by O(V)O(V) vertices, since each vertex is pushed at most once. Storing the forest itself (parent pointers, timestamps) is also Θ(V)\Theta(V).

Correctness. The central claim is: `DFS(G)` visits every vertex exactly once, and every edge is examined exactly once (per direction), producing a well-defined forest. The proof proceeds by strong induction on the order of `DFS-VISIT` calls, using the color invariant:

  • Invariant. At any point during execution, `WHITE` vertices are undiscovered, `GRAY` vertices are ancestors of the currently executing call on the recursion stack (i.e., currently "open"), and `BLACK` vertices are fully finished — every vertex reachable from them via unexplored edges has already been discovered.

Base case: before any call, all vertices are `WHITE`, satisfying the invariant vacuously. Inductive step: when `DFS-VISIT(G, u, tree)` is called, `u` was `WHITE` (guaranteed by the caller's check), so `u` was undiscovered — consistent with the invariant. Immediately, `u` is colored `GRAY`, correctly marking it as newly opened. For each neighbor vv: if vv is `WHITE`, the invariant guarantees vv was truly undiscovered, so recursing into it is safe and it becomes a new tree edge child of uu; if vv is `GRAY` or `BLACK`, it has already been (or is being) discovered elsewhere, so skipping it is correct — re-entering it would violate "each vertex visited once" and would in fact loop forever on a cyclic graph if the check were omitted. After all neighbors are exhausted, `u` is colored `BLACK`, correctly marking it finished, matching the invariant that a finished vertex has no more work. Termination: every recursive call strictly decreases the number of `WHITE` vertices by at least one (the vertex it discovers), and there are finitely many vertices, so the recursion cannot proceed forever. The outer loop's `for each vertex u` similarly runs ∣V∣|V| times, calling `DFS-VISIT` only on still-`WHITE` vertices, guaranteeing all components are covered exactly once. Since each edge (u,v)(u,v) is examined exactly once (when scanning `adj[u]`), and colored consistently by the invariant, the forest produced has exactly ∣V∣|V| vertices, each with a well-defined parent (or none, if a tree root), and the tree-edge set is acyclic by construction — parent pointers always point from a `GRAY` vertex to a freshly-discovered `WHITE` vertex, never creating a cycle among tree edges. ■\blacksquare

Honest tradeoffs. DFS gives no shortest-path guarantee — unlike Breadth-First Search, the tree-path from the root to a vertex in a DFS tree can be far longer than the true shortest path, since DFS commits to depth over breadth. Recursive DFS is elegant but risks stack overflow on graphs with very long paths (e.g., a graph that is essentially a chain of 10610^6 vertices); the iterative version trades code clarity for robustness. Finally, plain DFS (without timestamps) only tells you reachability and forest structure — extracting cycle information, topological order, or strongly connected components requires the timestamp/classification machinery layered on top, covered in DFS Edge Classification and Timestamps.

Worked example

Consider the directed graph from the figure, with adjacency lists (neighbors listed in alphabetical/insertion order):

VertexAdjacency list
A[B]
B[C, D]
C[]
D[F]
E[]
F[A, E]

Run `DFS(G)`, outer loop visiting vertices in order A, B, C, D, E, F.

StepActioncolor changesdiscovery/finishparent set
1outer loop: A is WHITE → call DFS-VISIT(A)A: WHITE→GRAYd[A]=1—
2scan A's list: B is WHITE → recurseB: WHITE→GRAYd[B]=2parent[B]=A
3scan B's list: C is WHITE → recurseC: WHITE→GRAYd[C]=3parent[C]=A→B→C
4C's list empty → finish CC: GRAY→BLACKf[C]=4—
5back in B: D is WHITE → recurseD: WHITE→GRAYd[D]=5parent[D]=B
6scan D's list: F is WHITE → recurseF: WHITE→GRAYd[F]=6parent[F]=D
7scan F's list: A is GRAY (ancestor, not WHITE) → skip, non-tree edge (back edge)———
8scan F's list: E is WHITE → recurseE: WHITE→GRAYd[E]=7parent[E]=F
9E's list empty → finish EE: GRAY→BLACKf[E]=8—
10back in F: no more neighbors → finish FF: GRAY→BLACKf[F]=9—
11back in D: no more neighbors → finish DD: GRAY→BLACKf[D]=10—
12back in B: no more neighbors → finish BB: GRAY→BLACKf[B]=11—
13back in A: no more neighbors → finish AA: GRAY→BLACKf[A]=12—
14outer loop: B,C,D,E,F all BLACK → done, one tree in forest———

The result: a single DFS tree (the graph is connected via directed reachability from A), with tree edges A→B, B→C, B→D, D→F, F→E, and exactly one non-tree edge, F→A, discovered while A was still an open ancestor (`GRAY`) — this is the hallmark of a back edge, which (as elaborated in DFS Edge Classification and Timestamps) certifies that the directed graph contains a cycle: A→B→D→F→A. Notice the parenthesis structure of the timestamps: [d[A],f[A]]=[1,12][d[A], f[A]] = [1,12] properly contains [d[B],f[B]]=[2,11][d[B], f[B]] = [2, 11], which properly contains [d[C],f[C]]=[3,4][d[C], f[C]] = [3,4] and [d[D],f[D]]=[5,10][d[D], f[D]] = [5, 10], which contains [d[F],f[F]]=[6,9][d[F], f[F]] = [6, 9], which contains [d[E],f[E]]=[7,8][d[E], f[E]] = [7, 8] — this nesting is not a coincidence but a theorem (the parenthesis theorem) that always holds for DFS timestamps, since a vertex's interval exactly spans the time its subtree is being explored.

Common pitfalls & traps

PitfallWhy it happensFix
Infinite recursion on cyclic graphsForgetting to check/set the visited marker before recursingAlways mark a vertex visited before recursing into its neighbors, and check the marker first
Missing disconnected componentsCalling DFS only from a single starting vertexWrap DFS in an outer loop over all vertices, launching a new tree for each still-unvisited one
Stack overflow on deep/path-like graphsRecursive DFS depth equals the longest path, unbounded for large inputsUse the iterative, explicit-stack version, or increase language recursion limits with caution
Re-scanning adjacency list from scratch each time a stack frame is revisited (iterative version)Not saving per-vertex progress through the neighbor listStore an iterator/index per vertex so each edge is examined once, preserving O(V+E)
Confusing DFS tree-path length with shortest pathDFS commits to depth, not breadthUse Breadth-First Search when the shortest path in an unweighted graph is required
Treating a "no unvisited neighbor found" edge as always a tree edgeNot distinguishing WHITE (new) vs GRAY/BLACK (already seen) neighborsClassify: only WHITE-neighbor edges are tree edges; GRAY/BLACK-neighbor edges are back/forward/cross

Pioneers & history

Depth-first search as a systematic algorithmic technique — with the formal color/timestamp machinery, complexity analysis, and its use as a subroutine for planarity testing, biconnectivity, and strong connectivity — was crystallized by Robert Tarjan and John Hopcroft in the early 1970s, building on maze-traversal ideas that predate computing (the "Trémaux" wall-following strategy for solving mazes, and Charles Pierre Trémaux's 19th-century graph-traversal rule, are often cited as an intellectual ancestor). Their work turned an intuitive backtracking heuristic into a rigorously analyzed O(V+E)O(V+E) algorithm with a battery of powerful applications.

Origins · 1 minJohn E. Hopcroft and how this was discovered
JE
John E. Hopcroft
born 1939 · Cornell University and MIT; co-developed the linear-time DFS framework and its applications to graph connectivity, alongside Robert Tarjan, work recognized by the 1986 Turing Award

History
1959
Edsger Dijkstra publishes shortest-path work; graph traversal ideas mature in parallel across early algorithms research.
1971–1972
Robert Tarjan formalizes DFS-based algorithms for strongly connected components and biconnected components.
1973
Hopcroft and Tarjan publish an O(V+E)O(V+E) DFS-based planarity testing algorithm.
1974
Hopcroft and Tarjan's textbook-shaping papers establish DFS as a general-purpose linear-time traversal framework.
1986
Hopcroft and Tarjan receive the Turing Award for fundamental achievements in the design of algorithms and data structures, including this line of work.

Where this matters

Compilers and build systems (such as `make`, Bazel, and package managers like `npm` or `pip`) run DFS-based cycle detection over dependency graphs to reject circular dependencies, and DFS-based Topological Sort to compute a valid build/install order. Garbage collectors in managed-memory runtimes (JVM, .NET CLR, V8) use an iterative, stack-based DFS (or its close cousin, mark-and-sweep tracing) to identify all objects reachable from root references. Version control systems and file-system tools use DFS to walk directory trees. Maze-generation and maze-solving code in games literally implements the "commit and backtrack" idea directly. Network tools use DFS-style traversal to enumerate reachable hosts or to detect cycles in routing/dependency graphs. Static analyzers and IDEs use DFS over control-flow or call graphs to detect infinite recursion, unreachable code, or circular imports. In each case, the appeal is the same property proved above: linear-time, low-memory exploration that leaves behind a forest rich enough to answer deeper structural questions.

Ask the tutor: How do discovery and finish timestamps let DFS detect cycles and compute a topological order?

Is DFS guaranteed to find the shortest path between two vertices?

No. DFS commits to depth over breadth, so the path it discovers from the root to any vertex in the DFS tree can be much longer than the true shortest path in terms of edge count. For unweighted shortest paths, Breadth-First Search is the correct tool, since it explores vertices in order of increasing distance from the source, layer by layer. DFS is instead the right tool when you need to explore all reachable structure efficiently, detect cycles, or compute orderings like topological sort — problems where path length is irrelevant but full exploration and backtracking structure matter.

Does DFS behave differently on undirected versus directed graphs?

The core algorithm is identical, but the interpretation of non-tree edges differs. In an undirected graph, every non-tree edge examined is a back edge (an edge to an ancestor), because undirected DFS never encounters "forward" or "cross" edges — every edge you can reach leads either to a new vertex (tree edge) or back to an already-open ancestor. In directed graphs, non-tree edges can be back, forward, or cross edges, since directed graphs allow more complex relationships between the DFS tree structure and the original edge set. This richer classification, covered in DFS Edge Classification and Timestamps, is what enables cycle detection specifically in directed graphs.

Why does DFS need an outer loop over all vertices instead of a single call?

A graph may be disconnected, or in the directed case, some vertices may simply not be reachable from a single chosen start vertex. A single call to `DFS-VISIT(s)` only discovers vertices reachable from `s`. To guarantee every vertex is eventually visited and included in the forest, the outer `DFS(G)` procedure loops over all vertices, launching a fresh DFS tree from any vertex still colored `WHITE`. This is why the output is called a DFS forest rather than a DFS tree — it may consist of multiple trees, one per connected component (undirected) or per "unreached" region (directed).

How is DFS different from Breadth-First Search in terms of underlying data structure?

DFS naturally uses a stack (either the implicit call stack via recursion, or an explicit stack in the iterative version) so that the most recently discovered vertex is explored next, producing deep, narrow trees. Breadth-First Search instead uses a queue, so the earliest-discovered vertex is explored next, producing shallow, wide trees that expand outward in layers. Both share the same O(V+E) time complexity and the same visited-marking discipline, but the choice of stack versus queue is exactly what produces their very different traversal shapes and different guarantees (DFS: structural exploration; BFS: shortest paths in unweighted graphs).

Can DFS be used to detect cycles in a graph?

Yes, and this is one of its most important applications. In a directed graph, a cycle exists if and only if DFS ever encounters a back edge — an edge to a vertex that is currently `GRAY` (an open ancestor on the current recursion path). In an undirected graph, a cycle exists if DFS encounters an edge to an already-visited vertex that is not the immediate parent (to avoid falsely flagging the trivial back-and-forth along a tree edge). This detection needs only the color-based marking already present in standard DFS, with no extra machinery, which is why compilers and dependency-resolution tools rely on it directly.

What is the DFS forest's parenthesis theorem, and why does it hold?

If you write "(" at each vertex's discovery time and ")" at its finish time, and sort all these marks by time, the resulting sequence of parentheses is always properly nested — no interval `[d[u], f[u]]` partially overlaps another; each pair is either disjoint or one strictly contains the other. This holds because a vertex's finish time is set only after its entire subtree of the DFS tree has been fully explored and finished, so any descendant's interval is necessarily nested inside its ancestor's interval, and any unrelated vertex's interval is explored either entirely before or entirely after. It is the timestamp foundation exploited in DFS Edge Classification and Timestamps.

Why is DFS considered a "framework" rather than a single algorithm?

The bare traversal — visit, mark, recurse, backtrack — only computes reachability and a forest of parent pointers. Its real power comes from instrumenting the traversal: adding discovery/finish timestamps turns it into a cycle detector and the basis of Topological Sort; adding low-link values on top of timestamps turns it into Tarjan's algorithm for strongly connected components; running it twice with transposed edges gives Kosaraju's SCC algorithm. Because so many distinct, powerful algorithms are just "DFS plus a small amount of extra bookkeeping," DFS is best understood as a reusable traversal skeleton rather than a single-purpose procedure.

Before this

Where this leads