title: Path With Minimum Effort
slug: path-with-minimum-effort
difficulty: medium
leetcode_id: 1631
leetcode_url: https://leetcode.com/problems/path-with-minimum-effort/
categories:
  - graphs
  - arrays
  - binary-search
  - heap
patterns:
  - binary-search
  - bfs
  - heap
  - matrix-traversal

description: |
  You are a hiker preparing for an upcoming hike. You are given `heights`, a 2D array of size `rows x columns`, where `heights[row][col]` represents the height of cell `(row, col)`. You are situated in the top-left cell, `(0, 0)`, and you hope to travel to the bottom-right cell, `(rows-1, columns-1)` (i.e., **0-indexed**). You can move **up**, **down**, **left**, or **right**, and you wish to find a route that requires the minimum **effort**.

  A route's **effort** is the **maximum absolute difference** in heights between two consecutive cells of the route.

  Return *the minimum **effort** required to travel from the top-left cell to the bottom-right cell*.

constraints: |
  - `rows == heights.length`
  - `columns == heights[i].length`
  - `1 <= rows, columns <= 100`
  - `1 <= heights[i][j] <= 10^6`

examples:
  - input: "heights = [[1,2,2],[3,8,2],[5,3,5]]"
    output: "2"
    explanation: "The route of [1,3,5,3,5] has a maximum absolute difference of 2 in consecutive cells. This is better than the route of [1,2,2,2,5], where the maximum absolute difference is 3."
  - input: "heights = [[1,2,3],[3,8,4],[5,3,5]]"
    output: "1"
    explanation: "The route of [1,2,3,4,5] has a maximum absolute difference of 1 in consecutive cells, which is better than route [1,3,5,3,5]."
  - input: "heights = [[1,2,1,1,1],[1,2,1,2,1],[1,2,1,2,1],[1,2,1,2,1],[1,1,1,2,1]]"
    output: "0"
    explanation: "This route does not require any effort."

