DSA sheet · Graphs · Dijkstra pattern

Path With Minimum Effort

The last Dijkstra problem, and it brings two new things. First, the graph is a grid, so there's no adjacency list: neighbours are the 4 cells around you. Second, a path's cost is not a sum but the biggest single jump along it. The teacher shows that Dijkstra still works if we just replace "+" with "max". At the end she leaves a homework: solve it with binary search on the answer, and pick between the two methods by looking at the constraints. Both are written out in full below.

Every problem below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Before starting

A grid is a graph

Every cell (r, c) is a node. Two cells are joined by an edge if they touch up, down, left or right (not diagonally). The edges are undirected (you can step both ways), and here each edge has a weight: the height difference between the two cells.

         (r-1, c)
            |
 (r, c-1) —(r, c)— (r, c+1)
            |
         (r+1, c)

We never build an adjacency list for a grid. The neighbours come from a small list of directions (dr, dc), and each one needs a bounds check so we don't step off the grid.

the 4 neighbours of a cell, with a bounds check
grid = [[1, 2, 2],
        [3, 8, 2],
        [5, 3, 5]]
m, n = len(grid), len(grid[0])
directions = [(0, 1), (0, -1), (-1, 0), (1, 0)]   # right, left, up, down

r, c = 0, 0
for dr, dc in directions:
    nr, nc = r + dr, c + dc
    if 0 <= nr < m and 0 <= nc < n:              # stay inside the grid
        print((nr, nc), abs(grid[r][c] - grid[nr][nc]))
# prints (0, 1) 1  then  (1, 0) 2   (left and up are outside)

Visited in grids, BFS, and why this problem uses Dijkstra

In earlier grid problems (Number of Islands, Rotting Oranges) we used BFS with a queue and marked cells visited so we'd never enter them twice. That works when every step costs the same. Here steps have different weights, so we need Dijkstra: a min-heap (always continue from the cheapest cell so far) and, instead of visited, "enter a cell again only if you arrive with a smaller value". Background and a picture of plain BFS failing: Dijkstra page.

Binary search on the answer (for Part B)

When the answer is a number, and "can I do it with at most X?" is a yes/no question whose answers look like no no no yes yes yes as X grows, we can binary search for the first "yes". Each check costs one simple BFS. That's Part B.


Part A · Dijkstra on the grid

LeetCode 1631

1The question in simple words

You get a grid heights of size rows × cols. You start at the top-left cell (0, 0) and want to reach the bottom-right cell (rows−1, cols−1), moving up, down, left or right.

The effort of a path is the largest absolute height difference between two neighbouring cells on that path. ("Absolute" means ignore the sign: going 5 → 3 and 3 → 5 both count as 2.) Return the smallest effort over all paths.

Her example (LeetCode example 1):

heights
  1  2  2
  3  8  2
  5  3  5
path X: along the top, then down
  1→ 2→ 2
  3  8  2       jumps: 1, 0, 0, 3
  5  3  5       effort = 3
path Y: down the left, then along the bottom
  1  2  2
  3  8  2       jumps: 2, 2, 2, 2
  5→ 3→ 5       effort = 2

Path X has small jumps, but one jump of 3 (2 → 5) and the effort is the worst jump, so 3. Path Y has jumps all of size 2, so its effort is 2. No path does better. Answer: 2.

Doubt: so the total of the jumps doesn't matter at all?
→ Right. Path X's jumps add up to 4 and path Y's to 8, but Y wins. Only the single biggest jump counts. Think of it as the steepest step you have to climb: a long walk with gentle steps beats a short walk with one cliff.

2What the constraints tell us

3Intuition

This is Dijkstra where the "distance" of a cell is the smallest effort with which we can reach it. From a cell with effort e, stepping to a neighbour with jump j gives that neighbour max(e, j), not e + j. The min-heap always continues from the cell with the smallest effort so far, so the first time we pop the bottom-right cell, its effort is the minimum.

4Building the logic from her example

Where do we start? BFS or DFS?

The question fixes the start: (0, 0), with effort 0 (no jumps yet). She says DFS could work too, but for "reach a target with the least cost" problems she prefers BFS, and with a heap BFS becomes Dijkstra.

