Shortest Path & MST
When edges have costs, plain BFS no longer finds the cheapest route. A min-heap fixes that: always expand the cheapest thing you know about next. Dijkstra uses it to find shortest paths; Prim uses it to connect everything as cheaply as possible.
1. What is this pattern for?
Two classic heap-driven algorithms on weighted graphs:
- Dijkstra (single-source shortest paths). Distances from one start to every node, when all edge weights are non-negative. Network delay, cheapest flights (without a stop limit), path with minimum effort, swim in rising water.
- Prim (minimum spanning tree, MST). The cheapest set of edges that connects all nodes, with no single start and end. Connect all points, cheapest cabling.
They look almost identical in code. The only difference is the number pushed onto the heap: Dijkstra pushes the total distance from the start, Prim pushes just the edge weight.
2. How it works (bisected)
- Build an adjacency list
node → [(neighbour, weight), …](or compute edges on the fly for a complete graph of points). - Push the start onto a min-heap:
(0, start). - Pop the cheapest entry. If its node is already settled, skip it (a stale entry). Otherwise settle it: its value is final.
- Relax its edges. Push
(dist + w, nbr)for Dijkstra, or(w, nbr)for Prim, for every unsettled neighbour. - Stop when the heap is empty (Dijkstra) or when every node is in the tree (Prim).
3. Seeing a problem: how to spot Shortest Path / MST
Trigger words
- "minimum time / cost / effort to reach", "network delay", "cheapest path" with weights
- "signal reaches all nodes", "how long until everyone receives it"
- "connect all points / cities / houses with minimum total cost"
- "minimum spanning tree", "every pair connected"
- a grid where moving into a cell costs its value or the height difference
Shape of the input
- An edge list
(u, v, w)withw ≥ 0, or points whose pairwise distance is the cost. - A grid treated as a graph with per-move costs.
- Brute-force smell: BFS that ignores weights, or trying every path / every subset of edges.
4. When to use it, and when not to
| Use Dijkstra / Prim when… | Prefer something else when… |
|---|---|
| Weights differ and are non-negative | Every edge costs the same: plain BFS is simpler and O(V + E) |
| You need shortest distances from one source | Edges can be negative: Bellman–Ford (also handles "at most k stops") |
| You must connect all nodes at minimum total cost | Edges are given as a sparse list: Kruskal (sort edges + union-find) is often easier |
| A grid with per-cell costs (minimum effort, swim in water) | Weights are only 0 or 1: 0-1 BFS with a deque |
Cost: O(E log E) time with a binary heap and lazy deletion, O(V + E) space. On a complete graph of n points, Prim is O(n² log n) with a heap, or O(n²) with a simple array.
5. The Python template (memorize this)
import heapq
# A) Dijkstra: shortest distance from start to every node (weights >= 0)
def dijkstra(graph, start):
dist = {} # node -> final shortest distance
heap = [(0, start)]
while heap:
d, node = heapq.heappop(heap)
if node in dist:
continue # stale entry: already settled
dist[node] = d
for nbr, w in graph.get(node, []):
if nbr not in dist:
heapq.heappush(heap, (d + w, nbr)) # TOTAL distance
return dist
# B) Prim: total weight of a minimum spanning tree
def prim(n, edges_of):
in_tree = set()
heap = [(0, 0)] # (edge weight, node); node 0 joins for free
total = 0
while len(in_tree) < n:
w, node = heapq.heappop(heap)
if node in in_tree:
continue
in_tree.add(node)
total = total + w
for nbr, cost in edges_of(node):
if nbr not in in_tree:
heapq.heappush(heap, (cost, nbr)) # just the EDGE weight
return total
What the code is doing, line by line:
heapqkeeps the smallest tuple atheap[0]; tuples compare by their first item, so the cheapest entry is popped first.if node in dist: continue: a node may be pushed several times with different values. The first pop is the smallest, so later ones are stale. Skipping them is called lazy deletion.- Dijkstra settles a node the first time it is popped: with non-negative weights, any other route would go through something at least as far away, so it can't be shorter.
(d + w, nbr)vs(cost, nbr): the one-character difference between the algorithms. Dijkstra cares about the whole route from the start; Prim only about the edge that attaches the next node to the tree.- Prim stops when all
nnodes are in the tree; it then contains exactlyn − 1edges.
Dry run of template A on the graph A→B (4), A→C (1), C→B (2), B→D (1), start A:
| pop | action | pushed |
|---|---|---|
| (0, A) | settle A = 0 | (4, B), (1, C) |
| (1, C) | settle C = 1 | (3, B) |
| (3, B) | settle B = 3 (not 4: the route through C is cheaper) | (4, D) |
| (4, B) | stale, B already settled → skip | — |
| (4, D) | settle D = 4 | — |
A BFS would have reached B "first" through the direct edge with cost 4; Dijkstra correctly finds 3.
6. How to remember it forever
How this hook works: what “Closest unvisited city next” means
What the slogan means, phrase by phrase
- “Closest unvisited city next”
- Always settle the node with the smallest known total distance. That node is final, because every road is non-negative, so a detour can't come back cheaper.
- “Pop the cheapest, settle it, push its neighbours”
- The loop:
heappop, skip it if already settled, record it, push the neighbours. - “Dijkstra pushes the trip total; Prim pushes just the last road”
- The same loop answers two questions: shortest paths (key =
d + w) and minimum spanning tree (key =w).
Why this picture fits the pattern: Weighted graphs break BFS: fewer roads isn't the same as fewer minutes. A min-heap that always hands you the closest unsettled city fixes that, and the hook says what goes into the heap for each of the two algorithms.
The story → the code
| In the story | In the code |
|---|---|
| Cities you could drive to next | heap of (distance, node) |
| The closest one | d, node = heapq.heappop(heap) |
| Already visited | if node in dist: continue |
| Settle it | dist[node] = d |
| Dijkstra: trip total | heappush(heap, (d + w, nbr)) |
| Prim: just the last road | heappush(heap, (w, nbr)) |
Watch it on numbers
Roads A–B 4, A–C 1, C–B 2, B–D 1. Start at A.
- Settle A at 0. Push B (4) and C (1).
- Closest is C (1): settle it. Push B via C: 1 + 2 = 3.
- Closest is B (3, not 4): settle it. Push D: 4.
- Pop B (4): already settled, so skip this stale entry. Settle D at 4. Result A 0, C 1, B 3, D 4.
The trap it saves you from
Negative edges break "settled is final" (use Bellman-Ford). Not skipping stale heap entries does wasted or wrong work. Running BFS on weighted edges finds the fewest roads, not the cheapest route.
It still works when…
Network Delay Time is the largest settled distance. Cheapest Flights Within K Stops adds the stop count to the state. Min Cost to Connect All Points is Prim: push only the last road.
Picture
Ink spreading on paper that soaks in faster along cheap edges: each node turns dark at exactly its shortest distance.
Chant
"Pop, skip if seen, settle, push. Total for Dijkstra, edge for Prim."
Safety rule
"No negative edges." Dijkstra's "settled is final" breaks if an edge can lower a distance later.
7. LeetCode problems, solved step by step
Medium 743. Network Delay Time Start here Google Nvidia Amazon Netflix
Problem: times[i] = (u, v, w) is a directed edge from node u to v taking w time. A signal starts at k. Return the time until all n nodes have received it, or -1 if some never do.
Solve it on LeetCode ↗ · Video walkthrough ↗
times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 22times = [[1,2,1]], n = 2, k = 11Constraints: 1 ≤ k ≤ n ≤ 100 · 1 ≤ times.length ≤ 6000 · times[i].length == 3 · 1 ≤ ui, vi ≤ n · ui != vi · 0 ≤ wi ≤ 100 · All the pairs (ui, vi) are unique. (i.e., no multiple edges.)
Hint 1
You need the shortest travel time from k to every node, with different edge weights.
Hint 2
Dijkstra with a min-heap; the answer is the largest shortest time, or −1 if some node is unreachable.
From brute force to the pattern: BFS ignores weights; trying all paths is exponential; Bellman–Ford is O(V · E). With non-negative weights Dijkstra is O(E log E).
Spot it: Shortest travel times from one source with non-negative weights → Dijkstra. The answer is the largest of those shortest times (the last node to hear the signal).
Approach:
- Build an adjacency list
u → [(v, w), …]. - Push
(0, k)onto a min-heap.diststores each node's final shortest time. - Pop the smallest time. If that node is already settled, skip it; otherwise settle it and push
(time + w, v)for every unsettled neighbour. - If all
nnodes are settled, return the largest time; else-1.
import heapq
class Solution:
def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int:
graph = {}
for u, v, w in times:
if u not in graph:
graph[u] = []
graph[u].append((v, w))
dist = {} # node -> final shortest time
heap = [(0, k)] # (time so far, node)
while heap:
time, node = heapq.heappop(heap)
if node in dist:
continue # already settled by a faster route
dist[node] = time
for nxt, w in graph.get(node, []):
if nxt not in dist:
heapq.heappush(heap, (time + w, nxt))
if len(dist) < n:
return -1 # some node never received the signal
return max(dist.values())
What the code is doing, line by line:
graph: adjacency list of outgoing edges with their weights.heap = [(0, k)]: tuples compare by their first item, so the heap always pops the smallest known time.if node in dist: continue: a node can be pushed several times with different times; only the first pop (the smallest) counts. This "lazy deletion" replaces a decrease-key operation.dist[node] = time: final. With non-negative weights, no later route can be shorter than the smallest time on the heap.heapq.heappush(heap, (time + w, nxt)): a candidate time for each neighbour.if len(dist) < n: return -1, elsemax(dist.values()): everyone must be reached, and the slowest arrival is the answer.
Dry run on the example:
times = [[2, 1, 1], [2, 3, 1], [3, 4, 1]], n = 4, k = 2 (LeetCode Example 1). Returns 2.
| pop (time, node) | action | pushed | dist after |
|---|---|---|---|
| (0, 2) | settle 2 | (1, 1), (1, 3) | {2: 0} |
| (1, 1) | settle 1 (no outgoing edges) | — | {2: 0, 1: 1} |
| (1, 3) | settle 3 | (2, 4) | {2: 0, 1: 1, 3: 1} |
| (2, 4) | settle 4 (no outgoing edges) | — | {2: 0, 1: 1, 3: 1, 4: 2} |
All 4 nodes are settled; the largest time is 2.
Edge cases to test:
- An unreachable node → −1.
- A single node → 0.
- Several edges between the same pair.
Complexity: O(E log E) time with a binary heap, O(V + E) space.
Medium 1584. Min Cost to Connect All Points Start here Google Amazon
Problem: Connect all points on a plane so every pair is connected by some path. The cost of an edge between two points is their Manhattan distance |x1 − x2| + |y1 − y2|. Return the minimum total cost.
Solve it on LeetCode ↗ · Video walkthrough ↗
points = [[0,0],[2,2],[3,10],[5,2],[7,0]]20points = [[3,12],[-2,5],[-4,1]]18Constraints: 1 ≤ points.length ≤ 1000 · -10⁶ ≤ xi, yi ≤ 10⁶ · All pairs (xi, yi) are distinct.
Hint 1
Connecting everything at minimum total cost is a minimum spanning tree.
Hint 2
Prim's algorithm: grow from point 0, always adding the cheapest edge out of the tree (min-heap keyed by Manhattan distance).
From brute force to the pattern: Trying all spanning trees is exponential. Prim's with a heap is O(n² log n) on this complete graph (or O(n²) with an array); Kruskal sorts the n² edges.
Spot it: "Connect everything as cheaply as possible" is a minimum spanning tree. The graph is complete (every pair can be connected), so Prim's algorithm, which grows one tree from a start point, is a natural fit.
Approach:
- Start the tree with point 0: push
(0, 0)= (cost to attach, point) onto a min-heap. - Pop the cheapest entry. If that point is already in the tree, skip it. Otherwise add it and its cost to the total.
- Push
(distance, j)for every pointjnot yet in the tree. - Stop when all points are in the tree.
import heapq
class Solution:
def minCostConnectPoints(self, points: List[List[int]]) -> int:
n = len(points)
in_tree = [False] * n
heap = [(0, 0)] # (cost to attach this point, point index)
total = 0
added = 0
while added < n:
cost, i = heapq.heappop(heap)
if in_tree[i]:
continue # already attached more cheaply
in_tree[i] = True
total = total + cost
added = added + 1
xi, yi = points[i]
for j in range(n):
if not in_tree[j]:
xj, yj = points[j]
heapq.heappush(heap, (abs(xi - xj) + abs(yi - yj), j))
return total
What the code is doing, line by line:
in_tree: which points are already connected.heap = [(0, 0)]: attaching the first point is free.if in_tree[i]: continue: a point can be on the heap several times; only its cheapest entry (popped first) is used.total = total + cost: the cheapest edge leaving the tree is always safe to add. That's the cut property that makes Prim correct.for j in range(n): ... heappush(...): new candidate edges from the point we just added.while added < n: a spanning tree is finished when allnpoints are in it (n − 1edges).
Dry run on the example:
points = [[0, 0], [2, 2], [3, 10], [5, 2], [7, 0]] (LeetCode Example 1). Returns 20.
| pop (cost, point) | action | total |
|---|---|---|
| (0, 0) | add point 0 [0, 0] | 0 |
| (4, 1) | add point 1 [2, 2] | 4 |
| (3, 3) | add point 3 [5, 2] | 7 |
| (4, 4) | add point 4 [7, 0] | 11 |
| (7, 3) | already in the tree → skip | 11 |
| (7, 4) | already in the tree → skip | 11 |
| (7, 4) | already in the tree → skip | 11 |
| (9, 2) | add point 2 [3, 10] | 20 |
Edge cases to test:
- A single point → 0.
- Duplicate points (distance 0).
- Collinear points.
Complexity: O(n² log n) time (up to n² heap pushes), O(n²) heap space. (An array-based Prim without a heap is O(n²) time, O(n) space.)
8. Common mistakes
if node in dist: continue, a node is processed several times and its neighbours get wrong (too large) distances pushed again.d + w builds a shortest-path tree, which is not the minimum spanning tree.-1 if some node is never settled. Check len(dist) == n.9. Self-check quiz
Shortest path in a grid where every move costs 1. Which algorithm?
log factors for nothing.In Dijkstra, when is a node's distance final?
In Prim's algorithm, what number is pushed with a neighbour?
The graph has a negative edge weight (but no negative cycle). Which algorithm still works?
10. Practice list (in order)
| # | Problem | Level | Data structure | Twist |
|---|---|---|---|---|
| 743 | Network Delay Time | Medium | Dijkstra: always settle the closest unsettled node next | |
| 1584 | Min Cost to Connect All Points | Medium | Prim's MST: grow one tree, always adding the cheapest edge out of it | |
| 1091 | Shortest Path in Binary Matrix | Medium | Solved in 12 · Breadth-First Search (BFS): 8-direction grid BFS, count cells | |
| 787 | Cheapest Flights Within K Stops | Medium | Bellman–Ford limited to k + 1 rounds, relaxing from a copy of the previous round | |
| 1514 | Path with Maximum Probability | Medium | Dijkstra with a max-heap on probability (multiplying, never increasing) | |
| 1631 | Path With Minimum Effort | Medium | Dijkstra where a path's cost is its largest step, not the sum | |
| 2812 | Find the Safest Path in a Grid | Medium | Multi-source BFS from thieves gives each cell's safeness; then a max-min Dijkstra path | |
| 3286 | Find a Safe Walk Through a Grid | Medium | 0-1 BFS: the cheapest path counts unsafe cells; safe if that count < health | |
| 778 | Swim in Rising Water | Hard | Solved in 13 · Matrix Traversal: Dijkstra / heap-BFS on the grid (see heaps) | |
| 1293 | Shortest Path in a Grid with Obstacles Elimination | Hard | Solved in 12 · Breadth-First Search (BFS): State = (r, c, removals left) | |
| 1489 | Find Critical and Pseudo-Critical Edges in Minimum Spanning Tree | Hard | Kruskal three ways: normal, without edge e (critical if worse), with e forced first (pseudo if equal) | |
| 3600 | Maximize Spanning Tree Stability with Upgrades | Hard | Binary search the stability x; check with union-find: must edges, then edges ≥ x, then upgradable edges (count ≤ k) | |
| 4003 | Minimum Cost Path with Alternating Directions III | Hard | Dijkstra over (cell, parity of the next action); moves, penalised moves and waits are the edges | |
| 3620 | Network Recovery Pathways | Hard | Binary search the score x; keep edges with cost ≥ x and check the cheapest path in the DAG is ≤ k | |
| 3928 | Minimum Cost to Buy Apples II | Hard | Dijkstra on a two-layer graph: empty (cost) and carrying (cost·tax); buying apples moves you between layers | |
| 1928 | Minimum Cost to Reach Destination in Time | Hard | DP over (time used, city): relax every edge for every time value; fees are the cost, time the budget |
This page's own problems are solved step by step in section 7. Rows marked "Solved in …" link to the page where that problem is worked through.
Medium 787. Cheapest Flights Within K Stops Google Meta Amazon Cisco Apple
Problem: Return the cheapest price from src to dst using at most k stops (k + 1 flights), or −1.
n = 4, flights = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]], src = 0, dst = 3, k = 1700n = 3, flights = [[0,1,100],[1,2,100],[0,2,500]], src = 0, dst = 2, k = 1200Constraints: 2 ≤ n ≤ 100 · 0 ≤ flights.length ≤ (n * (n - 1) / 2) · flights[i].length == 3 · 0 ≤ fromi, toi < n · fromi != toi · 1 ≤ pricei ≤ 10⁴ · There will not be any multiple flights between two cities. · 0 ≤ src, dst, k < n · src != dst
Hint 1
The limit is on the number of edges. Which shortest-path algorithm counts edges in rounds?
Hint 2
Run Bellman–Ford for k + 1 rounds; be careful to use the previous round's values.
From brute force to the pattern: DFS over all routes is exponential; limited Bellman–Ford is O(kE).
Spot it: A shortest path with a limit on the number of edges. Bellman–Ford's round i finds the best prices using at most i edges, so run exactly k + 1 rounds. Relax from a copy of the previous round so one round never chains two flights.
Why not another pattern? Plain Dijkstra ignores the stop limit: a cheaper path with too many stops can block a valid one.
Approach:
price[src] = 0, others ∞.- Repeat
k + 1times:new = price[:]; for each flight(u, v, w):new[v] = min(new[v], price[u] + w). - Return
price[dst]or −1.
class Solution:
def findCheapestPrice(self, n: int, flights: List[List[int]], src: int, dst: int, k: int) -> int:
INF = float("inf")
price = [INF] * n
price[src] = 0
for _ in range(k + 1): # at most k + 1 flights
new = price[:] # read old values so each round adds only one flight
for u, v, w in flights:
if price[u] + w < new[v]:
new[v] = price[u] + w
price = new
return price[dst] if price[dst] < INF else -1
What the code is doing, line by line:
- After round
i,price[v]is the cheapest cost using at mostiflights. - Reading from
price(old) while writing tonewprevents using two flights in one round.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
src == dst.- A cheap route with too many stops.
- No route → −1.
Complexity: O(k · E) time, O(n) space.
Medium 1514. Path with Maximum Probability Google
Problem: Edges have success probabilities. Return the maximum probability of a path from start_node to end_node.
n = 3, edges = [[0,1],[1,2],[0,2]], succProb = [0.5,0.5,0.2], start = 0, end = 20.25000n = 3, edges = [[0,1],[1,2],[0,2]], succProb = [0.5,0.5,0.3], start = 0, end = 20.30000Constraints: 2 ≤ n ≤ 10^4 · 0 ≤ start, end < n · start != end · 0 ≤ a, b < n · a != b · 0 ≤ succProb.length == edges.length ≤ 2*10^4 · 0 ≤ succProb[i] ≤ 1 · There is at most one edge between every two nodes.
Hint 1
How does a path's probability change as it gets longer?
Hint 2
That's like non-negative edge weights: use Dijkstra with max instead of min.
From brute force to the pattern: Bellman–Ford is O(VE); Dijkstra is O(E log V).
Spot it: Multiplying by numbers ≤ 1 never increases the probability, just as adding non-negative weights never decreases a distance → Dijkstra with a max-heap.
Why not another pattern? BFS ignores weights; Bellman–Ford is O(VE).
Approach:
best[start] = 1; heap of(−prob, node).- Pop the most probable node; skip stale entries; stop at the end node.
- Relax:
p · edge_prob > best[v]→ update and push.
import heapq
class Solution:
def maxProbability(self, n: int, edges: List[List[int]], succProb: List[float], start_node: int, end_node: int) -> float:
adj = [[] for _ in range(n)]
for (a, b), p in zip(edges, succProb):
adj[a].append((b, p))
adj[b].append((a, p))
best = [0.0] * n
best[start_node] = 1.0
heap = [(-1.0, start_node)] # max-heap via negation
while heap:
neg, u = heapq.heappop(heap)
p = -neg
if u == end_node:
return p # the first time we pop it, it's optimal
if p < best[u]:
continue # stale entry
for v, q in adj[u]:
if p * q > best[v]:
best[v] = p * q
heapq.heappush(heap, (-best[v], v))
return 0.0
What the code is doing, line by line:
- Probabilities only shrink along a path, which is what makes Dijkstra's greedy choice valid.
- Stale heap entries are skipped by comparing with
best.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- No path → 0.
- start == end → 1 (not in constraints but handled).
- Several paths with equal probability.
Complexity: O(E log V) time, O(V + E) space.
Medium 1631. Path With Minimum Effort Start here Google Amazon
Problem: A path's effort is its largest absolute height difference between consecutive cells. Return the minimum effort from top-left to bottom-right.
heights = [[1,2,2],[3,8,2],[5,3,5]]2heights = [[1,2,3],[3,8,4],[5,3,5]]1Constraints: rows == heights.length · columns == heights[i].length · 1 ≤ rows, columns ≤ 100 · 1 ≤ heights[i][j] ≤ 10⁶
Hint 1
The cost of a path is its maximum step. Does Dijkstra still work with max instead of +?
Hint 2
Alternatively, binary-search the effort and check reachability with BFS.
From brute force to the pattern: All paths are exponential; binary search + BFS is O(mn log H); Dijkstra is O(mn log mn).
Spot it: Minimising a maximum along a path still works with Dijkstra: the cost max(effort so far, step) never decreases along a path.
Why not another pattern? Plain BFS ignores the costs; DP right/down only misses paths that go up or left.
Approach:
effort[0][0] = 0; heap(0, 0, 0).- Pop the smallest effort; for each neighbour:
e = max(effort, |Δh|); if smaller, update and push. - Return when the target is popped.
import heapq
class Solution:
def minimumEffortPath(self, heights: List[List[int]]) -> int:
rows, cols = len(heights), len(heights[0])
effort = [[float("inf")] * cols for _ in range(rows)]
effort[0][0] = 0
heap = [(0, 0, 0)]
while heap:
e, r, c = heapq.heappop(heap)
if (r, c) == (rows - 1, cols - 1):
return e
if e > effort[r][c]:
continue # stale
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if 0 <= nr < rows and 0 <= nc < cols:
ne = max(e, abs(heights[nr][nc] - heights[r][c])) # the path's worst step
if ne < effort[nr][nc]:
effort[nr][nc] = ne
heapq.heappush(heap, (ne, nr, nc))
return 0
What the code is doing, line by line:
- The cost of extending a path is
max, which is monotone, so Dijkstra's first pop of a cell is optimal. - The answer is returned as soon as the target is popped.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- A 1×1 grid → 0.
- A detour that avoids a big step.
- All equal heights → 0.
Complexity: O(mn log(mn)) time, O(mn) space.
Medium 2812. Find the Safest Path in a Grid Google Meta
Problem: A path's safeness is the minimum distance from any of its cells to the nearest thief. Maximise it from (0, 0) to (n−1, n−1).
grid = [[1,0,0],[0,0,0],[0,0,1]]0grid = [[0,0,1],[0,0,0],[0,0,0]]2Constraints: 1 ≤ grid.length == n ≤ 400 · grid[i].length == n · grid[i][j] is either 0 or 1. · There is at least one thief in the grid.
Hint 1
First compute each cell's distance to the nearest thief.
Hint 2
Then find the path whose smallest distance is as large as possible.
From brute force to the pattern: BFS from every cell to find thieves is O(n⁴); multi-source BFS + Dijkstra is O(n² log n).
Spot it: Two steps: multi-source BFS from all thieves computes each cell's distance to the nearest thief. Then maximise the minimum along a path: Dijkstra with a max-heap (the bottleneck path).
Why not another pattern? Computing distances by BFS from every cell is O(n⁴).
Approach:
- BFS from all thieves at once:
dist[r][c]. - Max-heap from (0, 0) with value
dist[0][0]; the value of a path ismin(path value, dist[cell]). - Return when the target is popped.
import heapq
from collections import deque
class Solution:
def maximumSafenessFactor(self, grid: List[List[int]]) -> int:
n = len(grid)
dist = [[-1] * n for _ in range(n)]
queue = deque()
for r in range(n):
for c in range(n):
if grid[r][c] == 1:
dist[r][c] = 0 # every thief is a BFS source
queue.append((r, c))
while queue:
r, c = queue.popleft()
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if 0 <= nr < n and 0 <= nc < n and dist[nr][nc] < 0:
dist[nr][nc] = dist[r][c] + 1
queue.append((nr, nc))
best = [[-1] * n for _ in range(n)]
best[0][0] = dist[0][0]
heap = [(-dist[0][0], 0, 0)] # max-heap on the path's safeness
while heap:
neg, r, c = heapq.heappop(heap)
safe = -neg
if (r, c) == (n - 1, n - 1):
return safe
if safe < best[r][c]:
continue
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if 0 <= nr < n and 0 <= nc < n:
s = min(safe, dist[nr][nc]) # the path is only as safe as its worst cell
if s > best[nr][nc]:
best[nr][nc] = s
heapq.heappush(heap, (-s, nr, nc))
return 0
What the code is doing, line by line:
- Multi-source BFS layer
dis exactly the cells at distancedfrom the nearest thief. - Safeness along a path only decreases (
min), so Dijkstra with a max-heap finds the best bottleneck.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- A thief at the start or end → 0.
- One thief.
- Grid of size 1.
Complexity: O(n² log n) time, O(n²) space.
Medium 3286. Find a Safe Walk Through a Grid Google
Problem: Unsafe cells cost 1 health. Can you get from (0, 0) to (m−1, n−1) and still have health ≥ 1?
grid = [[0,1,0,0,0],[0,1,0,1,0],[0,0,0,1,0]], health = 1truegrid = [[0,1,1,0,0,0],[1,0,1,0,0,0],[0,1,1,1,0,1],[0,0,1,0,1,0]], health = 3falseConstraints: m == grid.length · n == grid[i].length · 1 ≤ m, n ≤ 50 · 2 ≤ m * n · 1 ≤ health ≤ m + n · grid[i][j] is either 0 or 1.
Hint 1
You need the path with the fewest unsafe cells.
Hint 2
Edge weights are 0 or 1: use a deque.
From brute force to the pattern: Trying all paths is exponential; 0-1 BFS is linear.
Spot it: Minimise the number of unsafe cells on a path: a shortest path with weights 0 and 1 → 0-1 BFS with a deque (push front for cost 0, back for cost 1).
Why not another pattern? Plain BFS counts steps, not unsafe cells; Dijkstra works but 0-1 BFS is simpler and linear.
Approach:
cost[0][0] = grid[0][0]; deque with the start.- Pop front; for each neighbour
nc = cost + grid[nr][nc]; if better, update; push front if the cell is safe, back otherwise. - Return
cost[end] < health.
from collections import deque
class Solution:
def findSafeWalk(self, grid: List[List[int]], health: int) -> bool:
m, n = len(grid), len(grid[0])
cost = [[float("inf")] * n for _ in range(m)] # fewest unsafe cells to reach each cell
cost[0][0] = grid[0][0]
dq = deque([(0, 0)])
while dq:
r, c = dq.popleft()
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if 0 <= nr < m and 0 <= nc < n:
w = grid[nr][nc]
if cost[r][c] + w < cost[nr][nc]:
cost[nr][nc] = cost[r][c] + w
if w == 0:
dq.appendleft((nr, nc)) # free step: same distance level
else:
dq.append((nr, nc)) # costs one health
return cost[m - 1][n - 1] < health # at least 1 health left
What the code is doing, line by line:
- The deque stays sorted by cost: cost-0 moves go to the front, cost-1 moves to the back.
- The start cell counts too if it's unsafe.
- Health stays positive if the unsafe cells on the best path are fewer than
health.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Unsafe start cell.
- Health exactly equal to the unsafe count → false.
- A detour around unsafe cells.
Complexity: O(m · n) time and space.
Hard 1489. Find Critical and Pseudo-Critical Edges in Minimum Spanning Tree Google
Problem: Return the indices of critical MST edges (removing them increases the MST weight) and pseudo-critical ones (in some MST, not all).
n = 5, edges = [[0,1,1],[1,2,1],[2,3,2],[0,3,2],[0,4,3],[3,4,3],[1,4,6]][[0,1],[2,3,4,5]]n = 4, edges = [[0,1,1],[1,2,1],[2,3,1],[0,3,1]][[],[0,1,2,3]]Constraints: 2 ≤ n ≤ 100 · 1 ≤ edges.length ≤ min(200, n * (n - 1) / 2) · edges[i].length == 3 · 0 ≤ ai < bi < n · 1 ≤ weighti ≤ 1000 · All pairs (ai, bi) are distinct.
Hint 1
Compute the MST weight once.
Hint 2
For each edge, what happens to the MST weight if you remove it? If you force it in?
From brute force to the pattern: Listing every MST is exponential; two Kruskal runs per edge are O(E²).
Spot it: With ≤ 200 edges, test each edge with Kruskal + union-find: skipping it makes the MST heavier (or disconnected) → critical; otherwise, forcing it in first still gives the optimal weight → pseudo-critical.
Why not another pattern? Enumerating all MSTs is exponential.
Approach:
- Sort edge indices by weight;
best = kruskal(). - For each edge
e:kruskal(skip=e) > best→ critical. - Else
kruskal(force=e) == best→ pseudo-critical.
class Solution:
def findCriticalAndPseudoCriticalEdges(self, n: int, edges: List[List[int]]) -> List[List[int]]:
order = sorted(range(len(edges)), key=lambda i: edges[i][2])
def kruskal(skip=-1, force=-1):
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
weight = 0
used = 0
if force >= 0:
a, b, w = edges[force]
parent[find(a)] = find(b) # this edge goes in first
weight += w
used += 1
for i in order:
if i == skip:
continue
a, b, w = edges[i]
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb # cheapest edge joining two components
weight += w
used += 1
return weight if used == n - 1 else float("inf") # inf: not connected
best = kruskal()
critical, pseudo = [], []
for i in range(len(edges)):
if kruskal(skip=i) > best:
critical.append(i) # every MST needs it
elif kruskal(force=i) == best:
pseudo.append(i) # some MST can use it
return [critical, pseudo]
What the code is doing, line by line:
- Kruskal adds the cheapest edge that connects two components; union-find detects that.
- If the graph without
ehas a heavier MST (or is disconnected),eis in every MST. - Otherwise, if an MST containing
estill has the optimal weight,eis in some but not all MSTs.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- All equal weights (everything pseudo-critical).
- A bridge edge (critical).
- Tie-breaking between equal weights.
Complexity: O(E² · α(n)) time after sorting, O(n) space.
Hard 3600. Maximize Spanning Tree Stability with Upgrades Google
Problem: Build a spanning tree containing all must edges; up to k optional edges may double their strength. Maximise the weakest edge of the tree, or −1.
n = 3, edges = [[0,1,2,1],[1,2,3,0]], k = 12n = 3, edges = [[0,1,4,0],[1,2,3,0],[0,2,1,0]], k = 26Constraints: 2 ≤ n ≤ 10⁵ · 1 ≤ edges.length ≤ 10⁵ · edges[i] = [ui, vi, si, musti] · 0 ≤ ui, vi < n · ui != vi · 1 ≤ si ≤ 10⁵ · musti is either 0 or 1. · 0 ≤ k ≤ n · There are no duplicate edges.
Hint 1
Maximise the minimum edge strength: binary search on the answer.
Hint 2
For a fixed target, which edges can be used for free, which need an upgrade, and which are useless?
Hint 3
Union-find: add must edges, then free edges, then upgraded edges, counting upgrades.
From brute force to the pattern: Enumerating spanning trees is exponential; binary search + three union-find passes is near-linear per check.
Spot it: "Maximise the minimum" → binary search on the stability x. For a fixed x, greedily build the tree with union-find: must edges first (each needs strength ≥ x), then optional edges already ≥ x (free), then edges that reach x only when doubled (each costs one upgrade).
Why not another pattern? Trying every spanning tree is exponential; a single maximum spanning tree ignores the must edges and the upgrade budget.
Approach:
- Must edges forming a cycle → −1. Graph not connectable even using every edge → −1.
feasible(x): union the must edges (fail if any has strength < x); add optional edges withs ≥ x; then those with2s ≥ x, counting upgrades; feasible if connected with ≤ k upgrades.- Binary-search the largest feasible
x.
class Solution:
def maxStability(self, n: int, edges: List[List[int]], k: int) -> int:
def build(x): # can we span the graph with every edge >= x? returns upgrades used, or -1
parent = list(range(n))
def find(a):
while parent[a] != a:
parent[a] = parent[parent[a]]
a = parent[a]
return a
joined = 0
for u, v, s, must in edges:
if must:
if s < x or find(u) == find(v):
return -1 # a required edge is too weak, or makes a cycle
parent[find(u)] = find(v)
joined += 1
for u, v, s, must in edges:
if not must and s >= x and find(u) != find(v):
parent[find(u)] = find(v) # strong enough as it is
joined += 1
upgrades = 0
for u, v, s, must in edges:
if not must and s < x <= 2 * s and find(u) != find(v):
parent[find(u)] = find(v) # needs doubling
joined += 1
upgrades += 1
return upgrades if joined == n - 1 else -1
if build(0) < 0:
return -1 # impossible even without a strength requirement
lo, hi = 0, 2 * max(s for _, _, s, _ in edges)
while lo < hi:
mid = (lo + hi + 1) // 2
used = build(mid)
if 0 <= used <= k:
lo = mid # stability mid is achievable
else:
hi = mid - 1
return lo
What the code is doing, line by line:
- Must edges are always in the tree, so they cap the stability and can't be upgraded.
- Using free edges before upgradable ones minimises upgrades (spanning trees form a matroid, so the greedy is optimal).
- If stability
xis achievable, every smaller value is too, so binary search applies. build(0)checks that a spanning tree exists at all (and that must edges have no cycle).
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Must edges with a cycle → −1.
- Disconnected graph → −1.
k = 0(no upgrades).
Complexity: O(E · α(n) · log S) time, O(n) space.
Hard 4003. Minimum Cost Path with Alternating Directions III Meta
Problem: Odd actions should move right/down, even actions left/up; violating moves and waits cost penalty of the current cell, and every move pays the destination's entry cost (i + 1)(j + 1). Return the minimum total cost to reach the bottom-right cell.
m = 2, n = 2, penalty = [[5,3],[1,4]]8m = 2, n = 2, penalty = [[0,7],[3,2]]7Constraints: 1 ≤ m, n ≤ 10⁵ · 2 ≤ m * n ≤ 10⁵ · penalty.length == m · penalty[i].length == n · 0 ≤ penalty[i][j] ≤ 10⁵
Hint 1
The cost of a move depends on whether the action number is odd or even. Add that to the state.
Hint 2
Waiting is just another edge that flips the parity.
Hint 3
All costs are positive: use Dijkstra.
From brute force to the pattern: Exploring action sequences is exponential; Dijkstra over 2·m·n states is near-linear.
Spot it: Costs depend on the cell and whether the next action is odd or even, so the state is (cell, parity). All costs are positive → Dijkstra on 2 · m · n states with up to 5 edges each (4 moves + wait).
Why not another pattern? A right/down DP misses that waiting (or stepping back) can be cheaper to fix the parity.
Approach:
- Start:
dist[(0, 0), odd] = 1(the entry cost of (0, 0)). - From
(r, c, p): each neighbour costs its entry cost, pluspenalty[r][c]if the direction violatesp; waiting costspenalty[r][c]. The parity flips either way. - Return the smallest distance to the target in either parity.
import heapq
class Solution:
def minCost(self, m: int, n: int, penalty: List[List[int]]) -> int:
INF = float("inf")
dist = [[[INF, INF] for _ in range(n)] for _ in range(m)] # [cell][next action is odd(0) / even(1)]
dist[0][0][0] = 1 # pay the entrance of (0, 0)
heap = [(1, 0, 0, 0)]
while heap:
d, r, c, p = heapq.heappop(heap)
if (r, c) == (m - 1, n - 1):
return d
if d > dist[r][c][p]:
continue # stale entry
q = 1 - p # parity after this action
options = [(r, c, penalty[r][c])] # wait
for nr, nc, forward in ((r + 1, c, True), (r, c + 1, True), (r - 1, c, False), (r, c - 1, False)):
if 0 <= nr < m and 0 <= nc < n:
follows = forward if p == 0 else not forward # odd: right/down, even: left/up
cost = (nr + 1) * (nc + 1) + (0 if follows else penalty[r][c])
options.append((nr, nc, cost))
for nr, nc, cost in options:
if d + cost < dist[nr][nc][q]:
dist[nr][nc][q] = d + cost
heapq.heappush(heap, (d + cost, nr, nc, q))
return -1
What the code is doing, line by line:
- Parity 0 means the next action is odd-numbered (right/down follow the rule); parity 1 means even (left/up follow it).
- Every action flips the parity, whether it was a move or a wait.
- All costs are positive (entry costs are at least 1), so Dijkstra's first pop of the target is optimal.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Waiting on a zero-penalty cell to fix the parity.
- A single row or column.
- Stepping back being cheaper than waiting.
Complexity: O(mn log(mn)) time, O(mn) space.
Hard 3620. Network Recovery Pathways Amazon
Problem: In a DAG with offline nodes, find a path from 0 to n−1 through online nodes with total cost ≤ k that maximises its smallest edge cost, or −1.
edges = [[0,1,5],[1,3,10],[0,2,3],[2,3,4]], online = [true,true,true,true], k = 103edges = [[0,1,7],[1,4,5],[0,2,6],[2,3,6],[3,4,2],[2,4,6]], online = [true,true,true,false,true], k = 126Hint 1
Maximise the minimum edge: binary search on it.
Hint 2
For a threshold, keep only edges at least that big and check the cheapest path against k.
Hint 3
The graph is a DAG: shortest paths come from a topological-order DP.
From brute force to the pattern: All paths can be exponential; binary search + DAG DP is O((n + m) log m).
Spot it: "Maximise the minimum edge" → binary search on the score x: using only edges with cost ≥ x, is the shortest path (a DP in topological order, since it's a DAG) at most k? Feasibility only gets harder as x grows.
Why not another pattern? Enumerating all paths in a DAG can be exponential.
Approach:
- Topological order of the DAG (Kahn).
ok(x): DPdist[v] = min(dist[u] + c)over edges withc ≥ xand online endpoints; feasible ifdist[n − 1] ≤ k.- Binary-search
xover the sorted distinct costs; −1 if evenx = 0fails.
from collections import deque
class Solution:
def findMaxPathScore(self, edges: List[List[int]], online: List[bool], k: int) -> int:
n = len(online)
out = [[] for _ in range(n)]
indeg = [0] * n
for u, v, c in edges:
out[u].append((v, c))
indeg[v] += 1
order = []
queue = deque(i for i in range(n) if indeg[i] == 0)
while queue:
u = queue.popleft()
order.append(u)
for v, _ in out[u]:
indeg[v] -= 1
if indeg[v] == 0:
queue.append(v)
def cheapest_with_min(x): # cheapest 0 -> n-1 path using only edges of cost >= x
INF = float("inf")
dist = [INF] * n
dist[0] = 0
for u in order:
if dist[u] == INF or not online[u]:
continue
for v, c in out[u]:
if c >= x and online[v] and dist[u] + c < dist[v]:
dist[v] = dist[u] + c
return dist[n - 1]
costs = sorted({c for _, _, c in edges})
if cheapest_with_min(0) > k:
return -1 # no valid path at all
lo, hi = 0, len(costs) - 1 # answer is one of the edge costs
while lo < hi:
mid = (lo + hi + 1) // 2
if cheapest_with_min(costs[mid]) <= k:
lo = mid # a path with every edge >= costs[mid] fits the budget
else:
hi = mid - 1
return costs[lo]
What the code is doing, line by line:
- The score of the best path is one of the edge costs, so searching over sorted costs suffices.
- Raising the threshold removes edges, so the cheapest path can only get more expensive: monotone.
- In a DAG, relaxing edges in topological order gives shortest paths in linear time.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- No valid path → −1.
- Offline intermediate nodes.
- Budget exactly equal to a path's cost.
Complexity: O((n + m) log m) time, O(n + m) space.
Hard 3928. Minimum Cost to Buy Apples II Amazon
Problem: From each shop i, either buy locally, or travel empty to shop j, buy there, and return carrying apples (roads cost cost · tax). Return the minimum for every shop.
n = 2, prices = [8,3], roads = [[0,1,1,2]][6,3]n = 3, prices = [9,4,6], roads = [[0,1,1,3],[1,2,4,2]][8,4,6]Constraints: 1 ≤ n ≤ 1000 · prices.length == n · 1 ≤ prices[i] ≤ 10⁹ · 0 ≤ roads.length ≤ min(n × (n - 1) / 2, 2000) · roads[i] = [ui, vi, costi, taxi] · 0 ≤ ui, vi ≤ n - 1 · ui != vi · 1 ≤ costi ≤ 10⁹ · 1 ≤ taxi ≤ 100 · There are no repeated edges.
Hint 1
The cost of a road depends on whether you're carrying apples.
Hint 2
Put "carrying or not" in the state: two layers of the graph, connected by buying.
From brute force to the pattern: Trying every shop j with separate shortest paths needs the same distances; the layered graph computes them in one search per start.
Spot it: Model the trip as a shortest path on two copies of the graph: layer 0 (travelling empty, edge cost cost) and layer 1 (carrying, edge cost cost · tax). Buying at shop j jumps from (j, 0) to (j, 1) for price[j]. Then ans[i] = distance from (i, 0) to (i, 1) → Dijkstra.
Why not another pattern? Trying every shop j with two separate shortest paths is the same work but more code; one layered Dijkstra per start handles it.
Approach:
- Adjacency list with
(v, cost, tax). - For each start
i: Dijkstra over states(node, carrying); buying is the edge(j, 0) → (j, 1)with weightprice[j]. - Stop when
(i, 1)is popped.
import heapq
class Solution:
def minCost(self, n: int, prices: List[int], roads: List[List[int]]) -> List[int]:
adj = [[] for _ in range(n)]
for u, v, cost, tax in roads:
adj[u].append((v, cost, tax))
adj[v].append((u, cost, tax))
answer = []
for start in range(n):
dist = {(start, 0): 0}
heap = [(0, start, 0)] # (cost so far, shop, carrying apples?)
while heap:
d, u, carry = heapq.heappop(heap)
if (u, carry) == (start, 1):
answer.append(d) # back home with apples
break
if d > dist.get((u, carry), float("inf")):
continue
moves = [] if carry else [(u, 1, prices[u])] # buy apples here
for v, cost, tax in adj[u]:
moves.append((v, carry, cost * tax if carry else cost)) # carrying is pricier
for v, c2, w in moves:
if d + w < dist.get((v, c2), float("inf")):
dist[(v, c2)] = d + w
heapq.heappush(heap, (d + w, v, c2))
return answer
What the code is doing, line by line:
- Layer 0 is "still looking for apples", layer 1 is "carrying apples home".
- The only way into layer 1 is buying, so every path from
(i, 0)to(i, 1)is a valid plan, including buying locally (zero roads). - Positive weights make Dijkstra exact; each start costs
O((n + m) log n).
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Buying locally is cheapest.
- No roads.
- High tax makes far shops useless.
Complexity: O(n · (n + m) log n) time, O(n + m) space (n ≤ 1000, m ≤ 2000).
Hard 1928. Minimum Cost to Reach Destination in Time Microsoft
Problem: Travel from city 0 to city n−1 within maxTime, paying each passed city's fee. Return the minimum total fee, or −1.
maxTime = 30, edges = [[0,1,10],[1,2,10],[2,5,10],[0,3,1],[3,4,10],[4,5,15]], passingFees = [5,1,2,20,20,3]11maxTime = 29, edges = [[0,1,10],[1,2,10],[2,5,10],[0,3,1],[3,4,10],[4,5,15]], passingFees = [5,1,2,20,20,3]48Constraints: 1 ≤ maxTime ≤ 1000 · n == passingFees.length · 2 ≤ n ≤ 1000 · n - 1 ≤ edges.length ≤ 1000 · 0 ≤ xi, yi ≤ n - 1 · 1 ≤ timei ≤ 1000 · 1 ≤ passingFees[j] ≤ 1000 · The graph may contain multiple edges between two nodes. · The graph does not contain self loops.
Hint 1
Dijkstra alone can't handle two quantities. Add time to the state.
Hint 2
DP over time: relax every edge at every time step.
From brute force to the pattern: Exploring all paths is exponential; the time-indexed DP is O(maxTime · E).
Spot it: A shortest path with a resource limit: the state needs the time used. With maxTime ≤ 1000 and 1000 edges, a DP over time relaxing every edge is fast (a constrained Dijkstra also works).
Why not another pattern? Plain Dijkstra on fees ignores the time limit; on time it ignores fees.
Approach:
best[t][v]= min fee arriving atvat timet;best[0][0] = fee[0].- For
tascending, for each edge both ways:best[t + w][v] = min(best[t][u] + fee[v]). - Answer: min over
t ≤ maxTimeofbest[t][n − 1].
class Solution:
def minCost(self, maxTime: int, edges: List[List[int]], passingFees: List[int]) -> int:
n = len(passingFees)
INF = float("inf")
best = [[INF] * n for _ in range(maxTime + 1)] # best[t][v]: cheapest fees reaching v at time t
best[0][0] = passingFees[0]
for t in range(maxTime + 1):
row = best[t]
for u, v, w in edges:
if t + w > maxTime:
continue
if row[u] < INF and row[u] + passingFees[v] < best[t + w][v]:
best[t + w][v] = row[u] + passingFees[v] # drive u -> v
if row[v] < INF and row[v] + passingFees[u] < best[t + w][u]:
best[t + w][u] = row[v] + passingFees[u] # drive v -> u
answer = min(best[t][n - 1] for t in range(maxTime + 1))
return answer if answer < INF else -1
What the code is doing, line by line:
- Times only increase along a path, so processing
tin increasing order sees every predecessor first. - Fees are paid on entering a city (and for the start city).
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Too far in the time limit → −1.
- Parallel roads with different times.
- Cheaper but slower route.
Complexity: O(maxTime · E) time, O(maxTime · n) space.