3778. Minimum Distance Excluding One Maximum Weighted Edge 🔒
Problem Description
You are given a positive integer n and a 2D integer array edges, where each edges[i] = [u_i, v_i, w_i].
There is a weighted, connected, simple, undirected graph with n nodes labeled from 0 to n - 1. Each entry [u_i, v_i, w_i] in edges represents an edge between node u_i and node v_i with a positive weight w_i.
The cost of a path is defined as the sum of the weights of the edges in that path, but excluding the edge with the maximum weight. If there are multiple edges in the path that share the maximum weight, only the first such edge is excluded from the sum.
Your task is to return an integer representing the minimum cost of a path traveling from node 0 to node n - 1.
In other words, you want to find a path from node 0 to node n - 1 where you are allowed to drop the largest-weight edge along the way (counting its weight as 0), and you want to choose the path so that the resulting total weight is as small as possible.
How We Pick the Algorithm
Why Dijkstra's Algorithm?
This problem maps to Dijkstra's Algorithm through a short path in the full flowchart.
Finding the minimum-cost path from node 0 to n-1 with the option to skip one maximum-weight edge is a stateful shortest path problem solved by Dijkstra.
Open in FlowchartIntuition
The key observation is that "excluding the edge with the maximum weight" is the same as being allowed to make one edge along the path free (its weight becomes 0).
Why? Suppose we have already fixed a path. The cost is the sum of all edge weights minus the largest one. To minimize the cost over all paths, we want this "free" edge to ideally be the largest edge on the path. But notice that if we simply give ourselves the freedom to zero out any one edge on the chosen path, the optimal choice will always naturally fall on the edge with the maximum weight — because zeroing out the heaviest edge removes the most weight. So instead of explicitly tracking which edge is the maximum, we can just allow ourselves one chance to set any single edge's weight to 0, and the optimization will take care of selecting the best edge to drop.
This transforms the problem into: find the shortest path from node 0 to node n - 1, where along the way we have exactly one opportunity to traverse an edge for free.
This is a classic shortest path with a state problem, perfectly suited for Dijkstra's algorithm. The twist is that the state of each node is no longer just "where we are," but also "whether we have already used our one free-edge opportunity." So we expand each node into two states:
dist[u][0]: the minimum total weight to reach nodeuwithout having used the free opportunity yet.dist[u][1]: the minimum total weight to reach nodeuhaving already used the free opportunity.
From any node in state 0, we have two choices when crossing an edge (v, w):
- Pay the full weight
wand stay in state0, moving todist[v][0]. - Use our free opportunity here, paying
0, and move into state1atdist[v][1].
Once we are in state 1, the opportunity is gone, so every edge must be paid in full and we remain in state 1.
By running Dijkstra over this expanded graph of 2n states, the first time we settle node n - 1 in state 1 gives us the answer — the minimum cost path where exactly one (optimally the heaviest) edge has been excluded.
Solution Approach
We solve this using Dijkstra's Algorithm over an expanded state space.
Step 1: Build the adjacency list.
We first convert edges into an adjacency list g, where g[u] stores all edges (v, w) connected to node u, indicating there is an edge with weight w between node u and node v. Since the graph is undirected, each edge [u, v, w] is added to both g[u] and g[v].
g = [[] for _ in range(n)]
for u, v, w in edges:
g[u].append((v, w))
g[v].append((u, w))
Step 2: Define the distance array with the extra state.
We define a 2D array dist, where:
dist[u][0]represents the minimum sum of path weights from node0to nodeuwithout using the opportunity to treat an edge weight as0.dist[u][1]represents the minimum sum of path weights from node0to nodeuhaving already used the opportunity to treat an edge weight as0.
All values are initialized to infinity, except dist[0][0] = 0, since we begin at node 0 with cost 0 and no opportunity used.
Step 3: Run Dijkstra with a priority queue.
We use a priority queue (min-heap) pq to store pending nodes as tuples (cur, u, used), where cur is the current accumulated path weight, u is the current node, and used is whether the free opportunity has been used. Initially we push (0, 0, 0).
In each iteration, we pop the tuple (cur, u, used) with the smallest path weight from the heap. If cur > dist[u][used], this entry is outdated, so we skip it.
cur, u, used = heappop(pq) if cur > dist[u][used]: continue
Step 4: Early termination.
If the current node u is node n - 1 and we have already used the opportunity (used == 1), then because Dijkstra always pops the smallest path weight first, the current cur is guaranteed to be the minimum cost. We return it immediately.
if u == n - 1 and used: return cur
Step 5: Relax neighboring edges.
For each edge (v, w) of node u, we consider two transitions:
-
Pay the full weight. Compute
nxt = cur + w. Ifnxt < dist[v][used], updatedist[v][used]and push(nxt, v, used)onto the heap. This keeps the sameusedstate. -
Use the free opportunity (only valid if
used == 0). Computenxt = cur(the edge costs nothing). Ifnxt < dist[v][1], updatedist[v][1]and push(nxt, v, 1). This transitions us into the "opportunity used" state.
for v, w in g[u]: nxt = cur + w if nxt < dist[v][used]: dist[v][used] = nxt heappush(pq, (nxt, v, used)) if used == 0: nxt = cur if nxt < dist[v][1]: dist[v][1] = nxt heappush(pq, (nxt, v, 1))
Step 6: Return the answer.
After the traversal ends, dist[n - 1][1] holds the minimum cost of reaching node n - 1 after using the free opportunity, which is returned as the answer.
Complexity Analysis.
Let m be the number of edges. The expanded graph has 2n states and each edge is relaxed for both states, so there are O(m) edges in the expanded graph. The time complexity is O(m log m) due to the heap operations, and the space complexity is O(n + m) for the adjacency list, distance array, and priority queue.
Example Walkthrough
Let's trace through the solution approach using a small example.
Input:
n = 4edges = [[0, 1, 4], [1, 3, 5], [0, 2, 2], [2, 3, 7]]
We want the minimum cost path from node 0 to node 3, where we may drop the single heaviest edge along the chosen path.
Step 1: Build the adjacency list.
g[0] = [(1, 4), (2, 2)] g[1] = [(0, 4), (3, 5)] g[2] = [(0, 2), (3, 7)] g[3] = [(1, 5), (2, 7)]
Step 2: Initialize the distance array.
Each node has two states: [without free used, with free used]. All start at infinity except dist[0][0] = 0.
dist[0] = [0, ∞] dist[1] = [∞, ∞] dist[2] = [∞, ∞] dist[3] = [∞, ∞]
The heap starts with (0, 0, 0) — cost 0, at node 0, free opportunity unused.
Step 3–5: Run Dijkstra.
Pop (0, 0, 0). Node 0, used = 0. Relax both neighbors:
- Edge
(1, 4):- Pay full:
nxt = 0 + 4 = 4→ updatedist[1][0] = 4, push(4, 1, 0). - Use free:
nxt = 0→ updatedist[1][1] = 0, push(0, 1, 1).
- Pay full:
- Edge
(2, 2):- Pay full:
nxt = 0 + 2 = 2→ updatedist[2][0] = 2, push(2, 2, 0). - Use free:
nxt = 0→ updatedist[2][1] = 0, push(0, 2, 1).
- Pay full:
Pop (0, 1, 1). Node 1, used = 1 (free already spent). Not node 3, so continue. Relax:
- Edge
(0, 4):nxt = 0 + 4 = 4, butdist[0][1] = ∞, so update to4, push(4, 0, 1). - Edge
(3, 5):nxt = 0 + 5 = 5→ updatedist[3][1] = 5, push(5, 3, 1).
Pop (0, 2, 1). Node 2, used = 1. Relax:
- Edge
(0, 2):nxt = 0 + 2 = 2,dist[0][1] = 4 > 2, so updatedist[0][1] = 2, push(2, 0, 1). - Edge
(3, 7):nxt = 0 + 7 = 7,dist[3][1] = 5 < 7, no update.
Pop (2, 0, 1). Node 0, used = 1. Relax (everything reachable already cheaper), no improvements.
Pop (2, 2, 0). Node 2, used = 0. Relax:
- Edge
(0, 2): full4 > dist[0][0] = 0; freenxt = 2 > dist[0][1] = 2, no update. - Edge
(3, 7): fullnxt = 2 + 7 = 9 > dist[3][0]=∞, updatedist[3][0]=9; freenxt = 2,dist[3][1] = 5 < 2? No,2 < 5so updatedist[3][1] = 2, push(2, 3, 1).
Pop (2, 3, 1). Node 3 and used = 1 → early termination triggers. Return cur = 2.
Why this is optimal.
The chosen path is 0 → 2 → 3 with weights 2 and 7. The heaviest edge (7) is dropped for free, leaving cost 2. Compare to the alternative path 0 → 1 → 3 (weights 4, 5): dropping the heaviest (5) leaves cost 4. The algorithm correctly settles on the cheaper option, 2.
Final answer: 2.
Solution Implementation
1from typing import List
2from heapq import heappush, heappop
3from math import inf
4
5
6class Solution:
7 def minCostExcludingMax(self, n: int, edges: List[List[int]]) -> int:
8 # Build an undirected adjacency list:
9 # graph[node] = list of (neighbor, weight)
10 graph: List[List[tuple[int, int]]] = [[] for _ in range(n)]
11 for u, v, w in edges:
12 graph[u].append((v, w))
13 graph[v].append((u, w))
14
15 # dist[node][used] = minimum cost to reach `node`
16 # used = 0 -> the "free edge" option is still available
17 # used = 1 -> the "free edge" option has already been consumed
18 dist: List[List[float]] = [[inf] * 2 for _ in range(n)]
19 dist[0][0] = 0
20
21 # Min-heap of (current_cost, node, used)
22 priority_queue: List[tuple[int, int, int]] = [(0, 0, 0)]
23
24 while priority_queue:
25 cost, node, used = heappop(priority_queue)
26
27 # Skip stale entries that are worse than the best known distance.
28 if cost > dist[node][used]:
29 continue
30
31 # If we reached the target and have already used the free edge,
32 # this is guaranteed to be the optimal answer (Dijkstra property).
33 if node == n - 1 and used:
34 return cost
35
36 for neighbor, weight in graph[node]:
37 # Option 1: traverse this edge normally (pay its weight).
38 next_cost = cost + weight
39 if next_cost < dist[neighbor][used]:
40 dist[neighbor][used] = next_cost
41 heappush(priority_queue, (next_cost, neighbor, used))
42
43 # Option 2: if the free option is still available, use it on
44 # this edge (skip paying its weight) and mark used = 1.
45 if used == 0:
46 next_cost = cost # no weight added
47 if next_cost < dist[neighbor][1]:
48 dist[neighbor][1] = next_cost
49 heappush(priority_queue, (next_cost, neighbor, 1))
50
51 # Reaching here means the target's "free edge used" state was finalized
52 # without an early return; return that best cost.
53 return dist[n - 1][1]
541class Solution {
2 public long minCostExcludingMax(int n, int[][] edges) {
3 // Build an adjacency list for the undirected weighted graph.
4 // Each entry stores {neighbor, weight}.
5 List<int[]>[] graph = new ArrayList[n];
6 Arrays.setAll(graph, key -> new ArrayList<>());
7 for (int[] edge : edges) {
8 int from = edge[0];
9 int to = edge[1];
10 int weight = edge[2];
11 graph[from].add(new int[] {to, weight});
12 graph[to].add(new int[] {from, weight});
13 }
14
15 // A large sentinel value used as "infinity" to avoid overflow during addition.
16 long infinity = Long.MAX_VALUE / 4;
17
18 // dist[node][state] is the minimum cost to reach `node` where:
19 // state = 0 -> no edge has been skipped (excluded) yet
20 // state = 1 -> exactly one edge has already been skipped (its cost ignored)
21 long[][] dist = new long[n][2];
22 for (int i = 0; i < n; i++) {
23 dist[i][0] = infinity;
24 dist[i][1] = infinity;
25 }
26 dist[0][0] = 0;
27
28 // Dijkstra over an expanded state space (node, skipUsed).
29 // Each element in the queue is {currentCost, node, skipUsed}.
30 PriorityQueue<long[]> pq =
31 new PriorityQueue<>(Comparator.comparingLong(state -> state[0]));
32 pq.add(new long[] {0, 0, 0});
33
34 while (!pq.isEmpty()) {
35 long[] top = pq.poll();
36 long currentCost = top[0];
37 int node = (int) top[1];
38 int skipUsed = (int) top[2];
39
40 // Skip stale entries that have already been improved.
41 if (currentCost > dist[node][skipUsed]) {
42 continue;
43 }
44
45 // Reaching the last node with the skip already used yields the answer.
46 if (node == n - 1 && skipUsed == 1) {
47 return currentCost;
48 }
49
50 for (int[] adjacent : graph[node]) {
51 int next = adjacent[0];
52 int weight = adjacent[1];
53
54 // Option 1: traverse this edge normally, paying its weight.
55 long normalCost = currentCost + weight;
56 if (normalCost < dist[next][skipUsed]) {
57 dist[next][skipUsed] = normalCost;
58 pq.add(new long[] {normalCost, next, skipUsed});
59 }
60
61 // Option 2: if no edge has been skipped yet, skip this edge's cost.
62 // This moves us into state 1 without adding the edge weight.
63 if (skipUsed == 0) {
64 long skippedCost = currentCost;
65 if (skippedCost < dist[next][1]) {
66 dist[next][1] = skippedCost;
67 pq.add(new long[] {skippedCost, next, 1});
68 }
69 }
70 }
71 }
72
73 // Fallback: shortest cost to the destination with one edge skipped.
74 return dist[n - 1][1];
75 }
76}
771class Solution {
2public:
3 long long minCostExcludingMax(int n, vector<vector<int>>& edges) {
4 // Build an undirected weighted adjacency list.
5 // adjacency[node] holds pairs of (neighbor, weight).
6 vector<vector<pair<int, int>>> adjacency(n);
7 for (auto& edge : edges) {
8 int from = edge[0], to = edge[1], weight = edge[2];
9 adjacency[from].push_back({to, weight});
10 adjacency[to].push_back({from, weight});
11 }
12
13 // A large sentinel value representing "unreachable".
14 // Divided by 4 to avoid overflow when summing edge weights.
15 const long long INF = LLONG_MAX / 4;
16
17 // distance[node][skipUsed] = minimum cost to reach `node`,
18 // where skipUsed == 0 means we have NOT yet skipped an edge's cost,
19 // and skipUsed == 1 means we already used our one free skip.
20 vector<array<long long, 2>> distance(n, {INF, INF});
21 distance[0][0] = 0;
22
23 // Min-heap ordered by accumulated cost.
24 // Each entry is {cost, node, skipUsed}.
25 priority_queue<
26 array<long long, 3>,
27 vector<array<long long, 3>>,
28 greater<array<long long, 3>>
29 > minHeap;
30 minHeap.push({0, 0, 0});
31
32 while (!minHeap.empty()) {
33 auto top = minHeap.top();
34 minHeap.pop();
35
36 long long currentCost = top[0];
37 int node = static_cast<int>(top[1]);
38 int skipUsed = static_cast<int>(top[2]);
39
40 // Skip stale heap entries that have been superseded
41 // by a cheaper path already finalized.
42 if (currentCost > distance[node][skipUsed]) {
43 continue;
44 }
45
46 // We reached the target having already used the free skip;
47 // because Dijkstra pops states in non-decreasing cost order,
48 // this is guaranteed to be the optimal answer.
49 if (node == n - 1 && skipUsed == 1) {
50 return currentCost;
51 }
52
53 // Relax all neighboring edges.
54 for (auto [neighbor, weight] : adjacency[node]) {
55 // Option 1: traverse the edge and pay its weight.
56 long long nextCost = currentCost + weight;
57 if (nextCost < distance[neighbor][skipUsed]) {
58 distance[neighbor][skipUsed] = nextCost;
59 minHeap.push({nextCost, neighbor, skipUsed});
60 }
61
62 // Option 2: if we still have our free skip available,
63 // traverse this edge for free (skip its cost) and mark
64 // the skip as consumed.
65 if (skipUsed == 0) {
66 long long freeCost = currentCost;
67 if (freeCost < distance[neighbor][1]) {
68 distance[neighbor][1] = freeCost;
69 minHeap.push({freeCost, neighbor, 1});
70 }
71 }
72 }
73 }
74
75 // Fallback: return the best cost to the target with the skip used.
76 return distance[n - 1][1];
77 }
78};
791/**
2 * Finds the minimum path cost from node 0 to node n-1 while allowing
3 * exactly one edge along the path to be excluded (treated as cost 0).
4 * Uses a state-expanded Dijkstra: each node carries a flag indicating
5 * whether the free-edge skip has already been used.
6 */
7function minCostExcludingMax(n: number, edges: number[][]): number {
8 // Adjacency list: graph[node] = list of [neighbor, weight]
9 const graph: [number, number][][] = Array.from({ length: n }, () => []);
10 for (const [from, to, weight] of edges) {
11 graph[from].push([to, weight]);
12 graph[to].push([from, weight]); // Undirected edge
13 }
14
15 const INF = Infinity;
16
17 // dist[node][skipUsed]:
18 // skipUsed = 0 -> shortest cost reaching `node` without skipping any edge
19 // skipUsed = 1 -> shortest cost reaching `node` having skipped one edge
20 const dist: number[][] = Array.from({ length: n }, () => [INF, INF]);
21 dist[0][0] = 0;
22
23 // Min-heap ordered by cost, tie-broken by node index.
24 // Each entry: [cost, node, skipUsed]
25 const pq = new PriorityQueue<[number, number, number]>((a, b) =>
26 a[0] === b[0] ? a[1] - b[1] : a[0] - b[0],
27 );
28
29 pq.enqueue([0, 0, 0]);
30
31 while (pq.size() > 0) {
32 const [cost, node, skipUsed] = pq.dequeue()!;
33
34 // Skip stale entries that were already improved.
35 if (cost > dist[node][skipUsed]) {
36 continue;
37 }
38
39 // Reached the destination with the skip already consumed: optimal answer.
40 if (node === n - 1 && skipUsed === 1) {
41 return cost;
42 }
43
44 for (const [next, weight] of graph[node]) {
45 // Option 1: traverse this edge normally, paying its weight.
46 const costNormal = cost + weight;
47 if (costNormal < dist[next][skipUsed]) {
48 dist[next][skipUsed] = costNormal;
49 pq.enqueue([costNormal, next, skipUsed]);
50 }
51
52 // Option 2: if no edge has been skipped yet, skip this one (cost stays the same).
53 if (skipUsed === 0) {
54 const costSkipped = cost;
55 if (costSkipped < dist[next][1]) {
56 dist[next][1] = costSkipped;
57 pq.enqueue([costSkipped, next, 1]);
58 }
59 }
60 }
61 }
62
63 // Fallback: best cost reaching the destination with the skip used.
64 return dist[n - 1][1];
65}
66Time and Space Complexity
-
Time Complexity:
O(m × log n), wherenandmare the number of nodes and edges respectively.This is a Dijkstra-style shortest path algorithm running on a state-expanded graph. The state space consists of
2 × nstates, since each nodeuis paired with a binary flagused ∈ {0, 1}indicating whether one edge weight has been waived (the "excluding max" relaxation that setsnxt = cur). The graph is built with adjacency lists, so each edge(u, v, w)is stored twice (once for each direction), givingO(m)total adjacency entries.During the Dijkstra traversal, each edge can be processed in both layers (
used = 0andused = 1), so the total number of edge relaxations isO(2m) = O(m). Each relaxation may trigger aheappush, and each pop from the priority queue costsO(log)of the heap size. Since the heap holds states bounded by2n, each heap operation costsO(log n). Combining the relaxations with the logarithmic heap operations yields an overall time complexity ofO(m × log n). -
Space Complexity:
O(n + m), wherenandmare the number of nodes and edges respectively.The adjacency list
gstores every edge twice, consumingO(n + m)space. Thedistarray holds two states per node, requiringO(2n) = O(n)space. The priority queuepqmay contain up toO(m)entries in the worst case (since each relaxation can push a new entry). Summing these contributions gives a total space complexity ofO(n + m).
Common Pitfalls
Pitfall 1: Returning dist[n - 1][0] instead of dist[n - 1][1]
A very common mistake is to return the wrong distance state at the end. Since the problem requires dropping the maximum-weight edge along the path, the answer must come from the state where the free opportunity has been used (used == 1), not the state where it is still available (used == 0).
Why this is wrong: dist[n - 1][0] is the cost of reaching the target while paying for every edge. This corresponds to a path where no edge was dropped, which violates the problem statement. The intended answer always drops one edge, so you must read from dist[n - 1][1].
# WRONG return dist[n - 1][0] # CORRECT return dist[n - 1][1]
Note that even on a single-edge path from 0 to n - 1, that one edge is the maximum and must be dropped — so the cost is 0, which only dist[n - 1][1] captures.
Pitfall 2: Forgetting that "dropping the max edge" is equivalent to "dropping any one edge"
The problem says to exclude the maximum-weight edge, but the code simply allows skipping any one edge for free. New solvers often worry this is incorrect.
Why the equivalence holds: For any fixed path, the choice that minimizes the resulting cost is always to drop the largest edge (dropping a smaller edge would leave a larger one in the sum, yielding a bigger total). Since Dijkstra searches over all paths and all free-drop choices simultaneously, the optimal (path, drop) combination it finds will naturally have the dropped edge being the maximum on that path. Therefore, "drop any one edge" and "drop the max edge" produce the same minimum cost.
Pitfall variant: Trying to track "the current maximum edge weight on the path" as part of the state and subtracting it at the end. This blows up the state space (one state per possible max value) and is unnecessary — the free-edge formulation already handles it cleanly.
Pitfall 3: Not modeling the problem as a layered/expanded state graph
A natural but flawed first attempt is to run plain Dijkstra on the original graph and then "subtract the largest edge" afterward. This fails because:
- The largest edge on the shortest path is not necessarily the choice that minimizes
total - max. A slightly longer path with a much heavier droppable edge can be cheaper. - You cannot decide which edge to drop greedily during a single-layer traversal.
The expanded state (node, used) is essential: it lets the algorithm carry the "have I dropped an edge yet?" information and explore both branches consistently.
Pitfall 4: Pushing the free-transition with the wrong cost
When using the free opportunity, the next cost must be cost (the edge contributes 0), not cost + w:
# WRONG — still adds the weight if used == 0: next_cost = cost + weight # bug! ... # CORRECT — the dropped edge costs nothing if used == 0: next_cost = cost ...
Adding the weight here silently defeats the entire purpose of the second state.
Pitfall 5: Mishandling graphs with no usable path or trivial n
Edge cases to verify:
n == 1: source and target are the same node.dist[0][0] = 0, and sinceusedwas never required to be1for the start, you should confirm the expected output (typically0). The early-returnif node == n - 1 and usedwould not trigger for the start sinceused == 0, so the finalreturn dist[n - 1][1]path matters here.- The problem guarantees the graph is connected, so a path always exists; without that guarantee you would need to handle an
infresult.
Ready to land your dream job?
Unlock your dream job with a 5-minute quiz for a personalized study roadmap!
Get My RoadmapWhat are the two properties the problem needs to have for dynamic programming to be applicable? (Select 2)
Recommended Readings
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion If you prefer videos here's a video that explains recursion in a fun and easy way Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask him
Runtime Overview When learning about algorithms and data structures you'll frequently encounter the term time complexity This concept is fundamental in computer science and offers insights into how long an algorithm takes to complete given a certain input size What is Time Complexity Time complexity describes how the time needed
Want a Structured Path to Master System Design Too? Don’t Miss This!