What's in the heap? → (effort, row, col)

A cell is no longer a single number, so the entry holds its row and column, with effort first so the heap orders by effort.

The new rule: carry the maximum, not the sum

She walks it: from 1 we can go right to 2 (jump 1) or down to 3 (jump 2). Push (1, 0, 1) and (2, 1, 0). The heap gives the effort-1 cell first; that's why we need a min-heap: explore the gentlest path first.

From that 2 at (0,1), stepping right to the other 2 is a jump of 0. Do we save 0? No. The path so far already contains a jump of 1, and the effort is the biggest jump on the whole path. So we save max(1, 0) = 1. Stepping down to 8 instead is a jump of 6, so that path's effort becomes max(1, 6) = 6.

Rule 1: new effortnew_effort = max(effort_so_far, abs(h[r][c] - h[nr][nc]))
We don't know which cell will be the last one, so every cell keeps the worst jump of the path that reached it.
Doubt: Dijkstra was proved for sums. Why is it still correct with max?
→ The proof only needs one thing: extending a path can never make its cost smaller. With sums of non-negative weights that's true; with max it's also true, because max(e, j) ≥ e. So when the heap pops the smallest effort for a cell, no other path can come back later and beat it. That's why the first pop of the target is the answer.

The dist grid: start with infinity, save only improvements

Like the dist array in Dijkstra, but 2D: dist[r][c] = smallest effort found so far to reach (r, c). All infinity, except dist[0][0] = 0. We push a neighbour only if new_effort < dist[nr][nc].

Her example of why this replaces visited: from the 2 at (0,1), going back to the start (0,0) would give effort 1, but (0,0) already has 0. Not smaller → don't go. So the condition itself stops us going back and forth, and no visited grid is needed. And when a better effort does arrive, we must be allowed back in: the 8 in the middle first gets 6 (from above, via 2→8), later 5 (from the left, via 3→8). We overwrite 6 with 5.

Rule 2: improve or ignoreif new_effort < dist[nr][nc]: dist[nr][nc] = new_effort; push
Equal is not better: arriving with the same effort again is pointless (her "earlier it was 2, now it's also 2, so don't go").

Stale entries

The 8 sits in the heap twice, as (6, 1, 1) and (5, 1, 1). If an entry is popped with an effort bigger than dist[r][c], a better path already handled that cell: continue.

When is the answer final? On pop, not on push

This is a point she stresses. The bottom-right cell is first pushed with effort 3 (path X). If we stopped there we'd answer 3, which is wrong. When we push, we don't yet know what's still to come. When we pop, the heap promises nothing smaller is left. Later the cell is pushed again with 2 (path Y), and (2, 2, 2) comes out of the heap before (3, 2, 2). So: check for the target right after popping.

Doubt: in her board run she writes values into the effort grid when she pops, but the code writes when pushing.
→ Same as in the Dijkstra video. Use the code's way (write on push). Then the < check also stops worse entries from even entering the heap. The answer is the same either way.

5Approach steps

  1. m, n = rows, cols. dist = m × n grid of infinity; dist[0][0] = 0.
  2. Heap = [(0, 0, 0)] (effort, row, col).
  3. Pop the smallest. If its effort > dist at that cell → stale, continue.
  4. If it's the bottom-right cell → return its effort.
  5. For each of the 4 directions: skip if outside the grid. Else new_effort = max(effort, |height difference|). If smaller than dist[nr][nc]: save it and push.

6Code (Python)

Minimum Effort with Dijkstra
import heapq

class Solution:
    def minimumEffortPath(self, heights):
        m, n = len(heights), len(heights[0])
        dist = [[float('inf')] * n for _ in range(m)]
        dist[0][0] = 0
        pq = [(0, 0, 0)]                                    # (effort, row, col)
        directions = [(0, 1), (0, -1), (-1, 0), (1, 0)]

        while pq:
            effort, r, c = heapq.heappop(pq)
            if effort > dist[r][c]:                         # stale entry
                continue
            if r == m - 1 and c == n - 1:                   # target popped = final
                return effort
            for dr, dc in directions:
                nr, nc = r + dr, c + dc
                if nr < 0 or nr >= m or nc < 0 or nc >= n:  # off the grid
                    continue
                edge = abs(heights[r][c] - heights[nr][nc]) # this one jump
                new_effort = max(effort, edge)              # worst jump on the path
                if new_effort < dist[nr][nc]:
                    dist[nr][nc] = new_effort
                    heapq.heappush(pq, (new_effort, nr, nc))
        return dist[m - 1][n - 1]

