2538. Difference Between Maximum and Minimum Price Sum


Problem Description

In this problem, we're given an undirected tree that consists of n nodes. A tree is a connected graph with no cycles, and since it's undirected, connections between nodes are bidirectional. The nodes are labeled from 0 to n - 1. We are also given a 2D array edges which contains n - 1 pairs of integers, where each pair [a_i, b_i] represents an edge between node a_i and node b_i.

Additionally, every node has an associated price, as defined in the integer array price, where price[i] is the price of the ith node. The "price sum" of a path in the tree is the sum of prices of all nodes along that path.

The task is to determine the maximum possible "cost" of any rooting of the tree. Here, the "cost" after choosing a node root to be the root of the tree is defined as the difference between the maximum and minimum price sum of all paths that start from the chosen root.

To solve this problem, you must consider all possible ways to root the tree and calculate the cost for each. The answer is the maximum of these costs.

Intuition

The intuition behind the solution involves dynamic programming and depth-first search (DFS). Since the input is a tree, we can start from any node and perform a DFS to search through all nodes and find the price sum of all paths starting from the current node.

Now, when we pick a node to root the tree, each connected node can either contribute to the maximum price sum or the minimum price sum based on the path originating from the root. So for each node, we need to track two values during DFS:

  • The maximum price sum a that can be obtained by including the current node in the path.
  • The second best (or the maximum price sum of the subtree excluding the current node) b.

At each step, we compare the current node's price with the discovered price sums from its children. As DFS unwinds, we update the global maximum ans by considering the possible paths that can be formed by including the current node and the best price sums from its children.

The recursive dfs function achieves this by returning the best (maximum) and second best (maximum excluding the current) price sums for each node. The variables a and b are continually updated as the DFS explores the tree, and every time we call dfs recursively, we consider the node's own price plus the best and second-best prices from the child.

The solution concludes after the DFS traversal, where ans holds the maximum possible cost among all possible root choices, which is the required answer.

Learn more about Tree, Depth-First Search and Dynamic Programming patterns.

Not Sure What to Study? Take the 2-min Quiz to Find Your Missing Piece

Which of the two traversal algorithms (BFS and DFS) can be used to find whether two nodes are connected?

Solution Approach

