Facebook Pixel

2378. Choose Edges to Maximize Score in a Tree 🔒

Problem Description

You have a weighted tree with n nodes numbered from 0 to n - 1, where node 0 is the root.

The tree is given as a 2D array edges of size n, where edges[i] = [par_i, weight_i] means:

  • par_i is the parent of node i
  • The edge connecting node i to its parent has weight weight_i
  • For the root node (node 0), edges[0] = [-1, -1] since it has no parent

Your task is to select some edges from the tree such that:

  1. No two selected edges are adjacent (they cannot share a common node)
  2. The sum of weights of the selected edges is maximized

Return the maximum possible sum of weights. You can choose to select no edges at all, in which case the sum would be 0.

Example of adjacent edges: If edge 1 connects nodes a and b, and edge 2 connects nodes b and c, then these two edges are adjacent because they share node b.

The problem is essentially asking you to find the maximum weight independent set of edges in the tree, where no two edges in the set share a common vertex.

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 a weighted tree, which is a special type of graph. We have nodes (0 to n-1) and edges with weights connecting them.

Is it a tree?

  • Yes: The problem clearly states we're working with a weighted tree. A tree is a connected acyclic graph with n nodes and n-1 edges, where each node (except the root) has exactly one parent.

DFS

  • Yes: Since we're dealing with a tree structure, DFS (Depth-First Search) is the appropriate approach.

Conclusion: The flowchart suggests using DFS for this tree problem.

This makes perfect sense because:

  1. We need to explore the tree structure recursively from the root
  2. For each subtree, we need to make decisions about whether to include edges or not
  3. The problem requires computing optimal values for each subtree (dynamic programming on trees)
  4. DFS allows us to process children before parents, which is essential for bottom-up computation of the maximum score

The DFS approach combined with dynamic programming (tree DP) allows us to:

  • Visit each node exactly once
  • Calculate the maximum score for each subtree
  • Track two states at each node: including or excluding the edge to its parent
  • Build up the solution from leaves to root
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When we look at this problem, we need to select edges such that no two edges share a common node. This is a classic constraint that suggests we need to make local decisions at each node that will contribute to a global optimal solution.

Let's think about what happens at any node in the tree. For each node, we have an edge connecting it to its parent (except the root). The key insight is that we have two choices:

  1. Include the edge between this node and its parent
  2. Don't include the edge between this node and its parent

These two choices have different implications:

  • If we include the edge to the parent, then we cannot include any edges from this node to its children (they would be adjacent and share this node)
  • If we don't include the edge to the parent, then we have the freedom to potentially include one edge to one of our children