explanation:
  intuition: |
    Imagine hiking through a mountainous terrain represented as a grid. At each step, you feel the "effort" of climbing up or down — the bigger the height difference, the harder it is. Your goal is to find a path from the top-left corner to the bottom-right corner that minimises the **worst single step** along the way.

    The key insight is that we're not minimising the *total* effort (sum of all differences), but rather the *maximum* effort in any single step. Think of it like this: if your worst step has a height difference of 5, that defines your path's difficulty — even if every other step is easy.

    This "minimax" objective suggests a different approach from typical shortest path problems. We have two elegant strategies:

    1. **Binary search on the answer**: What if we could ask, "Can I reach the destination if my maximum allowed effort is `k`?" If yes, try a smaller `k`. If no, we need a larger `k`. This transforms the optimisation problem into a series of yes/no reachability checks.

    2. **Modified Dijkstra's algorithm**: Instead of minimising total distance, we track the minimum "maximum effort so far" to reach each cell. Using a min-heap prioritised by effort, we always expand the most promising path first.

  approach: |
    We present two approaches: **Binary Search + BFS** and **Dijkstra's Algorithm**.

    **Approach A: Binary Search + BFS**

    **Step 1: Define the search space**

    - The minimum possible effort is `0` (all cells have the same height)
    - The maximum possible effort is `10^6 - 1` (maximum height difference based on constraints)
    - We binary search on this range to find the minimum effort that allows us to reach the destination

    &nbsp;

    **Step 2: Implement the feasibility check**

    - Given a maximum allowed effort `k`, use BFS to check if we can reach `(rows-1, cols-1)` from `(0, 0)`
    - Only traverse edges where the height difference is `<= k`
    - If we reach the destination, the answer is `k` or lower; otherwise, we need a larger effort

    &nbsp;

    **Step 3: Binary search for the answer**

    - If `can_reach(mid)` is true, search the lower half (`right = mid`)
    - If false, search the upper half (`left = mid + 1`)
    - Return `left` when the search converges

    &nbsp;

    **Approach B: Dijkstra's Algorithm**

    **Step 1: Initialise data structures**

    - `effort`: 2D array tracking the minimum effort to reach each cell (initialised to infinity)
    - `effort[0][0] = 0`: Starting cell requires zero effort
    - Min-heap: `[(0, 0, 0)]` representing `(current_max_effort, row, col)`

    &nbsp;

    **Step 2: Process cells greedily**

    - Pop the cell with the smallest effort from the heap
    - If we've reached `(rows-1, cols-1)`, return the effort immediately
    - For each neighbour, calculate the new effort: `max(current_effort, |height_diff|)`
    - If this is better than the previously recorded effort for that neighbour, update and add to heap

    &nbsp;

    **Step 3: Return the result**

    - The first time we pop the destination cell, we have found the minimum effort

    &nbsp;

    Dijkstra's works here because we're using a min-heap: we always process the path with the smallest maximum effort first, guaranteeing optimality.

  common_pitfalls:
    - title: Using Standard BFS/DFS Alone
      description: |
        Standard BFS finds the shortest path by number of edges, and DFS explores deeply first. Neither directly minimises the maximum edge weight along a path.

        BFS/DFS can be used as a subroutine (to check reachability given a constraint), but you need an outer structure (binary search or Dijkstra's priority queue) to find the optimal answer.
      wrong_approach: "Plain BFS/DFS to find path with minimum effort"
      correct_approach: "Binary Search + BFS or Dijkstra's algorithm"

    - title: Minimising Total Effort Instead of Maximum
      description: |
        This problem asks for the **minimum of the maximum** effort along any path — not the sum of all efforts. This is a minimax problem.

        For example, a path with steps [3, 1, 1, 1] has effort 3 (the max), while [2, 2, 2, 2] has effort 2 — even though the latter has a higher sum.
      wrong_approach: "Summing height differences along the path"
      correct_approach: "Tracking maximum height difference along each path"

    - title: Not Using a Min-Heap in Dijkstra's
      description: |
        Without a min-heap, you lose the greedy property that makes Dijkstra's work. You might process a cell via a suboptimal path first, then need to reprocess it later.

        The min-heap ensures we always expand the path with the smallest maximum effort, so the first time we reach the destination is guaranteed to be optimal.
      wrong_approach: "Using a regular queue or processing in arbitrary order"
      correct_approach: "Use heapq with (effort, row, col) tuples"

    - title: Forgetting to Check All Four Directions
      description: |
        Unlike some grid problems, this one allows movement in all four directions (up, down, left, right). Missing a direction means potentially missing the optimal path.

        Always iterate over all four directions: `[(0,1), (0,-1), (1,0), (-1,0)]`.
      wrong_approach: "Only moving right and down"
      correct_approach: "Exploring all four orthogonal directions"

  key_takeaways:
    - "**Minimax problems**: When minimising the maximum cost, binary search on the answer or modified Dijkstra's are powerful techniques"
    - "**Binary search on answer space**: Transform 'find minimum X' into 'is X achievable?' and binary search"
    - "**Modified Dijkstra's**: Works for any problem where you need the 'best' path under a monotonic metric — not just shortest distance"
    - "**Grid as graph**: Cells are nodes, adjacent cells are edges with weight equal to height difference"

  time_complexity: "O(m × n × log(maxHeight)) for binary search approach, O(m × n × log(m × n)) for Dijkstra's. Both are efficient for the given constraints."
  space_complexity: "O(m × n). We need space for the visited/effort array and the BFS queue or Dijkstra's heap."

solutions:
  - approach_name: Dijkstra's Algorithm
    is_optimal: true
    code: |
      import heapq

      def minimum_effort_path(heights: list[list[int]]) -> int:
          rows, cols = len(heights), len(heights[0])

          # effort[r][c] = minimum effort to reach cell (r, c)
          effort = [[float('inf')] * cols for _ in range(rows)]
          effort[0][0] = 0

          # Min-heap: (current_max_effort, row, col)
          heap = [(0, 0, 0)]

          directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]

          while heap:
              curr_effort, r, c = heapq.heappop(heap)

              # Found the destination - return immediately
              if r == rows - 1 and c == cols - 1:
                  return curr_effort

              # Skip if we've found a better path to this cell
              if curr_effort > effort[r][c]:
                  continue

              # Explore all four neighbours
              for dr, dc in directions:
                  nr, nc = r + dr, c + dc

                  if 0 <= nr < rows and 0 <= nc < cols:
                      # Effort to reach neighbour = max of current effort and this edge
                      height_diff = abs(heights[nr][nc] - heights[r][c])
                      new_effort = max(curr_effort, height_diff)

                      # Only update if we found a better path
                      if new_effort < effort[nr][nc]:
                          effort[nr][nc] = new_effort
                          heapq.heappush(heap, (new_effort, nr, nc))

          return effort[rows - 1][cols - 1]
    explanation: |
      **Time Complexity:** O(m × n × log(m × n)) — Each cell can be added to the heap multiple times, but we process at most O(m × n) pops. Each heap operation is O(log(m × n)).

      **Space Complexity:** O(m × n) — For the effort array and heap.

      We use a modified Dijkstra's algorithm where instead of summing edge weights, we track the maximum edge weight along the path. The min-heap ensures we always process the most promising path first.

  - approach_name: Binary Search + BFS
    is_optimal: true
    code: |
      from collections import deque

      def minimum_effort_path(heights: list[list[int]]) -> int:
          rows, cols = len(heights), len(heights[0])
          directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]

          def can_reach(max_effort: int) -> bool:
              """Check if we can reach destination with at most max_effort."""
              if max_effort < 0:
                  return False

              visited = [[False] * cols for _ in range(rows)]
              queue = deque([(0, 0)])
              visited[0][0] = True

              while queue:
                  r, c = queue.popleft()

                  # Reached the destination
                  if r == rows - 1 and c == cols - 1:
                      return True

                  for dr, dc in directions:
                      nr, nc = r + dr, c + dc

                      if 0 <= nr < rows and 0 <= nc < cols and not visited[nr][nc]:
                          # Only traverse if height diff is within allowed effort
                          if abs(heights[nr][nc] - heights[r][c]) <= max_effort:
                              visited[nr][nc] = True
                              queue.append((nr, nc))

              return False

          # Binary search on the answer
          left, right = 0, 10**6 - 1

          while left < right:
              mid = (left + right) // 2

              if can_reach(mid):
                  right = mid  # Try smaller effort
              else:
                  left = mid + 1  # Need more effort

          return left
    explanation: |
      **Time Complexity:** O(m × n × log(maxHeight)) — Binary search over the effort range (log(10^6) iterations), each requiring O(m × n) BFS.

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

      We binary search on the answer: for each candidate effort `k`, BFS checks if we can reach the destination using only edges with height difference ≤ `k`. If yes, we try smaller; if no, we try larger.
