256 lines
12 KiB
YAML
256 lines
12 KiB
YAML
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
|
||
|
||
|
||
|
||
**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
|
||
|
||
|
||
|
||
**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
|
||
|
||
|
||
|
||
**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)`
|
||
|
||
|
||
|
||
**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
|
||
|
||
|
||
|
||
**Step 3: Return the result**
|
||
|
||
- The first time we pop the destination cell, we have found the minimum effort
|
||
|
||
|
||
|
||
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.
|