7Code line by line

linewhat it means
dist = [[float('inf')] * n for _ in range(m)]One "best effort so far" per cell. Built with a comprehension so each row is a separate list.
dist[0][0] = 0 pq = [(0, 0, 0)]The start costs no effort. Effort first in the tuple → the heap sorts by effort.
directions = [(0, 1), (0, -1), (-1, 0), (1, 0)]Right, left, up, down (her order). Each pair is (change in row, change in col).
effort, r, c = heapq.heappop(pq)The cell we can currently reach with the least effort.
if effort > dist[r][c]: continueOld entry; a gentler path already got here.
if r == m - 1 and c == n - 1: return effortPopped target = smallest possible effort (pushing it was not enough).
if nr < 0 or nr >= m or nc < 0 or nc >= n: continueBounds check: this direction leaves the grid.
edge = abs(...)Size of this one step, ignoring up/down.
new_effort = max(effort, edge)The path's effort is its worst step. The only line that differs from distance-Dijkstra (which has +).
if new_effort < dist[nr][nc]:Go there only if this path is gentler than any before. This is what replaces visited.
return dist[m - 1][n - 1]Never reached in practice: every grid is connected, so the target always gets popped. Kept so the function always returns a number.

8Dry run: heap and dist grid after every pop

Heights as above. dist grid written row by row, rows separated by "|". Heap entries are (effort, row, col), sorted.

Start: dist = 0 ∞ ∞ | ∞ ∞ ∞ | ∞ ∞ ∞, heap = (0,0,0).

poppopped (e, r, c)neighbours checked → new effortheap AFTERdist AFTER
1(0, 0, 0) h=1(0,1) h2: max(0,1)=1 ✓ · (1,0) h3: max(0,2)=2 ✓(1,0,1) (2,1,0)0 1 ∞ | 2 ∞ ∞ | ∞ ∞ ∞
2(1, 0, 1) h=2(0,2) h2: max(1,0)=1 ✓ · (0,0): 1 < 0? no · (1,1) h8: max(1,6)=6 ✓(1,0,2) (2,1,0) (6,1,1)0 1 1 | 2 6 ∞ | ∞ ∞ ∞
3(1, 0, 2) h=2(0,1): 1 < 1? no · (1,2) h2: max(1,0)=1 ✓(1,1,2) (2,1,0) (6,1,1)0 1 1 | 2 6 1 | ∞ ∞ ∞
4(1, 1, 2) h=2(1,1): 6 < 6? no · (0,2): no · (2,2) h5: max(1,3)=3 ✓(2,1,0) (3,2,2) (6,1,1)0 1 1 | 2 6 1 | ∞ ∞ 3
5(2, 1, 0) h=3(1,1) h8: max(2,5)=5 < 6 ✓ · (0,0): no · (2,0) h5: max(2,2)=2 ✓(2,2,0) (3,2,2) (5,1,1) (6,1,1)0 1 1 | 2 5 1 | 2 ∞ 3
6(2, 2, 0) h=5(2,1) h3: max(2,2)=2 ✓ · (1,0): 2 < 2? no(2,2,1) (3,2,2) (5,1,1) (6,1,1)0 1 1 | 2 5 1 | 2 2 3
7(2, 2, 1) h=3(2,2) h5: max(2,2)=2 < 3 ✓ · (2,0): no · (1,1): 5 < 5? no(2,2,2) (3,2,2) (5,1,1) (6,1,1)0 1 1 | 2 5 1 | 2 2 2
8(2, 2, 2)target popped → return 2(3,2,2) (5,1,1) (6,1,1) left, never needed
after pop 4: target pushed with 3
  0  1  1
  2  6  1
  ∞  ∞  3   ← not final yet!
