Depth-first search
On This Lesson 11 topics
Lesson Overview
Breadth-first search explores in rings. Depth-first search (DFS) does the opposite: it follows one path as far as it can go, and only when it hits a dead end does it back up and try the next branch. Think of walking through a maze with one hand on the wall, or of clicking links on a web page, then links on that page, and only returning when a page has nothing new.
DFS is shorter to write than BFS, because recursion does the bookkeeping for you through the call stack. It is just as cheap, and it is the right tool for questions like "can I get there at all?", "is there a cycle?", and "in what order must these be built?". It is the wrong tool for "what is the shortest route?". This lesson teaches you to tell the difference.
All code was compiled and run with JDK 21.
Why this matters: choosing the wrong traversal is a classic way to get a result that looks right on small tests and wrong in production. By the end you will choose with a reason.
This lesson builds on "Breadth-first search" (visited set, adjacency list), on recursion and the call stack from earlier modules, and on Deque from the collections module.
Learning Objectives
By the end of this lesson you will be able to:
- Describe DFS as going deep first and backing up at dead ends.
- Write recursive DFS with a visited set.
- Write an iterative DFS with an explicit stack and explain how its order can differ.
- Trace DFS by hand on a small graph.
- State DFS cost as O(V + E).
- Explain why DFS does not guarantee the shortest path.
- Choose between BFS and DFS for a given problem.
Written Explanation
The idea: go deep, then back up
From the current node, DFS picks the first unvisited neighbor and moves there. From that node it again picks the first unvisited neighbor, and so on. When a node has no unvisited neighbors left, DFS returns to the previous node and tries its next neighbor. It stops when it has backed all the way to the start with nothing left to try.
void dfs(String node, Set<String> visited) {
if (!visited.add(node)) return;
for (String next : g.getOrDefault(node, List.of())) {
dfs(next, visited);
}
}Reading it:
visited.add(node)returnsfalseif the node was already seen. Then we return at once: that is the visited check, and it is what stops cycles.- Otherwise the node is new. We then call
dfson every neighbor, one after another. Each recursive call explores completely before the next neighbor is tried.
The "back up" step is automatic. When a call returns, control goes back to the caller's loop, which moves to its next neighbor. The call stack remembers the path from the start to the current node.
Why the visited set is still needed
The rule from BFS applies here. In a graph with a cycle, A calls B and B calls A, which calls B again, forever. In recursive DFS that failure shows up sooner than a never-ending loop: it ends with a StackOverflowError, as you saw in the recursion lessons. The visited set cuts the loop on the second visit.
DFS with an explicit stack
Recursion is not the only way. You can replace the call stack with your own Deque used as a stack (push and pop). This matters for very deep graphs, as we will see.
Deque<String> stack = new ArrayDeque<>();
stack.push(start);
while (!stack.isEmpty()) {
String cur = stack.pop();
if (!visited.add(cur)) continue;
// process cur here
List<String> ns = g.getOrDefault(cur, List.of());
for (int i = ns.size() - 1; i >= 0; i--) stack.push(ns.get(i));
}Two details. First, a node can be pushed several times before it is popped, so the visited check happens at pop time, not at push time. Second, the stack is last in, first out. To visit neighbors in the same order as the recursive version, push them in reverse so the first neighbor ends up on top. If you push them in normal order, the traversal is still a valid DFS, but it visits the last neighbor first, so the order differs.
Compare the structures: BFS uses a queue and finds the nearest nodes first. DFS uses a stack, which is either the call stack or your own, and finds the deepest nodes first.
Cost
DFS marks each node once and scans each adjacency list once, so its cost is O(V + E), the same as BFS. A standard reference gives the same bound for DFS. The difference is not speed but memory and order. BFS memory grows with the width of the graph (a whole ring in the queue). DFS memory grows with its depth (the path from the start). Recursive DFS on a graph that is one long chain needs one stack frame per node, which is the next point.
Depth limits
The JVM's call stack is finite. Recursive DFS on a very long chain, such as 100,000 nodes in a row, can end in StackOverflowError. The exact limit depends on the JVM settings and the size of each frame, so do not rely on a particular number. The iterative version uses heap memory for its stack and handles such graphs, as the third example shows.
DFS does not find the shortest path
DFS commits to the first neighbor, and it records a path to a node as "whatever way I happened to arrive". That route can wind through half the graph. A standard reference makes this point: in a tree, DFS paths are the only paths so they are shortest, but on a general graph this is not the case.
Choosing between BFS and DFS
| You need | Use |
|---|---|
| The route with the fewest edges (unweighted) | BFS |
| Distance in hops from a start | BFS |
| "Can I get from A to B at all?" | Either; DFS is shorter to write |
| Visit everything in a connected piece | Either |
| Detect a cycle in dependencies | DFS (next lesson) |
| Order tasks so dependencies come first | DFS (next lesson) |
When in doubt, ask whether you need the shortest route. If yes, BFS.
Examples
Example 1: tracing DFS by hand (simple)
The graph from the last lessons: A: [B, C]; B: [A, D]; C: [A, D]; D: [B, C, E]; E: [D]. Call dfs("A"):
| Call | Action | Order so far |
|---|---|---|
| dfs(A) | new; go to first neighbor B | A |
| dfs(B) | new; neighbors A (seen), D | A, B |
| dfs(D) | new; neighbors B (seen), C | A, B, D |
| dfs(C) | new; neighbors A (seen), D (seen); return | A, B, D, C |
| back in D | next neighbor E | |
| dfs(E) | new; neighbor D (seen); return | A, B, D, C, E |
| returns | D, B, A finish |
DFS order: A, B, D, C, E. BFS gave A, B, C, D, E. Notice that DFS reached D through B, then C through D: it went three nodes deep before returning to visit C from D rather than from A.
Example 2: order, an iterative twin, and a reachability check (practical)
import java.util.*;
public class L04 {
static Map<String, List<String>> g = new HashMap<>();
static void edge(String a, String b) {
g.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
g.computeIfAbsent(b, k -> new ArrayList<>()).add(a);
}
static void dfs(String node, Set<String> visited, List<String> order) {
if (!visited.add(node)) return;
order.add(node);
for (String next : g.getOrDefault(node, List.of())) dfs(next, visited, order);
}
static boolean reachable(String from, String to) {
if (!g.containsKey(from) || !g.containsKey(to)) return false;
Set<String> v = new HashSet<>();
dfs(from, v, new ArrayList<>());
return v.contains(to);
}
public static void main(String[] args) {
edge("A","B"); edge("A","C"); edge("B","D"); edge("C","D"); edge("D","E");
List<String> o = new ArrayList<>();
dfs("A", new HashSet<>(), o);
System.out.println(o);
edge("X","Y");
System.out.println(reachable("A","E") + " " + reachable("A","Y") + " " + reachable("A","Q"));
}
}Output:
[A, B, D, C, E]
true false falseThe order matches the hand trace. The iterative version with neighbors pushed in reverse, which I also ran, printed the same [A, B, D, C, E]. reachable answers the lab's "can I get there at all" question: E is reachable from A, Y is not (it is in a separate piece), and Q is not even in the graph.
Example 3: a deep chain breaks recursion (realistic)
Build a chain of 100,001 nodes, n0 to n100000, each linked to the next, then run the recursive dfs from n0:
g.clear();
for (int i = 0; i < 100000; i++) edge("n" + i, "n" + (i + 1));
try {
dfs("n0", new HashSet<>(), new ArrayList<>());
System.out.println("deep ok");
} catch (StackOverflowError e) {
System.out.println("StackOverflowError");
}
System.out.println(dfsIter("n0").size());On my machine with default JVM settings, the output was:
StackOverflowError
100001Here dfsIter is the explicit-stack version from the Written Explanation, returning the visiting order. It visited all 100,001 nodes. Whether the recursive call overflows on your machine depends on its stack size, so treat the exact depth as machine-dependent. The lesson is the pattern: recursion depth equals path length, and some real graphs are long. Metro maps are small, but a long chain of dependencies or links can be deep.
Backend Insight
DFS shows up where "finish this branch before the next" is the natural shape. Resolving a library's dependencies, then their dependencies, is DFS. So is crawling a site's pages by following links, and scanning a directory tree. Any depth-first walk over linked data that can contain cycles needs a visited set to protect against loops.
The practical trade-off is memory and failure mode. A recursive DFS on deep input can fail with a StackOverflowError, which in a request handler is a production incident. When the depth is not under your control, prefer the iterative version. And when a requirement says "fewest hops", use BFS: DFS can return a valid but long route and still look correct in tests with small graphs.
Common Mistakes
Mistake 1: Using DFS for shortest paths
- Incorrect: running DFS and reporting the first path it finds as the fewest-stops route.
- Correct: use BFS.
DFS finds a path, not necessarily the shortest. On the exercise graph below, the first DFS route from S to E has 5 edges while the shortest has 3.
Mistake 2: Forgetting the visited set
Cycles cause infinite recursion, ending in StackOverflowError. Mark each node on entry.
Mistake 3: Checking visited at the wrong time in the iterative version
A node can be pushed several times before it is popped, so skip already-visited nodes at pop time, as shown above.
Mistake 4: Assuming the iterative order equals the recursive order
Pushing neighbors in normal order visits them in reverse. On the five-node graph, pushing in normal order gives [A, C, D, E, B] instead of [A, B, D, C, E]. Both are valid DFS orders. Push in reverse when you need to match the recursive version.
Exercises
Try each exercise yourself before opening the solutions.
Use this undirected graph, neighbors tried in the order shown: S: [A, B]; A: [S, C]; B: [S, C, D]; C: [A, B, E]; D: [B, E]; E: [C, D].
Level A: Foundation
A1. Write the recursive DFS visiting order from S.
Show solution A1
A1. S, A, C, B, D, E. From S go to A; from A (S seen) go to C; from C (A seen) go to B; from B (S, C seen) go to D; from D (B seen) go to E; E's neighbors C and D are seen.
A2. Which data structure does recursive DFS use to remember the path, and which does BFS use?
Show solution A2
A2. Recursive DFS uses the call stack (or an explicit stack in the iterative form). BFS uses a queue.
A3. For each task, choose BFS or DFS: (a) fewest stops between two stations, (b) check whether two servers are connected at all, (c) number of hops from a user to everyone else.
Show solution A3
A3. (a) BFS. (b) DFS (BFS works too, but DFS is shorter to write). (c) BFS.
Level B: Application
B1. DFS from S reaches E through a route of discovery. Give that route (which node first reached which) and its number of edges, and compare it with the shortest route from S to E.
Show solution B1
B1. E was first reached from D, D from B, B from C, C from A, A from S: S, A, C, B, D, E, which is 5 edges. The shortest route from S to E has 3 edges (for example S, A, C, E). The DFS route is longer because it kept going deeper before it tried other branches.
B2. Write a recursive method countReachable(String node, Set<String> visited) that returns how many nodes are reachable from node, counting itself.
Show solution B2
B2.
static int countReachable(String node, Set<String> visited) {
if (!visited.add(node)) return 0;
int total = 1;
for (String next : g.getOrDefault(node, List.of())) {
total += countReachable(next, visited);
}
return total;
}I ran it on A-B, B-C, D-E and a lone node F: from A it returns 3. After removing the A-B edge it returns 1.
B3. The iterative DFS pushes neighbors in normal order (no reversal). Starting at S on the graph above, what order does it produce? Compute the stack contents by hand.
Show solution B3
B3. S, B, D, E, C, A. Pop S, push A, B; pop B, push S, C, D; pop D, push B, E; pop E, push C, D; pop D (seen, skip); pop C (new), push A, B, E; E and B are skipped, then A is visited. I ran this and got [S, B, D, E, C, A].
Level C: Challenge
C1. The graph is a chain with a million nodes. Explain why recursive DFS is risky and which change fixes it.
Show solution C1
C1. Recursion depth equals the length of the chain, so a million frames would very likely overflow the call stack. The fix is the iterative version with an explicit Deque stack, which keeps its data on the heap, or bounding the depth.
C2. Someone proposes: "Use DFS and keep the shortest of all paths found". Explain why this is a poor idea compared with BFS. Think about how many paths there can be.
Show solution C2
C2. The number of distinct paths between two nodes can grow explosively with the number of nodes, so trying them all would be enormously slower than BFS, which finds a shortest path in a single O(V + E) pass. I did not run this experiment; the claim is the standard reason BFS is used for fewest-edge routes.
Mini Challenge
Write a small class with both traversals on the metro map: Airport-Center, Center-Market, Market-Harbor, Harbor-Park, Park-Center, Stadium-Center, Island-Dock. Include dfs, an iterative dfsIter, and reachable(from, to).
- Print the DFS order from Airport.
- Check that
reachable("Airport","Harbor")is true andreachable("Airport","Dock")is false. - Print the BFS order too, and compare. Where do they differ?
- Add a try-catch for
StackOverflowErroraround a very long chain of your own to see whether it occurs on your machine.
Check your work: DFS and BFS both visit every station connected to Airport and none of Island and Dock, but the order differs.
Summary & Next Lesson
Key takeaways
- DFS follows one path as deep as possible, then backs up. It uses recursion (the call stack) or an explicit stack, and always a visited set.
- Cost is O(V + E), the same as BFS. Order and memory use differ.
- Recursive DFS depth equals the longest path, so very deep graphs can overflow the stack. Use an iterative stack for them.
- In the iterative form, check visited at pop time, and push neighbors in reverse to match the recursive order.
- DFS does not find the shortest path. Use BFS for fewest edges. Use DFS for reachability, cycles, and dependency order.
Next lesson
The next lesson, "Applications: dependencies and cycles", applies DFS to a directed graph of dependencies, to detect a cycle and to produce a valid build order.
Self-check
- What does DFS do when a node has no unvisited neighbors left?
- Which structure does recursive DFS use to remember its path?
- Does DFS guarantee the shortest path?
- Why can recursive DFS fail on a very long chain?
- You need the route with the fewest stops. BFS or DFS?
Show answers
- It backs up to the previous node and tries that node's next neighbor.
- The call stack.
- No. BFS does for unweighted graphs.
- Recursion depth equals the path length, and the call stack can overflow.
- BFS.
References
- CP-Algorithms, Depth First Search. Supports DFS running in O(n + m), being naturally recursive, and not producing shortest paths on general graphs (only on trees).
- Oracle, ArrayDeque (Java SE 21 API). Supports using
ArrayDequeas a stack and that it prohibitsnullelements; the documentation says it is likely faster thanStack. - The
StackOverflowErrorresult in Example 3 is from one run on one machine and depends on JVM settings; no depth limit is claimed. All Java code was compiled and run with JDK 21. The claim in C2 about path counts is a standard argument that I did not measure.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.