title: Longest Increasing Path in a Matrix
slug: longest-increasing-path-in-a-matrix
difficulty: hard
leetcode_id: 329
leetcode_url: https://leetcode.com/problems/longest-increasing-path-in-a-matrix/
categories:
  - graphs
  - dynamic-programming
  - arrays
patterns:
  - dfs
  - dynamic-programming
  - matrix-traversal

function_signature: "def longest_increasing_path(matrix: list[list[int]]) -> int:"

test_cases:
  visible:
    - input: { matrix: [[9, 9, 4], [6, 6, 8], [2, 1, 1]] }
      expected: 4
    - input: { matrix: [[3, 4, 5], [3, 2, 6], [2, 2, 1]] }
      expected: 4
    - input: { matrix: [[1]] }
      expected: 1
  hidden:
    - input: { matrix: [[1, 2], [4, 3]] }
      expected: 4
    - input: { matrix: [[1, 2, 3], [6, 5, 4], [7, 8, 9]] }
      expected: 9
    - input: { matrix: [[7, 8, 9], [6, 1, 2], [5, 4, 3]] }
      expected: 7
    - input: { matrix: [[1, 1, 1], [1, 1, 1], [1, 1, 1]] }
      expected: 1
    - input: { matrix: [[9, 8, 7], [6, 5, 4], [3, 2, 1]] }
      expected: 9
    - input: { matrix: [[1, 2, 3, 4, 5]] }
      expected: 5
    - input: { matrix: [[5], [4], [3], [2], [1]] }
      expected: 5

description: |
  Given an `m x n` integers `matrix`, return *the length of the longest increasing path in* `matrix`.

  From each cell, you can either move in four directions: left, right, up, or down. You **may not** move **diagonally** or move **outside the boundary** (i.e., wrap-around is not allowed).

constraints: |
  - `m == matrix.length`
  - `n == matrix[i].length`
  - `1 <= m, n <= 200`
  - `0 <= matrix[i][j] <= 2^31 - 1`

examples:
  - input: "matrix = [[9,9,4],[6,6,8],[2,1,1]]"
    output: "4"
    explanation: "The longest increasing path is [1, 2, 6, 9]."
  - input: "matrix = [[3,4,5],[3,2,6],[2,2,1]]"
    output: "4"
    explanation: "The longest increasing path is [3, 4, 5, 6]. Moving diagonally is not allowed."
  - input: "matrix = [[1]]"
    output: "1"
    explanation: "A single cell forms a path of length 1."