after pop 5: the 8 improves 6 → 5
  0  1  1
  2  5  1
  2  ∞  3
after pop 7: target improves to 2
  0  1  1
  2  5  1
  2  2  2   ← popped next → answer

The two leftover entries for the target, (3, 2, 2), and for the 8, (6, 1, 1) and (5, 1, 1), are never popped because we return first. Had they been popped, (3, 2, 2) and (6, 1, 1) would be stale.

9Complexity & remember

Remember Part ADijkstra on a grid: heap of (effort, r, c), 4 directions + bounds check, new = max(effort, |jump|), push only if smaller, skip stale, return when the target is popped. No visited grid.

Part B · Binary search on the answer + BFS

She leaves this as homework, explains the idea and compares the costs. Here it is written out fully.

1The question, asked differently

Instead of "what's the smallest effort?", ask a yes/no question: "can I get from start to end if I'm never allowed a jump bigger than X?" Find the smallest X for which the answer is yes.

2What the constraints tell us

3Intuition: why binary search is allowed

If you can make the trip with jump limit X, you can certainly make it with any bigger limit (the same path still works). So as X grows, the answers go no, no, …, no, yes, yes, …, yes. They switch only once. Binary search finds that switch point.

Her walk-through on the example: the largest value is 8, so try the middle, 4: possible? Yes, so the answer is at most 4 → look lower. Try 2: yes → look lower. Try 1: no. So the answer is 2. Only a few BFS runs.

4Building the check

can_reach(limit): a normal BFS from (0, 0), stepping only to neighbours whose height difference is ≤ limit. Return True if we reach the bottom-right cell.

Doubt: this BFS uses a visited grid, but Dijkstra didn't. Why the difference?
→ The check only asks "can I get there at all?", a yes/no about reaching, with no cost to improve. Once a cell is reached, reaching it again tells us nothing new, so visited is exactly right (and needed, or the BFS would loop forever). Dijkstra had to allow re-entry because a later arrival could bring a better effort.
Doubt: what bounds does she use?
→ She takes the largest value, 8, as the top. Any number at least as big as the true answer works as the top. max − min (here 8 − 1 = 7) is the tightest safe choice, and the bottom is 0 (a flat path needs no effort).

5Approach steps

  1. lo = 0, hi = max(heights) − min(heights). The answer is in [lo, hi], and hi surely works.
  2. While lo < hi: mid = (lo + hi) // 2.
  3. If can_reach(mid): the answer is mid or smaller → hi = mid.
  4. Else: mid is too strict → lo = mid + 1.
  5. Return lo (= hi, the smallest limit that works).

6Code (Python)

Minimum Effort with binary search on the answer
from collections import deque

class Solution:
    def minimumEffortPath(self, heights):
        m, n = len(heights), len(heights[0])
        directions = [(0, 1), (0, -1), (-1, 0), (1, 0)]

        def can_reach(limit):                       # BFS using only jumps <= limit
            visited = [[False] * n for _ in range(m)]
            visited[0][0] = True
            queue = deque([(0, 0)])
            while queue:
                r, c = queue.popleft()
                if r == m - 1 and c == n - 1:
                    return True
                for dr, dc in directions:
                    nr, nc = r + dr, c + dc
                    if 0 <= nr < m and 0 <= nc < n and not visited[nr][nc] \
                            and abs(heights[r][c] - heights[nr][nc]) <= limit:
                        visited[nr][nc] = True
                        queue.append((nr, nc))
            return False

        lo = 0
        hi = max(map(max, heights)) - min(map(min, heights))
        while lo < hi:
            mid = (lo + hi) // 2
            if can_reach(mid):
                hi = mid                            # works: try smaller
            else:
                lo = mid + 1                        # too strict: go bigger
        return lo

7Code line by line

