title: Unique Paths
slug: unique-paths
difficulty: medium
leetcode_id: 62
leetcode_url: https://leetcode.com/problems/unique-paths/
categories:
  - arrays
  - dynamic-programming
  - math
patterns:
  - dynamic-programming

function_signature: "def unique_paths(m: int, n: int) -> int:"

test_cases:
  visible:
    - input: { m: 3, n: 7 }
      expected: 28
    - input: { m: 3, n: 2 }
      expected: 3
    - input: { m: 1, n: 1 }
      expected: 1
  hidden:
    - input: { m: 7, n: 3 }
      expected: 28
    - input: { m: 3, n: 3 }
      expected: 6
    - input: { m: 10, n: 10 }
      expected: 48620

description: |
  There is a robot on an `m x n` grid. The robot is 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.

  Given the two integers `m` and `n`, return *the number of possible unique paths that the robot can take to reach the bottom-right corner*.

  The test cases are generated so that the answer will be less than or equal to `2 * 10^9`.

constraints: |
  - `1 <= m, n <= 100`

examples:
  - input: "m = 3, n = 7"
    output: "28"
    explanation: "There are 28 unique paths from the top-left to the bottom-right corner of a 3x7 grid."
  - input: "m = 3, n = 2"
    output: "3"
    explanation: "From the top-left corner, there are 3 ways to reach the bottom-right corner: Right -> Down -> Down, Down -> Down -> Right, and Down -> Right -> Down."

explanation:
  intuition: |
    Imagine a city grid where you can only walk east or south. You start at the northwest corner and want to reach the southeast corner. How many different routes can you take?

    The key insight is that **every path has the same total length**: to go from `(0, 0)` to `(m-1, n-1)`, you must make exactly `m-1` moves down and `n-1` moves right, regardless of the order. The problem becomes: in how many different ways can you arrange these moves?

    Think of it like this: at each cell, the number of ways to reach it equals the sum of ways to reach the cell above it plus the cell to the left of it. Why? Because you can only arrive from those two directions! This is the **optimal substructure** property that makes dynamic programming work.

    For cells in the first row, there's only one way to reach them (keep going right). Similarly, for cells in the first column, there's only one way (keep going down). From there, we can build up the solution cell by cell.

  approach: |
    We solve this using a **2D Dynamic Programming** approach:

    **Step 1: Create a DP table**

    - Create a 2D array `dp` of size `m x n`
    - `dp[i][j]` represents the number of unique paths to reach cell `(i, j)`

    &nbsp;

    **Step 2: Initialise the base cases**

    - Fill the first row with `1`: there's only one way to reach any cell in the first row (all right moves)
    - Fill the first column with `1`: there's only one way to reach any cell in the first column (all down moves)

    &nbsp;

    **Step 3: Fill the DP table**

    - For each cell `(i, j)` where `i > 0` and `j > 0`:
    - `dp[i][j] = dp[i-1][j] + dp[i][j-1]`
    - This sums the paths from the cell above and the cell to the left

    &nbsp;

    **Step 4: Return the result**

    - Return `dp[m-1][n-1]`, which contains the total number of unique paths to the destination

  common_pitfalls:
    - title: Off-by-One Errors
      description: |
        A common mistake is confusing grid dimensions with indices. If the grid is `m x n`, the bottom-right corner is at index `(m-1, n-1)`, not `(m, n)`.

        Similarly, when iterating, ensure your loops go from `0` to `m-1` and `0` to `n-1` respectively.
      wrong_approach: "Using m and n directly as indices"
      correct_approach: "Use m-1 and n-1 for the destination cell"

    - title: Forgetting Base Case Initialisation
      description: |
        If you don't properly initialise the first row and first column to `1`, your DP will produce incorrect results. Cells like `dp[0][j]` with an uninitialised `dp[0][j-1]` will give wrong values.

        The base case is fundamental: there's exactly one way to reach any cell along the edges (all moves in one direction).
      wrong_approach: "Starting iteration from (0,0) without initialisation"
      correct_approach: "Initialise first row and first column to 1 before the main DP loop"

    - title: Using Recursion Without Memoisation
      description: |
        A naive recursive solution without memoisation has exponential time complexity O(2^(m+n)). For `m = n = 100`, this would never complete.

        Each cell's value depends on previously computed values, so either use bottom-up DP or top-down recursion with memoisation.
      wrong_approach: "Plain recursion recalculating the same cells"
      correct_approach: "Bottom-up DP or memoised recursion"

  key_takeaways:
    - "**Classic 2D DP pattern**: When counting paths in a grid with restricted movement, think about how many ways you can arrive at each cell"
    - "**Optimal substructure**: The solution to a cell depends only on solutions to smaller subproblems (cells above and to the left)"
    - "**Space optimisation possible**: Since each row only depends on the previous row, you can reduce space from O(m*n) to O(n)"
    - "**Mathematical alternative**: This is equivalent to choosing `m-1` down moves from `m+n-2` total moves, giving C(m+n-2, m-1)"

  time_complexity: "O(m * n). We fill each cell in the `m x n` DP table exactly once."
  space_complexity: "O(m * n). We store the number of paths for every cell in the grid. This can be optimised to O(n) by only keeping the previous row."

