Facebook Pixel

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:

  1. We need to explore all possible paths that start and end at node 0
  2. The search space is small enough (max 4 edges per node, limited path length)
  3. We need to track state (visited nodes, current time, current quality)
  4. 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. Start at node 0 with its value already counted
  2. For each neighbor, check if we have enough time to visit it
  3. If we visit a new node, mark it as visited and add its value
  4. If we revisit a node, don't add its value again
  5. When we return to node 0, we have a complete path - update our answer if this path has better quality
  6. 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 at
  • cost: total time spent so far
  • value: 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] is True (node already visited in current path):
    • Recursively call dfs(v, cost + t, value) without adding the node's value again
  • If vis[v] is False (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

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 Evaluator

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

  1. Start at node 0

    • vis = [True, False, False, False]
    • cost = 0, value = 10
    • Check neighbors: nodes 1 and 2
  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)
  3. Path: 0 → 1 → 0

    • Back at node 0, cost = 20, value = 15
    • Update answer: ans = 15
    • Continue exploring from node 1...
  4. 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
  5. 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]
  6. 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
  7. 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) where min(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 list
  • O(n) for the visited array
  • O(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.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

What is an advantages of top-down dynamic programming vs bottom-up dynamic programming?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More