To solve the problem, the reference solution uses depth-first search (DFS) to traverse the tree and dynamic programming (DP) to keep track of the best and second-best price sums for paths originating from each node. Here's a walkthrough of how the code accomplishes this:

  1. A defaultdict g is used to represent the graph with an adjacency list. Each element in g is a list that contains all the nodes connected to a particular node.
  2. The dfs function is defined which will perform a depth-first search starting from a given node i, where fa is the node's parent (to avoid revisiting it).
  3. In the dfs function, two variables, a and b, are initialized. a represents the maximum price sum including the current node, and b represents the maximum price sum from the current node's subtree when it's not considered.
  4. The function iterates over all the nodes j connected to the current node i. If j is not the parent (i.e., it's not the node we came from), it recursively calls the dfs function to explore the subtree rooted at node j.
  5. The dfs function for child node j returns a pair of values c and d, where c is the maximum price sum including node j, and d is the maximum price sum from the subtree under j excluding itself.
  6. The global variable ans is updated during each call to dfs using the recursion stack to explore the subtree. It takes the maximum of the current ans, and two new potential maximums: a + d and b + c. The first value is the situation where we include the current node in the path, while the second value is the situation where we exclude the current node.
  7. The function then updates a and b for the current node i based on the values returned by its children, which is effectively a bottom-up update representing dynamic programming.
  8. After traversing and updating all connected nodes to i, the function returns the pair (a, b).
  9. The DFS starts with the first node (node 0 here as an arbitrary choice, since it's a tree and the result is not dependent on the choice of the root for DFS) and its parent set to -1 (as it has no parent).
  10. After the complete traversal, ans will contain the maximum possible cost after exploring all possible roots and paths in the tree.

Throughout this implementation, the code maintains a single traversal of the tree using DFS while cleverly updating the dynamic programming states (a, b), avoiding any redundant calculations.

In summary, the problem is solved by a single pass over the tree with DFS, which is both efficient and effective for trees, along with the usage of dynamic programming techniques to track and update the price sums.

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

Which of the following uses divide and conquer strategy?

Example Walkthrough

Let's consider a small example where we have a tree with 4 nodes, and the edges array is [[0, 1], [1, 2], [1, 3]]. The price array is [4, 2, 1, 3], which means the prices for nodes 0, 1, 2, and 3 are 4, 2, 1, and 3, respectively.

The tree would look like this:

1    0
2    |
3    1
4   / \
5  2   3 

Following the solution approach:

  1. We first convert the edges array into a graph representation using an adjacency list. For the example, g will be {0: [1], 1: [0, 2, 3], 2: [1], 3: [1]}.

  2. Initialize the global variable ans to 0. This will keep track of the maximum possible cost.

  3. We define a recursive dfs function that, starting with the root (we can start with any node since it's a tree; let's pick node 0 for simplicity), will traverse the tree in a depth-first manner.

  4. When we start the DFS from node 0, we find that it is connected to node 1. Since 0 is the root in this traversal, it has no parent, so we move to node 1.

  5. At node 1, we explore its children, nodes 2 and 3. In the recursion for node 2, we find that the price sum a is 2 (price of node 1) + 1 (price of node 2) = 3, and b is just the price of node 2 itself, which is 1.

  6. Similarly, DFS on node 3 gives a price sum a of 2 (price of node 1) + 3 (price of node 3) = 5, and b is again just the price of node 3, which is 3.

  7. For node 1, which has now gathered information from its children, we calculate a as the max of its own price plus each child’s a, giving us max(2 + 3, 2 + 5) = 7. The b for node 1 will be the max of b of its children since b represents the value excluding the node itself, so max(1, 3) = 3.

  8. Every time the dfs function returns to node 1 from its children nodes 2 and 3, we check to update our global ans. Since node 1 is connected to node 0 with price 4, the potential maximums could be a + b from children, yielding potential values 4 + 3 (node 1's price plus the second-best price sum from its subtree) = 7. As b from node 0 is 0 (since it's the root in our DFS and doesn't have any price to add), we also consider 4 + 7 = 11. We take the max of these to update ans.

  9. After exploring both branches of the tree, we find that the maximum possible price difference for node 1 as the root is 11.

  10. In a complete solution, we would run such a DFS starting from each node, but since we know the tree structure doesn't actually change with different roots (just the way we calculate price sums), the ans we obtained is, in fact, the maximum cost after considering all possible root selections in our small example. For a larger tree, we would have to repeat the DFS from each node to cover all possible rootings.

By using DFS and dynamic programming, we can efficiently calculate the maximum possible cost for any rooting of the tree.

Solution Implementation

1from typing import List
2from collections import defaultdict
3
4class Solution:
5    def maxOutput(self, n: int, edges: List[List[int]], price: List[int]) -> int:
6        # Depth First Search function to traverse graph and calculate maxOutput
7        def dfs(node, parent):
8            # Initialize current output and alternative output to the node's price
9            current_output, alternative_output = price[node], 0
10          
11            # Explore all the connected nodes.
12            for connected_node in graph[node]:
13                if connected_node != parent:  # Ensuring we don't backtrack
14                    max_with_current, max_without_current = dfs(connected_node, node)
15                    # Update the maximum answer found so far by considering new paths
16                    self.ans = max(self.ans, current_output + max_without_current, alternative_output + max_with_current)
17                    # Update current max outputs
18                    current_output = max(current_output, price[node] + max_with_current)
19                    alternative_output = max(alternative_output, price[node] + max_without_current)
20                  
21            return current_output, alternative_output
22
23        # Build a graph as an adjacency list from edges
24        graph = defaultdict(list)
25        for start, end in edges:
26            graph[start].append(end)
27            graph[end].append(start)
28      
29        # Initialize the answer (max output) to zero
30        self.ans = 0
31        # Start DFS traversal from node 0 with no parent (-1)
32        dfs(0, -1)
33      
34        # Return the answer after all possible max outputs are considered
35        return self.ans
36
1class Solution {
2    // Graph represented as adjacent lists
3    private List<Integer>[] graph;
4    // Max possible output stored globally
5    private long maxPossibleOutput;
6    // Prices associated with each node
7    private int[] nodePrices;
8
9    // Calculates the maximum output by traversing the graph
10    public long maxOutput(int n, int[][] edges, int[] prices) {
11        // Initialize the adjacencies list of the graph
12        graph = new List[n];
13        Arrays.setAll(graph, k -> new ArrayList<>());
14        // Build graph with given edges
15        for (int[] edge : edges) {
16            int from = edge[0], to = edge[1];
17            graph[from].add(to);
18            graph[to].add(from);
19        }
20        // Assign the price array to the global variable
21        this.nodePrices = prices;
22        // Start the DFS from node 0 with no parent (-1 indicates no parent)
23        dfs(0, -1);
24        // Return the maximum output computed
25        return maxPossibleOutput;
26    }
27
28    // Performs a DFS on the graph and returns the max production values
29    private long[] dfs(int node, int parent) {
30        // 'a' captures the max production when node 'i' is included
31        long includeCurrent = nodePrices[node];
32        // 'b' captures the max production when node 'i' is excluded
33        long excludeCurrent = 0;
34        // Traverse all the connected nodes
35        for (int neighbor : graph[node]) {
36            // If the neighbor is not the parent node
37            if (neighbor != parent) {
38                // Perform DFS on the neighbor
39                long[] neighborValues = dfs(neighbor, node);
40                long includeNeighbor = neighborValues[0];
41                long excludeNeighbor = neighborValues[1];
42              
43                // Max output may include this node and exclude neighbor
44                // or exclude this node but include neighbor
45                maxPossibleOutput = Math.max(maxPossibleOutput, 
46                    Math.max(includeCurrent + excludeNeighbor, excludeCurrent + includeNeighbor));
47              
48                // Update local production values for the inclusion or exclusion of this node
49                includeCurrent = Math.max(includeCurrent, nodePrices[node] + includeNeighbor);
50                excludeCurrent = Math.max(excludeCurrent, nodePrices[node] + excludeNeighbor);
51            }
52        }
53        // Return the max production values when this node is included and excluded
54        return new long[]{includeCurrent, excludeCurrent};
55    }
56}
57
1#include <vector>
2#include <functional>
3#include <algorithm>
4
5class Solution {
6public:
7    // Calculate the maximum output for any node based on the provided graph and prices.
8    long long maxOutput(int n, std::vector<std::vector<int>>& edges, std::vector<int>& prices) {
9        // Adjacency list to represent the graph
10        std::vector<std::vector<int>> graph(n);
11        for (auto& edge : edges) {
12            int from = edge[0], to = edge[1];
13            graph[from].push_back(to);
14            graph[to].push_back(from);
15        }
16
17        using ll = long long;
18        using pll = std::pair<ll, ll>;
19        ll answer = 0;
20      
21        // Depth-first search to explore the graph
22        // It returns the maximum price choosing and not choosing the current node
23        std::function<pll(int, int)> dfs = [&](int node, int parent) {
24            ll chooseNode = prices[node];  // Max price when choosing the current node
25            ll notChooseNode = 0;          // Max price when not choosing the current node
26          
27            for (int neighbor : graph[node]) {
28                if (neighbor != parent) {
29                    // Explore child nodes
30                    auto [chooseChild, notChooseChild] = dfs(neighbor, node);
31                  
32                    // Update the answer to the maximum output so far
33                    answer = std::max({answer, chooseNode + notChooseChild, notChooseNode + chooseChild});
34                  
35                    // Recurrence relations that update the max prices
36                    chooseNode = std::max(chooseNode, prices[node] + chooseChild);
37                    notChooseNode = std::max(notChooseNode, prices[node] + notChooseChild);
38                }
39            }
40            return pll{chooseNode, notChooseNode};
41        };
42
43        // Initiate depth-first search from node 0, with no parent
44        dfs(0, -1);
45
46        // Return the maximum price as the answer
47        return answer;
48    }
49};
50
1type Edge = [number, number];
2type Prices = number[];
3type Graph = number[][];
4type Pair = [number, number]; // Represents a pair with two long numbers, used to hold maximum prices
5
6// Adjacency list global variable to represent the graph
7let graph: Graph = [];
8
9// Function to calculate the maximum output for any node, using the provided graph and prices
10function maxOutput(n: number, edges: Edge[], prices: Prices) : bigint {
11    // Initialize the graph as an array of arrays with size n
12    graph = new Array(n).fill(0).map(() => []);
13
14    // Populate the graph with edges
15    edges.forEach((edge) => {
16        const [from, to] = edge;
17        graph[from].push(to);
18        graph[to].push(from);
19    });
20
21    // Variable to store the final result
22    let answer: bigint = BigInt(0);
23
24    // Depth-first search function that explores the graph
25    // It returns a tuple with the maximum price when choosing or not choosing the current node
26    function dfs(node: number, parent: number): Pair {
27        let chooseNode: bigint = BigInt(prices[node]); // Max price when choosing the current node
28        let notChooseNode: bigint = BigInt(0);        // Max price when not choosing the current node
29
30        graph[node].forEach((neighbor) => { 
31            if (neighbor !== parent) { 
32                // Perform DFS on child nodes
33                const [chooseChild, notChooseChild] = dfs(neighbor, node);
34
35                // Update the answer with the maximum output so far
36                answer = BigInt(Math.max(Number(answer), Number(chooseNode + notChooseChild), Number(notChooseNode + chooseChild)));
37
38                // Update the max prices based on the recursive calls
39                chooseNode = BigInt(Math.max(Number(chooseNode), Number(prices[node] + chooseChild)));
40                notChooseNode = BigInt(Math.max(Number(notChooseNode), Number(prices[node] + notChooseChild)));
41            }
42        });
43        return [chooseNode, notChooseNode];
44    }
45
46    // Start the DFS from node 0, assuming there's no parent for the root
47    dfs(0, -1);
48
49    // Return the maximum price computed
50    return answer;
51}
52
Not Sure What to Study? Take the 2-min Quiz

Which type of traversal does breadth first search do?

Time and Space Complexity

The given Python code defines a method maxOutput that calculates the maximum output based on certain conditions using a Depth First Search (DFS) algorithm.

Time Complexity:

The time complexity of the DFS is O(N), where N is the number of nodes in the graph. This is because the DFS algorithm visits each node exactly once. Since the graph is represented using an adjacency list, and each edge is considered twice (once for each node it connects), running the DFS starting from the initial node (node 0) will result in traversing each edge two times — once for each direction.

However, inside the DFS, for each node, we loop through all its connected neighbors. The sum total of all the iterations through neighbors over the course of the entire DFS will equal the number of edges, which for an undirected graph is 2*(N-1) (since it's a connected tree and there are N-1 edges for N nodes).

Therefore, the overall time complexity of the code is O(N) because there's a constant amount of work done per edge (the number of edges is proportional to the number of nodes in a tree).

Space Complexity:

The space complexity consists of:

  1. The space taken by the recursive call stack during DFS: In the worst case, this can be O(N) if the graph is structured as a linked list (degenerates to a linked list).
  2. The space taken by the adjacency list g: This will also be O(N), since it stores all N-1 edges in both directions.
  3. The space needed for any additional variables is constant and does not scale with N, so we can ignore this in our analysis.

Therefore, the space complexity of the method is O(N) for storing the graph and O(N) for the recursion stack, giving a combined space complexity of O(N).

Learn more about how to find time and space complexity quickly using problem constraints.

Fast Track Your Learning with Our Quick Skills Quiz:

What's the output of running the following function using input 56?

1KEYBOARD = {
2    '2': 'abc',
3    '3': 'def',
4    '4': 'ghi',
5    '5': 'jkl',
6    '6': 'mno',
7    '7': 'pqrs',
8    '8': 'tuv',
9    '9': 'wxyz',
10}
11
12def letter_combinations_of_phone_number(digits):
13    def dfs(path, res):
14        if len(path) == len(digits):
15            res.append(''.join(path))
16            return
17
18        next_number = digits[len(path)]
19        for letter in KEYBOARD[next_number]:
20            path.append(letter)
21            dfs(path, res)
22            path.pop()
23
24    res = []
25    dfs([], res)
26    return res
27
1private static final Map<Character, char[]> KEYBOARD = Map.of(
2    '2', "abc".toCharArray(),
3    '3', "def".toCharArray(),
4    '4', "ghi".toCharArray(),
5    '5', "jkl".toCharArray(),
6    '6', "mno".toCharArray(),
7    '7', "pqrs".toCharArray(),
8    '8', "tuv".toCharArray(),
9    '9', "wxyz".toCharArray()
10);
11
12public static List<String> letterCombinationsOfPhoneNumber(String digits) {
13    List<String> res = new ArrayList<>();
14    dfs(new StringBuilder(), res, digits.toCharArray());
15    return res;
16}
17
18private static void dfs(StringBuilder path, List<String> res, char[] digits) {
19    if (path.length() == digits.length) {
20        res.add(path.toString());
21        return;
22    }
23    char next_digit = digits[path.length()];
24    for (char letter : KEYBOARD.get(next_digit)) {
25        path.append(letter);
26        dfs(path, res, digits);
27        path.deleteCharAt(path.length() - 1);
28    }
29}
30
1const KEYBOARD = {
2    '2': 'abc',
3    '3': 'def',
4    '4': 'ghi',
5    '5': 'jkl',
6    '6': 'mno',
7    '7': 'pqrs',
8    '8': 'tuv',
9    '9': 'wxyz',
10}
11
12function letter_combinations_of_phone_number(digits) {
13    let res = [];
14    dfs(digits, [], res);
15    return res;
16}
17
18function dfs(digits, path, res) {
19    if (path.length === digits.length) {
20        res.push(path.join(''));
21        return;
22    }
23    let next_number = digits.charAt(path.length);
24    for (let letter of KEYBOARD[next_number]) {
25        path.push(letter);
26        dfs(digits, path, res);
27        path.pop();
28    }
29}
30

Recommended Readings


Got a question? Ask the Teaching Assistant anything you don't understand.

Still not clear? Ask in the Forum,  Discord or Submit the part you don't understand to our editors.

←
↑TA đŸ‘šâ€đŸ«