Understand
30-second explanation
Build the interview-ready mental model before comparing implementations.
Count the ways to walk from the top-left to the bottom-right of an m×n grid, moving only right or down, where some cells are blocked. It's a grid DP: dp[i][j] = number of paths reaching cell (i, j). A blocked cell has 0 paths; otherwise dp[i][j] = dp[i-1][j] + dp[i][j-1] (paths arriving from above plus from the left). Seed dp[0][0] = 1 only if the start is free. O(m·n) time, reducible to O(n) space. The trap is an obstacle on the start or in the first row/column — it zeroes out everything downstream of it.
Interview contextWhat is testedWhere candidates slipHow to stand outExplore interview contextHide interview context
What's being tested
Whether you set up dp[i][j] = dp[i-1][j] + dp[i][j-1], force blocked cells to 0, seed the start correctly, and handle obstacles in the first row/column that zero out the rest of that line.
Where candidates slip
Hard-coding dp[0][0] = 1 without checking the start is free, initializing the whole first row/column to 1 ignoring obstacles, or counting a blocked cell as passable.
How to stand out
State the move constraint that gives the recurrence (right/down → from above or left), explain why a blocked first-row cell zeroes everything after it, then offer the O(n) rolling-row compression and note the no-obstacle closed form.
Choose
Compare the available approaches
Use constraints and trade-offs to select the route you can defend in an interview.
Interactive decision treeInteractiveChoose the right approachFirst decision previewCount top-left to bottom-right pathsAny obstacles?NoYesExplore the full treeCollapse the tree
Learn & trace
Build each solution step by step
Move from the core idea to a visual trace, implementation, proof, and edge cases.
Approach 1 of 3
Brute Force — Recursion Over Right/Down Choices
1Hint 1Explore hintHide hint
At each free cell you can go right or down. How many ways reach a given cell? Sum the ways that reach the cells you could have stepped from.
2Hint 2Explore hintHide hint
Define solve(i, j) = number of paths from the start to cell (i, j). It comes from the cell above and the cell to the left.
3Hint 3Explore hintHide hint
Base cases: a blocked cell has 0 paths; the start (0,0) has 1 if free. Out-of-bounds predecessors contribute 0.
A · Core idea
Define solve(i, j) = the number of distinct right/down paths from the start (0, 0) to cell (i, j).
- If
(i, j)is blocked, no path can be on it → 0. - If
(i, j)is the start and it's free → 1 (the empty path). - Otherwise sum the paths arriving from the only two cells you could have stepped from: above
solve(i-1, j)(ifi > 0) and leftsolve(i, j-1)(ifj > 0).
Why the recurrence sums exactly two predecessors. The robot moves only right or down, so the last step onto (i, j) was either a downward step from (i-1, j) or a rightward step from (i, j-1). Every path to (i, j) ends with one of those two steps, and the two sets are disjoint (they differ in the final move), so the count is the sum of the counts to those predecessors. This 'sum the ways into my predecessors' is the defining shape of a counting DP on a DAG.
Why blocked cells are a hard zero. A blocked cell can never be stood on, so zero paths reach it and it forwards zero to its successors. Returning 0 there automatically routes the count around obstacles — no special-casing of neighbours needed.
Why boundaries contribute 0, not 1. A cell in the top row has no cell above it; a cell in the left column has none to its left. Treating those missing predecessors as 0 (rather than skipping the base case) keeps the recurrence uniform. Only the start seeds a 1.
Why we don't ship this. solve(i-1, j) and solve(i, j-1) both recurse into solve(i-1, j-1), and so on, so the same cell is recomputed exponentially often. Caching solve(i, j) collapses it to O(m·n).
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
grid = [[0,0],[0,0]] (no obstacles, 2×2)Live grid state
Cells change as the traversal moves
solve(0,0)free start
2C · Code with teaching comments
Reference implementation previewpublic int uniquePathsWithObstacles(int[][] grid) { int m = grid.length, n = grid[0].length; return solve(grid, m - 1, n - 1); } Explore the full codeHide the full code
D · Why it works and complexityO(2^(m+n)) timeO(m + n) spaceOpen the invariant and proofExplore the proofHide the proof
The state is the cell (i, j) and the recursion revisits the same cell through many path prefixes. A 2-D grid state plus exponential overlap is the textbook signal for an O(m·n) DP.
ComplexityO(2^(m+n)) time, O(m + n) space — Each cell branches into its two predecessors and those branches overlap heavily; the unmemoized tree is exponential in m + n. Recursion depth is O(m + n).
E · Edge cases and pitfalls5blocked start — grid[0][0] == 1 → solve returns 0 immediately.Explore edge casesHide edge cases
Edge cases handled
blocked startgrid[0][0] == 1 → solve returns 0 immediately.1×1 free gridStart equals end and is free → 1 path.
Pitfalls
- Returning 1 for out-of-bounds predecessors instead of 0 — over-counts along the borders.
- Forgetting the obstacle check before the start check — a blocked start would wrongly return 1.
- Skipping this approach — the 'sum of two predecessors' recurrence is what the interviewer wants verbalized.
Approach 2 of 3
Top-Down Memoization on (i, j)
1Hint 1Explore hintHide hint
solve(i, j) returns the same count every call. Where can you store it so each cell is computed once?
2Hint 2Explore hintHide hint
Use a 2-D memo of size m × n. Mark cells 'uncomputed' with -1 (real counts are ≥ 0), check on entry, store before returning.
3Hint 3Explore hintHide hint
There are O(m·n) cells and each does O(1) work → O(m·n) time and space.
A · Core idea
Keep the 'sum of two predecessors' recurrence but cache solve(i, j) so each cell is computed once.
The mental shift: the count into a cell is fixed. However the recursion reaches (i, j), the number of paths from the start to that cell is a single value. Caching by (i, j) removes all the duplicated subtrees, leaving O(m·n) distinct cells each resolved in O(1).
Sentinel choice. Initialise the memo to -1 for 'uncomputed' — real path counts are non-negative, and a free start legitimately returns 1, so 0 can't double as 'empty'. Blocked cells return 0 before any caching.
A language note (Java memo vs Python tabulation). This recursion nests up to O(m + n) deep. For the 100×100 bound that's ~200 — fine in both languages. But for very large grids the depth could approach Python's default recursion limit of about 1000, at which point you'd raise the limit with sys.setrecursionlimit or, cleaner, switch to the bottom-up table (next approach) that uses no stack at all. Top-down memoization is the natural default in Java; bottom-up is the safer default in Python once grids get tall or wide.
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
grid = [[0,0,0],[0,1,0],[0,0,0]]Live grid state
Cells change as the traversal moves
top row & left colall reachable cells cache 1 (single direction in)
2C · Code with teaching comments
Reference implementation previewpublic int uniquePathsWithObstacles(int[][] grid) { int m = grid.length, n = grid[0].length; Integer[][] memo = new Integer[m][n]; return solve(grid, m - 1, n - 1, memo); }Explore the full codeHide the full code
D · Why it works and complexityO(m·n) timeO(m·n) spaceOpen the invariant and proofExplore the proofHide the proof
Standard DP upgrade: the correct recurrence plus a cache keyed on the cell (i, j). The thinking — two predecessors, obstacle = 0, free start = 1 — was done in the brute force.
ComplexityO(m·n) time, O(m·n) space — There are m·n distinct (i, j) states; each combines two cached sub-results in O(1) → O(m·n) time. The memo is O(m·n); recursion depth is O(m + n) on top.
E · Edge cases and pitfalls5obstacle blocking all routes — Every path to the end passes a blocked cell → end cell caches 0.Explore edge casesHide edge cases
Edge cases handled
obstacle blocking all routesEvery path to the end passes a blocked cell → end cell caches 0.large grid in PythonDepth m+n may near the recursion limit; raise it or use the table.
Pitfalls
- Using 0 as the sentinel — a free start returns 1 and blocked cells return 0, so 0 is a real value; use null/-1.
- Checking the start base case before the obstacle guard — a blocked start would wrongly return 1.
- Deep recursion in Python on tall/wide grids — tabulation avoids the stack.
Approach 3 of 3 · Optimal
Bottom-Up — Grid Table, Rolling Row (Optimal)
1Hint 1Explore hintHide hint
Let dp[i][j] = number of paths to cell (i, j). Fill row by row, left to right, so the cell above and the cell to the left are already done.
2Hint 2Explore hintHide hint
Seed dp[0][0] = 1 if the start is free, else 0. A blocked cell sets dp[i][j] = 0; otherwise dp[i][j] = up + left (treat off-grid as 0).
3Hint 3Explore hintHide hint
Since row i depends only on row i-1 and the cell to its left, keep a single rolling row of length n → O(n) space. Answer is the last cell.
A · Core idea
Let dp[i][j] be the number of right/down paths from the start to cell (i, j). Sweep the grid top to bottom, left to right, so both predecessors are already computed when you reach a cell. The answer is dp[m-1][n-1].
Seeding the start. dp[0][0] = 1 only if (0, 0) is free; if the start is blocked it's 0 and the whole grid stays 0. This single check is the most-missed detail — hard-coding dp[0][0] = 1 reports phantom paths through a blocked start.
The fill rule. For every other cell: if it's blocked, dp[i][j] = 0; otherwise dp[i][j] = up + left, where up = dp[i-1][j] (0 if i == 0) and left = dp[i][j-1] (0 if j == 0). Treating off-grid predecessors as 0 makes the first row and first column fall out naturally — each has just one incoming direction.
Why obstacles in the first row/column are sharp. In the top row a cell's only predecessor is its left neighbour; once an obstacle sets a cell to 0, every cell to its right inherits left = 0 and stays 0. The first column behaves the same vertically. Initializing borders to a blanket 1 (a common bug) ignores this and over-counts.
Why the sweep order is valid. dp[i][j] reads only dp[i-1][j] (row above, already filled) and dp[i][j-1] (same row, already filled this pass). Top-to-bottom, left-to-right satisfies both — no recursion, so Python's stack limit is irrelevant.
Space compression. Because row i depends only on row i-1 and the current row's left cell, a single rolling array of length n suffices: when you process cell j, row[j] still holds the value from the row above (the 'up') and row[j-1] holds the freshly updated 'left'. Set row[j] = 0 for obstacles. That drops space from O(m·n) to O(n).
No-obstacle aside. Without any blocks the answer is the binomial C(m+n-2, m-1) — worth mentioning, but obstacles break the closed form, which is exactly why DP earns its place.
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
grid = [[0,0,0],[0,1,0],[0,0,0]]Live grid state
Cells change as the traversal moves
row 0[1, 1, 1] (all free, left-propagated)
2C · Code with teaching comments
D · Why it works and complexityO(m·n) timeO(n) with a rolling row spaceOpen the invariant and proofExplore the proofHide the proof
This is the canonical grid counting DP: each cell sums its in-edges (up + left), a blocked cell is a 0, fill in dependency order, then compress to one row. The same skeleton powers Unique Paths (no obstacles), Minimum Path Sum (min instead of sum), and Dungeon Game (reverse sweep).
ComplexityO(m·n) time, O(n) with a rolling row space — Filling m·n cells with O(1) work each is O(m·n) time. Each row needs only the previous row's values, so one rolling array of length n gives O(n) space.
E · Edge cases and pitfalls6blocked start — row[0] seeded to 0 → entire grid stays 0 → answer 0.Explore edge casesHide edge cases
Edge cases handled
blocked startrow[0] seeded to 0 → entire grid stays 0 → answer 0.obstacle across an entire rowNo cell below is reachable from above past that row → end cell 0.1×n or m×1 gridSingle line of cells; any obstacle blocks the only path → 0, else 1.
Pitfalls
- Seeding dp[0][0] = 1 without checking the start is free.
- Initializing the whole first row/column to 1 — ignores obstacles that should zero the rest of the line.
- Adding the left neighbour into a blocked cell instead of forcing it to 0 — leaks paths through walls.
Practice
Try the solution yourself
Edit the optimal implementation, test another input, and observe the result.
Editable code previewpublic int uniquePathsWithObstacles(int[][] grid) { int m = grid.length, n = grid[0].length; long[] row = new long[n]; row[0] = (grid[0][0] == 0) ? 1 : 0; // seed only if start is free for (int j = 1; j < n; j++) {Explore the playgroundClose the playground
Avoid mistakes
Recognize the interview traps
Understand why common attempts fail before reviewing the corrected implementation.
1Unconditional start seed
If grid[0][0] is an obstacle there are zero paths, but hard-coding dp[0][0] = 1 reports phantom routes that start on a wall.
Explore the code comparisonHide the code comparison
Unconditional start seed
If grid[0][0] is an obstacle there are zero paths, but hard-coding dp[0][0] = 1 reports phantom routes that start on a wall.
Don't write this
dp[0][0] = 1 # ignores a blocked startWrite this instead
dp[0][0] = 1 if grid[0][0] == 0 else 02Blanket first row/column = 1
The first row/column have one incoming direction, so an obstacle there zeroes every later cell on that line. Setting them all to 1 over-counts paths that actually run into a wall.
Explore the code comparisonHide the code comparison
Blanket first row/column = 1
The first row/column have one incoming direction, so an obstacle there zeroes every later cell on that line. Setting them all to 1 over-counts paths that actually run into a wall.
Don't write this
for (int j = 0; j < n; j++) dp[0][j] = 1; // ignores obstacles in row 0Write this instead
dp[0][0] = grid[0][0] == 0 ? 1 : 0;
for (int j = 1; j < n; j++) {
dp[0][j] = grid[0][j] == 1 ? 0 : dp[0][j - 1];
}3Blocked cell still sums predecessors
If you compute up + left for an obstacle cell, paths leak through the wall and propagate. A blocked cell must be a hard 0 regardless of its neighbours.
Explore the code comparisonHide the code comparison
Blocked cell still sums predecessors
If you compute up + left for an obstacle cell, paths leak through the wall and propagate. A blocked cell must be a hard 0 regardless of its neighbours.
Don't write this
row[j] = row[j] + row[j - 1] # runs even when grid[i][j] == 1Write this instead
if grid[i][j] == 1:
row[j] = 0
else:
row[j] = row[j] + row[j - 1]Revise
Carry the pattern into the interview
Pattern to remember
Grid DP — paths into a cell = paths from above + paths from the left
Code pattern to memorize
if grid[i][j] is blocked: dp[i][j] = 0
elif i == 0 and j == 0: dp[i][j] = 1
else: dp[i][j] = (i>0 ? dp[i-1][j] : 0) + (j>0 ? dp[i][j-1] : 0)
answer = dp[m-1][n-1]Rules to remember
- 1Moves are right or down only — so any path into
(i, j)arrives from(i-1, j)(above) or(i, j-1)(left). - 2A blocked cell contributes 0 paths; its
dpis forced to 0 and it passes nothing on. - 3Seed the start:
dp[0][0] = 1only if(0, 0)is free — a blocked start means zero total paths. - 4First row / first column have a single incoming direction; an obstacle there zeroes every cell after it along that line.
- 5Each row depends only on the row above and the cell to the left, so you can compress to one rolling row → O(n) space.
Use this pattern when
- Count (not find) the lattice paths in a grid with right/down (or monotone) moves
- Cells or edges can be blocked and you must route around them
- A DAG of grid cells where each cell's value sums its predecessors
- Combinatorial path counting that obstacles make non-closed-form
Choose another pattern when
- No obstacles and you just need the count → closed form C(m+n-2, m-1), no DP
- You can move in 4 directions / revisit cells → it's a graph traversal, not a DAG DP
- You need the shortest or minimum-cost path → BFS / Dijkstra / min-path-sum DP, not counting
- Path count overflows → use a big integer or take a modulus
Interview takeaway
Count Paths with Obstacles is the entry-level grid DP: every cell sums the paths from above and from the left, a blocked cell is a hard 0, and the start must be free to count at all. Watch the first row and column — one obstacle there cuts off everything downstream. The same dp underlies Unique Paths, Minimum Path Sum, and Dungeon Game.
Continue
Transfer the pattern to another problem
Use a direct variation or pattern sibling to prove the idea is reusable.
Transferable patternCanonical grid counting DP on a right/down DAG. Recognise it when you count monotone lattice paths and cells can be blocked: each free cell sums the paths from above and from the left, a blocked cell is 0, and the free start seeds 1. Compress to one rolling row. Same skeleton as Unique Paths, Minimum Path Sum, and Dungeon Game.
Practice queueDirect variations and pattern siblings
Direct follow-ups
- Unique PathsNo obstacles — same DP, or the closed form C(m+n-2, m-1).
- Minimum Path SumSame grid skeleton, but take min of up/left and add the cell's cost instead of summing counts.
- Dungeon GameComing soonRight/down grid DP filled in reverse — track minimum health needed to survive.
- Unique Paths IIIComing soonMust visit every free cell with 4-directional moves — switches to backtracking, not a simple DP.