DFS is the default traversal: it is shorter than BFS, uses less memory on deep-and-narrow structures, and the recursion usually mirrors the problem's own shape. Reach for it whenever the question is about whether things are connected, or about exploring everything, rather than about the shortest route.
The idea
Go as deep as possible along one path, then back up and try the next. Recursion gives you the backing-up for free — the call stack is the path you took to get here.
def dfs(node, seen):
if node in seen:
return
seen.add(node)
for nxt in neighbours(node):
dfs(nxt, seen)void dfs(Node node, Set<Node> seen) {
if (!seen.add(node)) return; // add() is false if already present
for (Node next : neighbours(node)) {
dfs(next, seen);
}
}void dfs(Node node, unordered_set<Node>& seen) {
if (!seen.insert(node).second) return; // false if already present
for (const Node& next : neighbours(node)) {
dfs(next, seen);
}
}function dfs(node, seen) {
if (seen.has(node)) return;
seen.add(node);
for (const next of neighbours(node)) {
dfs(next, seen);
}
}On a grid, "neighbours" is the four-direction sweep, and the bounds check doubles as the base case:
DIRECTIONS = ((1, 0), (-1, 0), (0, 1), (0, -1))
def flood(grid, r, c):
if not (0 <= r < len(grid) and 0 <= c < len(grid[0])):
return # off the board
if grid[r][c] != 1:
return # water, or already visited
grid[r][c] = 0 # mark, in place
for dr, dc in DIRECTIONS:
flood(grid, r + dr, c + dc)static final int[][] DIRECTIONS = {{1,0},{-1,0},{0,1},{0,-1}};
void flood(int[][] grid, int r, int c) {
// Bounds and validity both live at the TOP of the function, so the four
// call sites stay one line each.
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length) return;
if (grid[r][c] != 1) return; // water, or already visited
grid[r][c] = 0; // mark, in place
for (int[] d : DIRECTIONS) {
flood(grid, r + d[0], c + d[1]);
}
}const int DIRECTIONS[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
void flood(vector<vector<int>>& grid, int r, int c) {
if (r < 0 || r >= (int)grid.size() || c < 0 || c >= (int)grid[0].size()) return;
if (grid[r][c] != 1) return; // water, or already visited
grid[r][c] = 0; // mark, in place
for (auto& d : DIRECTIONS) {
flood(grid, r + d[0], c + d[1]);
}
}static const int DIRECTIONS[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
void flood(char** grid, int rows, int cols, int r, int c) {
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
if (grid[r][c] != '1') return; /* water, or already visited */
grid[r][c] = '0'; /* mark, in place */
for (int i = 0; i < 4; i++) {
flood(grid, rows, cols, r + DIRECTIONS[i][0], c + DIRECTIONS[i][1]);
}
}const DIRECTIONS = [[1,0],[-1,0],[0,1],[0,-1]];
function flood(grid, r, c) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length) return;
if (grid[r][c] !== 1) return; // water, or already visited
grid[r][c] = 0; // mark, in place
for (const [dr, dc] of DIRECTIONS) {
flood(grid, r + dr, c + dc);
}
}Pushing the bounds and validity checks into the top of the function rather than guarding before each recursive call is what keeps grid DFS short. Write it the other way and the call site grows four compound conditions.
Counting components
Number of Islands is the pattern's canonical form: sweep the grid, and every time you land on unvisited land, that is one new component — flood it so it is never counted again.
def num_islands(grid):
count = 0
for r in range(len(grid)):
for c in range(len(grid[0])):
if grid[r][c] == 1:
flood(grid, r, c)
count += 1
return countint numIslands(char[][] grid) {
int count = 0;
for (int r = 0; r < grid.length; r++) {
for (int c = 0; c < grid[0].length; c++) {
if (grid[r][c] == '1') {
flood(grid, r, c); // sink it so it is never counted twice
count++;
}
}
}
return count;
}int numIslands(vector<vector<char>>& grid) {
int count = 0;
for (int r = 0; r < (int)grid.size(); r++) {
for (int c = 0; c < (int)grid[0].size(); c++) {
if (grid[r][c] == '1') {
flood(grid, r, c); // sink it so it is never counted twice
count++;
}
}
}
return count;
}int numIslands(char** grid, int rows, int cols) {
int count = 0;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] == '1') {
flood(grid, rows, cols, r, c);
count++;
}
}
}
return count;
}function numIslands(grid) {
let count = 0;
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[0].length; c++) {
if (grid[r][c] === '1') {
flood(grid, r, c); // sink it so it is never counted twice
count++;
}
}
}
return count;
}The same skeleton answers Max Area of Island (return a size from the recursion instead
of counting), Number of Connected Components (the graph version), and Clone Graph
(carry a old → new map and return the copy).
Reversing the problem
Surrounded Regions and Pacific Atlantic Water Flow are the two that teach the most
useful idea in this pattern: **when the condition is hard to check going forwards, start from the exceptions instead.**
Surrounded Regions asks you to flip every region not touching the border. Testing each region for border-contact is fiddly; flooding inward from the border marks exactly the survivors, and everything left unmarked is by definition surrounded.
Pacific Atlantic asks which cells drain to both oceans. Simulating drainage from each cell is O((mn)²). Instead run DFS uphill from each ocean's edge — inverting the flow condition — and intersect the two reachable sets. One pass each.
def pacific_atlantic(heights):
rows, cols = len(heights), len(heights[0])
pacific, atlantic = set(), set()
def climb(r, c, seen, prev):
if (r, c) in seen or not (0 <= r < rows and 0 <= c < cols):
return
if heights[r][c] < prev: # water cannot flow uphill into here
return
seen.add((r, c))
for dr, dc in DIRECTIONS:
climb(r + dr, c + dc, seen, heights[r][c])
for c in range(cols):
climb(0, c, pacific, 0)
climb(rows - 1, c, atlantic, 0)
for r in range(rows):
climb(r, 0, pacific, 0)
climb(r, cols - 1, atlantic, 0)
return [list(cell) for cell in pacific & atlantic]List<List<Integer>> pacificAtlantic(int[][] heights) {
int rows = heights.length, cols = heights[0].length;
boolean[][] pacific = new boolean[rows][cols];
boolean[][] atlantic = new boolean[rows][cols];
for (int c = 0; c < cols; c++) {
climb(heights, pacific, 0, c, Integer.MIN_VALUE);
climb(heights, atlantic, rows - 1, c, Integer.MIN_VALUE);
}
for (int r = 0; r < rows; r++) {
climb(heights, pacific, r, 0, Integer.MIN_VALUE);
climb(heights, atlantic, r, cols - 1, Integer.MIN_VALUE);
}
List<List<Integer>> out = new ArrayList<>();
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (pacific[r][c] && atlantic[r][c]) out.add(List.of(r, c));
return out;
}
/** Runs UPHILL from the ocean edge — the inverted condition is the trick. */
private void climb(int[][] h, boolean[][] seen, int r, int c, int prev) {
if (r < 0 || r >= h.length || c < 0 || c >= h[0].length) return;
if (seen[r][c] || h[r][c] < prev) return;
seen[r][c] = true;
for (int[] d : new int[][]{{1,0},{-1,0},{0,1},{0,-1}}) {
climb(h, seen, r + d[0], c + d[1], h[r][c]);
}
}void climb(const vector<vector<int>>& h, vector<vector<bool>>& seen,
int r, int c, int prev) {
if (r < 0 || r >= (int)h.size() || c < 0 || c >= (int)h[0].size()) return;
if (seen[r][c] || h[r][c] < prev) return; // cannot flow uphill into here
seen[r][c] = true;
int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
for (auto& d : dirs) climb(h, seen, r + d[0], c + d[1], h[r][c]);
}
vector<vector<int>> pacificAtlantic(vector<vector<int>>& heights) {
int rows = heights.size(), cols = heights[0].size();
vector<vector<bool>> pacific(rows, vector<bool>(cols, false));
vector<vector<bool>> atlantic(rows, vector<bool>(cols, false));
for (int c = 0; c < cols; c++) {
climb(heights, pacific, 0, c, INT_MIN);
climb(heights, atlantic, rows - 1, c, INT_MIN);
}
for (int r = 0; r < rows; r++) {
climb(heights, pacific, r, 0, INT_MIN);
climb(heights, atlantic, r, cols - 1, INT_MIN);
}
vector<vector<int>> out;
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (pacific[r][c] && atlantic[r][c]) out.push_back({r, c});
return out;
}function pacificAtlantic(heights) {
const rows = heights.length, cols = heights[0].length;
const pacific = Array.from({length: rows}, () => new Array(cols).fill(false));
const atlantic = Array.from({length: rows}, () => new Array(cols).fill(false));
// Runs UPHILL from the ocean edge — the inverted condition is the trick.
const climb = (r, c, seen, prev) => {
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
if (seen[r][c] || heights[r][c] < prev) return;
seen[r][c] = true;
for (const [dr, dc] of [[1,0],[-1,0],[0,1],[0,-1]]) {
climb(r + dr, c + dc, seen, heights[r][c]);
}
};
for (let c = 0; c < cols; c++) {
climb(0, c, pacific, -Infinity);
climb(rows - 1, c, atlantic, -Infinity);
}
for (let r = 0; r < rows; r++) {
climb(r, 0, pacific, -Infinity);
climb(r, cols - 1, atlantic, -Infinity);
}
const out = [];
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
if (pacific[r][c] && atlantic[r][c]) out.push([r, c]);
return out;
}DFS with undo: Word Search
When a cell may be reused by a different path, marking is not permanent — you mark on the way in and unmark on the way out. That is backtracking, and Word Search is the crossover problem:
def exist(board, word):
def search(r, c, i):
if i == len(word):
return True
if not (0 <= r < len(board) and 0 <= c < len(board[0])):
return False
if board[r][c] != word[i]:
return False
board[r][c] = "#" # mark for THIS path only
found = any(search(r + dr, c + dc, i + 1) for dr, dc in DIRECTIONS)
board[r][c] = word[i] # undo
return found
return any(
search(r, c, 0) for r in range(len(board)) for c in range(len(board[0]))
)boolean exist(char[][] board, String word) {
for (int r = 0; r < board.length; r++)
for (int c = 0; c < board[0].length; c++)
if (search(board, word, r, c, 0)) return true;
return false;
}
private boolean search(char[][] board, String word, int r, int c, int i) {
if (i == word.length()) return true;
if (r < 0 || r >= board.length || c < 0 || c >= board[0].length) return false;
if (board[r][c] != word.charAt(i)) return false;
char saved = board[r][c];
board[r][c] = '#'; // mark for THIS path only
boolean found = search(board, word, r + 1, c, i + 1)
|| search(board, word, r - 1, c, i + 1)
|| search(board, word, r, c + 1, i + 1)
|| search(board, word, r, c - 1, i + 1);
board[r][c] = saved; // undo — a cell blocked here must be free elsewhere
return found;
}bool search(vector<vector<char>>& board, const string& word,
int r, int c, int i) {
if (i == (int)word.size()) return true;
if (r < 0 || r >= (int)board.size() || c < 0 || c >= (int)board[0].size())
return false;
if (board[r][c] != word[i]) return false;
char saved = board[r][c];
board[r][c] = '#'; // mark for THIS path only
bool found = search(board, word, r + 1, c, i + 1)
|| search(board, word, r - 1, c, i + 1)
|| search(board, word, r, c + 1, i + 1)
|| search(board, word, r, c - 1, i + 1);
board[r][c] = saved; // undo
return found;
}
bool exist(vector<vector<char>>& board, string word) {
for (int r = 0; r < (int)board.size(); r++)
for (int c = 0; c < (int)board[0].size(); c++)
if (search(board, word, r, c, 0)) return true;
return false;
}function exist(board, word) {
const search = (r, c, i) => {
if (i === word.length) return true;
if (r < 0 || r >= board.length || c < 0 || c >= board[0].length) return false;
if (board[r][c] !== word[i]) return false;
const saved = board[r][c];
board[r][c] = '#'; // mark for THIS path only
const found = search(r + 1, c, i + 1) || search(r - 1, c, i + 1)
|| search(r, c + 1, i + 1) || search(r, c - 1, i + 1);
board[r][c] = saved; // undo
return found;
};
for (let r = 0; r < board.length; r++)
for (let c = 0; c < board[0].length; c++)
if (search(r, c, 0)) return true;
return false;
}Getting this distinction right is most of what separates the DFS questions from each other: connectivity marks permanently, path search marks and undoes.
Complexity
O(V + E) — on a grid, O(rows × cols), since each cell is visited a constant number of
times. Space is O(V) worst case for the recursion stack, which on a full grid means the
stack can reach rows × cols deep. On very large grids that is a real stack-overflow
risk, and converting to an explicit stack is the fix worth naming.
Mistakes that cost the round
- Marking permanently in a path-search problem (or forgetting to undo), which makes
valid answers unreachable.
- Using DFS for a shortest path. It finds a path, not the shortest one.
- Recursing without a visited set on a cyclic graph — infinite recursion. Trees are
the exception, which is why tree DFS looks deceptively simple.
- Checking bounds at the call site in four separate conditions instead of at the top
of the function.
- Ignoring stack depth on grids the size of the constraints.
What to drill
- Number of Islands — count components by flooding.
- Clone Graph — DFS carrying a map.
- Surrounded Regions — flood from the border instead.
- Pacific Atlantic Water Flow — invert the condition, intersect two sweeps.
- Word Search — mark and undo.
All on the 22 DSA Patterns sheet.
Frequently asked
What is the difference between DFS and BFS?
DFS goes as deep as it can before backtracking, using a stack (usually the call stack); BFS expands level by level using a queue. BFS finds shortest paths on unweighted graphs and DFS does not, but DFS uses O(depth) space rather than O(width) and is usually shorter to write for connectivity and exhaustive-exploration problems.
Should I mark a cell visited permanently in DFS?
Permanently when you are measuring connectivity — islands, components, regions — because each cell belongs to exactly one answer. Mark and then undo when you are searching for a path, as in Word Search, because a cell blocked for the current path must be available to a different one.
Can DFS cause a stack overflow?
Yes. On a grid where every cell is connected, the recursion can reach rows × cols deep, which exceeds the default stack limit in many languages at typical LeetCode constraint sizes. Converting the recursion to an explicit stack is the standard fix and a good thing to mention unprompted.