explanation:
  intuition: |
    Imagine the matrix as a landscape where each cell's value represents its elevation. You're trying to find the longest route where you're always climbing uphill.

    The key insight is that this problem has **optimal substructure**: the longest path starting from any cell equals 1 (the cell itself) plus the maximum of the longest paths from its valid neighbours (neighbours with strictly greater values).

    Think of it like water flowing downhill. If you flip the perspective and consider paths going from higher to lower values, water from any cell can only flow to cells with smaller values. The longest path from a cell is determined by where its "downstream" neighbours can reach.

    Here's why memoisation works so well: once you've computed the longest increasing path starting from cell `(i, j)`, that answer never changes. No matter which cell you're exploring later, if it can move to `(i, j)`, you already know the best path from there. This turns what would be exponential exploration into a linear traversal of the matrix.

    The directed acyclic graph (DAG) structure is crucial. Since we can only move to strictly greater values, there are no cycles. This guarantees that our DFS will terminate and that dynamic programming is applicable.

  approach: |
    We solve this using **DFS with Memoisation**:

    **Step 1: Set up the memoisation cache**

    - Create a 2D array `memo` of the same dimensions as the matrix
    - `memo[i][j]` will store the longest increasing path starting from cell `(i, j)`
    - Initialise all values to `0` (or use a dictionary for sparse storage)

    &nbsp;

    **Step 2: Define the DFS function**

    - For a cell `(i, j)`, if `memo[i][j]` is already computed (non-zero), return it immediately
    - Otherwise, explore all four neighbours (up, down, left, right)
    - For each neighbour `(ni, nj)` where `matrix[ni][nj] > matrix[i][j]`:
      - Recursively compute the longest path from `(ni, nj)`
      - Track the maximum path length among all valid neighbours
    - Set `memo[i][j] = 1 + max_neighbour_path` (1 for the current cell plus the best continuation)
    - Return `memo[i][j]`

    &nbsp;

    **Step 3: Iterate through all cells**

    - For each cell in the matrix, call the DFS function
    - Track the global maximum path length across all starting cells
    - Cells with cached results will return immediately, ensuring each cell is fully computed only once

    &nbsp;

    **Step 4: Return the result**

    - Return the maximum path length found

    &nbsp;

    The memoisation ensures that each cell is visited and computed exactly once, giving us optimal time complexity. The DFS naturally handles the dependency order since smaller-value cells depend on larger-value cells, and there are no cycles.

  common_pitfalls:
    - title: Brute Force Without Memoisation
      description: |
        A naive DFS that doesn't cache results will recompute paths from the same cell multiple times.

        Consider a matrix where many paths converge to the same cell. Without memoisation, you'd compute the path from that cell once for every path that reaches it.

        With a `200 x 200` matrix, this can lead to exponential time complexity, causing **Time Limit Exceeded** errors.
      wrong_approach: "Plain DFS exploring all paths without caching"
      correct_approach: "DFS with memoisation to cache computed path lengths"

    - title: Forgetting Boundary Checks
      description: |
        When exploring neighbours, you must check that the neighbour indices are within bounds before accessing the matrix.

        Accessing `matrix[-1][0]` or `matrix[m][n]` will cause index errors or incorrect results.

        Always validate `0 <= ni < m` and `0 <= nj < n` before comparing values.
      wrong_approach: "Checking only the value condition without bounds"
      correct_approach: "Check bounds first, then check if neighbour value is greater"

    - title: Using Non-Strict Inequality
      description: |
        The path must be **strictly increasing**. Using `>=` instead of `>` when comparing neighbour values can create infinite loops (since equal adjacent values would let you bounce back and forth forever).

        The problem specifies "increasing path", which means each step must go to a strictly larger value.
      wrong_approach: "Using matrix[ni][nj] >= matrix[i][j]"
      correct_approach: "Using matrix[ni][nj] > matrix[i][j]"

    - title: Modifying the Matrix
      description: |
        Some solutions attempt to mark visited cells by modifying the matrix values. This breaks the algorithm because:

        1. You might need to visit the same cell from different starting points
        2. The memoised value depends on the original matrix values

        Use a separate `memo` array instead of modifying the input.
      wrong_approach: "Setting matrix[i][j] = -1 to mark as visited"
      correct_approach: "Use a separate memo array for caching"

  key_takeaways:
    - "**DFS + Memoisation pattern**: When exploring paths in a DAG structure, memoisation converts exponential brute force into polynomial time"
    - "**Recognising DAG structure**: The strictly increasing constraint ensures no cycles, making dynamic programming applicable"
    - "**Top-down vs bottom-up**: This problem is naturally suited to top-down DP (DFS with memo) since we explore from arbitrary starting points"
    - "**Matrix traversal foundation**: This pattern extends to many grid problems where you need to find optimal paths with constraints"

  time_complexity: "O(m * n). Each cell is computed exactly once and cached. The DFS visits each cell at most once for computation, with O(1) lookups for cached results."
  space_complexity: "O(m * n). We use a 2D memo array of the same size as the input matrix. The recursion stack can also reach O(m * n) depth in the worst case (e.g., a strictly increasing snake path)."

