Facebook Pixel

2920. Maximum Points After Collecting Coins From All Nodes

Problem Description

You are given an undirected tree rooted at node 0 with n nodes labeled from 0 to n - 1. The tree structure is defined by a 2D array edges of length n - 1, where edges[i] = [ai, bi] indicates an edge between nodes ai and bi.

Each node i contains coins[i] coins, and you need to collect all coins from the tree starting from the root. There's an important constraint: you can only collect coins from a node after collecting coins from all its ancestors.

When collecting coins from node i, you have two options:

  1. Option 1: Collect all coins and receive coins[i] - k points. If this value is negative, you lose abs(coins[i] - k) points.

  2. Option 2: Collect all coins and receive floor(coins[i] / 2) points. However, if you choose this option, the coins at all nodes in the subtree rooted at node i (including node i itself) will be halved - specifically, for each node j in the subtree, coins[j] becomes floor(coins[j] / 2).

Your goal is to determine the maximum total points you can obtain by collecting coins from all nodes in the tree, choosing the optimal collection method for each node.

The key challenge is deciding which collection method to use at each node, considering that Option 2 affects all descendants of that node by halving their coin values, which impacts future collection decisions.

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 describes an undirected tree with nodes and edges. A tree is a special type of graph with no cycles and n-1 edges for n nodes.

Is it a tree?

  • Yes: The problem states "There exists an undirected tree rooted at node 0" and we have n nodes with n-1 edges, which is the definition of a tree structure.

DFS

  • Conclusion reached: Since we have a tree structure, the flowchart leads us directly to DFS (Depth-First Search).

This makes perfect sense for our problem because:

  1. We need to traverse the tree starting from the root (node 0)
  2. We must collect coins from ancestors before descendants (enforcing a parent-to-child traversal order)
  3. We need to make decisions at each node that affect its entire subtree (Option 2 halves coins in the entire subtree)
  4. The problem requires exploring different choices at each node and calculating the maximum points possible

The DFS pattern is ideal here because:

  • It naturally traverses from root to leaves, respecting the ancestor-descendant relationship
  • It allows us to recursively solve subproblems (subtrees) and combine their results
  • With memoization, we can efficiently handle the overlapping subproblems that arise from the two collection options at each node

Conclusion: The flowchart correctly identifies this as a tree-based problem that should be solved using DFS with memoization to explore all possible collection strategies and find the maximum points.

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 make a choice at each node: take coins[i] - k points (Option 1) or take floor(coins[i] / 2) points and halve all coins in the subtree (Option 2).

The key insight is that Option 2 doesn't just affect the current node - it affects all descendants. If we choose Option 2 at a node, every coin value in its subtree gets halved. This means if we use Option 2 multiple times along a path from root to leaf, the coins get halved multiple times.

Think about it this way: if a node's ancestor used Option 2 j times, then the effective coin value at this node becomes coins[i] >> j (right shift by j bits, which is equivalent to dividing by 2^j).

This observation leads us to track how many times Option 2 has been used by ancestors. We can model our state as (current_node, times_halved). At each state, we have two choices:

  • Use Option 1: Get (coins[i] >> j) - k points, where j is the number of times ancestors used Option 2
  • Use Option 2: Get coins[i] >> (j + 1) points, and pass j + 1 to all children

Since coins[i] ≤ 10^4, after about 14 right shifts, the value becomes 0. This bounds our state space - we don't need to consider j > 14.

The recursive nature of the problem (making decisions that affect subtrees) combined with the bounded state space makes this perfect for DFS with memoization. We explore all possible combinations of using Option 1 or Option 2 at each node, keeping track of how many halvings have been applied, and return the maximum points achievable.

The beauty of this approach is that instead of actually modifying the coin values in the tree, we just track how many times we've applied the halving operation and compute the effective value on the fly using bit shifts.

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

Solution Approach

The solution uses DFS with memoization to explore all possible collection strategies. Let's walk through the implementation step by step:

1. Graph Construction: First, we build an adjacency list representation of the tree from the edges:

g = [[] for _ in range(n)]
for a, b in edges:
    g[a].append(b)
    g[b].append(a)

2. DFS Function Design: The core function dfs(i, fa, j) has three parameters:

  • i: Current node being processed
  • fa: Parent node (to avoid revisiting in undirected tree)
  • j: Number of times Option 2 has been used by ancestors (number of right shifts needed)

