Files
codetutor/backend/data/questions/unique-paths-ii.yaml

257 lines
12 KiB
YAML

title: Unique Paths II
slug: unique-paths-ii
difficulty: medium
leetcode_id: 63
leetcode_url: https://leetcode.com/problems/unique-paths-ii/
categories:
- arrays
- dynamic-programming
patterns:
- slug: dynamic-programming
is_optimal: false
- slug: matrix-traversal
is_optimal: true
function_signature: "def unique_paths_with_obstacles(obstacle_grid: list[list[int]]) -> int:"
test_cases:
visible:
- input: { obstacle_grid: [[0, 0, 0], [0, 1, 0], [0, 0, 0]] }
expected: 2
- input: { obstacle_grid: [[0, 1], [0, 0]] }
expected: 1
- input: { obstacle_grid: [[0, 0], [0, 0]] }
expected: 2
hidden:
- input: { obstacle_grid: [[1]] }
expected: 0
- input: { obstacle_grid: [[0]] }
expected: 1
- input: { obstacle_grid: [[1, 0]] }
expected: 0
- input: { obstacle_grid: [[0, 0, 0, 0], [0, 0, 0, 0]] }
expected: 4
- input: { obstacle_grid: [[0, 1, 0, 0, 0], [1, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]] }
expected: 0
- input: { obstacle_grid: [[0, 0, 0], [0, 0, 0], [0, 0, 1]] }
expected: 0
description: |
You are given an `m x n` integer array `grid`. There is a robot initially located at the **top-left corner** (i.e., `grid[0][0]`). The robot tries to move to the **bottom-right corner** (i.e., `grid[m - 1][n - 1]`). The robot can only move either down or right at any point in time.
An obstacle and space are marked as `1` or `0` respectively in `grid`. A path that the robot takes cannot include **any** square that is an obstacle.
Return *the number of possible unique paths that the robot can take to reach the bottom-right corner*.
The testcases are generated so that the answer will be less than or equal to `2 * 10^9`.
constraints: |
- `m == obstacleGrid.length`
- `n == obstacleGrid[i].length`
- `1 <= m, n <= 100`
- `obstacleGrid[i][j]` is `0` or `1`
examples:
- input: "obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]"
output: "2"
explanation: "There is one obstacle in the middle of the 3x3 grid. There are two ways to reach the bottom-right corner: Right -> Right -> Down -> Down, or Down -> Down -> Right -> Right."
- input: "obstacleGrid = [[0,1],[0,0]]"
output: "1"
explanation: "There is one obstacle blocking the top row. The only path is Down -> Right."
explanation:
intuition: |
Imagine you're navigating a city grid where some intersections are blocked by construction. You start at the top-left and need to reach the bottom-right, but you can only travel right or down (no backtracking).
This is a classic **counting problem** with a key insight: the number of ways to reach any cell equals the sum of ways to reach the cells directly above it and to its left. Why? Because those are the only two directions you could have come from.
Think of it like this: if there are 3 ways to reach the cell above you, and 2 ways to reach the cell to your left, then there are exactly 3 + 2 = 5 ways to reach your current cell. This is the **principle of addition** in combinatorics.
The obstacle twist adds one modification: if a cell contains an obstacle, there are **zero** ways to reach it (and zero ways to pass through it). This "blocks" all paths that would have gone through that cell.
approach: |
We solve this using **Dynamic Programming** with a 2D table:
**Step 1: Handle edge cases**
- If the starting cell `grid[0][0]` is an obstacle, return `0` immediately (no paths exist)
- If the destination cell `grid[m-1][n-1]` is an obstacle, return `0`
&nbsp;
**Step 2: Create a DP table**
- `dp[i][j]`: Number of unique paths to reach cell `(i, j)`
- Initialise `dp[0][0] = 1` (one way to "reach" the starting position: start there)
&nbsp;
**Step 3: Fill the first row**
- For each cell in the first row, you can only arrive from the left
- If there's no obstacle: `dp[0][j] = dp[0][j-1]`
- If there's an obstacle: `dp[0][j] = 0` (and all cells to the right become unreachable)
&nbsp;
**Step 4: Fill the first column**
- For each cell in the first column, you can only arrive from above
- If there's no obstacle: `dp[i][0] = dp[i-1][0]`
- If there's an obstacle: `dp[i][0] = 0` (and all cells below become unreachable)
&nbsp;
**Step 5: Fill the rest of the table**
- For each cell `(i, j)` where `i > 0` and `j > 0`:
- If it's an obstacle: `dp[i][j] = 0`
- Otherwise: `dp[i][j] = dp[i-1][j] + dp[i][j-1]` (sum of paths from above and left)
&nbsp;
**Step 6: Return the answer**
- Return `dp[m-1][n-1]`, the number of paths to the bottom-right corner
common_pitfalls:
- title: Forgetting to Check the Start and End Cells
description: |
If the starting cell `grid[0][0]` or ending cell `grid[m-1][n-1]` contains an obstacle, the answer is immediately `0`. Many solutions forget this check and proceed to fill the DP table incorrectly.
Always handle these edge cases upfront before any DP logic.
wrong_approach: "Start filling DP table without checking start/end obstacles"
correct_approach: "Check grid[0][0] and grid[m-1][n-1] first, return 0 if either is blocked"
- title: Not Propagating Zero Through First Row/Column
description: |
Once you encounter an obstacle in the first row, **all cells to its right** become unreachable (since you can only come from the left). The same applies to the first column going downward.
For example, with `[[0, 0, 1, 0]]`, after the obstacle at index 2, indices 3 and beyond have `0` paths, not `1`.
A common mistake is to reset the count only for the obstacle cell itself, allowing subsequent cells to incorrectly inherit paths.
wrong_approach: "Only set obstacle cell to 0, continue counting for cells after it"
correct_approach: "Once an obstacle appears in row 0 or column 0, all subsequent cells in that row/column have 0 paths"
- title: Confusing Obstacle Value with Path Count
description: |
The grid uses `1` to mark obstacles and `0` for open spaces. The DP table uses numbers representing *path counts*. Don't confuse these two meanings.
A cell with `grid[i][j] = 0` (no obstacle) might have `dp[i][j] = 5` (five paths reach it).
wrong_approach: "Mixing up grid values with DP table values"
correct_approach: "Grid indicates obstacles (0/1); DP table counts paths (0 to 2*10^9)"
key_takeaways:
- "**DP for counting**: When asked to count paths/combinations, DP often applies. The number of ways to reach a state equals the sum of ways to reach predecessor states."
- "**Obstacle handling**: Obstacles act as 'zero propagators' — they block all paths through them, which can cascade through dependent cells."
- "**Space optimisation possible**: Since each row only depends on the current and previous row, you can reduce space from O(m*n) to O(n) using a 1D array."
- "**Foundation for harder problems**: This pattern extends to problems with different movement rules, multiple obstacles, or weighted paths."
time_complexity: "O(m * n). We visit each cell in the grid exactly once to compute its path count."
space_complexity: "O(m * n) for the standard DP solution. Can be optimised to O(n) by using a single row array, since each cell only depends on the cell above and to the left."
solutions:
- approach_name: Dynamic Programming (2D Table)
is_optimal: true
code: |
def unique_paths_with_obstacles(obstacle_grid: list[list[int]]) -> int:
m, n = len(obstacle_grid), len(obstacle_grid[0])
# If start or end is blocked, no paths exist
if obstacle_grid[0][0] == 1 or obstacle_grid[m - 1][n - 1] == 1:
return 0
# dp[i][j] = number of unique paths to reach cell (i, j)
dp = [[0] * n for _ in range(m)]
# Starting position: one way to be here (start here)
dp[0][0] = 1
# Fill first column: can only come from above
for i in range(1, m):
if obstacle_grid[i][0] == 1:
dp[i][0] = 0 # Blocked, and all below become 0
else:
dp[i][0] = dp[i - 1][0]
# Fill first row: can only come from the left
for j in range(1, n):
if obstacle_grid[0][j] == 1:
dp[0][j] = 0 # Blocked, and all to the right become 0
else:
dp[0][j] = dp[0][j - 1]
# Fill the rest: sum of paths from above and left
for i in range(1, m):
for j in range(1, n):
if obstacle_grid[i][j] == 1:
dp[i][j] = 0 # Obstacle: no paths through here
else:
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[m - 1][n - 1]
explanation: |
**Time Complexity:** O(m * n) — We iterate through every cell once.
**Space Complexity:** O(m * n) — We store a full 2D DP table.
This solution builds up the path counts from the starting cell to every reachable cell. Each cell's value represents the total number of unique paths that can reach it, considering obstacles that block certain routes.
- approach_name: Space-Optimised DP (1D Array)
is_optimal: true
code: |
def unique_paths_with_obstacles(obstacle_grid: list[list[int]]) -> int:
m, n = len(obstacle_grid), len(obstacle_grid[0])
# If start or end is blocked, no paths exist
if obstacle_grid[0][0] == 1 or obstacle_grid[m - 1][n - 1] == 1:
return 0
# dp[j] = number of paths to reach column j in the current row
dp = [0] * n
dp[0] = 1 # Starting position
for i in range(m):
for j in range(n):
if obstacle_grid[i][j] == 1:
dp[j] = 0 # Blocked cell
elif j > 0:
# dp[j] already has value from row above (paths from top)
# Add dp[j-1] for paths from the left
dp[j] += dp[j - 1]
# If j == 0 and no obstacle, dp[j] keeps its value from above
return dp[n - 1]
explanation: |
**Time Complexity:** O(m * n) — Same iteration through all cells.
**Space Complexity:** O(n) — Only a single row is stored.
This optimisation works because when computing row `i`, we only need values from row `i-1` (stored in `dp[j]` before update) and the current row to the left (`dp[j-1]` after update). By processing left-to-right, `dp[j]` naturally transitions from "paths from above" to "total paths to this cell".
- approach_name: Brute Force (Recursion)
is_optimal: false
code: |
def unique_paths_with_obstacles(obstacle_grid: list[list[int]]) -> int:
m, n = len(obstacle_grid), len(obstacle_grid[0])
def count_paths(i: int, j: int) -> int:
# Out of bounds or obstacle
if i >= m or j >= n or obstacle_grid[i][j] == 1:
return 0
# Reached destination
if i == m - 1 and j == n - 1:
return 1
# Sum paths going right and going down
return count_paths(i, j + 1) + count_paths(i + 1, j)
return count_paths(0, 0)
explanation: |
**Time Complexity:** O(2^(m+n)) — Exponential due to overlapping subproblems.
**Space Complexity:** O(m + n) — Recursion stack depth.
This naive recursive approach explores every possible path by trying both directions at each cell. Without memoisation, it recomputes the same subproblems many times, making it impractical for grids larger than about 10x10. Included to show why DP is essential.