Facebook Pixel

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:

  1. The minimum sum of non-leaf nodes for the subarray arr[i:j+1]
  2. 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. It determines the immediate cost (product of max values from left and right)
  2. 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:

  1. Try all possible split points k where i ≤ k < j

  2. 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
  3. 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 Evaluator

Example 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 nodes
    • dfs(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:

  1. Memoization cache: The @cache decorator stores results for all possible (i, j) pairs. Since there are O(n^2) valid pairs where i ≤ j, the cache requires O(n^2) space.
  2. 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.

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

A heap is a ...?


Recommended Readings

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

Load More