Go as deep as possible before backtracking. DFS explores one branch fully before trying the next β making it perfect for path finding, cycle detection, and topological sorting.
IntermediateDepth-First Search (DFS) is a graph traversal algorithm β a systematic way to visit every node in a graph (a collection of nodes connected by edges). DFS picks a direction and follows it as far as possible before backing up. Think of exploring a maze by always turning right until you hit a dead end, then retracing your steps to the last fork and trying a different path.
The idea is ancient. French mathematician Charles Pierre TrΓ©maux described a version of it in the 19th century as a strategy for solving mazes. You mark where you've been, go as deep as you can, and backtrack when stuck. Computer scientists formalized it in the 1970s β Tarjan and Hopcroft built some of the most elegant graph algorithms on top of DFS, including bridge-finding and strongly connected components.
DFS uses a stack β either the call stack (via recursion, where the programming language tracks function calls automatically) or an explicit stack data structure you manage yourself. The stack remembers where to go back to when you hit a dead end. This is the fundamental difference from BFS (Breadth-First Search), which uses a queue and explores level by level instead of diving deep.
DFS assigns each node two timestamps that encode a lot of structural information:
These timestamps tell you about the graph's structure. If node A's interval [d_A, f_A] completely contains node B's interval [d_B, f_B], then B is a descendant of A in the DFS tree. This is called the parenthesis theorem β the intervals either nest perfectly or don't overlap at all, like properly matched parentheses.
BFS and DFS both visit every node. The difference is how they explore, and that changes what problems each one solves naturally:
Simple and clean. The call stack handles backtracking automatically β when a recursive call returns, you're back at the parent. The downside: deep graphs can overflow the stack. Python's default recursion limit is 1000 (you can check with sys.getrecursionlimit()). A chain graph with 10,000 nodes will crash.
No recursion limit issues, but computing discovery/finish times is trickier β you need to push sentinel markers onto the stack to know when to record finish times. Use the iterative version when the graph might be deep (10,000+ nodes) or when you want explicit control over the traversal.
DFS classifies every edge in the graph into one of four types. This classification is central to cycle detection and topological sort:
You can classify edges using the node colors/states during DFS. When you encounter a neighbor that's:
On trees, DFS is simpler because there are no cycles and no "visited" check needed (just don't revisit the parent). Most binary tree problems β height, diameter, path sum, lowest common ancestor β are DFS by nature. You recurse on children, combine results, return up.
On general graphs, you need the visited set. Without it, a cycle sends DFS into an infinite loop. This is a mistake beginners make constantly.
Click any node to start DFS. Watch the algorithm dive deep along one branch before backtracking. Nodes show discovery/finish times (d/f). The stack contents update on the right. Backtracking edges are shown in red.
sys.setrecursionlimit(200000) in Python) or switch to iterative DFS. In interviews, always mention this tradeoff.
| Representation | Time | Space | Notes |
|---|---|---|---|
| Adjacency List | O(V + E) | O(V) | Same as BFS. Each vertex/edge visited once. |
| Adjacency Matrix | O(VΒ²) | O(V) | Must scan entire row per vertex to find neighbors |
| Tree (n nodes) | O(n) | O(h) | h = height. O(n) for skewed, O(log n) balanced. |
| Grid (R Γ C) | O(R Γ C) | O(R Γ C) | Each cell visited once. Stack depth = path length. |
Time is identical to BFS: O(V + E) with adjacency lists. Here V is the number of vertices (nodes) and E is the number of edges. You visit each vertex once and scan its adjacency list once, giving V + E total work.
The space difference between DFS and BFS is in what they store. DFS keeps only the current path on the stack β O(V) worst case for a chain graph, but often much less. BFS stores an entire frontier level in the queue, which can be O(V) on the last level of a complete binary tree. For trees specifically, DFS space is O(h) where h is the height. On a balanced tree that's O(log n). On a completely skewed tree (basically a linked list), it's O(n).
def dfs(graph, node, visited=None):
"""Basic recursive DFS on an adjacency list.
graph: dict mapping each node to a list of its neighbors
e.g. {0: [1, 2], 1: [3], 2: [3], 3: []}
node: starting node
visited: set of already-visited nodes (created on first call)
"""
if visited is None:
visited = set()
visited.add(node)
print(node) # Process the node β replace with whatever work you need
for neighbor in graph[node]:
if neighbor not in visited:
dfs(graph, neighbor, visited)
return visited
def dfs_timestamps(graph):
"""Compute discovery and finish times for all nodes.
These timestamps power edge classification, topological sort,
and SCC algorithms. The time counter increments globally across
all DFS trees in the forest.
"""
visited = set()
discovery = {}
finish = {}
time = [0] # Mutable container so the nested function can modify it
# (Python closures can read but not assign to enclosing variables)
def visit(node):
time[0] += 1
discovery[node] = time[0]
visited.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
visit(neighbor)
time[0] += 1
finish[node] = time[0]
# Handle disconnected graphs: try starting DFS from every node
for node in graph:
if node not in visited:
visit(node)
return discovery, finish
function dfsIterative(graph, start) {
const visited = new Set();
const stack = [start];
const order = [];
while (stack.length > 0) {
const node = stack.pop();
if (visited.has(node)) continue;
visited.add(node);
order.push(node);
// Push neighbors in reverse order so we visit them
// in the same order as recursive DFS (stack is LIFO,
// so last pushed = first popped)
const neighbors = graph[node] || [];
for (let i = neighbors.length - 1; i >= 0; i--) {
if (!visited.has(neighbors[i])) {
stack.push(neighbors[i]);
}
}
}
return order;
}
def has_cycle(graph):
"""Detect cycles in a directed graph using three-state DFS.
WHITE (0) = unvisited
GRAY (1) = in-progress (on the current DFS path)
BLACK (2) = finished (all descendants explored)
A back edge (to a GRAY node) means a cycle exists.
"""
WHITE, GRAY, BLACK = 0, 1, 2
color = {node: WHITE for node in graph}
def dfs(u):
color[u] = GRAY
for v in graph[u]:
if color[v] == GRAY:
return True # Back edge found β cycle!
if color[v] == WHITE and dfs(v):
return True
color[u] = BLACK
return False
# Check every node (graph might be disconnected)
return any(color[node] == WHITE and dfs(node) for node in graph)
def count_islands(grid):
"""Count connected components of '1's in a 2D grid.
Classic 'Number of Islands' problem. Each island is a connected
component of land cells ('1'). DFS from each unvisited '1' marks
the whole island as visited.
"""
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
count = 0
def flood_fill(r, c):
# Out of bounds or not land? Stop.
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
return
grid[r][c] = '#' # Mark visited by modifying in-place
# Recurse in all 4 directions
flood_fill(r + 1, c)
flood_fill(r - 1, c)
flood_fill(r, c + 1)
flood_fill(r, c - 1)
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
flood_fill(r, c)
count += 1
return count
Search engines like Google use crawlers to discover web pages. At its core, a web crawler is DFS (or BFS) over the graph of hyperlinks. You start at a seed URL, fetch the page, extract all links, and follow each one β going deeper and deeper until you've found everything reachable.
In practice, crawlers use BFS more often (to prioritize pages close to well-known sites), but the DFS approach works well for focused crawls β say, discovering all pages within a single domain.
import requests
from urllib.parse import urljoin, urlparse
from html.parser import HTMLParser
class LinkExtractor(HTMLParser):
"""Pulls all href links out of an HTML page."""
def __init__(self):
super().__init__()
self.links = []
def handle_starttag(self, tag, attrs):
if tag == 'a':
for name, value in attrs:
if name == 'href':
self.links.append(value)
def crawl_domain(start_url, max_pages=100):
"""DFS crawl of a single domain. Discovers all reachable pages.
This mirrors how DFS works on any graph:
- Nodes = web pages (URLs)
- Edges = hyperlinks from one page to another
- visited set = pages we've already fetched
- Stack = pages to visit next (using the call stack here)
"""
domain = urlparse(start_url).netloc
visited = set()
pages_found = []
def visit(url):
if len(visited) >= max_pages:
return
if url in visited:
return
# Normalize URL and ensure we stay on the same domain
parsed = urlparse(url)
if parsed.netloc != domain:
return
visited.add(url)
pages_found.append(url)
try:
response = requests.get(url, timeout=5)
parser = LinkExtractor()
parser.feed(response.text)
for link in parser.links:
# Convert relative URLs to absolute
absolute = urljoin(url, link)
visit(absolute) # DFS: go deep before coming back
except Exception:
pass # Skip pages that fail to load
visit(start_url)
return pages_found
# Usage: crawl_domain("https://example.com")
Notice the structure: it's the exact same pattern as basic DFS β visit node, mark visited, recurse on unvisited neighbors. The "graph" is implicit (defined by hyperlinks rather than an adjacency list), but the algorithm is identical.
sys.setrecursionlimit() or use iterative DFS. In interviews, mention this tradeoff β it shows you think about practical constraints, not just theory.
| Problem | Difficulty | Pattern | Link |
|---|---|---|---|
| Number of Islands | Medium | Grid DFS / flood fill | LC #200 |
| Clone Graph | Medium | DFS + hash map | LC #133 |
| Course Schedule | Medium | Cycle detection (directed) | LC #207 |
| Course Schedule II | Medium | Topological sort | LC #210 |
| Path Sum | Easy | Tree DFS | LC #112 |
| Surrounded Regions | Medium | Border DFS | LC #130 |
| Word Search | Medium | Backtracking DFS | LC #79 |
| Max Area of Island | Medium | Grid DFS + tracking size | LC #695 |
| Critical Connections in a Network | Hard | Tarjan's bridges | LC #1192 |