InterviewKata
Pattern 20 of 24

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?

Planning a road trip with a map that shows driving times. BFS counts roads: it would happily take three short hops over one long highway even if the highway is faster overall. Dijkstra counts minutes: it keeps a "closest city not yet visited" list and always drives to that one next, so when it reaches a city, no shorter route can still be waiting.

Two classic heap-driven algorithms on weighted graphs:

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)

  1. Build an adjacency list node → [(neighbour, weight), …] (or compute edges on the fly for a complete graph of points).
  2. Push the start onto a min-heap: (0, start).
  3. Pop the cheapest entry. If its node is already settled, skip it (a stale entry). Otherwise settle it: its value is final.
  4. Relax its edges. Push (dist + w, nbr) for Dijkstra, or (w, nbr) for Prim, for every unsettled neighbour.
  5. 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) with w ≥ 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.
The one question to ask"Do edges have different non-negative costs, and do I need the cheapest route (Dijkstra) or the cheapest way to connect everything (Prim)?"

4. When to use it, and when not to

Use Dijkstra / Prim when…Prefer something else when…
Weights differ and are non-negativeEvery edge costs the same: plain BFS is simpler and O(V + E)
You need shortest distances from one sourceEdges can be negative: Bellman–Ford (also handles "at most k stops")
You must connect all nodes at minimum total costEdges 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:

  1. heapq keeps the smallest tuple at heap[0]; tuples compare by their first item, so the cheapest entry is popped first.
  2. 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.
  3. 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.
  4. (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.
  5. Prim stops when all n nodes are in the tree; it then contains exactly n − 1 edges.

Dry run of template A on the graph A→B (4), A→C (1), C→B (2), B→D (1), start A:

popactionpushed
(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

Memory hook: "Closest unvisited city next"Pop the cheapest, settle it, push its neighbours. Dijkstra pushes the trip total; Prim pushes just the last road.
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 storyIn the code
Cities you could drive to nextheap of (distance, node)
The closest oned, node = heapq.heappop(heap)
Already visitedif node in dist: continue
Settle itdist[node] = d
Dijkstra: trip totalheappush(heap, (d + w, nbr))
Prim: just the last roadheappush(heap, (w, nbr))

Watch it on numbers

Roads A–B 4, A–C 1, C–B 2, B–D 1. Start at A.

  1. Settle A at 0. Push B (4) and C (1).
  2. Closest is C (1): settle it. Push B via C: 1 + 2 = 3.
  3. Closest is B (3, not 4): settle it. Push D: 4.
  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

New to this pattern? Start with theseSolve 743. Network Delay Time, then 1584. Min Cost to Connect All Points, then 1631. Path With Minimum Effort. They are marked Start here below. Read the explanation, press Play on the step-through, then close the page and solve it yourself on LeetCode.
Medium 743. Network Delay Time Start here Google Nvidia Amazon Netflix GraphHeap

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.

Example 1
Input: times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2
Output: 2
Example 2
Input: times = [[1,2,1]], n = 2, k = 1
Output: 1

Constraints: 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:

  1. Build an adjacency list u → [(v, w), …].
  2. Push (0, k) onto a min-heap. dist stores each node's final shortest time.
  3. Pop the smallest time. If that node is already settled, skip it; otherwise settle it and push (time + w, v) for every unsettled neighbour.
  4. If all n nodes 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:

  1. graph: adjacency list of outgoing edges with their weights.
  2. heap = [(0, k)]: tuples compare by their first item, so the heap always pops the smallest known time.
  3. 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.
  4. dist[node] = time: final. With non-negative weights, no later route can be shorter than the smallest time on the heap.
  5. heapq.heappush(heap, (time + w, nxt)): a candidate time for each neighbour.
  6. if len(dist) < n: return -1, else max(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)actionpusheddist 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 GraphHeap

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.

Example 1
Input: points = [[0,0],[2,2],[3,10],[5,2],[7,0]]
Output: 20
Explanation: We can connect the points as shown above to get the minimum cost of 20. Notice that there is a unique path between every pair of points.
Example 2
Input: points = [[3,12],[-2,5],[-4,1]]
Output: 18

Constraints: 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:

  1. Start the tree with point 0: push (0, 0) = (cost to attach, point) onto a min-heap.
  2. Pop the cheapest entry. If that point is already in the tree, skip it. Otherwise add it and its cost to the total.
  3. Push (distance, j) for every point j not yet in the tree.
  4. 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:

  1. in_tree: which points are already connected.
  2. heap = [(0, 0)]: attaching the first point is free.
  3. if in_tree[i]: continue: a point can be on the heap several times; only its cheapest entry (popped first) is used.
  4. total = total + cost: the cheapest edge leaving the tree is always safe to add. That's the cut property that makes Prim correct.
  5. for j in range(n): ... heappush(...): new candidate edges from the point we just added.
  6. while added < n: a spanning tree is finished when all n points are in it (n − 1 edges).

Dry run on the example:

points = [[0, 0], [2, 2], [3, 10], [5, 2], [7, 0]] (LeetCode Example 1). Returns 20.

pop (cost, point)actiontotal
(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 → skip11
(7, 4)already in the tree → skip11
(7, 4)already in the tree → skip11
(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

Negative weightsDijkstra assumes a settled distance can never improve. A negative edge breaks that; use Bellman–Ford.
Settling when pushing instead of when poppingMarking a node done when it's first pushed locks in the first route found, not the shortest. Settle on pop.
Not skipping stale heap entriesWithout if node in dist: continue, a node is processed several times and its neighbours get wrong (too large) distances pushed again.
Using Dijkstra's total in PrimPrim must push the edge weight only. Pushing d + w builds a shortest-path tree, which is not the minimum spanning tree.
Forgetting unreachable nodes"Time for all nodes to receive the signal" is -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?

With equal weights, BFS order is already distance order; a heap adds log factors for nothing.

In Dijkstra, when is a node's distance final?

The first pop carries the smallest value ever pushed for that node; with non-negative weights nothing smaller can appear later.

In Prim's algorithm, what number is pushed with a neighbour?

Prim grows a tree; the cost of adding a node is only the edge that attaches it.

The graph has a negative edge weight (but no negative cycle). Which algorithm still works?

Bellman–Ford relaxes every edge V − 1 times and doesn't rely on "settled is final".

10. Practice list (in order)

#ProblemLevelData structureTwist
743Network Delay TimeMediumGraphHeapDijkstra: always settle the closest unsettled node next
1584Min Cost to Connect All PointsMediumGraphHeapPrim's MST: grow one tree, always adding the cheapest edge out of it
1091Shortest Path in Binary MatrixMediumMatrixQueueSolved in 12 · Breadth-First Search (BFS): 8-direction grid BFS, count cells
787Cheapest Flights Within K StopsMediumGraphBellman–Ford limited to k + 1 rounds, relaxing from a copy of the previous round
1514Path with Maximum ProbabilityMediumGraphHeapDijkstra with a max-heap on probability (multiplying, never increasing)
1631Path With Minimum EffortMediumMatrixHeapDijkstra where a path's cost is its largest step, not the sum
2812Find the Safest Path in a GridMediumMatrixQueueHeapMulti-source BFS from thieves gives each cell's safeness; then a max-min Dijkstra path
3286Find a Safe Walk Through a GridMediumMatrixDeque0-1 BFS: the cheapest path counts unsafe cells; safe if that count < health
778Swim in Rising WaterHardMatrixHeapSolved in 13 · Matrix Traversal: Dijkstra / heap-BFS on the grid (see heaps)
1293Shortest Path in a Grid with Obstacles EliminationHardMatrixQueueSolved in 12 · Breadth-First Search (BFS): State = (r, c, removals left)
1489Find Critical and Pseudo-Critical Edges in Minimum Spanning TreeHardGraphArrayKruskal three ways: normal, without edge e (critical if worse), with e forced first (pseudo if equal)
3600Maximize Spanning Tree Stability with UpgradesHardGraphArrayBinary search the stability x; check with union-find: must edges, then edges ≥ x, then upgradable edges (count ≤ k)
4003Minimum Cost Path with Alternating Directions IIIHardMatrixHeapDijkstra over (cell, parity of the next action); moves, penalised moves and waits are the edges
3620Network Recovery PathwaysHardGraphArrayBinary search the score x; keep edges with cost ≥ x and check the cheapest path in the DAG is ≤ k
3928Minimum Cost to Buy Apples IIHardGraphHeapDijkstra on a two-layer graph: empty (cost) and carrying (cost·tax); buying apples moves you between layers
1928Minimum Cost to Reach Destination in TimeHardGraphArrayDP 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 Graph

Problem: Return the cheapest price from src to dst using at most k stops (k + 1 flights), or −1.

Example 1
Input: n = 4, flights = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]], src = 0, dst = 3, k = 1
Output: 700
Explanation: The graph is shown above. The optimal path with at most 1 stop from city 0 to 3 is marked in red and has cost 100 + 600 = 700. Note that the path through cities [0,1,2,3] is cheaper but is invalid because it uses 2 stops.
Example 2
Input: n = 3, flights = [[0,1,100],[1,2,100],[0,2,500]], src = 0, dst = 2, k = 1
Output: 200
Explanation: The graph is shown above. The optimal path with at most 1 stop from city 0 to 2 is marked in red and has cost 100 + 100 = 200.

Constraints: 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:

  1. price[src] = 0, others ∞.
  2. Repeat k + 1 times: new = price[:]; for each flight (u, v, w): new[v] = min(new[v], price[u] + w).
  3. 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:

  1. After round i, price[v] is the cheapest cost using at most i flights.
  2. Reading from price (old) while writing to new prevents 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 GraphHeap

Problem: Edges have success probabilities. Return the maximum probability of a path from start_node to end_node.

Example 1
Input: n = 3, edges = [[0,1],[1,2],[0,2]], succProb = [0.5,0.5,0.2], start = 0, end = 2
Output: 0.25000
Explanation: There are two paths from start to end, one having a probability of success = 0.2 and the other has 0.5 * 0.5 = 0.25.
Example 2
Input: n = 3, edges = [[0,1],[1,2],[0,2]], succProb = [0.5,0.5,0.3], start = 0, end = 2
Output: 0.30000

Constraints: 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:

  1. best[start] = 1; heap of (−prob, node).
  2. Pop the most probable node; skip stale entries; stop at the end node.
  3. 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:

  1. Probabilities only shrink along a path, which is what makes Dijkstra's greedy choice valid.
  2. 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 MatrixHeap

Problem: A path's effort is its largest absolute height difference between consecutive cells. Return the minimum effort from top-left to bottom-right.

Example 1
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.
Example 2
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].

Constraints: 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:

  1. effort[0][0] = 0; heap (0, 0, 0).
  2. Pop the smallest effort; for each neighbour: e = max(effort, |Δh|); if smaller, update and push.
  3. 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:

  1. The cost of extending a path is max, which is monotone, so Dijkstra's first pop of a cell is optimal.
  2. 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 MatrixQueueHeap

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).

