2065. Maximum Path Quality of a Graph
Problem Description
You have an undirected graph with n
nodes numbered from 0
to n - 1
. Each node has a value given by values[i]
for node i
. The graph's edges are defined by a 2D array edges
, where each element edges[j] = [u_j, v_j, time_j]
represents an undirected edge between nodes u_j
and v_j
that takes time_j
seconds to traverse.
Your task is to find a valid path that:
- Starts at node
0
- Ends at node
0
(returns to the starting point) - Takes at most
maxTime
seconds to complete - Can visit the same node multiple times
The quality of a path is calculated as the sum of values of all unique nodes visited during the path. This means if you visit a node multiple times, its value is only counted once in the quality calculation.
You need to return the maximum possible quality among all valid paths.
Key constraints:
- Each node has at most 4 edges connected to it
- You must start and end at node
0
- The total time of the path cannot exceed
maxTime
- Node values are only counted once in the quality sum, regardless of how many times you visit them
Example:
If you have a path that visits nodes 0 → 1 → 2 → 1 → 0
, and the values are [5, 10, 15, ...]
, the quality would be 5 + 10 + 15 = 30
(node 1's value is only counted once despite being visited twice).
Flowchart Walkthrough
First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:
Is it a graph?
- Yes: The problem explicitly involves an undirected graph with nodes and edges. We have nodes numbered from 0 to n-1 and edges connecting them with travel times.
Since the answer is "yes" for a graph problem, the flowchart doesn't continue down the "no" path. However, let's analyze the problem characteristics to understand why backtracking is the appropriate approach:
Does the problem have small constraints?
- Yes: The problem states that each node has at most 4 edges connected to it, and with maxTime constraints, the number of edges we can traverse is limited. The reference solution notes that we can traverse at most 10 edges (maxTime/min(time_j) = 100/10 = 10), making the search space manageable.
Brute force / Backtracking?
- Yes: Given the small constraints and the need to explore all possible valid paths from node 0 back to node 0, backtracking is ideal. We need to:
- Try different paths from the current node
- Keep track of visited nodes (for quality calculation)
- Backtrack when we've explored a path
- Ensure we don't exceed the time limit
Why Backtracking fits perfectly:
- We need to explore all possible paths that start and end at node 0
- The search space is small enough (max 4 edges per node, limited path length)
- We need to track state (visited nodes, current time, current quality)
- We need to undo choices (unmark nodes as visited) when backtracking
Conclusion: The flowchart analysis confirms that backtracking is the optimal approach for this problem. The DFS with backtracking allows us to exhaustively search all valid paths while maintaining the visited state for quality calculation and ensuring we stay within the time constraints.
Intuition
The key insight comes from examining the constraints. With at most 4 edges per node and a maximum time limit, we realize the search space is actually quite small. Even in the worst case where each edge takes minimum time (10 seconds), we can only traverse about 10 edges within maxTime = 100
. This means our paths won't be very long.
Since we need to find the maximum quality among all valid paths from node 0 back to node 0, and the search space is manageable, we can afford to explore every possible path. This naturally leads us to a backtracking approach.
The tricky part is handling the quality calculation - we want to count each node's value only once, even if we visit it multiple times. This suggests we need to track which nodes we've visited in our current path. When we visit a node for the first time, we add its value to our quality sum. If we visit it again, we don't add its value again.
Here's the thought process:
- Start at node 0 with its value already counted
- For each neighbor, check if we have enough time to visit it
- If we visit a new node, mark it as visited and add its value
- If we revisit a node, don't add its value again
- When we return to node 0, we have a complete path - update our answer if this path has better quality
- Backtrack by unmarking nodes we visited (so other paths can count them fresh)
The beauty of this approach is that the backtracking naturally handles the exploration of all paths, while the visited array ensures we correctly calculate the quality by counting each node's value at most once per path. The DFS recursion stack keeps track of our current path, and we only need to maintain the cumulative time and quality as we go.
Learn more about Graph and Backtracking patterns.
Solution Approach
The solution uses DFS with backtracking to explore all valid paths. Let's walk through the implementation step by step:
1. Graph Representation
First, we build an adjacency list g
to represent the graph. For each edge [u, v, t]
, we add (v, t)
to g[u]
and (u, t)
to g[v]
since the graph is undirected.
g = [[] for _ in range(n)]
for u, v, t in edges:
g[u].append((v, t))
g[v].append((u, t))
2. Visited Tracking
We maintain a boolean array vis
of length n
to track which nodes are currently in our path. Initially, we mark node 0 as visited since we start from there.
vis = [False] * n vis[0] = True
3. DFS Function
The core logic is in the dfs(u, cost, value)
function where:
u
: current node we're atcost
: total time spent so farvalue
: total quality (sum of unique node values) collected so far
4. Base Case When we reach node 0 (which means we've completed a cycle back to start), we update our answer:
if u == 0:
ans = max(ans, value)
5. Exploring Neighbors
For each neighbor v
of current node u
with edge time t
:
- First check if we have enough time:
cost + t <= maxTime
- If
vis[v]
isTrue
(node already visited in current path):- Recursively call
dfs(v, cost + t, value)
without adding the node's value again
- Recursively call
- If
vis[v]
isFalse
(first time visiting this node):- Mark it as visited:
vis[v] = True
- Recursively call with added value:
dfs(v, cost + t, value + values[v])
- Backtrack by unmarking:
vis[v] = False
- Mark it as visited:
6. Starting the Search We initiate the DFS from node 0 with:
- Starting cost: 0
- Starting value:
values[0]
(node 0's value)
The algorithm cleverly handles the requirement that each node's value should be counted at most once per path. By maintaining the vis
array and only adding a node's value when we first visit it in the current path, we ensure correct quality calculation. The backtracking (setting vis[v] = False
) allows other paths to count that node's value independently.
The time complexity is bounded by the small search space - with at most 4 edges per node and limited path length, the algorithm efficiently explores all possibilities despite being exponential in nature.
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.
Example Graph:
- Nodes: 4 nodes (0, 1, 2, 3)
- Values: [10, 5, 15, 20]
- Edges: [[0,1,10], [0,2,15], [1,3,20], [2,3,10]]
- maxTime: 30
Graph Visualization:
0(10)---10---1(5) | | 15 20 | | 2(15)---10---3(20)
DFS Exploration:
-
Start at node 0
- vis = [True, False, False, False]
- cost = 0, value = 10
- Check neighbors: nodes 1 and 2
-
Path: 0 → 1
- cost = 10, value = 10 + 5 = 15
- vis = [True, True, False, False]
- From node 1, can go to: node 0 (cost=20) or node 3 (cost=30)
-
Path: 0 → 1 → 0
- Back at node 0, cost = 20, value = 15
- Update answer: ans = 15
- Continue exploring from node 1...
-
Path: 0 → 1 → 3
- cost = 30, value = 15 + 20 = 35
- vis = [True, True, False, True]
- From node 3, time budget exhausted (any move would exceed 30)
- Backtrack: vis[3] = False
-
Backtrack to node 0, explore second neighbor
- vis = [True, False, False, False]
- Path: 0 → 2
- cost = 15, value = 10 + 15 = 25
- vis = [True, False, True, False]
-
Path: 0 → 2 → 3
- cost = 25, value = 25 + 20 = 45
- vis = [True, False, True, True]
- From node 3, can only afford to go back to node 2 (cost=35 > 30 for node 1)
- Backtrack: vis[3] = False
-
Path: 0 → 2 → 0
- Back at node 0, cost = 30, value = 25
- Update answer: ans = max(15, 25) = 25
Key Observations:
- When we visit node 1 twice in path 0→1→0→1, we don't add value[1] again
- The vis array tracks nodes in current path, preventing double-counting
- Backtracking (vis[v] = False) allows other paths to count that node's value
- We only update the answer when we return to node 0
- The algorithm explores all feasible paths within the time limit
Final Answer: 25 (from path 0→2→0 with nodes {0, 2} visited)
Solution Implementation
1class Solution:
2 def maximalPathQuality(
3 self, values: List[int], edges: List[List[int]], maxTime: int
4 ) -> int:
5 def dfs(current_node: int, time_spent: int, current_value: int) -> None:
6 """
7 Depth-first search to explore all possible paths.
8
9 Args:
10 current_node: Current node in the traversal
11 time_spent: Total time spent so far in the path
12 current_value: Total value collected so far in the path
13 """
14 # If we're back at node 0, update the maximum answer
15 if current_node == 0:
16 nonlocal max_quality
17 max_quality = max(max_quality, current_value)
18
19 # Explore all neighboring nodes
20 for neighbor, travel_time in adjacency_list[current_node]:
21 # Only proceed if we have enough time remaining
22 if time_spent + travel_time <= maxTime:
23 if visited[neighbor]:
24 # If neighbor is already visited, don't add its value again
25 dfs(neighbor, time_spent + travel_time, current_value)
26 else:
27 # Mark as visited, add value, explore, then backtrack
28 visited[neighbor] = True
29 dfs(neighbor, time_spent + travel_time, current_value + values[neighbor])
30 visited[neighbor] = False # Backtrack: unmark visited
31
32 # Initialize graph data structures
33 num_nodes = len(values)
34 adjacency_list = [[] for _ in range(num_nodes)]
35
36 # Build adjacency list representation of the graph
37 for node_u, node_v, travel_time in edges:
38 adjacency_list[node_u].append((node_v, travel_time))
39 adjacency_list[node_v].append((node_u, travel_time))
40
41 # Track visited nodes to avoid counting their values multiple times
42 visited = [False] * num_nodes
43 visited[0] = True # Start from node 0
44
45 # Initialize the maximum quality answer
46 max_quality = 0
47
48 # Start DFS from node 0 with initial time 0 and initial value of node 0
49 dfs(0, 0, values[0])
50
51 return max_quality
52
1class Solution {
2 // Graph represented as adjacency list where each node stores list of [neighbor, time] pairs
3 private List<int[]>[] graph;
4 // Track visited nodes to avoid counting their values multiple times
5 private boolean[] visited;
6 // Node values array
7 private int[] nodeValues;
8 // Maximum time constraint for the path
9 private int timeLimit;
10 // Store the maximum quality found
11 private int maxQuality;
12
13 public int maximalPathQuality(int[] values, int[][] edges, int maxTime) {
14 int nodeCount = values.length;
15
16 // Initialize the graph as adjacency list
17 graph = new List[nodeCount];
18 Arrays.setAll(graph, index -> new ArrayList<>());
19
20 // Build undirected graph from edges
21 for (int[] edge : edges) {
22 int nodeU = edge[0];
23 int nodeV = edge[1];
24 int travelTime = edge[2];
25
26 // Add bidirectional edges
27 graph[nodeU].add(new int[] {nodeV, travelTime});
28 graph[nodeV].add(new int[] {nodeU, travelTime});
29 }
30
31 // Initialize visited array and mark starting node as visited
32 visited = new boolean[nodeCount];
33 visited[0] = true;
34
35 // Store values and time limit for use in DFS
36 this.nodeValues = values;
37 this.timeLimit = maxTime;
38
39 // Start DFS from node 0 with initial time 0 and initial quality as value of node 0
40 dfs(0, 0, values[0]);
41
42 return maxQuality;
43 }
44
45 /**
46 * Performs depth-first search to find the maximum quality path
47 * @param currentNode - current node in the path
48 * @param currentTime - accumulated time spent so far
49 * @param currentQuality - accumulated quality value so far
50 */
51 private void dfs(int currentNode, int currentTime, int currentQuality) {
52 // Update maximum quality when we return to node 0
53 if (currentNode == 0) {
54 maxQuality = Math.max(maxQuality, currentQuality);
55 }
56
57 // Explore all neighbors of current node
58 for (int[] neighborInfo : graph[currentNode]) {
59 int neighborNode = neighborInfo[0];
60 int travelTime = neighborInfo[1];
61 int newTime = currentTime + travelTime;
62
63 // Only continue if we have enough time remaining
64 if (newTime <= timeLimit) {
65 if (visited[neighborNode]) {
66 // If neighbor is already visited, don't add its value again
67 dfs(neighborNode, newTime, currentQuality);
68 } else {
69 // Mark as visited, add its value, explore, then backtrack
70 visited[neighborNode] = true;
71 dfs(neighborNode, newTime, currentQuality + nodeValues[neighborNode]);
72 visited[neighborNode] = false; // Backtrack
73 }
74 }
75 }
76 }
77}
78
1class Solution {
2public:
3 int maximalPathQuality(vector<int>& values, vector<vector<int>>& edges, int maxTime) {
4 int n = values.size();
5
6 // Build adjacency list representation of the graph
7 // Each node stores pairs of (neighbor_node, time_cost)
8 vector<vector<pair<int, int>>> graph(n);
9 for (const auto& edge : edges) {
10 int u = edge[0], v = edge[1], time = edge[2];
11 graph[u].emplace_back(v, time);
12 graph[v].emplace_back(u, time);
13 }
14
15 // Track visited nodes to avoid counting their values multiple times
16 vector<bool> visited(n, false);
17 visited[0] = true;
18
19 // Maximum path quality found so far
20 int maxQuality = 0;
21
22 // DFS function to explore all valid paths
23 // Parameters:
24 // - currentNode: current node in the path
25 // - timeCost: accumulated time cost so far
26 // - pathValue: accumulated value of unique nodes visited
27 function<void(int, int, int)> dfs = [&](int currentNode, int timeCost, int pathValue) {
28 // Update answer when we return to node 0
29 if (currentNode == 0) {
30 maxQuality = max(maxQuality, pathValue);
31 }
32
33 // Explore all neighbors
34 for (const auto& [nextNode, edgeTime] : graph[currentNode]) {
35 // Only proceed if we have enough time remaining
36 if (timeCost + edgeTime <= maxTime) {
37 if (visited[nextNode]) {
38 // Node already visited, don't add its value again
39 dfs(nextNode, timeCost + edgeTime, pathValue);
40 } else {
41 // Mark as visited and add its value
42 visited[nextNode] = true;
43 dfs(nextNode, timeCost + edgeTime, pathValue + values[nextNode]);
44 // Backtrack: unmark for other paths to use
45 visited[nextNode] = false;
46 }
47 }
48 }
49 };
50
51 // Start DFS from node 0 with initial time 0 and value of node 0
52 dfs(0, 0, values[0]);
53
54 return maxQuality;
55 }
56};
57
1/**
2 * Finds the maximum path quality in a graph within time constraints
3 * @param values - Array of node values
4 * @param edges - Array of edges with format [node1, node2, time]
5 * @param maxTime - Maximum allowed time for traversal
6 * @returns Maximum quality value achievable
7 */
8function maximalPathQuality(values: number[], edges: number[][], maxTime: number): number {
9 const nodeCount: number = values.length;
10
11 // Build adjacency list representation of the graph
12 // Each node maps to array of [neighborNode, travelTime] pairs
13 const adjacencyList: Array<Array<[number, number]>> = Array.from(
14 { length: nodeCount },
15 () => []
16 );
17
18 // Populate adjacency list with bidirectional edges
19 for (const [nodeU, nodeV, travelTime] of edges) {
20 adjacencyList[nodeU].push([nodeV, travelTime]);
21 adjacencyList[nodeV].push([nodeU, travelTime]);
22 }
23
24 // Track visited nodes to avoid counting their values multiple times
25 const isVisited: boolean[] = Array(nodeCount).fill(false);
26 isVisited[0] = true; // Mark starting node as visited
27
28 // Store the maximum quality found
29 let maxQuality: number = 0;
30
31 /**
32 * Depth-first search to explore all possible paths
33 * @param currentNode - Current node being explored
34 * @param currentTime - Time spent so far
35 * @param currentQuality - Quality accumulated so far
36 */
37 const performDFS = (currentNode: number, currentTime: number, currentQuality: number): void => {
38 // Update maximum quality when we return to starting node
39 if (currentNode === 0) {
40 maxQuality = Math.max(maxQuality, currentQuality);
41 }
42
43 // Explore all neighbors
44 for (const [neighborNode, travelTime] of adjacencyList[currentNode]) {
45 const newTime: number = currentTime + travelTime;
46
47 // Only proceed if we have enough time remaining
48 if (newTime <= maxTime) {
49 if (isVisited[neighborNode]) {
50 // If already visited, don't add its value again
51 performDFS(neighborNode, newTime, currentQuality);
52 } else {
53 // Mark as visited, add value, explore, then backtrack
54 isVisited[neighborNode] = true;
55 performDFS(neighborNode, newTime, currentQuality + values[neighborNode]);
56 isVisited[neighborNode] = false; // Backtrack
57 }
58 }
59 }
60 };
61
62 // Start DFS from node 0 with initial quality of node 0's value
63 performDFS(0, 0, values[0]);
64
65 return maxQuality;
66}
67
Time and Space Complexity
Time Complexity: O(n + m + 4^(maxTime/min(time_j)))
The time complexity consists of:
O(n + m)
for building the adjacency list from the edges- The DFS exploration complexity, which in the worst case can be
O(4^(maxTime/min(time_j)))
The exponential part arises because:
- From each node, we can potentially explore multiple paths (up to degree of the node)
- We can revisit nodes multiple times as long as the time constraint allows
- The maximum depth of recursion is bounded by
maxTime/min(time_j)
wheremin(time_j)
is the minimum edge weight - At each level, we might branch to multiple neighbors, and since we can revisit nodes, the branching factor can be up to 4 in dense graphs (considering bidirectional edges)
Space Complexity: O(n + m + maxTime/min(time_j))
The space complexity consists of:
O(n + m)
for storing the graph adjacency listO(n)
for the visited arrayO(maxTime/min(time_j))
for the recursion stack depth in the worst case, which represents the maximum number of edges we can traverse within the time limit
Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall 1: Incorrectly Handling the Starting Node's Value
The Problem:
A common mistake is forgetting to include node 0's value in the initial quality calculation or accidentally double-counting it. Some implementations might start with current_value = 0
instead of current_value = values[0]
, which would miss counting the starting node's value entirely.
Why It Happens:
Since we mark visited[0] = True
before starting the DFS, and we only add values for unvisited nodes during traversal, node 0's value would never be added if we don't initialize it properly.
The Fix:
Always initialize the DFS with values[0]
as the starting quality:
# Correct initialization dfs(0, 0, values[0]) # Start with node 0's value included # Incorrect initialization that misses node 0's value # dfs(0, 0, 0) # This would miss counting values[0]
Pitfall 2: Not Properly Backtracking the Visited State
The Problem:
Forgetting to reset visited[neighbor] = False
after exploring a path leads to incorrect results. Without proper backtracking, once a node is visited in any path, it would remain marked as visited for all subsequent path explorations, preventing other valid paths from counting that node's value.
Why It Happens: The backtracking step can be easily overlooked, especially when focusing on the forward exploration logic. The algorithm explores multiple paths that might share common nodes, and each path should independently track which nodes it has visited.
The Fix: Always reset the visited state after exploring a branch:
if not visited[neighbor]: visited[neighbor] = True dfs(neighbor, time_spent + travel_time, current_value + values[neighbor]) visited[neighbor] = False # Critical: Must backtrack!
Example of the Bug: Consider a graph where node 0 connects to nodes 1 and 2, and both 1 and 2 connect back to 0. Without proper backtracking:
- Path 1:
0 → 1 → 0
would mark node 1 as visited - Path 2:
0 → 2 → 1 → 0
would not be able to count node 1's value because it's still marked as visited from Path 1
This would result in a lower quality score than the correct implementation.
What is an advantages of top-down dynamic programming vs bottom-up dynamic programming?
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
Backtracking Template Prereq DFS with States problems dfs_with_states Combinatorial search problems Combinatorial search problems involve finding groupings and assignments of objects that satisfy certain conditions Finding all permutations combinations subsets and solving Sudoku are classic combinatorial problems The time complexity of combinatorial problems often grows rapidly with the size of
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
Want a Structured Path to Master System Design Too? Don’t Miss This!