Maze Search (Grid Backtracking)
Find a path from start to exit in a grid by recursively stepping into open neighbors, marking cells on the current path and unmarking on retreat.
Overview
Maze search is Depth-First Search (DFS) on an implicit grid graph, written as backtracking. From the current cell, try each of the four directions; if the neighbor is inside the grid, open, and not on the current path, step into it and recurse. If the recursion reaches the exit, propagate true and leave the path marks in place; otherwise unmark and try the next direction.
Two variants matter: find any path (mark visited permanently — each cell needs to be explored once, giving O(rows · cols)) versus enumerate all simple paths (un-mark on retreat — exponential, because a cell may be part of many different paths).
Intuition
A mental model before the formal terms.
You are in the maze with a piece of chalk. At each cell you mark the floor, then try up/right/down/left. When you hit a wall or a chalk-marked cell, turn to the next direction. When every direction fails, erase the chalk and step back. The chalk trail at the moment you reach the exit is the path.
For "any path", you keep the chalk even after retreating: a cell that led nowhere once will lead nowhere again, so never re-enter it.
How it works
- Base cases: out of bounds, wall, or already on the path →
false. Cell equals the target →true. - Mark the cell as on the path and append it to
path. - For each direction
(dr, dc)in[(1,0),(0,1),(-1,0),(0,-1)], recurse on(r+dr, c+dc); if it returnstrue, returntrueimmediately. - None succeeded: pop the cell from
path, unmark (for all-paths) or leave marked (for any-path), and returnfalse.
Why it works
The marking guarantees the current path is simple (no repeated cells), so the recursion depth is bounded by the number of cells and terminates.
For any-path: if a cell cannot reach the exit via cells not on the current path, it also cannot reach it later — the set of unexplored cells only shrinks — so permanent marking loses nothing. This is exactly the DFS visited-set argument.
For all-paths: unmarking restores the state so that sibling directions explore the same cell through a different prefix, which is required because a cell may lie on many distinct simple paths.
Recognition
How to tell a problem wants this.
- 2D grid of open/blocked cells; "is there a path", "print a path", "count all paths".
- 4-directional (or 8-directional) movement with an obstacle predicate.
- If the question is shortest path, this is the wrong tool — see BFS Shortest Path (Unweighted).
Interactive visualization
Play, step, change the input. ← → and space work too.
1dfs(r, c):2 if out of bounds or wall or visited: return false3 visited[r][c] = true; path.push((r,c))4 if (r,c) == T: return true5 for each direction: if dfs(next): return true6 path.pop(); return false // backtrackPseudocode
1dfs(r, c):2 if out of bounds or wall or onPath[r][c]: return false3 path.push((r, c)); onPath[r][c] = true4 if (r, c) == target: return true5 for (dr, dc) in DIRS:6 if dfs(r + dr, c + dc): return true7 path.pop(); onPath[r][c] = false # keep true for any-path variant8 return falseImplementations
11 · Direction vectors2DIRS = [(1, 0), (0, 1), (-1, 0), (0, -1)]3 4 5def find_path(grid: list[list[int]], start: tuple[int, int],6 target: tuple[int, int]) -> list[tuple[int, int]] | None:7 """grid[r][c] == 0 open, 1 wall. Returns one start-to-target path or None."""8 rows, cols = len(grid), len(grid[0])9 visited = [[False] * cols for _ in range(rows)]10 path: list[tuple[int, int]] = []11 12 def dfs(r: int, c: int) -> bool:132 · Reject out-of-bounds, walls, and revisits14 if not (0 <= r < rows and 0 <= c < cols):15 return False16 if grid[r][c] == 1 or visited[r][c]:17 return False183 · Choose: mark the cell and extend the path19 visited[r][c] = True # permanent: any-path variant20 path.append((r, c))21 if (r, c) == target:22 return True234 · Explore the four neighbors24 for dr, dc in DIRS:25 if dfs(r + dr, c + dc):26 return True275 · Un-choose: drop the cell from the path28 path.pop() # visited stays True: dead cell29 return False30 31 return path if dfs(*start) else None- The chained comparison
0 <= r < rows and 0 <= c < colsis the idiomatic bounds check. visitedis built with a list comprehension — one independent row list per row.- Section 3 marks the cell permanently and appends to
path; tuples compare by value, so(r, c) == targetjust works. - Section 4 tries the four neighbors in order and short-circuits on the first success, leaving the successful route in
path. - Section 5 pops the failed cell but keeps
visited[r][c]True: a cell that could not reach the target once never can. dfs(*start)unpacks the start tuple into the two positional arguments.
CPython's default recursion limit is 1000, which a ~32×32 grid can exceed on a snaking path.
- Raise the limit with
sys.setrecursionlimit(rows * cols + 100)for larger grids, or rewrite with an explicit list-based stack (no limit, and faster). [[False] * cols for _ in range(rows)]is correct;[[False] * cols] * rowsaliases one row object rows times.- Tuples are hashable, so a
set[tuple[int, int]]is a drop-in alternative to the boolean matrix.
- Building the visited matrix with
*on the outer list, so marking one row marks them all. - Recursing on big grids without raising the recursion limit —
RecursionErrorat depth 1000. - Unmarking visited on retreat when only one path is needed, blowing up to exponential time.
- Out-of-bounds indexing: C++ is undefined behavior (check first, always); JS/TS yield
undefined; Python raises IndexError — and negative indices silently wrap, so the explicit range check is still required. - Recursion depth can reach R·C: Python hits its 1000-frame default limit first (
sys.setrecursionlimitor an explicit stack); JS/TS and C++ fail later but just as hard. - Coordinate equality: Python tuples compare by value; C++
std::pairhasoperator==; JS/TS arrays compare by reference, so coordinates are compared element-wise.
Complexity
Any-path with permanent visited marks touches each cell once. Enumerating all simple paths is exponential in grid size. Recursion depth can reach R·C — iterate with an explicit stack for large grids.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Existence or one example of a path in a grid with obstacles.
- Enumerating or counting all simple paths on a small grid.
- Flood fill / connected regions (Connected Components on a grid) where DFS order is irrelevant.
- Shortest path in an unweighted grid — DFS returns an arbitrary path; use BFS Shortest Path (Unweighted).
- Weighted cells — use Dijkstra's Algorithm or A* Search.
- Counting paths on a grid with only right/down moves — that has overlapping subproblems and is Grid DP,
O(R · C)instead of exponential.
Alternatives
Common mistakes
- Forgetting the bounds check before indexing
grid[r][c]. - Unmarking visited in the any-path variant, turning
O(RC)into exponential time. - Not unmarking in the all-paths variant, missing most paths.
- Marking the cell visited after the recursive calls instead of before, causing infinite recursion between two adjacent cells.
- Stack overflow on large grids (
10^6cells) — use an explicit stack.
Interview patterns
- Rat in a Maze (return one path / all paths as direction strings).
- Number of Islands and flood fill: DFS with permanent marking, counted per start.
- Unique Paths III: all paths that visit every open cell exactly once — backtracking with a remaining-cells counter.
- Choosing between BFS, DFS, Dijkstra and DPAdvanced
- Stack versus queueBeginner
- Recursion versus iterationIntermediate
- When space complexity mattersIntermediate
- Course ScheduleIntermediate
- Number of IslandsIntermediate
- Word SearchAdvanced