Example 1
Input: grid = [[1,0,0],[0,0,0],[0,0,1]]
Output: 0
Explanation: All paths from (0, 0) to (n - 1, n - 1) go through the thieves in cells (0, 0) and (n - 1, n - 1).
Example 2
Input: grid = [[0,0,1],[0,0,0],[0,0,0]]
Output: 2
Explanation: The path depicted in the picture above has a safeness factor of 2 since: - The closest cell of the path to the thief at cell (0, 2) is cell (0, 0). The distance between them is | 0 - 0 | + | 0 - 2 | = 2. It can be shown that there are no other paths with a higher safeness factor.

Constraints: 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:

  1. BFS from all thieves at once: dist[r][c].
  2. Max-heap from (0, 0) with value dist[0][0]; the value of a path is min(path value, dist[cell]).
  3. 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:

  1. Multi-source BFS layer d is exactly the cells at distance d from the nearest thief.
  2. 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 MatrixDeque

Problem: Unsafe cells cost 1 health. Can you get from (0, 0) to (m−1, n−1) and still have health ≥ 1?

Example 1
Input: grid = [[0,1,0,0,0],[0,1,0,1,0],[0,0,0,1,0]], health = 1
Output: true
Explanation: The final cell can be reached safely by walking along the gray cells below.
Example 2
Input: grid = [[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 = 3
Output: false
Explanation: A minimum of 4 health points is needed to reach the final cell safely.

Constraints: 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:

  1. cost[0][0] = grid[0][0]; deque with the start.
  2. Pop front; for each neighbour nc = cost + grid[nr][nc]; if better, update; push front if the cell is safe, back otherwise.
  3. 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:

  1. The deque stays sorted by cost: cost-0 moves go to the front, cost-1 moves to the back.
  2. The start cell counts too if it's unsafe.
  3. 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 GraphArray

Problem: Return the indices of critical MST edges (removing them increases the MST weight) and pseudo-critical ones (in some MST, not all).

Example 1
Input: n = 5, edges = [[0,1,1],[1,2,1],[2,3,2],[0,3,2],[0,4,3],[3,4,3],[1,4,6]]
Output: [[0,1],[2,3,4,5]]
Explanation: The figure above describes the graph. The following figure shows all the possible MSTs: Notice that the two edges 0 and 1 appear in all MSTs, therefore they are critical edges, so we return them in the first list of the output. The edges 2, 3, 4, and 5 are only part of some MSTs, therefore they are considered pseudo-critical edges. We add them to the second list of the output.
Example 2
Input: n = 4, edges = [[0,1,1],[1,2,1],[2,3,1],[0,3,1]]
Output: [[],[0,1,2,3]]
Explanation: We can observe that since all 4 edges have equal weight, choosing any 3 edges from the given 4 will yield an MST. Therefore all 4 edges are pseudo-critical.

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:

  1. Sort edge indices by weight; best = kruskal().
  2. For each edge e: kruskal(skip=e) > best → critical.
  3. 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:

  1. Kruskal adds the cheapest edge that connects two components; union-find detects that.
  2. If the graph without e has a heavier MST (or is disconnected), e is in every MST.
  3. Otherwise, if an MST containing e still has the optimal weight, e is 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 GraphArray

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.

Example 1
Input: n = 3, edges = [[0,1,2,1],[1,2,3,0]], k = 1
Output: 2
Explanation: Edge [0,1] with strength = 2 must be included in the spanning tree. Edge [1,2] is optional and can be upgraded from 3 to 6 using one upgrade. The resulting spanning tree includes these two edges with strengths 2 and 6. The minimum strength in the spanning tree is 2, which is the maximum possible stability.
Example 2
Input: n = 3, edges = [[0,1,4,0],[1,2,3,0],[0,2,1,0]], k = 2
Output: 6
Explanation: Since all edges are optional and up to k = 2 upgrades are allowed. Upgrade edges [0,1] from 4 to 8 and [1,2] from 3 to 6. The resulting spanning tree includes these two edges with strengths 8 and 6. The minimum strength in the tree is 6, which is the maximum possible stability.

Constraints: 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:

  1. Must edges forming a cycle → −1. Graph not connectable even using every edge → −1.
  2. feasible(x): union the must edges (fail if any has strength < x); add optional edges with s ≥ x; then those with 2s ≥ x, counting upgrades; feasible if connected with ≤ k upgrades.
  3. 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:

  1. Must edges are always in the tree, so they cap the stability and can't be upgraded.
  2. Using free edges before upgradable ones minimises upgrades (spanning trees form a matroid, so the greedy is optimal).
  3. If stability x is achievable, every smaller value is too, so binary search applies.
  4. 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 MatrixHeap

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.

Example 1
Input: m = 2, n = 2, penalty = [[5,3],[1,4]]
Output: 8
Explanation: The optimal path is: Start at cell (0, 0) with entry cost (0 + 1) * (0 + 1) = 1. Move 1: Move down to cell (1, 0) with entry cost (1 + 1) * (0 + 1) = 2. Move 2: Move right to cell (1, 1) with entry cost (1 + 1) * (1 + 1) = 4 and an extra cost of penalty[1][0] = 1 for violating the even parity rule. Thus, the total cost is 1 + 2 + 4 + 1 = 8.
Example 2
Input: m = 2, n = 2, penalty = [[0,7],[3,2]]
Output: 7
Explanation: The optimal path is: Start at cell (0, 0) with entry cost (0 + 1) * (0 + 1) = 1. Move 1: Wait at cell (0, 0) with an extra cost of penalty[0][0] = 0 to flip to even parity. Move 2: Move right to cell (0, 1) with entry cost (0 + 1) * (1 + 1) = 2 and an extra cost of penalty[0][0] = 0 for violating the even parity rule. Move 3: Move down to cell (1, 1) with entry cost (1 + 1) * (1 + 1) = 4. Thus, the total cost is 1 + 0 + 2 + 0 + 4 = 7.

Constraints: 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:

  1. Start: dist[(0, 0), odd] = 1 (the entry cost of (0, 0)).
  2. From (r, c, p): each neighbour costs its entry cost, plus penalty[r][c] if the direction violates p; waiting costs penalty[r][c]. The parity flips either way.
  3. 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:

  1. Parity 0 means the next action is odd-numbered (right/down follow the rule); parity 1 means even (left/up follow it).
  2. Every action flips the parity, whether it was a move or a wait.
  3. 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 GraphArray

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.

Example 1
Input: edges = [[0,1,5],[1,3,10],[0,2,3],[2,3,4]], online = [true,true,true,true], k = 10
Output: 3
Explanation: The graph has two possible routes from node 0 to node 3: Path 0 → 1 → 3 Total cost = 5 + 10 = 15, which exceeds k (15 > 10), so this path is invalid. Path 0 → 2 → 3 Total cost = 3 + 4 = 7 <= k, so this path is valid. The minimum edge‐cost along this path is min(3, 4) = 3. There are no other valid paths. Hence, the maximum among all valid path‐scores is 3.
Example 2
Input: edges = [[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 = 12
Output: 6
Explanation: Node 3 is offline, so any path passing through 3 is invalid. Consider the remaining routes from 0 to 4: Path 0 → 1 → 4 Total cost = 7 + 5 = 12 <= k, so this path is valid. The minimum edge‐cost along this path is min(7, 5) = 5. Path 0 → 2 → 3 → 4 Node 3 is offline, so this path is invalid regardless of cost. Path 0 → 2 → 4 Total cost = 6 + 6 = 12 <= k, so this path is valid. The minimum edge‐cost along this path is min(6, 6) = 6. Among the two valid paths, their scores are 5 and 6. Therefore, the answer is 6.
Hint 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:

  1. Topological order of the DAG (Kahn).
  2. ok(x): DP dist[v] = min(dist[u] + c) over edges with c ≥ x and online endpoints; feasible if dist[n − 1] ≤ k.
  3. Binary-search x over the sorted distinct costs; −1 if even x = 0 fails.
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:

  1. The score of the best path is one of the edge costs, so searching over sorted costs suffices.
  2. Raising the threshold removes edges, so the cheapest path can only get more expensive: monotone.
  3. 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 GraphHeap

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.

Example 1
Input: n = 2, prices = [8,3], roads = [[0,1,1,2]]
Output: [6,3]
Explanation: Shop i prices[i] Shop j prices[j] costi taxi Travel cost Return cost Total Minimum 0 8 1 3 1 2 1 1 * 2 = 2 1 + 2 + 3 = 6 min(8, 6) = 6 1 3 0 8 1 2 1 1 * 2 = 2 1 + 2 + 8 = 11 min(3, 11) = 3 Thus, the answer is [6, 3].
Example 2
Input: n = 3, prices = [9,4,6], roads = [[0,1,1,3],[1,2,4,2]]
Output: [8,4,6]
Explanation: Shop i prices[i] Shop j prices[j] costi taxi Travel cost Return cost Total Minimum 0 9 1 4 1 3 1 1 * 3 = 3 1 + 3 + 4 = 8 min(9, 8) = 8 1 4 2 6 4 2 4 4 * 2 = 8 4 + 8 + 6 = 18 min(4, 18) = 4 2 6 1 4 4 2 4 4 * 2 = 8 4 + 8 + 4 = 16 min(6, 16) = 6 Thus, the answer is [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:

  1. Adjacency list with (v, cost, tax).
  2. For each start i: Dijkstra over states (node, carrying); buying is the edge (j, 0) → (j, 1) with weight price[j].
  3. 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:

  1. Layer 0 is "still looking for apples", layer 1 is "carrying apples home".
  2. 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).
  3. 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 GraphArray

Problem: Travel from city 0 to city n−1 within maxTime, paying each passed city's fee. Return the minimum total fee, or −1.

Example 1
Input: 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]
Output: 11
Explanation: The path to take is 0 -> 1 -> 2 -> 5, which takes 30 minutes and has $11 worth of passing fees.
Example 2
Input: maxTime = 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]
Output: 48
Explanation: The path to take is 0 -> 3 -> 4 -> 5, which takes 26 minutes and has $48 worth of passing fees. You cannot take path 0 -> 1 -> 2 -> 5 since it would take too long.

Constraints: 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:

  1. best[t][v] = min fee arriving at v at time t; best[0][0] = fee[0].
  2. For t ascending, for each edge both ways: best[t + w][v] = min(best[t][u] + fee[v]).
  3. Answer: min over t ≤ maxTime of best[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:

  1. Times only increase along a path, so processing t in increasing order sees every predecessor first.
  2. 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.