This naturally leads to a dynamic programming approach where we track two states for each node:

  • a: Maximum score when we cannot use edges to children (because we used the edge to parent)
  • b: Maximum score when we can potentially use edges to children (because we didn't use the edge to parent)

For state a: Since we can't use any edges to children, we simply sum up the best scores from all children when they have freedom to use their parent edges (their b values).

For state b: We can choose at most one edge to a child. So we try each child edge and see which gives us the maximum benefit. The benefit of choosing edge to child j with weight w is: w + (child j's score when it can't use parent edge) - (child j's score when it can use parent edge) = w + x - y.

Starting from the leaves and working our way up to the root using DFS, we can compute these values for each node. Since the root has no parent edge, we return the b value of the root (the case where it has freedom to use edges to its children).

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

Solution Approach

Based on our intuition, we implement a tree dynamic programming solution using DFS. Here's how the solution works:

Data Structure Setup:

  • We build an adjacency list g using a defaultdict(list) to represent the tree structure
  • For each node i (starting from 1), we store its children as tuples (child_node, edge_weight)
  • We skip edges[0] since it represents the root with no parent

DFS Function Design: The dfs(i) function computes two values for node i:

  • a: Maximum score when the edge between node i and its parent is selected (cannot select edges to children)
  • b: Maximum score when the edge between node i and its parent is not selected (can select at most one edge to a child)

Core Algorithm:

  1. Initialize variables:

    • a = 0: Score when parent edge is selected
    • b = 0: Score when parent edge is not selected
    • t = 0: Tracks the maximum benefit of selecting one child edge
  2. Process each child j with edge weight w:

    • Recursively call dfs(j) to get child's scores (x, y)
      • x: Child's score when its parent edge is selected
      • y: Child's score when its parent edge is not selected
  3. Update scores:

    • a += y: If we select the parent edge of node i, we cannot select any edges to children, so we take each child's y value
    • b += y: Start with the baseline of not selecting any child edges
    • t = max(t, x - y + w): Calculate the benefit of selecting the edge to child j. The benefit is w (edge weight) plus the difference between selecting (x) and not selecting (y) the child's parent edge
  4. Final calculation:

    • b += t: Add the maximum benefit from selecting one child edge to b
    • Return (a, b)

Main Execution:

  • Call dfs(0) for the root node
  • Return the second value b, which represents the maximum score when the root's (non-existent) parent edge is not selected

The time complexity is O(n) as we visit each node once, and the space complexity is O(n) for the recursion stack and adjacency list.

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.

Consider a tree with 4 nodes:

    0 (root)
   / \
  1   2
 /
3

With edges array:

  • edges[0] = [-1, -1] (root, no parent)
  • edges[1] = [0, 5] (node 1's parent is 0, edge weight = 5)
  • edges[2] = [0, 3] (node 2's parent is 0, edge weight = 3)
  • edges[3] = [1, 2] (node 3's parent is 1, edge weight = 2)

Step 1: Build adjacency list

  • g[0] = [(1, 5), (2, 3)] (node 0 has children 1 and 2)
  • g[1] = [(3, 2)] (node 1 has child 3)
  • g[2] = [] (node 2 is a leaf)
  • g[3] = [] (node 3 is a leaf)

Step 2: DFS traversal starting from root (node 0)

Processing node 3 (leaf):

  • No children, so a = 0, b = 0, t = 0
  • Return (0, 0)

Processing node 2 (leaf):

  • No children, so a = 0, b = 0, t = 0
  • Return (0, 0)

Processing node 1:

  • Has child 3 with edge weight 2
  • Call dfs(3) → returns (0, 0), so x = 0, y = 0
  • a = 0 (sum of children's y values)
  • b = 0 (baseline)
  • t = max(0, 0 - 0 + 2) = 2 (benefit of selecting edge to child 3)
  • b = 0 + 2 = 2
  • Return (0, 2)

Processing node 0 (root):

  • Has children 1 (weight 5) and 2 (weight 3)

  • Call dfs(1) → returns (0, 2), so x = 0, y = 2

    • a = 2 (running sum)
    • b = 2 (running baseline)
    • t = max(0, 0 - 2 + 5) = 3 (benefit of selecting edge to child 1)
  • Call dfs(2) → returns (0, 0), so x = 0, y = 0

    • a = 2 + 0 = 2 (final sum)
    • b = 2 + 0 = 2 (updated baseline)
    • t = max(3, 0 - 0 + 3) = 3 (still edge to child 1 is best)
  • b = 2 + 3 = 5

  • Return (2, 5)

Final answer: Return b = 5 from root

This corresponds to selecting edge (0→1) with weight 5. We cannot also select edge (1→3) with weight 2 because they share node 1, making them adjacent. The maximum possible sum is 5.

Solution Implementation

1from collections import defaultdict
2from typing import List
3
4class Solution:
5    def maxScore(self, edges: List[List[int]]) -> int:
6        """
7        Calculate the maximum score in a tree where each node can contribute to the score
8        through its edges. The tree is represented by edges array where edges[i] = [parent, weight]
9        for node i (node 0 is the root with no parent).
10      
11        Args:
12            edges: List where edges[i] = [parent_of_i, weight_to_parent] for node i
13      
14        Returns:
15            Maximum possible score
16        """
17      
18        def dfs(node):
19            """
20            Perform DFS to calculate maximum scores for subtree rooted at 'node'.
21          
22            Returns:
23                tuple: (score_without_using_node, score_with_or_without_using_node)
24                - First value: max score if we don't use any edge from this node
25                - Second value: max score considering we might use one edge from this node
26            """
27            # score_without: max score without using any edge from current node
28            # score_with: max score possibly using one edge from current node
29            score_without = 0
30            score_with = 0
31            max_edge_contribution = 0
32          
33            # Process all children of current node
34            for child, edge_weight in adjacency_list[node]:
35                # Recursively get scores for child subtree
36                child_without, child_with = dfs(child)
37              
38                # If we don't use current node's edges, we take the best from each child
39                score_without += child_with
40              
41                # Base score (not using edge to this child)
42                score_with += child_with
43              
44                # Track the maximum gain from using the edge to one specific child
45                # (child_without - child_with + edge_weight) represents the gain from using this edge
46                max_edge_contribution = max(max_edge_contribution, 
47                                           child_without - child_with + edge_weight)
48          
49            # Add the best edge contribution (if any) to score_with
50            score_with += max_edge_contribution
51          
52            return score_without, score_with
53      
54        # Build adjacency list representation of the tree
55        adjacency_list = defaultdict(list)
56      
57        # Start from index 1 since node 0 is the root (has no parent)
58        for node_id, (parent_node, weight) in enumerate(edges[1:], start=1):
59            adjacency_list[parent_node].append((node_id, weight))
60      
61        # Start DFS from root (node 0) and return the maximum score
62        _, max_score = dfs(0)
63        return max_score
64
1class Solution {
2    // Adjacency list to store the tree structure
3    // Each node maps to a list of [childNode, edgeWeight] pairs
4    private List<int[]>[] adjacencyList;
5
6    public long maxScore(int[][] edges) {
7        int nodeCount = edges.length;
8      
9        // Initialize adjacency list for all nodes
10        adjacencyList = new List[nodeCount];
11        Arrays.setAll(adjacencyList, index -> new ArrayList<>());
12      
13        // Build the tree structure (starting from node 1 since node 0 is root)
14        // edges[i][0] is the parent of node i, edges[i][1] is the edge weight
15        for (int childNode = 1; childNode < nodeCount; ++childNode) {
16            int parentNode = edges[childNode][0];
17            int edgeWeight = edges[childNode][1];
18            adjacencyList[parentNode].add(new int[] {childNode, edgeWeight});
19        }
20      
21        // Start DFS from root node (0) and return the maximum score
22        return dfs(0)[1];
23    }
24
25    private long[] dfs(int currentNode) {
26        // scoreWithoutEdge: maximum score of subtree without selecting any edge to children
27        long scoreWithoutEdge = 0;
28      
29        // scoreWithEdge: maximum score of subtree with potentially selecting one edge to a child
30        long scoreWithEdge = 0;
31      
32        // maxGain: tracks the maximum gain from selecting one edge to a child
33        long maxGain = 0;
34      
35        // Process all children of current node
36        for (int[] childInfo : adjacencyList[currentNode]) {
37            int childNode = childInfo[0];
38            int edgeWeight = childInfo[1];
39          
40            // Recursively calculate scores for child subtree
41            long[] childScores = dfs(childNode);
42            long childScoreWithoutEdge = childScores[0];
43            long childScoreWithEdge = childScores[1];
44          
45            // Add the best score from each child when not selecting current edge
46            scoreWithoutEdge += childScoreWithEdge;
47            scoreWithEdge += childScoreWithEdge;
48          
49            // Calculate potential gain from selecting this edge
50            // Gain = edgeWeight + childScoreWithoutEdge - childScoreWithEdge
51            maxGain = Math.max(maxGain, childScoreWithoutEdge - childScoreWithEdge + edgeWeight);
52        }
53      
54        // Add the maximum gain to scoreWithEdge (selecting at most one edge)
55        scoreWithEdge += maxGain;
56      
57        // Return both scores: [without selecting edge, with selecting at most one edge]
58        return new long[] {scoreWithoutEdge, scoreWithEdge};
59    }
60}
61
1class Solution {
2public:
3    long long maxScore(vector<vector<int>>& edges) {
4        int n = edges.size();
5      
6        // Build adjacency list: g[parent] = list of (child, weight) pairs
7        vector<vector<pair<int, int>>> adjacencyList(n);
8        for (int i = 1; i < n; ++i) {
9            int parent = edges[i][0];
10            int weight = edges[i][1];
11            adjacencyList[parent].emplace_back(i, weight);
12        }
13      
14        using ll = long long;
15        using pll = pair<ll, ll>;
16      
17        // DFS function returns pair: (score without taking any edge from subtree, max score with at most one edge from subtree)
18        function<pll(int)> dfs = [&](int node) -> pll {
19            ll scoreWithoutEdge = 0;  // Score when not taking any edge from this subtree
20            ll scoreWithEdge = 0;     // Score when taking at most one edge from this subtree
21            ll maxGain = 0;            // Maximum gain from choosing one edge among all children
22          
23            // Process all children of current node
24            for (auto& [child, edgeWeight] : adjacencyList[node]) {
25                auto [childWithoutEdge, childWithEdge] = dfs(child);
26              
27                // Add the best score from each child (with at most one edge selected)
28                scoreWithoutEdge += childWithEdge;
29                scoreWithEdge += childWithEdge;
30              
31                // Track the maximum gain if we choose the edge to this child
32                // Gain = score without taking edge from child + edge weight - score with edge from child
33                maxGain = max(maxGain, childWithoutEdge - childWithEdge + edgeWeight);
34            }
35          
36            // Add the best gain from choosing one edge
37            scoreWithEdge += maxGain;
38          
39            return make_pair(scoreWithoutEdge, scoreWithEdge);
40        };
41      
42        // Return the maximum score starting from root (node 0)
43        return dfs(0).second;
44    }
45};
46
1function maxScore(edges: number[][]): number {
2    const n = edges.length;
3  
4    // Build adjacency list: adjacencyList[parent] = list of [child, weight] pairs
5    const adjacencyList: number[][][] = Array(n).fill(null).map(() => []);
6    for (let i = 1; i < n; i++) {
7        const parent = edges[i][0];
8        const weight = edges[i][1];
9        adjacencyList[parent].push([i, weight]);
10    }
11  
12    // DFS function returns tuple: [score without taking any edge from subtree, max score with at most one edge from subtree]
13    const dfs = (node: number): [number, number] => {
14        let scoreWithoutEdge = 0;  // Score when not taking any edge from this subtree
15        let scoreWithEdge = 0;     // Score when taking at most one edge from this subtree
16        let maxGain = 0;           // Maximum gain from choosing one edge among all children
17      
18        // Process all children of current node
19        for (const [child, edgeWeight] of adjacencyList[node]) {
20            const [childWithoutEdge, childWithEdge] = dfs(child);
21          
22            // Add the best score from each child (with at most one edge selected)
23            scoreWithoutEdge += childWithEdge;
24            scoreWithEdge += childWithEdge;
25          
26            // Track the maximum gain if we choose the edge to this child
27            // Gain = score without taking edge from child + edge weight - score with edge from child
28            maxGain = Math.max(maxGain, childWithoutEdge - childWithEdge + edgeWeight);
29        }
30      
31        // Add the best gain from choosing one edge
32        scoreWithEdge += maxGain;
33      
34        return [scoreWithoutEdge, scoreWithEdge];
35    };
36  
37    // Return the maximum score starting from root (node 0)
38    return dfs(0)[1];
39}
40

Time and Space Complexity

Time Complexity: O(n)

The algorithm performs a depth-first search (DFS) traversal of the tree. Each node is visited exactly once during the DFS traversal. For each node, the algorithm iterates through all its children, performing constant-time operations for each child edge. Since the total number of edges in a tree is n-1 (where n is the number of nodes), and each edge is processed once when visiting its parent node, the total time complexity is O(n).

Space Complexity: O(n)

The space complexity consists of:

  1. The adjacency list g which stores all edges of the tree, requiring O(n) space since there are n-1 edges in a tree with n nodes
  2. The recursion call stack for DFS, which in the worst case (a skewed tree) can go up to depth n, requiring O(n) space
  3. Local variables in each recursive call (a, b, t, x, y) use O(1) space per call, totaling O(n) across all recursive calls

Therefore, the overall space complexity is O(n).

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

Common Pitfalls

1. Misunderstanding the Dynamic Programming States

A common mistake is incorrectly interpreting what the two DP states represent. Many developers initially think:

  • a = maximum score when we select the edge from node i to its parent
  • b = maximum score when we don't select the edge from node i to its parent

However, the actual semantics are more nuanced:

  • a = maximum score when the edge to node i (from its parent) is selected
  • b = maximum score when the edge to node i (from its parent) is not selected

This distinction is crucial because when the parent edge is selected, we cannot select any edges to children (adjacency constraint).

2. Incorrectly Calculating the Benefit of Selecting a Child Edge

When calculating t = max(t, x - y + w), developers often make these errors:

  • Using x + w instead of x - y + w: This fails to account for the opportunity cost. We need to subtract y because that's what we would have gotten without selecting this edge.
  • Using y - x + w: This reverses the logic and will produce incorrect results.

Correct approach: The benefit is (score with child edge selected) - (baseline score without it) = (x + w) - y = x - y + w

3. Forgetting to Initialize the Maximum Benefit to 0

Setting t = 0 is important because it represents the option of not selecting any child edge. If you initialize it to negative infinity or skip initialization, you might force the selection of a negative-weight edge, which would decrease the total score.

4. Incorrect Tree Construction

A subtle but critical error is starting the enumeration from index 0 instead of 1:

# Wrong:
for node_id, (parent_node, weight) in enumerate(edges):
    adjacency_list[parent_node].append((node_id, weight))

# Correct:
for node_id, (parent_node, weight) in enumerate(edges[1:], start=1):
    adjacency_list[parent_node].append((node_id, weight))

The root node (index 0) has edges[0] = [-1, -1] and should be skipped when building the tree.

5. Returning the Wrong Value

After calling dfs(0), some might return a (first value) or max(a, b). The correct answer is always b (second value) because:

  • The root has no parent edge to select
  • b represents the maximum score when we're free to choose whether to select edges to children
  • For the root, this freedom always applies

Solution to avoid these pitfalls:

  1. Clearly document what each state represents
  2. Trace through a small example by hand to verify the logic
  3. Pay attention to array indexing when building the tree structure
  4. Remember that for the root node, we always want the "unrestricted" state
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

What is the best way of checking if an element exists in a sorted array once in terms of time complexity? Select the best that applies.


Recommended Readings

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

Load More