linewhat it means
def can_reach(limit):The yes/no question for one limit.
visited[0][0] = True queue = deque([(0, 0)])Plain BFS from the start. Mark when adding, so no cell is added twice.
abs(...) <= limitThe only extra condition: this step must not be steeper than the limit.
hi = max(map(max, heights)) - min(map(min, heights))The biggest jump that could ever exist. With this limit every step is allowed, so can_reach(hi) is always True.
mid = (lo + hi) // 2Try the middle limit.
hi = midmid works, so the answer is ≤ mid. Keep mid in the range (it might be the answer).
lo = mid + 1mid fails, so every limit ≤ mid fails too. Drop them.
return lolo == hi: the smallest limit that works.

8Dry run

Same grid: max 8, min 1 → lo = 0, hi = 7.

steplo, himidcan_reach(mid)?whynew lo, hi
10, 73yespath Y (jumps 2,2,2,2) fits under 30, 3
20, 31nofrom 1 we reach the three 2s (top row and right middle), but every step out of that group is a jump of 2 or more (1→3, 2→8, 2→5)2, 3
32, 32yespath Y again2, 2

lo = hi = 2 ✓. Three BFS runs.

BFS with limit 1: reached cells (*)
  1* 2* 2*
  3  8  2*
  5  3  5    ← (2,2) needs a jump of 3 ✗
BFS with limit 2: reached cells (*)
  1* 2* 2*
  3* 8  2*
  5* 3* 5*   ← reached ✓

9Complexity, and which method to pick

Her comparison with this problem's constraints:

log factorwork (mn = 104)
Dijkstralog(104) ≈ 14≈ 1.4·105 (smaller here)
binary search, heights ≤ 106log(106) ≈ 20≈ 2·105
binary search, if heights were ≤ 100log(100) ≈ 7≈ 7·104 (would win)

So neither is always faster: it depends on the constraints. Her interview tip: ask the interviewer how large the values can get. Small heights with a big grid → binary search. Huge heights → Dijkstra. Both pass on LeetCode.

Remember Part B"Smallest X such that it works" + "works for X ⇒ works for anything bigger" → binary search on X. Check = BFS that only takes steps ≤ X (with a visited grid). hi = mid on yes, lo = mid + 1 on no.

Part C · Revision page

Dijkstra (Part A)Binary search + BFS (Part B)
ideaalways continue from the gentlest path so farguess a limit, check it with BFS, halve the range
containerheapq of (effort, r, c)deque of (r, c)
repeats stopped bynew < dist[nr][nc] + stale skipvisited grid
key linemax(effort, |jump|)|jump| <= limit
answer read whentarget is poppedlo == hi
timeO(mn log mn)O(mn log maxHeight)
the 4 Dijkstra problemsgraphpath costwhat changes
Dijkstra (25)undirected listsumreturn the dist list
Network Delay (26)directed, nodes 1..nsumreturn max of dist, or −1
Cheapest Flights (27)directedsum, with a stop limitcarry stops; cost-only pruning fails; BFS works
Minimum Effort (28)gridmax of jumps4 directions; + becomes max
If you remember only 5 lines 1. Effort of a path = its biggest jump, so new effort = max(old, |jump|).
2. Dijkstra still works because max never makes a path cheaper.
3. Heap of (effort, r, c), 4 directions with bounds check, no visited grid.
4. Return when the target is popped, not when it's pushed.
5. Binary search alternative: smallest limit for which a BFS with jumps ≤ limit reaches the end.
Mistakes to avoid ✗ adding jumps (effort + edge) instead of taking the max
✗ saving just the current jump (0) instead of the path's max (her "don't save 0" point)
✗ returning when the target is pushed (gives 3 instead of 2 here)
✗ forgetting the bounds check
✗ [[inf] * n] * m (every row is the same list)
✗ in binary search, hi = mid - 1 on success (you may skip the answer)
test it yourself (paste under either solution)
s = Solution()
print(s.minimumEffortPath([[1, 2, 2], [3, 8, 2], [5, 3, 5]]))      # 2
print(s.minimumEffortPath([[1, 2, 3], [3, 8, 4], [5, 3, 5]]))      # 1
print(s.minimumEffortPath([[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]]))   # 0
print(s.minimumEffortPath([[7]]))                                  # 0
print(s.minimumEffortPath([[1, 10, 1]]))                           # 9

Based on this video: Path With Minimum Effort