576. Out of Boundary Paths - Explanation

Problem Link



Prerequisites

Before attempting this problem, you should be comfortable with:

  • Recursion - The base solutions use recursive depth-first exploration of grid cells
  • Dynamic Programming (Memoization) - Caching overlapping subproblems with state (row, col, moves) is essential for efficiency
  • 2D Grid/Matrix Traversal - Moving in four directions and handling boundary conditions
  • Modular Arithmetic - Results must be computed modulo 10^9 + 7 to prevent overflow

1. Recursion

Intuition

We want to count all paths that start from a given cell and eventually move out of the grid, using at most maxMove moves. At each cell, we can move in four directions, and each move decrements our remaining moves.

The base cases are: if we step outside the grid, we found one valid path; if we run out of moves while still inside, this path does not count. We recursively explore all four directions and sum up the results.

Algorithm

  1. Define a recursive function dfs(r, c, moves) that returns the number of paths to exit from cell (r, c) with the given number of moves remaining.
  2. Base case 1: If (r, c) is outside the grid, return 1 (we found a valid exit path).
  3. Base case 2: If moves is 0, return 0 (no moves left, cannot exit).
  4. Recursively call dfs for all four neighbors with moves - 1.
  5. Return the sum of all four directions, modulo 10^9 + 7.
class Solution:
    def findPaths(self, m: int, n: int, maxMove: int, startRow: int, startColumn: int) -> int:
        ROWS, COLS = m, n
        MOD = 10**9 + 7

        def dfs(r, c, moves):
            if r < 0 or r >= ROWS or c < 0 or c >= COLS:
                return 1
            if moves == 0:
                return 0

            return (
                dfs(r + 1, c, moves - 1) +
                dfs(r - 1, c, moves - 1) +
                dfs(r, c + 1, moves - 1) +
                dfs(r, c - 1, moves - 1)
            ) % MOD

        return dfs(startRow, startColumn, maxMove)

Time & Space Complexity

  • Time complexity: O(4N)O(4 ^ N)
  • Space complexity: O(N)O(N)

Where mm is the number of rows, nn is the number of columns, and NN is the maximum number of allowed moves.


2. Dynamic Programming (Top-Down)

Intuition

The recursive solution has many overlapping subproblems since the same (row, col, moves) state can be reached through different paths. We can use memoization to cache results and avoid redundant computation.

Each state is defined by three parameters: current position (r, c) and remaining moves. Since there are m * n * maxMove possible states, memoization reduces the time complexity dramatically.

Algorithm

  1. Create a 3D cache indexed by (row, col, moves).
  2. Define dfs(r, c, moves):
    • If out of bounds, return 1.
    • If moves is 0, return 0.
    • If already computed, return the cached value.
    • Otherwise, compute the sum of all four directions and cache it.
  3. Return dfs(startRow, startColumn, maxMove).
class Solution:
    def findPaths(self, m: int, n: int, maxMove: int, startRow: int, startColumn: int) -> int:
        ROWS, COLS = m, n
        MOD = 10**9 + 7
        cache = {}

        def dfs(r, c, moves):
            if r < 0 or r >= ROWS or c < 0 or c >= COLS:
                return 1
            if moves == 0:
                return 0
            if (r, c, moves) in cache:
                return cache[(r, c, moves)]

            cache[(r, c, moves)] = (
                dfs(r + 1, c, moves - 1) +
                dfs(r - 1, c, moves - 1) +
                dfs(r, c + 1, moves - 1) +
                dfs(r, c - 1, moves - 1)
            ) % MOD
            return cache[(r, c, moves)]

        return dfs(startRow, startColumn, maxMove)

Time & Space Complexity

  • Time complexity: O(mnN)O(m * n * N)
  • Space complexity: O(mnN)O(m * n * N)

Where mm is the number of rows, nn is the number of columns, and NN is the maximum number of allowed moves.


3. Dynamic Programming (Bottom-Up)

Intuition

Instead of recursion, we can fill the DP table iteratively. We build up from 1 move to maxMove, computing how many ways each cell can reach the boundary with exactly that many moves remaining.

For each cell, we look at its four neighbors. If a neighbor is out of bounds, that contributes 1 path. If the neighbor is valid, we add its value from the previous move count.

