2714. Find Shortest Path with K Hops 🔒
Problem Description
You are given a connected, undirected, weighted graph with n
nodes (labeled from 0 to n-1) and a list of edges. Each edge is represented as [u, v, w]
, meaning there's an edge between nodes u
and v
with weight w
.
You need to find the shortest path from a starting node s
to a destination node d
, but with a special power: you can "hop over" (make the weight 0) for at most k
edges along your path.
Key Points:
- The graph has
n
nodes (0-indexed) - Each edge in
edges
is bidirectional with format[u, v, w]
wherew
is the weight - You want to go from node
s
to noded
- You can choose up to
k
edges and treat their weights as 0 (effectively "hopping" over them for free) - Return the minimum total path weight from
s
tod
Example Understanding:
If you have a path from s
to d
that uses edges with weights [5, 3, 7, 2] and k=2
, you could choose to hop over the two heaviest edges (7 and 5), making your total cost only 3 + 2 = 5 instead of 5 + 3 + 7 + 2 = 17.
The challenge is to strategically choose which edges to hop over to minimize the total path weight from source to destination.
Intuition
The problem asks us to find the shortest path with the ability to skip up to k
edges. This is a variation of the classic shortest path problem with an additional dimension - the number of "hops" we've used.
Think of it this way: at each node, we don't just care about "what's the shortest distance to reach here?" but rather "what's the shortest distance to reach here having used exactly 0 hops, 1 hop, 2 hops, ..., up to k hops?"
This leads us to track state in two dimensions:
- Which node we're at
- How many hops we've used so far
For each node, we maintain k+1
different distances: dist[node][hops_used]
represents the minimum distance to reach that node using exactly hops_used
free edges.
When we're at a node with some number of hops already used, we have two choices for each neighboring edge:
- Use a hop: Skip this edge (treat its weight as 0) if we haven't used all
k
hops yet. This moves us to the neighbor with one more hop used but no additional distance. - Pay the cost: Travel the edge normally, adding its weight to our distance but keeping the same hop count.
This naturally fits into Dijkstra's algorithm framework, but instead of relaxing just distances, we're relaxing distance-hop pairs. We use a priority queue with entries (distance, node, hops_used)
and always process the smallest distance first.
The key insight is that we're essentially running Dijkstra on an expanded graph where each original node is split into k+1
copies (one for each possible hop count), and we're finding the shortest path in this expanded state space.
Finally, the answer is the minimum among all dist[d][0]
, dist[d][1]
, ..., dist[d][k]
since we can reach the destination using any number of hops from 0 to k.
Learn more about Graph, Shortest Path and Heap (Priority Queue) patterns.
Solution Approach
The solution implements a modified Dijkstra's algorithm to handle the additional dimension of hop counts.
Step 1: Build the Graph
g = [[] for _ in range(n)]
for u, v, w in edges:
g[u].append((v, w))
g[v].append((u, w))
We create an adjacency list representation where g[u]
contains all neighbors of node u
along with their edge weights. Since the graph is undirected, we add edges in both directions.
Step 2: Initialize Distance Matrix
dist = [[inf] * (k + 1) for _ in range(n)]
dist[s][0] = 0
We create a 2D distance array where dist[u][t]
represents the minimum distance to reach node u
using exactly t
hops. Initially, all distances are set to infinity except dist[s][0] = 0
(we start at source s
with 0 hops used and 0 distance).
Step 3: Priority Queue Setup
pq = [(0, s, 0)] # (distance, node, hops_used)
We use a min-heap priority queue that stores tuples of (current_distance, current_node, hops_used)
. This ensures we always process the state with minimum distance first.
Step 4: Modified Dijkstra's Algorithm
while pq: dis, u, t = heappop(pq) for v, w in g[u]: # Option 1: Use a hop (make edge weight 0) if t + 1 <= k and dist[v][t + 1] > dis: dist[v][t + 1] = dis heappush(pq, (dis, v, t + 1)) # Option 2: Pay the edge cost if dist[v][t] > dis + w: dist[v][t] = dis + w heappush(pq, (dis + w, v, t))
For each state (dis, u, t)
popped from the queue, we explore all neighbors v
of node u
:
-
Using a hop: If we haven't used all
k
hops (t + 1 <= k
), we can reach neighborv
with the same distancedis
but using one more hop. We updatedist[v][t + 1]
if this gives a better path. -
Paying the cost: We can reach neighbor
v
by paying the edge weightw
, keeping the same hop countt
. We updatedist[v][t]
ifdis + w
is better than the current known distance.
Step 5: Find the Answer
return int(min(dist[d]))
The shortest path to destination d
is the minimum among all possible hop counts: min(dist[d][0], dist[d][1], ..., dist[d][k])
.
Time Complexity: O((n * k) * log(n * k) * m)
where m
is the number of edges, as we potentially process each state (node, hops)
and each edge from that state.
Space Complexity: O(n * k)
for the distance matrix and priority queue.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a small example to illustrate the solution approach.
Given:
- Graph with 4 nodes (0, 1, 2, 3)
- Edges:
[[0,1,5], [1,2,3], [0,2,9], [2,3,1]]
- Start:
s = 0
, Destination:d = 3
- Maximum hops:
k = 1
Graph visualization:
0 ---5--- 1 | | 9 3 | | 2 ---1--- 3
Step 1: Initialize
-
Build adjacency list:
g[0] = [(1,5), (2,9)]
g[1] = [(0,5), (2,3)]
g[2] = [(0,9), (1,3), (3,1)]
g[3] = [(2,1)]
-
Initialize distance matrix
dist[node][hops_used]
:dist[0][0] = 0, dist[0][1] = inf dist[1][0] = inf, dist[1][1] = inf dist[2][0] = inf, dist[2][1] = inf dist[3][0] = inf, dist[3][1] = inf
-
Priority queue:
[(0, 0, 0)]
(distance=0, node=0, hops=0)
Step 2: Process from node 0 with 0 hops used
- Pop
(0, 0, 0)
from queue - Explore neighbors:
- To node 1 (edge weight 5):
- Option 1 (use hop):
dist[1][1] = 0
(better than inf) - Option 2 (pay cost):
dist[1][0] = 5
(better than inf) - Add to queue:
(0, 1, 1)
and(5, 1, 0)
- Option 1 (use hop):
- To node 2 (edge weight 9):
- Option 1 (use hop):
dist[2][1] = 0
(better than inf) - Option 2 (pay cost):
dist[2][0] = 9
(better than inf) - Add to queue:
(0, 2, 1)
and(9, 2, 0)
- Option 1 (use hop):
- To node 1 (edge weight 5):
Step 3: Process minimum distance state (0, 1, 1)
- Pop
(0, 1, 1)
(reached node 1 with distance 0 using 1 hop) - Explore neighbors:
- To node 0: Skip (no improvement)
- To node 2 (edge weight 3):
- Cannot use another hop (already used k=1)
- Option 2 (pay cost):
dist[2][1] = 3
(0+3=3, worse than current 0)
Step 4: Process (0, 2, 1)
- Pop
(0, 2, 1)
(reached node 2 with distance 0 using 1 hop) - Explore neighbors:
- To node 3 (edge weight 1):
- Cannot use another hop (already used k=1)
- Option 2 (pay cost):
dist[3][1] = 1
(0+1=1, better than inf) - Add to queue:
(1, 3, 1)
- To node 3 (edge weight 1):
Step 5: Process (1, 3, 1)
- Pop
(1, 3, 1)
- we've reached destination! - Continue processing remaining states...
Step 6: Process (5, 1, 0)
- Pop
(5, 1, 0)
(reached node 1 with distance 5 using 0 hops) - To node 2 (edge weight 3):
- Option 1 (use hop):
dist[2][1] = 5
(worse than current 0) - Option 2 (pay cost):
dist[2][0] = 8
(5+3=8, better than 9) - Add to queue:
(8, 2, 0)
- Option 1 (use hop):
Step 7: Process (8, 2, 0)
- Pop
(8, 2, 0)
- To node 3 (edge weight 1):
- Option 1 (use hop):
dist[3][1] = 8
(worse than current 1) - Option 2 (pay cost):
dist[3][0] = 9
(8+1=9, better than inf) - Add to queue:
(9, 3, 0)
- Option 1 (use hop):
Final Result:
dist[3][0] = 9
(path: 0→1→2→3 with weights 5+3+1)dist[3][1] = 1
(path: 0→2→3 with hop on 0→2, paying only for 2→3)- Answer:
min(9, 1) = 1
The optimal path uses the direct expensive edge 0→2 as a free hop, then pays only 1 for edge 2→3.
Solution Implementation
1from typing import List
2from heapq import heappush, heappop
3from math import inf
4
5class Solution:
6 def shortestPathWithHops(
7 self, n: int, edges: List[List[int]], s: int, d: int, k: int
8 ) -> int:
9 # Build adjacency list representation of the graph
10 graph = [[] for _ in range(n)]
11 for node_u, node_v, weight in edges:
12 graph[node_u].append((node_v, weight))
13 graph[node_v].append((node_u, weight)) # Undirected graph
14
15 # Initialize distance table: dist[node][hops_used] = minimum distance
16 # dist[i][j] represents minimum distance to reach node i using exactly j free hops
17 dist = [[inf] * (k + 1) for _ in range(n)]
18 dist[s][0] = 0 # Starting node with 0 hops used has distance 0
19
20 # Priority queue: (distance, current_node, hops_used)
21 priority_queue = [(0, s, 0)]
22
23 # Modified Dijkstra's algorithm
24 while priority_queue:
25 current_dist, current_node, hops_used = heappop(priority_queue)
26
27 # Explore all neighbors
28 for neighbor, edge_weight in graph[current_node]:
29 # Option 1: Use a free hop (if available)
30 if hops_used + 1 <= k and dist[neighbor][hops_used + 1] > current_dist:
31 dist[neighbor][hops_used + 1] = current_dist
32 heappush(priority_queue, (current_dist, neighbor, hops_used + 1))
33
34 # Option 2: Pay the edge weight (normal traversal)
35 if dist[neighbor][hops_used] > current_dist + edge_weight:
36 dist[neighbor][hops_used] = current_dist + edge_weight
37 heappush(priority_queue, (current_dist + edge_weight, neighbor, hops_used))
38
39 # Return minimum distance to destination across all possible hop counts
40 return int(min(dist[d]))
41
1class Solution {
2 public int shortestPathWithHops(int n, int[][] edges, int s, int d, int k) {
3 // Build adjacency list representation of the graph
4 List<int[]>[] graph = new List[n];
5 Arrays.setAll(graph, i -> new ArrayList<>());
6
7 // Add edges to the graph (undirected)
8 for (int[] edge : edges) {
9 int from = edge[0];
10 int to = edge[1];
11 int weight = edge[2];
12 graph[from].add(new int[] {to, weight});
13 graph[to].add(new int[] {from, weight});
14 }
15
16 // Priority queue for Dijkstra's algorithm
17 // Array format: [distance, node, hops_used]
18 PriorityQueue<int[]> priorityQueue = new PriorityQueue<>((a, b) -> a[0] - b[0]);
19 priorityQueue.offer(new int[] {0, s, 0});
20
21 // Distance array: dist[node][hops] = minimum distance to reach node using exactly 'hops' free hops
22 int[][] distances = new int[n][k + 1];
23 final int INFINITY = 1 << 30;
24 for (int[] row : distances) {
25 Arrays.fill(row, INFINITY);
26 }
27 distances[s][0] = 0;
28
29 // Modified Dijkstra's algorithm with hop tracking
30 while (!priorityQueue.isEmpty()) {
31 int[] current = priorityQueue.poll();
32 int currentDistance = current[0];
33 int currentNode = current[1];
34 int hopsUsed = current[2];
35
36 // Explore all neighbors
37 for (int[] neighbor : graph[currentNode]) {
38 int nextNode = neighbor[0];
39 int edgeWeight = neighbor[1];
40
41 // Option 1: Use a free hop (set edge weight to 0)
42 if (hopsUsed + 1 <= k && distances[nextNode][hopsUsed + 1] > currentDistance) {
43 distances[nextNode][hopsUsed + 1] = currentDistance;
44 priorityQueue.offer(new int[] {currentDistance, nextNode, hopsUsed + 1});
45 }
46
47 // Option 2: Use the edge normally with its weight
48 if (distances[nextNode][hopsUsed] > currentDistance + edgeWeight) {
49 distances[nextNode][hopsUsed] = currentDistance + edgeWeight;
50 priorityQueue.offer(new int[] {currentDistance + edgeWeight, nextNode, hopsUsed});
51 }
52 }
53 }
54
55 // Find the minimum distance to destination using any number of hops from 0 to k
56 int minimumDistance = INFINITY;
57 for (int hopCount = 0; hopCount <= k; ++hopCount) {
58 minimumDistance = Math.min(minimumDistance, distances[d][hopCount]);
59 }
60
61 return minimumDistance;
62 }
63}
64
1class Solution {
2public:
3 int shortestPathWithHops(int n, vector<vector<int>>& edges, int s, int d, int k) {
4 // Build adjacency list representation of the graph
5 vector<vector<pair<int, int>>> graph(n);
6 for (const auto& edge : edges) {
7 int u = edge[0];
8 int v = edge[1];
9 int weight = edge[2];
10 graph[u].emplace_back(v, weight);
11 graph[v].emplace_back(u, weight);
12 }
13
14 // Priority queue to store (distance, node, hops_used)
15 // Using min-heap based on distance
16 priority_queue<tuple<int, int, int>,
17 vector<tuple<int, int, int>>,
18 greater<tuple<int, int, int>>> minHeap;
19
20 // Initialize starting node with 0 distance and 0 hops used
21 minHeap.emplace(0, s, 0);
22
23 // dist[node][hops] = minimum distance to reach 'node' using exactly 'hops' free hops
24 const int INF = 0x3f3f3f3f;
25 vector<vector<int>> dist(n, vector<int>(k + 1, INF));
26 dist[s][0] = 0;
27
28 // Dijkstra's algorithm with state (node, hops_used)
29 while (!minHeap.empty()) {
30 auto [currentDist, currentNode, hopsUsed] = minHeap.top();
31 minHeap.pop();
32
33 // Skip if we've already found a better path to this state
34 if (currentDist > dist[currentNode][hopsUsed]) {
35 continue;
36 }
37
38 // Explore all neighbors
39 for (const auto& [neighbor, edgeWeight] : graph[currentNode]) {
40 // Option 1: Use a free hop (set edge weight to 0)
41 if (hopsUsed + 1 <= k && dist[neighbor][hopsUsed + 1] > currentDist) {
42 dist[neighbor][hopsUsed + 1] = currentDist;
43 minHeap.emplace(currentDist, neighbor, hopsUsed + 1);
44 }
45
46 // Option 2: Use the actual edge weight
47 if (dist[neighbor][hopsUsed] > currentDist + edgeWeight) {
48 dist[neighbor][hopsUsed] = currentDist + edgeWeight;
49 minHeap.emplace(currentDist + edgeWeight, neighbor, hopsUsed);
50 }
51 }
52 }
53
54 // Return the minimum distance to destination using at most k hops
55 return *min_element(dist[d].begin(), dist[d].end());
56 }
57};
58
1function shortestPathWithHops(n: number, edges: number[][], s: number, d: number, k: number): number {
2 // Build adjacency list representation of the graph
3 // graph[node] = array of [neighbor, weight] pairs
4 const graph: number[][][] = Array(n).fill(null).map(() => []);
5
6 for (const edge of edges) {
7 const u = edge[0];
8 const v = edge[1];
9 const weight = edge[2];
10 graph[u].push([v, weight]);
11 graph[v].push([u, weight]);
12 }
13
14 // Priority queue to store [distance, node, hopsUsed]
15 // Using min-heap based on distance
16 const minHeap: number[][] = [];
17
18 // Helper function to add element to min-heap
19 const heapPush = (item: number[]): void => {
20 minHeap.push(item);
21 minHeap.sort((a, b) => a[0] - b[0]);
22 };
23
24 // Helper function to remove minimum element from heap
25 const heapPop = (): number[] | undefined => {
26 return minHeap.shift();
27 };
28
29 // Initialize starting node with 0 distance and 0 hops used
30 heapPush([0, s, 0]);
31
32 // dist[node][hops] = minimum distance to reach 'node' using exactly 'hops' free hops
33 const INF = Number.MAX_SAFE_INTEGER;
34 const dist: number[][] = Array(n).fill(null).map(() => Array(k + 1).fill(INF));
35 dist[s][0] = 0;
36
37 // Dijkstra's algorithm with state (node, hopsUsed)
38 while (minHeap.length > 0) {
39 const current = heapPop();
40 if (!current) break;
41
42 const [currentDist, currentNode, hopsUsed] = current;
43
44 // Skip if we've already found a better path to this state
45 if (currentDist > dist[currentNode][hopsUsed]) {
46 continue;
47 }
48
49 // Explore all neighbors
50 for (const [neighbor, edgeWeight] of graph[currentNode]) {
51 // Option 1: Use a free hop (set edge weight to 0)
52 if (hopsUsed + 1 <= k && dist[neighbor][hopsUsed + 1] > currentDist) {
53 dist[neighbor][hopsUsed + 1] = currentDist;
54 heapPush([currentDist, neighbor, hopsUsed + 1]);
55 }
56
57 // Option 2: Use the actual edge weight
58 if (dist[neighbor][hopsUsed] > currentDist + edgeWeight) {
59 dist[neighbor][hopsUsed] = currentDist + edgeWeight;
60 heapPush([currentDist + edgeWeight, neighbor, hopsUsed]);
61 }
62 }
63 }
64
65 // Return the minimum distance to destination using at most k hops
66 return Math.min(...dist[d]);
67}
68
Time and Space Complexity
Time Complexity: O(n * k * (n + m) * log(n * k))
which simplifies to O(n^2 * k * log(n * k))
in the worst case when m = O(n^2)
.
The algorithm uses a modified Dijkstra's approach with an additional dimension for the number of hops. Each node can be visited up to k + 1
times (for different hop counts from 0 to k). The priority queue can contain at most n * (k + 1)
states. Each state extraction takes O(log(n * k))
time. For each extracted state, we explore all adjacent edges. In the worst case with a dense graph where m = O(n^2)
, we might push O(n * k)
states to the queue, each taking O(log(n * k))
time. However, the reference answer suggests O(n^2 * log n)
, which appears to be considering the case where k
is treated as a constant or when the graph is sparse and the dominant factor is n^2
comparisons across all possible paths.
Space Complexity: O(n * k)
The space is dominated by:
- The
dist
array:O(n * (k + 1))
=O(n * k)
- The adjacency list
g
:O(n + m)
wherem
is the number of edges - The priority queue can hold at most
O(n * k)
elements
Since the dist
array is the dominant factor, the overall space complexity is O(n * k)
, where n
represents the number of nodes and k
represents the maximum number of free hops allowed.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Not Checking if Current State is Already Processed
The current implementation may process the same state (node, hops_used)
multiple times if a better path to that state is found later. This happens because we don't check if the current distance from the priority queue is outdated.
Problem: When we pop (dis, u, t)
from the priority queue, dis
might be larger than dist[u][t]
if we've already found a better path to state (u, t)
after this entry was added to the queue.
Solution: Add a check to skip processing if we've already found a better path:
while priority_queue: current_dist, current_node, hops_used = heappop(priority_queue) # Skip if we've already found a better path to this state if current_dist > dist[current_node][hops_used]: continue # Rest of the code remains the same...
2. Inefficient Hop Usage Strategy
A subtle pitfall is that the algorithm explores all possibilities but doesn't guarantee optimal hop usage early on. We might use hops on small-weight edges early in the path when saving them for heavier edges later would be better.
Why this isn't actually a problem: The algorithm explores ALL possible combinations of hop usage by maintaining separate distances for each hop count. The state space (node, hops_used)
ensures we consider all strategies.
3. Memory Limit Issues with Large Graphs
For very large values of n
and k
, the 2D distance array dist[n][k+1]
can consume significant memory.
Solution: Use a dictionary to store only visited states:
from collections import defaultdict
dist = defaultdict(lambda: inf)
dist[(s, 0)] = 0
priority_queue = [(0, s, 0)]
while priority_queue:
current_dist, current_node, hops_used = heappop(priority_queue)
if current_dist > dist[(current_node, hops_used)]:
continue
for neighbor, edge_weight in graph[current_node]:
# Option 1: Use a hop
if hops_used + 1 <= k and dist[(neighbor, hops_used + 1)] > current_dist:
dist[(neighbor, hops_used + 1)] = current_dist
heappush(priority_queue, (current_dist, neighbor, hops_used + 1))
# Option 2: Pay the cost
if dist[(neighbor, hops_used)] > current_dist + edge_weight:
dist[(neighbor, hops_used)] = current_dist + edge_weight
heappush(priority_queue, (current_dist + edge_weight, neighbor, hops_used))
# Find answer
return min(dist[(d, i)] for i in range(k + 1))
4. Edge Case: Unreachable Destination
If the destination d
is not reachable from source s
, all values in dist[d]
will remain inf
.
Solution: Add a check before returning:
result = min(dist[d])
return -1 if result == inf else int(result)
5. Integer Overflow with Float Infinity
Using float('inf')
and then converting to int
can cause issues in some systems.
Solution: Use a large integer constant instead:
INF = 10**9 # or sys.maxsize
dist = [[INF] * (k + 1) for _ in range(n)]
The most critical pitfall is #1 (not checking for already processed states), which can significantly impact performance on dense graphs with many paths between nodes.
Which of the following is a good use case for backtracking?
Recommended Readings
https assets algo monster cover_photos graph svg Graph Fundamentals Tree with 0 cycle At this point you should be pretty familiar with trees A tree is a special kind of graph a connected acyclic cycle less graph A graph may contain cycle s and nodes could be disconnected A tree
Shortest Path Between A and B Prereq BFS on Graph problems graph_bfs Given an unweighted connected graph return the length of the shortest path between two nodes A and B in terms of the number of edges Assume there always exists a path between nodes A and B Input graph
https assets algo monster cover_photos heap svg Priority Queue and Heap What is the relationship between priority queue and heap Priority Queue is an Abstract Data Type and Heap is the concrete data structure we use to implement a priority queue Priority Queue A priority queue is a data structure
Want a Structured Path to Master System Design Too? Don’t Miss This!