3. Computing Scores for Both Options: At each node, we calculate scores for both collection methods:

  • Option 1: a = (coins[i] >> j) - k

    • The actual coin value is coins[i] >> j (shifted right j times due to ancestor halvings)
    • We subtract k to get the points
  • Option 2: b = coins[i] >> (j + 1)

    • This is equivalent to taking half of the current effective value
    • We shift right by j + 1 total times

4. Recursive Exploration: For each child node c (excluding the parent):

  • For Option 1: Add dfs(c, i, j) to score a

    • Children inherit the same number of halvings j
  • For Option 2: Add dfs(c, i, j + 1) to score b

    • Children see one additional halving, so we pass j + 1
    • Important optimization: Only recurse if j < 14 since after 14 shifts, values become 0

5. Memoization: The @cache decorator automatically memoizes the function results. This prevents recomputation when we encounter the same (node, parent, halvings) state multiple times.

6. Final Result: The function returns max(a, b) - the better of the two options. Starting from dfs(0, -1, 0):

  • Node 0 (root)
  • Parent -1 (no parent for root)
  • 0 halvings initially

The time complexity is O(n × 15) since we have at most n nodes and at most 15 different values of j (0 to 14). The space complexity is also O(n × 15) for the memoization cache.

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

    0 (coins=8)
   / \
  1   2
(coins=4) (coins=6)
  • Tree edges: [[0,1], [0,2]]
  • coins = [8, 4, 6]
  • k = 3

Step-by-step DFS traversal:

Starting at root (node 0, j=0):

  • Current coins: 8 >> 0 = 8
  • Option 1: 8 - 3 = 5 points
  • Option 2: 8 >> 1 = 4 points

Now we need to explore both options:

If we choose Option 1 at node 0:

  • We get 5 points from node 0
  • Visit child 1 with j=0:
    • coins[1] >> 0 = 4
    • Option 1: 4 - 3 = 1 point
    • Option 2: 4 >> 1 = 2 points
    • Choose Option 2: 2 points (better)
  • Visit child 2 with j=0:
    • coins[2] >> 0 = 6
    • Option 1: 6 - 3 = 3 points
    • Option 2: 6 >> 1 = 3 points
    • Choose either: 3 points
  • Total for Option 1 path: 5 + 2 + 3 = 10 points

