1130. Minimum Cost Tree From Leaf Values
Problem Description
You are given an array arr
of positive integers. Your task is to construct a binary tree with specific properties and find the minimum possible sum of all non-leaf node values.
The binary tree must satisfy these conditions:
- Each node has either 0 children (leaf node) or exactly 2 children
- The values in
arr
correspond to the leaf node values when the tree is traversed in-order - Each non-leaf node's value equals the product of the maximum leaf value in its left subtree and the maximum leaf value in its right subtree
The goal is to find the minimum possible sum of all non-leaf node values among all valid binary trees that can be constructed.
For example, if arr = [6, 2, 4]
, one possible tree structure could have:
- Leaf nodes with values 6, 2, and 4 (in in-order traversal)
- A non-leaf node with value
6 * 2 = 12
(product of max from left subtree containing 6 and max from right subtree containing 2) - Another non-leaf node with value
12 * 4 = 48
(product of max from left subtree and max from right subtree containing 4) - Total sum of non-leaf nodes would be
12 + 48 = 60
However, different tree structures would yield different sums, and you need to find the minimum possible sum.
The solution uses dynamic programming with memoization. The function dfs(i, j)
returns a tuple containing:
- The minimum sum of non-leaf nodes for the subarray
arr[i:j+1]
- The maximum leaf value in that range
For each subarray, it tries all possible ways to split it into left and right subtrees (by iterating k
from i
to j-1
), calculates the cost of creating a non-leaf node as the product of maximum values from both subtrees, and chooses the split that minimizes the total sum.
Intuition
The key insight is recognizing that this is an optimal binary tree construction problem where we need to make decisions about how to partition the array into left and right subtrees.
Since the array values must appear as leaves in in-order traversal, any contiguous subarray arr[i..j]
represents a valid subtree. When we create a non-leaf node for this subtree, we must split it into two parts: arr[i..k]
for the left subtree and arr[k+1..j]
for the right subtree.
The cost of creating this non-leaf node is the product of the maximum values from each subtree. This is where the optimization opportunity arises - different ways of splitting the array will result in different costs.
Consider why we need dynamic programming: if we have an array like [6, 2, 4, 3]
, we could build the tree in multiple ways:
- Split as
[6]
and[2, 4, 3]
, then further split the right part - Split as
[6, 2]
and[4, 3]
, then split both parts - Split as
[6, 2, 4]
and[3]
, then further split the left part
Each splitting decision affects the total cost because:
- It determines the immediate cost (product of max values from left and right)
- It determines how the subproblems are structured for further splitting
The overlapping subproblems nature becomes clear when we realize that the same subarray might be evaluated multiple times in different splitting scenarios. For instance, the subarray [2, 4]
might be considered as part of different larger subarrays.
The recursive structure emerges naturally: to find the minimum cost for arr[i..j]
, we try all possible split points k
, compute the cost as cost_left + cost_right + max_left * max_right
, and choose the minimum. We also need to track the maximum value in each range because parent nodes will need this information to calculate their costs.
This leads to the memoized recursion approach where dfs(i, j)
returns both the minimum cost for the subarray and its maximum value, allowing efficient computation of all possible tree structures.
Learn more about Stack, Greedy, Dynamic Programming and Monotonic Stack patterns.
Solution Approach
The solution uses memoized recursion to solve this dynamic programming problem. Let's walk through the implementation step by step.
Core Function Design:
The function dfs(i, j)
returns a tuple containing two values:
- The minimum sum of all non-leaf nodes for the subarray
arr[i..j]
- The maximum leaf value in the range
[i, j]
Base Case:
When i == j
, we have a single element which becomes a leaf node. There are no non-leaf nodes to add, so the sum is 0, and the maximum value is simply arr[i]
.
Recursive Case:
For a range [i, j]
where i < j
, we need to:
-
Try all possible split points
k
wherei ≤ k < j
-
For each split:
- Recursively solve the left subtree:
s1, mx1 = dfs(i, k)
- Recursively solve the right subtree:
s2, mx2 = dfs(k + 1, j)
- Calculate the cost of the current non-leaf node:
mx1 * mx2
- Total cost for this split:
t = s1 + s2 + mx1 * mx2
- Recursively solve the left subtree:
-
Track the minimum cost and corresponding maximum value:
- If this split gives a better (smaller) total cost, update our result
- The maximum value for the range is
max(mx1, mx2)
Mathematical Formulation:
dfs(i, j) = { (0, arr[i]) if i = j min{dfs(i,k) + dfs(k+1,j) + max[i,k] * max[k+1,j]} if i < j }
where the minimum is taken over all k ∈ [i, j-1]
.
Optimization with Memoization:
The @cache
decorator automatically memoizes the results, preventing redundant calculations of the same subproblems. This is crucial because the same subarray can appear in multiple decomposition paths.
Time Complexity Analysis:
- There are
O(n²)
unique subproblems (all possible ranges[i, j]
) - Each subproblem tries
O(n)
split points - Overall time complexity:
O(n³)
Space Complexity:
- Memoization cache stores
O(n²)
results - Recursion depth is at most
O(n)
- Overall space complexity:
O(n²)
The algorithm systematically explores all possible ways to construct the binary tree, using dynamic programming to avoid redundant calculations, and returns the minimum possible sum of non-leaf node values.
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 the solution with arr = [6, 2, 4]
.
We call dfs(0, 2)
to find the minimum sum for the entire array.
Step 1: dfs(0, 2) - Process array [6, 2, 4]
-
Try split at k=0: Left=[6], Right=[2, 4]
dfs(0, 0)
returns (0, 6) - single leaf, no non-leaf nodesdfs(1, 2)
needs to be computed:- Try split at k=1: Left=[2], Right=[4]
dfs(1, 1)
returns (0, 2)dfs(2, 2)
returns (0, 4)- Cost = 0 + 0 + 2×4 = 8
- Returns (8, 4) since max(2,4)=4
- Total cost = 0 + 8 + 6×4 = 32
-
Try split at k=1: Left=[6, 2], Right=[4]
dfs(0, 1)
needs to be computed:- Try split at k=0: Left=[6], Right=[2]
dfs(0, 0)
returns (0, 6)dfs(1, 1)
returns (0, 2)- Cost = 0 + 0 + 6×2 = 12
- Returns (12, 6) since max(6,2)=6
dfs(2, 2)
returns (0, 4)- Total cost = 12 + 0 + 6×4 = 36
Step 2: Compare results
- Split at k=0 gives total sum = 32
- Split at k=1 gives total sum = 36
- Minimum is 32
The optimal tree structure is:
(6×4=24) / \ 6 (2×4=8) / \ 2 4
With non-leaf nodes having values 24 and 8, giving a total sum of 32.
Note how the memoization helps: dfs(0, 0)
, dfs(1, 1)
, and dfs(2, 2)
are called multiple times but only computed once. The algorithm systematically explores all possible binary tree structures while maintaining the in-order traversal constraint.
Solution Implementation
1class Solution:
2 def mctFromLeafValues(self, arr: List[int]) -> int:
3 from functools import cache
4 from typing import List, Tuple
5
6 @cache
7 def dp(left: int, right: int) -> Tuple[int, int]:
8 """
9 Dynamic programming function to find minimum sum and maximum leaf value
10 in range [left, right].
11
12 Args:
13 left: Starting index of the subarray
14 right: Ending index of the subarray
15
16 Returns:
17 Tuple of (minimum_sum, maximum_leaf_value)
18 """
19 # Base case: single leaf node
20 if left == right:
21 return 0, arr[left]
22
23 # Initialize minimum sum and maximum value for this range
24 min_sum = float('inf')
25 max_value = -1
26
27 # Try all possible split points
28 for split_point in range(left, right):
29 # Recursively solve left and right subtrees
30 left_sum, left_max = dp(left, split_point)
31 right_sum, right_max = dp(split_point + 1, right)
32
33 # Calculate total sum for this split
34 # Sum = left subtree sum + right subtree sum + product of max values
35 current_sum = left_sum + right_sum + left_max * right_max
36
37 # Update minimum sum and corresponding maximum value
38 if min_sum > current_sum:
39 min_sum = current_sum
40 max_value = max(left_max, right_max)
41
42 return min_sum, max_value
43
44 # Return only the minimum sum (first element of tuple)
45 return dp(0, len(arr) - 1)[0]
46
1class Solution {
2 // Memoization table for storing results of subproblems
3 private Integer[][] memo;
4
5 // Table for storing maximum values in subarrays
6 private int[][] maxInRange;
7
8 public int mctFromLeafValues(int[] arr) {
9 int n = arr.length;
10
11 // Initialize memoization table
12 memo = new Integer[n][n];
13
14 // Initialize table to store maximum values in each subarray
15 maxInRange = new int[n][n];
16
17 // Precompute maximum values for all subarrays
18 for (int start = n - 1; start >= 0; start--) {
19 // Maximum of single element is the element itself
20 maxInRange[start][start] = arr[start];
21
22 // Calculate maximum for all subarrays starting at 'start'
23 for (int end = start + 1; end < n; end++) {
24 maxInRange[start][end] = Math.max(maxInRange[start][end - 1], arr[end]);
25 }
26 }
27
28 // Start the recursive calculation from the entire array
29 return calculateMinSum(0, n - 1);
30 }
31
32 private int calculateMinSum(int left, int right) {
33 // Base case: single leaf node has no non-leaf sum
34 if (left == right) {
35 return 0;
36 }
37
38 // Return memoized result if already computed
39 if (memo[left][right] != null) {
40 return memo[left][right];
41 }
42
43 // Initialize minimum sum to a large value
44 int minSum = Integer.MAX_VALUE;
45
46 // Try all possible split points
47 for (int splitPoint = left; splitPoint < right; splitPoint++) {
48 // Calculate sum for current split:
49 // left subtree sum + right subtree sum + product of max values
50 int currentSum = calculateMinSum(left, splitPoint) +
51 calculateMinSum(splitPoint + 1, right) +
52 maxInRange[left][splitPoint] * maxInRange[splitPoint + 1][right];
53
54 // Update minimum sum
55 minSum = Math.min(minSum, currentSum);
56 }
57
58 // Memoize and return the result
59 memo[left][right] = minSum;
60 return minSum;
61 }
62}
63
1class Solution {
2public:
3 int mctFromLeafValues(vector<int>& arr) {
4 int n = arr.size();
5
6 // dp[i][j] stores the minimum sum of non-leaf nodes for subarray arr[i..j]
7 vector<vector<int>> dp(n, vector<int>(n, 0));
8
9 // maxVal[i][j] stores the maximum value in subarray arr[i..j]
10 vector<vector<int>> maxVal(n, vector<int>(n, 0));
11
12 // Precompute maximum values for all subarrays
13 for (int i = n - 1; i >= 0; --i) {
14 maxVal[i][i] = arr[i]; // Single element case
15 for (int j = i + 1; j < n; ++j) {
16 // Maximum value in arr[i..j] is max of arr[i..j-1] and arr[j]
17 maxVal[i][j] = max(maxVal[i][j - 1], arr[j]);
18 }
19 }
20
21 // Define recursive function with memoization
22 function<int(int, int)> calculateMinSum = [&](int left, int right) -> int {
23 // Base case: single leaf node has no non-leaf nodes
24 if (left == right) {
25 return 0;
26 }
27
28 // Return memoized result if already computed
29 if (dp[left][right] > 0) {
30 return dp[left][right];
31 }
32
33 // Initialize result with a large value
34 int minSum = INT_MAX;
35
36 // Try all possible ways to split the range [left, right]
37 for (int splitPoint = left; splitPoint < right; ++splitPoint) {
38 // Calculate sum for left subtree + right subtree + current non-leaf node
39 // Current non-leaf node value = max(left subtree) * max(right subtree)
40 int currentSum = calculateMinSum(left, splitPoint) +
41 calculateMinSum(splitPoint + 1, right) +
42 maxVal[left][splitPoint] * maxVal[splitPoint + 1][right];
43
44 minSum = min(minSum, currentSum);
45 }
46
47 // Store and return the result
48 dp[left][right] = minSum;
49 return minSum;
50 };
51
52 // Calculate minimum sum for the entire array
53 return calculateMinSum(0, n - 1);
54 }
55};
56
1/**
2 * Calculates the minimum cost tree from leaf values
3 * Given an array of leaf values, constructs a binary tree where each leaf has a value from the array
4 * and each non-leaf node has value equal to the product of the largest leaf values in its left and right subtrees
5 * Returns the smallest possible sum of all non-leaf nodes
6 */
7function mctFromLeafValues(arr: number[]): number {
8 const n: number = arr.length;
9
10 // dp[i][j] stores the minimum cost to build a tree from arr[i] to arr[j]
11 const dp: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
12
13 // maxInRange[i][j] stores the maximum value in the range arr[i] to arr[j]
14 const maxInRange: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
15
16 // Precompute maximum values for all ranges
17 for (let i = n - 1; i >= 0; i--) {
18 // Single element range
19 maxInRange[i][i] = arr[i];
20
21 // Compute max for all ranges starting at i
22 for (let j = i + 1; j < n; j++) {
23 maxInRange[i][j] = Math.max(maxInRange[i][j - 1], arr[j]);
24 }
25 }
26
27 /**
28 * Recursive function with memoization to find minimum cost
29 * @param left - starting index of the range
30 * @param right - ending index of the range
31 * @returns minimum cost to build tree from arr[left] to arr[right]
32 */
33 const findMinCost = (left: number, right: number): number => {
34 // Base case: single leaf node has no cost
35 if (left === right) {
36 return 0;
37 }
38
39 // Return memoized result if already computed
40 if (dp[left][right] > 0) {
41 return dp[left][right];
42 }
43
44 // Initialize with a large value
45 let minCost: number = 1 << 30;
46
47 // Try all possible split points
48 for (let splitPoint = left; splitPoint < right; splitPoint++) {
49 // Cost = left subtree cost + right subtree cost + product of max values
50 const currentCost: number = findMinCost(left, splitPoint) +
51 findMinCost(splitPoint + 1, right) +
52 maxInRange[left][splitPoint] * maxInRange[splitPoint + 1][right];
53
54 minCost = Math.min(minCost, currentCost);
55 }
56
57 // Memoize and return the result
58 dp[left][right] = minCost;
59 return minCost;
60 };
61
62 // Find minimum cost for the entire array
63 return findMinCost(0, n - 1);
64}
65
Time and Space Complexity
Time Complexity: O(n^3)
The recursive function dfs(i, j)
computes the minimum cost for subarray arr[i:j+1]
. For each call to dfs(i, j)
, we iterate through all possible split points k
from i
to j-1
, which takes O(j - i)
time.
Due to memoization with @cache
, each unique (i, j)
pair is computed only once. There are O(n^2)
possible (i, j)
pairs where 0 ≤ i ≤ j < n
. For each pair, we perform O(n)
work in the worst case (when j - i
approaches n
). Therefore, the overall time complexity is O(n^2) × O(n) = O(n^3)
.
Space Complexity: O(n^2)
The space complexity consists of two components:
- Memoization cache: The
@cache
decorator stores results for all possible(i, j)
pairs. Since there areO(n^2)
valid pairs wherei ≤ j
, the cache requiresO(n^2)
space. - Recursion stack: The maximum depth of recursion is
O(n)
when we recursively split the array.
The dominant factor is the memoization cache, resulting in a total space complexity of O(n^2)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Maximum Value Tracking
A critical pitfall is updating the maximum value only when finding a better minimum sum. The maximum value for a range should always be the maximum of all leaves in that range, regardless of which split gives the minimum sum.
Incorrect Implementation:
if min_sum > current_sum:
min_sum = current_sum
max_value = max(left_max, right_max) # Wrong! Only updates on better sum
Correct Implementation:
@cache
def dp(left: int, right: int) -> Tuple[int, int]:
if left == right:
return 0, arr[left]
min_sum = float('inf')
overall_max = -1
for split_point in range(left, right):
left_sum, left_max = dp(left, split_point)
right_sum, right_max = dp(split_point + 1, right)
current_sum = left_sum + right_sum + left_max * right_max
min_sum = min(min_sum, current_sum)
# Always track the maximum value for this split
overall_max = max(overall_max, left_max, right_max)
return min_sum, overall_max
2. Stack Overflow with Large Inputs
Python's default recursion limit can cause stack overflow for large arrays. The recursive depth can reach O(n) in worst case.
Solution - Increase Recursion Limit:
import sys sys.setrecursionlimit(10000) # Adjust based on constraints
Alternative Solution - Bottom-up DP:
def mctFromLeafValues(self, arr: List[int]) -> int:
n = len(arr)
dp = [[0] * n for _ in range(n)]
max_val = [[0] * n for _ in range(n)]
# Initialize base cases
for i in range(n):
max_val[i][i] = arr[i]
# Fill max_val table
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
max_val[i][j] = max(max_val[i][j-1], arr[j])
# Fill dp table
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
dp[i][j] = float('inf')
for k in range(i, j):
cost = dp[i][k] + dp[k+1][j] + max_val[i][k] * max_val[k+1][j]
dp[i][j] = min(dp[i][j], cost)
return dp[0][n-1]
3. Off-by-One Errors in Split Range
A common mistake is using incorrect bounds for the split point iteration.
Wrong:
for split_point in range(left + 1, right + 1): # Misses valid splits
or
for split_point in range(left, right + 1): # Goes one too far
Correct:
for split_point in range(left, right): # Splits at positions [left, right-1]
4. Not Using Proper Memoization
Without memoization, the time complexity becomes exponential O(2^n), making the solution impractical for larger inputs.
Issue without memoization:
def dp(left, right): # No @cache decorator
# ... recursive calls will recompute same subproblems
Solution:
Ensure you're using either @cache
(Python 3.9+) or @lru_cache(None)
for older versions, or implement manual memoization with a dictionary.
A heap is a ...?
Recommended Readings
Stack Intro Imagine you have a pile of books on your desk If you want to add a new book you place it on top If you want to read a book you take it from the top And if you simply want to see which book is on the top you
Greedy Introduction div class responsive iframe iframe src https www youtube com embed WTslqPbj7I title YouTube video player frameborder 0 allow accelerometer autoplay clipboard write encrypted media gyroscope picture in picture web share allowfullscreen iframe div When do we use greedy Greedy algorithms tend to solve optimization problems Typically they will ask you to calculate the max min of some value Commonly you may see this phrased in the problem as max min longest shortest largest smallest etc These keywords can be identified by just scanning
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Want a Structured Path to Master System Design Too? Don’t Miss This!