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:
-
Option 1: Collect all coins and receive
coins[i] - k
points. If this value is negative, you loseabs(coins[i] - k)
points. -
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 nodei
(including nodei
itself) will be halved - specifically, for each nodej
in the subtree,coins[j]
becomesfloor(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 forn
nodes.
Is it a tree?
- Yes: The problem states "There exists an undirected tree rooted at node 0" and we have
n
nodes withn-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:
- We need to traverse the tree starting from the root (node 0)
- We must collect coins from ancestors before descendants (enforcing a parent-to-child traversal order)
- We need to make decisions at each node that affect its entire subtree (Option 2 halves coins in the entire subtree)
- 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.
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, wherej
is the number of times ancestors used Option 2 - Use Option 2: Get
coins[i] >> (j + 1)
points, and passj + 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 processedfa
: 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 rightj
times due to ancestor halvings) - We subtract
k
to get the points
- The actual coin value is
-
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 scorea
- Children inherit the same number of halvings
j
- Children inherit the same number of halvings
-
For Option 2: Add
dfs(c, i, j + 1)
to scoreb
- Children see one additional halving, so we pass
j + 1
- Important optimization: Only recurse if
j < 14
since after 14 shifts, values become 0
- Children see one additional halving, so we pass
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 EvaluatorExample 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:
- When we choose Option 2 at node 0, both children see halved coin values (j=1)
- The actual coins array never changes - we use bit shifts to calculate effective values
- At each node, we independently evaluate both options and choose the maximum
- The memoization would cache results like
dfs(1, 0, 0) = 2
anddfs(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.
Which of the following problems can be solved with backtracking (select multiple)
Recommended Readings
Everything About Trees A tree is a type of graph data structure composed of nodes and edges Its main properties are It is acyclic doesn't contain any cycles There exists a path from the root to any node Has N 1 edges where N is the number of nodes in the tree and
https assets algo monster cover_photos dfs svg Depth First Search Prereqs Recursion Review problems recursion_intro Trees problems tree_intro With a solid understanding of recursion under our belts we are now ready to tackle one of the most useful techniques in coding interviews Depth First Search DFS As the name suggests
Memoization Prereq Backtracking problems backtracking Memoization is a fancy word for a simple concept so is the case for a lot of things we learn in school It means saving the previous function call result in a dictionary and reading from it when we do the exact same call again
Want a Structured Path to Master System Design Too? Don’t Miss This!