Algorithm

  1. Create a 3D DP array dp[r][c][moves] representing paths from (r, c) with the given moves.
  2. Iterate moves from 1 to maxMove:
    • For each cell (r, c), check all four neighbors.
    • If a neighbor is out of bounds, add 1 to dp[r][c][moves].
    • Otherwise, add dp[neighbor][moves - 1].
  3. Return dp[startRow][startColumn][maxMove].
class Solution:
    def findPaths(self, m: int, n: int, maxMove: int, startRow: int, startColumn: int) -> int:
        MOD = 10**9 + 7
        dp = [[[0] * (maxMove + 1) for _ in range(n)] for _ in range(m)]

        for moves in range(1, maxMove + 1):
            for r in range(m):
                for c in range(n):
                    dp[r][c][moves] = (
                        (dp[r - 1][c][moves - 1] if r > 0 else 1) +
                        (dp[r + 1][c][moves - 1] if r < m - 1 else 1) +
                        (dp[r][c - 1][moves - 1] if c > 0 else 1) +
                        (dp[r][c + 1][moves - 1] if c < n - 1 else 1)
                    ) % MOD

        return dp[startRow][startColumn][maxMove]

Time & Space Complexity

  • Time complexity: O(mnN)O(m * n * N)
  • Space complexity: O(mnN)O(m * n * N)

Where mm is the number of rows, nn is the number of columns, and NN is the maximum number of allowed moves.


4. Dynamic Programming (Space Optimized)

Intuition

Since each move layer only depends on the previous move layer, we do not need the full 3D array. We can use two 2D arrays: one for the current move count and one for the previous. After processing each move, we swap them.

This reduces space from O(m * n * maxMove) to O(m * n).

Algorithm

  1. Create two 2D arrays: dp for the previous move count and tmp for the current.
  2. For each move from 1 to maxMove:
    • Reset tmp to zeros.
    • For each cell, add 1 for each out-of-bound neighbor, or add dp[neighbor] for valid neighbors.
    • Swap dp and tmp.
  3. Return dp[startRow][startColumn].
class Solution:
    def findPaths(self, m: int, n: int, maxMove: int, startRow: int, startColumn: int) -> int:
        MOD = 10**9 + 7
        dp = [[0] * n for _ in range(m)]

        for moves in range(1, maxMove + 1):
            tmp = [[0] * n for _ in range(m)]
            for r in range(m):
                for c in range(n):
                    if r + 1 == m:
                        tmp[r][c] = (tmp[r][c] + 1) % MOD
                    else:
                        tmp[r][c] = (tmp[r][c] + dp[r + 1][c]) % MOD
                    if r - 1 < 0:
                        tmp[r][c] = (tmp[r][c] + 1) % MOD
                    else:
                        tmp[r][c] = (tmp[r][c] + dp[r - 1][c]) % MOD
                    if c + 1 == n:
                        tmp[r][c] = (tmp[r][c] + 1) % MOD
                    else:
                        tmp[r][c] = (tmp[r][c] + dp[r][c + 1]) % MOD
                    if c - 1 < 0:
                        tmp[r][c] = (tmp[r][c] + 1) % MOD
                    else:
                        tmp[r][c] = (tmp[r][c] + dp[r][c - 1]) % MOD
            dp = tmp

        return dp[startRow][startColumn]

Time & Space Complexity

  • Time complexity: O(mnN)O(m * n * N)
  • Space complexity: O(mn)O(m * n)

Where mm is the number of rows, nn is the number of columns, and NN is the maximum number of allowed moves.


Common Pitfalls

Forgetting to Apply Modulo at Each Step

The number of paths can grow extremely large, causing integer overflow. A common mistake is only applying the modulo operation at the end. You must apply % (10^9 + 7) after each addition to prevent overflow, especially when summing results from multiple recursive calls or DP transitions.

Incorrect Base Case for Out-of-Bounds

Some implementations check if moves are zero before checking if the position is out of bounds. The correct order is to first check if we are outside the grid (return 1 for a valid exit path), then check if moves are exhausted (return 0). Reversing this order means we would never count paths that step outside on their last move.

Using Wrong Memoization Key

When using memoization, the cache key must include all three state variables: row, column, and remaining moves. Forgetting to include the move count in the key leads to incorrect caching where different move states overwrite each other, producing wrong answers.