solutions:
  - approach_name: DFS with Memoisation
    is_optimal: true
    code: |
      def longest_increasing_path(matrix: list[list[int]]) -> int:
          if not matrix or not matrix[0]:
              return 0

          m, n = len(matrix), len(matrix[0])
          # Cache to store longest path starting from each cell
          memo = [[0] * n for _ in range(m)]
          # Four directions: up, down, left, right
          directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

          def dfs(i: int, j: int) -> int:
              # Return cached result if already computed
              if memo[i][j] != 0:
                  return memo[i][j]

              # At minimum, the path length is 1 (the cell itself)
              max_length = 1

              # Explore all four neighbours
              for di, dj in directions:
                  ni, nj = i + di, j + dj
                  # Check bounds and strictly increasing condition
                  if 0 <= ni < m and 0 <= nj < n and matrix[ni][nj] > matrix[i][j]:
                      # Recurse and track the maximum path
                      max_length = max(max_length, 1 + dfs(ni, nj))

              # Cache the result before returning
              memo[i][j] = max_length
              return max_length

          # Try starting from every cell and track global maximum
          result = 0
          for i in range(m):
              for j in range(n):
                  result = max(result, dfs(i, j))

          return result
    explanation: |
      **Time Complexity:** O(m * n) — Each cell is computed exactly once due to memoisation.

      **Space Complexity:** O(m * n) — For the memo array and recursion stack.

      The DFS explores paths starting from each cell, but memoisation ensures we never recompute. The strictly increasing constraint guarantees no cycles, making this a DAG traversal problem perfectly suited for dynamic programming.

  - approach_name: Topological Sort (BFS)
    is_optimal: true
    code: |
      from collections import deque

      def longest_increasing_path(matrix: list[list[int]]) -> int:
          if not matrix or not matrix[0]:
              return 0

          m, n = len(matrix), len(matrix[0])
          directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

          # outdegree[i][j] = count of neighbours with greater values
          outdegree = [[0] * n for _ in range(m)]

          # Calculate outdegree for each cell
          for i in range(m):
              for j in range(n):
                  for di, dj in directions:
                      ni, nj = i + di, j + dj
                      if 0 <= ni < m and 0 <= nj < n and matrix[ni][nj] > matrix[i][j]:
                          outdegree[i][j] += 1

          # Start BFS from cells with outdegree 0 (local maxima)
          queue = deque()
          for i in range(m):
              for j in range(n):
                  if outdegree[i][j] == 0:
                      queue.append((i, j))

          # BFS layer by layer, counting the number of layers
          path_length = 0
          while queue:
              path_length += 1
              # Process all cells at current level
              for _ in range(len(queue)):
                  i, j = queue.popleft()
                  # Check all neighbours with smaller values
                  for di, dj in directions:
                      ni, nj = i + di, j + dj
                      if 0 <= ni < m and 0 <= nj < n and matrix[ni][nj] < matrix[i][j]:
                          outdegree[ni][nj] -= 1
                          # If all larger neighbours processed, add to queue
                          if outdegree[ni][nj] == 0:
                              queue.append((ni, nj))

          return path_length
    explanation: |
      **Time Complexity:** O(m * n) — Each cell is processed exactly once.

      **Space Complexity:** O(m * n) — For the outdegree array and queue.

      This approach treats the matrix as a DAG where edges point from smaller to larger values. We use topological sort starting from "sink" nodes (local maxima with no outgoing edges). The number of BFS layers equals the longest path length. This is an elegant alternative that avoids recursion.

  - approach_name: Brute Force DFS
    is_optimal: false
    code: |
      def longest_increasing_path(matrix: list[list[int]]) -> int:
          if not matrix or not matrix[0]:
              return 0

          m, n = len(matrix), len(matrix[0])
          directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

          def dfs(i: int, j: int) -> int:
              max_length = 1

              for di, dj in directions:
                  ni, nj = i + di, j + dj
                  if 0 <= ni < m and 0 <= nj < n and matrix[ni][nj] > matrix[i][j]:
                      # No caching - recomputes every time
                      max_length = max(max_length, 1 + dfs(ni, nj))

              return max_length

          result = 0
          for i in range(m):
              for j in range(n):
                  result = max(result, dfs(i, j))

          return result
    explanation: |
      **Time Complexity:** O(4^(m*n)) worst case — Exponential due to repeated exploration.

      **Space Complexity:** O(m * n) — Recursion stack depth.

      This naive approach recomputes paths from the same cell multiple times. While correct, it's far too slow for the given constraints and will result in TLE. Included to illustrate why memoisation is essential.
