1631. Path with Minimum Effort - Explanation
Description
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.
Example 1:
Input: heights = [
[1,1,1],
[3,2,4],
[2,5,4]
]
Output: 2Explanation: The route of [1,1,2,4,4] has a maximum absolute difference of 2 in consecutive cells.
Example 2:
Input: heights = [
[1,1,1],
[1,1,2],
[6,5,2]
]
Output: 1Explanation: The route of [1,1,1,1,1,2,2] has a maximum absolute difference of 1 in consecutive cells.
Constraints:
row == heights.lengthcol == heights[i].length1 <= row, col <= 1001 <= heights[i][j].length <= 1,000,000.
Topics
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dijkstra's Algorithm - Finding shortest paths using a priority queue with modified cost function
- Binary Search - Searching for the minimum feasible effort value
- Depth First Search (DFS) - Checking path reachability under a given constraint
- Union-Find / Disjoint Set Union (DSU) - Connecting components to determine reachability (Kruskal's approach)
- 2D Grid Traversal - Moving in four directions and handling grid boundaries
1. Dijkstra's Algorithm
Intuition
The effort of a path is defined as the maximum absolute difference between consecutive cells along that path. We want to minimize this maximum difference. Dijkstra's algorithm fits well because we can treat the "effort so far" as the cost and always expand the path with the smallest maximum effort. When we reach the destination, we have found the path with minimum effort.
Algorithm
- Initialize a min-heap with
(0, 0, 0)representing(effort, row, col)starting at the top-left cell. - Maintain a distance array where
dist[r][c]stores the minimum effort to reach cell(r, c). Initialize all values to infinity exceptdist[0][0] = 0. - Pop the cell with the smallest effort from the heap. If it is the destination, return the effort.
- For each of the four neighbors, calculate the new effort as the maximum of the current effort and the absolute height difference.
- If this new effort is better than the recorded distance for the neighbor, update it and push the neighbor to the heap.
- Continue until the destination is reached.
class Solution:
def minimumEffortPath(self, heights: List[List[int]]) -> int:
ROWS, COLS = len(heights), len(heights[0])
minHeap = [[0, 0, 0]] # [diff, row, col]
visit = set()
directions = [[0, 1], [0, -1], [1, 0], [-1, 0]]
while minHeap:
diff, r, c = heapq.heappop(minHeap)
if (r, c) in visit:
continue
visit.add((r, c))
if (r, c) == (ROWS - 1, COLS - 1):
return diff
for dr, dc in directions:
newR, newC = r + dr, c + dc
if (
newR < 0 or newC < 0 or
newR >= ROWS or newC >= COLS or
(newR, newC) in visit
):
continue
newDiff = max(diff, abs(heights[r][c] - heights[newR][newC]))
heapq.heappush(minHeap, [newDiff, newR, newC])
return 0Time & Space Complexity
- Time complexity:
- Space complexity:
Where is the number of rows and is the number of columns in the given matrix.
2. Binary Search + DFS
Intuition
We can binary search on the answer. For a given effort limit, we check whether it is possible to reach the destination using only edges with absolute differences at most that limit. dfs explores if a valid path exists under the constraint. If a path exists, we try a smaller limit; otherwise, we try a larger one.
Algorithm
- Binary search on the effort limit between
0and1,000,000(the maximum possible difference). - For each candidate limit (
mid), run adfsfrom(0, 0)to check if we can reach(ROWS-1, COLS-1)using only edges with absolute difference at mostmid. - In the
dfs, mark cells as visited and only move to neighbors where the height difference is within the limit. - If the
dfsreaches the destination, the limit is feasible. Record it and search for a smaller limit. - If the
dfsfails, search for a larger limit. - Return the smallest feasible limit found.
class Solution:
def minimumEffortPath(self, heights: List[List[int]]) -> int:
ROWS, COLS = len(heights), len(heights[0])
directions = [[0, 1], [0, -1], [1, 0], [-1, 0]]
def dfs(r, c, limit, visited):
if r == ROWS - 1 and c == COLS - 1:
return True
visited.add((r, c))
for dr, dc in directions:
newR, newC = r + dr, c + dc
if (newR < 0 or newC < 0 or
newR >= ROWS or newC >= COLS or
(newR, newC) in visited or
abs(heights[newR][newC] - heights[r][c]) > limit):
continue
if dfs(newR, newC, limit, visited):
return True
return False
l, r = 0, 1000000
res = r
while l <= r:
mid = (l + r) // 2
if dfs(0, 0, mid, set()):
res = mid
r = mid - 1
else:
l = mid + 1
return resTime & Space Complexity
- Time complexity:
- Space complexity:
Where is the number of rows and is the number of columns in the given matrix.
3. Kruskal's Algorithm
Intuition
We can view the grid as a graph where each cell is a node and edges connect adjacent cells with weights equal to the absolute height difference. The problem becomes finding the path from top-left to bottom-right that minimizes the maximum edge weight. Kruskal's algorithm processes edges in sorted order, and we stop as soon as the source and destination become connected. The last edge added determines the minimum effort.
Algorithm
- Create a list of all edges between adjacent cells, where each edge stores
(weight, cell1, cell2). Map each cell to a unique index. - Sort edges by weight in ascending order.
- Initialize a Disjoint Set Union (DSU) data structure.
- Process edges one by one. For each edge, union the two cells.
- After each union, check if the top-left cell (index
0) and bottom-right cell are connected. - If connected, return the current edge weight as the answer.
- If the grid has only one cell, return
0.
class DSU:
def __init__(self, n):
self.Parent = list(range(n + 1))
self.Size = [1] * (n + 1)
def find(self, node):
if self.Parent[node] != node:
self.Parent[node] = self.find(self.Parent[node])
return self.Parent[node]
def union(self, u, v):
pu = self.find(u)
pv = self.find(v)
if pu == pv:
return False
if self.Size[pu] < self.Size[pv]:
pu, pv = pv, pu
self.Size[pu] += self.Size[pv]
self.Parent[pv] = pu
return True
class Solution:
def minimumEffortPath(self, heights: List[List[int]]) -> int:
ROWS, COLS = len(heights), len(heights[0])
edges = []
for r in range(ROWS):
for c in range(COLS):
if r + 1 < ROWS:
edges.append((abs(heights[r][c] - heights[r + 1][c]), r * COLS + c, (r + 1) * COLS + c))
if c + 1 < COLS:
edges.append((abs(heights[r][c] - heights[r][c + 1]), r * COLS + c, r * COLS + c + 1))
edges.sort()
dsu = DSU(ROWS * COLS)
for weight, u, v in edges:
dsu.union(u, v)
if dsu.find(0) == dsu.find(ROWS * COLS - 1):
return weight
return 0Time & Space Complexity
- Time complexity:
- Space complexity:
Where is the number of rows and is the number of columns in the given matrix.
4. Shortest Path Faster Algorithm
Intuition
SPFA can also solve this problem by treating effort as the distance metric. We use a queue to process cells and update their minimum effort when a better path is found. Unlike Dijkstra's, SPFA does not require a priority queue, trading off guaranteed optimal ordering for simplicity and potential efficiency on some inputs.
Algorithm
- Initialize a
distarray with infinity for all cells exceptdist[0] = 0for the starting cell. - Use a queue and add the starting cell. Track which cells are currently in the queue.
- Dequeue a cell and mark it as not in the queue.
- For each neighbor, compute the new effort as
max(current effort, height difference). - If the new effort is less than the neighbor's recorded effort, update it. If the neighbor is not in the queue, add it.
- Continue until the queue is empty and return
distfor the bottom-right cell.
class Solution:
def minimumEffortPath(self, heights: List[List[int]]) -> int:
ROWS, COLS = len(heights), len(heights[0])
dist = [float('inf')] * (ROWS * COLS)
dist[0] = 0
in_queue = [False] * (ROWS * COLS)
def index(r, c):
return r * COLS + c
queue = deque([0])
in_queue[0] = True
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
while queue:
u = queue.popleft()
in_queue[u] = False
r, c = divmod(u, COLS)
for dr, dc in directions:
newR, newC = r + dr, c + dc
if 0 <= newR < ROWS and 0 <= newC < COLS:
v = index(newR, newC)
weight = abs(heights[r][c] - heights[newR][newC])
new_dist = max(dist[u], weight)
if new_dist < dist[v]:
dist[v] = new_dist
if not in_queue[v]:
queue.append(v)
in_queue[v] = True
return dist[ROWS * COLS - 1]Time & Space Complexity
- Time complexity:
- time in average case.
- time in worst case.
- Space complexity:
Where is the number of rows and is the number of columns in the given matrix.
Common Pitfalls
Summing Differences Instead of Taking Maximum
The effort of a path is defined as the maximum absolute difference along the path, not the sum. Using addition instead of max() when computing the new effort will give incorrect results. Always compute newEffort = max(currentEffort, abs(height[current] - height[neighbor])).
Confusing Single-Cell Grids
When the grid has only one cell, the answer is 0 since we are already at the destination. Some implementations may fail to handle this edge case, especially when the algorithm expects at least one move. Check for ROWS == 1 && COLS == 1 as a base case or ensure your algorithm naturally returns 0.
Incorrect Distance Initialization
In Dijkstra's algorithm, all distances should be initialized to infinity except for the starting cell which should be 0. Some implementations mistakenly initialize all distances to zero or forget to set the starting distance, causing the algorithm to skip valid relaxations or produce incorrect minimum efforts.
Sign in to join the discussion