solutions:
  - approach_name: 2D Dynamic Programming
    is_optimal: true
    code: |
      def unique_paths(m: int, n: int) -> int:
          # Create DP table where dp[i][j] = paths to reach (i, j)
          dp = [[1] * n for _ in range(m)]

          # Fill the table - first row and column are already 1
          for i in range(1, m):
              for j in range(1, n):
                  # Paths from above + paths from left
                  dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

          # Bottom-right corner has our answer
          return dp[m - 1][n - 1]
    explanation: |
      **Time Complexity:** O(m * n) — We visit each cell once.

      **Space Complexity:** O(m * n) — We store the entire DP table.

      We initialise the table with 1s (handling base cases implicitly), then iterate through each cell, summing the paths from above and from the left.

  - approach_name: Space-Optimised DP
    is_optimal: true
    code: |
      def unique_paths(m: int, n: int) -> int:
          # Only need to track the current row
          dp = [1] * n

          # Process each row
          for i in range(1, m):
              for j in range(1, n):
                  # dp[j] currently holds value from row above
                  # dp[j-1] holds value from left (already updated this row)
                  dp[j] += dp[j - 1]

          return dp[n - 1]
    explanation: |
      **Time Complexity:** O(m * n) — Same iteration as 2D approach.

      **Space Complexity:** O(n) — Only storing one row at a time.

      Since each cell only depends on the cell above and the cell to the left, we can reuse a single row. When we update `dp[j]`, it already contains the value from the previous row (the "above" value), and `dp[j-1]` has already been updated for the current row (the "left" value).

  - approach_name: Combinatorics (Mathematical)
    is_optimal: true
    code: |
      from math import comb

      def unique_paths(m: int, n: int) -> int:
          # Total moves needed: (m-1) down + (n-1) right = m+n-2 moves
          # Choose which (m-1) of those are down moves
          return comb(m + n - 2, m - 1)
    explanation: |
      **Time Complexity:** O(min(m, n)) — Computing the binomial coefficient.

      **Space Complexity:** O(1) — Only storing the result.

      Every path consists of exactly `m-1` down moves and `n-1` right moves. The number of unique paths equals the number of ways to arrange these moves, which is the binomial coefficient C(m+n-2, m-1). Python's `math.comb` computes this efficiently.

  - approach_name: Recursive with Memoisation
    is_optimal: false
    code: |
      from functools import lru_cache

      def unique_paths(m: int, n: int) -> int:
          @lru_cache(maxsize=None)
          def count_paths(i: int, j: int) -> int:
              # Base case: reached the destination
              if i == m - 1 and j == n - 1:
                  return 1
              # Out of bounds
              if i >= m or j >= n:
                  return 0
              # Sum paths going down and going right
              return count_paths(i + 1, j) + count_paths(i, j + 1)

          return count_paths(0, 0)
    explanation: |
      **Time Complexity:** O(m * n) — Each state computed once due to memoisation.

      **Space Complexity:** O(m * n) — Cache stores all states, plus O(m + n) recursion stack.

      This top-down approach starts from the origin and explores all paths recursively. Memoisation prevents redundant calculations. While correct, the bottom-up DP is generally preferred as it avoids recursion overhead.