If we choose Option 2 at node 0:

  • We get 4 points from node 0
  • Visit child 1 with j=1 (one halving applied):
    • coins[1] >> 1 = 2 (effective coins after parent's halving)
    • Option 1: 2 - 3 = -1 point
    • Option 2: 4 >> 2 = 1 point
    • Choose Option 2: 1 point (better)
  • Visit child 2 with j=1:
    • coins[2] >> 1 = 3 (effective coins after parent's halving)
    • Option 1: 3 - 3 = 0 points
    • Option 2: 6 >> 2 = 1 point
    • Choose Option 2: 1 point (better)
  • Total for Option 2 path: 4 + 1 + 1 = 6 points

Final Decision:

  • Option 1 at root gives total: 10 points
  • Option 2 at root gives total: 6 points
  • Maximum points achievable: 10

Key Observations from the Example:

  1. When we choose Option 2 at node 0, both children see halved coin values (j=1)
  2. The actual coins array never changes - we use bit shifts to calculate effective values
  3. At each node, we independently evaluate both options and choose the maximum
  4. The memoization would cache results like dfs(1, 0, 0) = 2 and dfs(1, 0, 1) = 1 for reuse if needed

This demonstrates how the algorithm explores all possible combinations of using Option 1 or Option 2 at each node while tracking the cumulative effect of halvings through the parameter j.

Solution Implementation

1class Solution:
2    def maximumPoints(self, edges: List[List[int]], coins: List[int], k: int) -> int:
3        from functools import cache
4        from typing import List
5      
6        # Build adjacency list representation of the tree
7        num_nodes = len(coins)
8        adjacency_list = [[] for _ in range(num_nodes)]
9        for node_a, node_b in edges:
10            adjacency_list[node_a].append(node_b)
11            adjacency_list[node_b].append(node_a)
12      
13        @cache
14        def dfs(current_node: int, parent_node: int, shift_count: int) -> int:
15            """
16            Calculate maximum points from subtree rooted at current_node.
17          
18            Args:
19                current_node: Current node being processed
20                parent_node: Parent of current node (to avoid revisiting)
21                shift_count: Number of right shifts already applied to coin values
22          
23            Returns:
24                Maximum points achievable from this subtree
25            """
26            # Option 1: Collect coin at current node with penalty k
27            # Coin value after shift_count right shifts, minus penalty
28            collect_with_penalty = (coins[current_node] >> shift_count) - k
29          
30            # Option 2: Apply one more right shift (halve the coin value)
31            # Coin value after (shift_count + 1) right shifts
32            collect_with_extra_shift = coins[current_node] >> (shift_count + 1)
33          
34            # Process all children of current node
35            for child_node in adjacency_list[current_node]:
36                if child_node != parent_node:  # Avoid going back to parent
37                    # Add points from child subtree with same shift count
38                    collect_with_penalty += dfs(child_node, current_node, shift_count)
39                  
40                    # Only consider extra shift if we haven't shifted too many times
41                    # (14 shifts would make most values 0 due to integer division)
42                    if shift_count < 14:
43                        collect_with_extra_shift += dfs(child_node, current_node, shift_count + 1)
44          
45            # Return the maximum of the two options
46            return max(collect_with_penalty, collect_with_extra_shift)
47      
48        # Start DFS from node 0 (root), with no parent (-1) and 0 shifts
49        maximum_points = dfs(0, -1, 0)
50      
51        # Clear the cache to free memory
52        dfs.cache_clear()
53      
54        return maximum_points
55
1class Solution {
2    private int k;
3    private int[] coins;
4    private Integer[][] dp;  // dp[node][halvingCount] = maximum points from subtree rooted at node with halvingCount halvings applied
5    private List<Integer>[] adjacencyList;
6
7    public int maximumPoints(int[][] edges, int[] coins, int k) {
8        this.k = k;
9        this.coins = coins;
10        int n = coins.length;
11      
12        // Initialize memoization table and adjacency list
13        dp = new Integer[n][15];  // Maximum 14 halvings needed (2^14 > 10^4)
14        adjacencyList = new List[n];
15        Arrays.setAll(adjacencyList, i -> new ArrayList<>());
16      
17        // Build undirected graph from edges
18        for (int[] edge : edges) {
19            int nodeA = edge[0];
20            int nodeB = edge[1];
21            adjacencyList[nodeA].add(nodeB);
22            adjacencyList[nodeB].add(nodeA);
23        }
24      
25        // Start DFS from node 0 with no parent (-1) and 0 halvings
26        return dfs(0, -1, 0);
27    }
28
29    /**
30     * Calculate maximum points from subtree rooted at current node
31     * @param currentNode - current node being processed
32     * @param parent - parent node to avoid revisiting in tree traversal
33     * @param halvingCount - number of halvings already applied from ancestors
34     * @return maximum points obtainable from this subtree
35     */
36    private int dfs(int currentNode, int parent, int halvingCount) {
37        // Return memoized result if already computed
38        if (dp[currentNode][halvingCount] != null) {
39            return dp[currentNode][halvingCount];
40        }
41      
42        // Option 1: Collect coins at current node (apply halvingCount times, then subtract k)
43        int collectOption = (coins[currentNode] >> halvingCount) - k;
44      
45        // Option 2: Halve all coins in subtree (apply one more halving)
46        int halveOption = coins[currentNode] >> (halvingCount + 1);
47      
48        // Process all children (neighbors except parent)
49        for (int child : adjacencyList[currentNode]) {
50            if (child != parent) {
51                // Add child's contribution for collect option
52                collectOption += dfs(child, currentNode, halvingCount);
53              
54                // Add child's contribution for halve option (if not exceeding max halvings)
55                if (halvingCount < 14) {
56                    halveOption += dfs(child, currentNode, halvingCount + 1);
57                }
58            }
59        }
60      
61        // Memoize and return the maximum of both options
62        return dp[currentNode][halvingCount] = Math.max(collectOption, halveOption);
63    }
64}
65
1class Solution {
2public:
3    int maximumPoints(vector<vector<int>>& edges, vector<int>& coins, int k) {
4        int n = coins.size();
5      
6        // dp[node][halvingCount] = maximum points from subtree rooted at node
7        // after applying halvingCount halving operations from ancestors
8        int dp[n][15];
9        memset(dp, -1, sizeof(dp));
10      
11        // Build adjacency list for the tree
12        vector<int> adjacencyList[n];
13        for (auto& edge : edges) {
14            int nodeA = edge[0];
15            int nodeB = edge[1];
16            adjacencyList[nodeA].emplace_back(nodeB);
17            adjacencyList[nodeB].emplace_back(nodeA);
18        }
19      
20        // DFS function to calculate maximum points
21        // currentNode: current node being processed
22        // parent: parent of current node (to avoid revisiting)
23        // halvingCount: number of halving operations applied by ancestors
24        auto dfs = [&](this auto&& dfs, int currentNode, int parent, int halvingCount) -> int {
25            // Return memoized result if already calculated
26            if (dp[currentNode][halvingCount] != -1) {
27                return dp[currentNode][halvingCount];
28            }
29          
30            // Option 1: Collect coins at current node (apply k penalty)
31            // Current coin value after halvingCount operations: coins[currentNode] >> halvingCount
32            int collectCoins = (coins[currentNode] >> halvingCount) - k;
33          
34            // Option 2: Apply halving operation (halve all coins in subtree)
35            // Current coin value after halvingCount+1 operations: coins[currentNode] >> (halvingCount + 1)
36            int halveCoins = coins[currentNode] >> (halvingCount + 1);
37          
38            // Process all children
39            for (int child : adjacencyList[currentNode]) {
40                if (child != parent) {
41                    // For option 1: collect coins, children use same halvingCount
42                    collectCoins += dfs(child, currentNode, halvingCount);
43                  
44                    // For option 2: halve coins, children use halvingCount+1
45                    // Maximum 14 halvings because coins[i] <= 10^4 < 2^14
46                    if (halvingCount < 14) {
47                        halveCoins += dfs(child, currentNode, halvingCount + 1);
48                    }
49                }
50            }
51          
52            // Store and return the maximum of two options
53            return dp[currentNode][halvingCount] = max(collectCoins, halveCoins);
54        };
55      
56        // Start DFS from node 0 with no parent (-1) and 0 halving operations
57        return dfs(0, -1, 0);
58    }
59};
60
1/**
2 * Calculates the maximum points that can be collected from a tree with coins
3 * @param edges - Array of edges representing tree connections
4 * @param coins - Array of coin values at each node
5 * @param k - Cost to collect coins
6 * @returns Maximum points that can be collected
7 */
8function maximumPoints(edges: number[][], coins: number[], k: number): number {
9    const nodeCount: number = coins.length;
10  
11    // Memoization table: dp[node][shiftCount] stores the maximum points
12    // when starting from node with coins already right-shifted by shiftCount
13    const memoTable: number[][] = Array.from(
14        { length: nodeCount }, 
15        () => Array(15).fill(-1)
16    );
17  
18    // Adjacency list representation of the tree
19    const adjacencyList: number[][] = Array.from(
20        { length: nodeCount }, 
21        () => []
22    );
23  
24    // Build the adjacency list from edges
25    for (const [nodeA, nodeB] of edges) {
26        adjacencyList[nodeA].push(nodeB);
27        adjacencyList[nodeB].push(nodeA);
28    }
29  
30    /**
31     * DFS to calculate maximum points from subtree rooted at current node
32     * @param currentNode - Current node being processed
33     * @param parent - Parent node to avoid revisiting
34     * @param shiftCount - Number of times coins have been right-shifted
35     * @returns Maximum points from subtree
36     */
37    const dfs = (currentNode: number, parent: number, shiftCount: number): number => {
38        // Return memoized result if already calculated
39        if (memoTable[currentNode][shiftCount] !== -1) {
40            return memoTable[currentNode][shiftCount];
41        }
42      
43        // Option 1: Collect coins at current node (with cost k)
44        // Coins are already shifted by shiftCount times
45        let collectCoinsScore: number = (coins[currentNode] >> shiftCount) - k;
46      
47        // Option 2: Shift coins one more time (halve their value)
48        // No cost k, but coins are worth less
49        let shiftCoinsScore: number = coins[currentNode] >> (shiftCount + 1);
50      
51        // Process all children nodes
52        for (const childNode of adjacencyList[currentNode]) {
53            if (childNode !== parent) {
54                // Add points from collecting coins in subtree
55                collectCoinsScore += dfs(childNode, currentNode, shiftCount);
56              
57                // Add points from shifting coins in subtree (if not at max shift)
58                if (shiftCount < 14) {
59                    shiftCoinsScore += dfs(childNode, currentNode, shiftCount + 1);
60                }
61            }
62        }
63      
64        // Memoize and return the maximum of both options
65        memoTable[currentNode][shiftCount] = Math.max(collectCoinsScore, shiftCoinsScore);
66        return memoTable[currentNode][shiftCount];
67    };
68  
69    // Start DFS from node 0 with no parent (-1) and 0 shifts
70    return dfs(0, -1, 0);
71}
72

Time and Space Complexity

Time Complexity: O(n × log M)

The algorithm uses DFS with memoization. The key insight is that parameter j represents how many times we've right-shifted the coin values. Since we're dividing by 2 with each shift, and the condition j < 14 limits the recursion depth, we effectively process at most log M levels where M is the maximum coin value. The memoization cache stores states (i, fa, j), and for each node i, we can have at most n different parent values and approximately log M different j values. Each state is computed once, and for each state, we iterate through all adjacent nodes. Since the tree has n-1 edges and each edge is traversed twice (once from each direction), the total time complexity is O(n × log M).

Space Complexity: O(n × log M)

The space is dominated by the memoization cache and the recursion stack. The cache stores up to n × n × log M states in the worst case (though in practice it's much less since not all parent-child combinations are valid in a tree). However, considering the tree structure, for each node we only need to store states with valid parent nodes, reducing the effective space to O(n × log M). The recursion stack depth is at most O(n) for a skewed tree, but since we're also considering the j parameter which goes up to log M, the total space complexity remains O(n × log M). The adjacency list g takes O(n) space, which doesn't affect the overall complexity.

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

Common Pitfalls

1. Incorrect Handling of the Shift Count Limit

The Pitfall: One common mistake is not properly limiting the recursion depth for Option 2. Without a proper bound, the recursion could continue indefinitely even when coin values have already become 0 after multiple halvings.

Why it happens: After repeatedly halving (right-shifting), any integer will eventually become 0. For most practical values, 14-15 right shifts are sufficient to reduce any reasonable coin value to 0. However, developers might:

  • Forget to add this optimization entirely, causing unnecessary recursion
  • Use an incorrect bound (too small or too large)
  • Place the condition check in the wrong location

Incorrect Implementation:

# WRONG: No limit on shift count
for child_node in adjacency_list[current_node]:
    if child_node != parent_node:
        collect_with_penalty += dfs(child_node, current_node, shift_count)
        collect_with_extra_shift += dfs(child_node, current_node, shift_count + 1)  # Could recurse forever!

Correct Implementation:

for child_node in adjacency_list[current_node]:
    if child_node != parent_node:
        collect_with_penalty += dfs(child_node, current_node, shift_count)
        # Only recurse for Option 2 if we haven't shifted too many times
        if shift_count < 14:
            collect_with_extra_shift += dfs(child_node, current_node, shift_count + 1)

2. Misunderstanding the Coin Value Calculation

The Pitfall: Incorrectly calculating the effective coin value after ancestors have used Option 2. A common mistake is to apply the halvings incorrectly or forget that the halvings accumulate.

Why it happens: The problem states that when Option 2 is chosen, all descendants' coins are halved. This effect is cumulative - if multiple ancestors choose Option 2, the coins are halved multiple times.

Incorrect Implementation:

# WRONG: Dividing by 2^j instead of right-shifting
collect_with_penalty = coins[current_node] // (2 ** shift_count) - k  # Inefficient
collect_with_extra_shift = coins[current_node] // (2 ** (shift_count + 1))  # Inefficient

# WRONG: Not accumulating the halvings correctly
collect_with_penalty = (coins[current_node] // 2) - k  # Ignores previous halvings

Correct Implementation:

# Right-shift is equivalent to integer division by 2^shift_count but more efficient
collect_with_penalty = (coins[current_node] >> shift_count) - k
collect_with_extra_shift = coins[current_node] >> (shift_count + 1)

3. Not Handling Negative Scores Properly

The Pitfall: The problem explicitly states that if coins[i] - k is negative, you lose points. This is already handled correctly by allowing negative values in the score calculation, but some might mistakenly try to "fix" this.

Incorrect Implementation:

# WRONG: Trying to avoid negative scores
collect_with_penalty = max(0, (coins[current_node] >> shift_count) - k)  # This changes the problem!

Correct Implementation:

# Allow negative values as per problem requirements
collect_with_penalty = (coins[current_node] >> shift_count) - k  # Can be negative

The key insight is that sometimes taking a negative score at one node might still be optimal if it prevents halving the coins of a profitable subtree.

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

Which of the following problems can be solved with backtracking (select multiple)


Recommended Readings

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

Load More