1774. Closest Dessert Cost


Problem Description

In this problem, the aim is to prepare a dessert with a cost as close to the target as possible. There is a selection of n ice cream base flavors, each with its own price, and m types of toppings, each topping also with its own price. The conditions for making a dessert are as follows:

  • Exactly one ice cream base must be chosen.
  • Toppings are optional, but if used, one can choose one or more types, including up to two of each type.
  • The goal is to select the combination of one base and any assortment of toppings so that the total cost is as close to the target as possible. If multiple combinations result in the same closest cost, the cheaper one is preferred.

Given are:

  • baseCosts, an array representing the cost of each ice cream base flavor.
  • toppingCosts, an array representing the cost of each type of topping.
  • target, an integer that represents the desired cost for the dessert.

The task is to find the closest possible cost of the dessert to the target price.

Intuition

The solution to this problem can be found by systematic trial and error, using a method known as Depth-First Search (DFS) to explore all possible combinations of toppings, while always keeping track of the running total cost. Here are the steps we take to find the solution:

  1. Use DFS to generate all possible sums of the topping costs. Since we can have zero, one, or two of each topping, by the time we traverse through all toppings with DFS, we would have considered every possible combination of topping costs.

  2. Sort the array of topping combinations. Sorting will be helpful for finding the closest sums to the target after combining with a base cost.

  3. Iterate through each of the base flavors and combine it with every topping combination.

  4. Utilize binary search (bisect_left) to quickly find the closest topping sum in the sorted array that, when added to the current base cost, is closest to the target. Binary search is more efficient than iterating over each sum since it can zero in on the right range quickly.

  5. Check both the nearest greater or equal and the immediately smaller sums to handle cases where the closest sum could be less than the target.

  6. Compare the total cost (sum of base and toppings) to the target, and if the absolute difference is lower than the difference of any previous combination (or if it's the same but the total cost is lower), update the answer accordingly.

This approach effectively evaluates every feasible combination of toppings and bases, while making use of efficient algorithms like sorting and binary search to minimize the amount of calculations required.

Learn more about Dynamic Programming and Backtracking patterns.

Not Sure What to Study? Take the 2-min Quiz to Find Your Missing Piece:

Which data structure is used to implement priority queue?

Solution Approach

The implementation of the solution uses recursive depth-first search (DFS), sorting, binary search, and some careful decision logic to evaluate all possible topping combinations and then find the closest cost to the target.

Let's break down the code:

  1. Depth-First Search (DFS):
1def dfs(i, t):
2    if i >= len(toppingCosts):
3        arr.append(t)
4        return
5    dfs(i + 1, t)
6    dfs(i + 1, t + toppingCosts[i])
  • We define a recursive function dfs, which takes an index i (indicating the current topping being considered) and a running total t of topping costs.
  • The base case is when i is equal to the length of toppingCosts array, meaning we have considered all toppings. At this point, we append the total to the arr list.
  • We make recursive calls to dfs, once without adding the current topping and once with adding the current topping's cost.
  1. Recursive Calculation of Topping Combinations:
1arr = []
2dfs(0, 0)
3arr.sort()
  • We initialize an empty list arr to store all possible sums of toppings.
  • The execution of DFS begins with the first topping (index 0) and a total cost of 0.
  • Once all combinations are calculated, we sort arr, which will allow us to perform efficient binary searches.
  1. Iterating Over Base Costs and Topping Combinations:
  • We iterate over every base cost and topping sum to cover all possible dessert costs.
  1. Binary Search with bisect_left:
1for x in baseCosts:
2    for y in arr:
3        i = bisect_left(arr, target - x - y)
  • For each base cost x and topping sum y, we use bisect_left from Python's bisect module to find the index i of the first element in arr that is not less than target - x - y. This helps us identify the closest toppings sum to achieve the target when added to the current base and topping sum.
  1. Evaluating the Closest Sum:
1for j in (i, i - 1):
2    if 0 <= j < len(arr):
3        t = abs(x + y + arr[j] - target)
4        if d > t or (d == t and ans > x + y + arr[j]):
5            d = t
6            ans = x + y + arr[j]
  • Since bisect_left may return an index pointing to a larger topping sum (when the exact sum is not present), we check both the sum at index i and i - 1 to cover the possibility that a smaller sum could be closer to the target.
  • We calculate the new distance t from the target for this combination.
  • If this distance is less than the smallest distance found so far (d), or if it's the same but the cost is lower, we update d and ans with the new distance and cost.
  1. Returning the Closest Cost:
  • Finally, we return ans, which by the end of the loops, holds the cost of the dessert closest to the target.

The combination of DFS to generate all sums and binary search to quickly find the nearest sums leads to a solution that is both comprehensive and efficient.

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

Which data structure is used to implement recursion?

Example Walkthrough

Let's illustrate the solution approach with a small example.

Suppose we have the following:

  • baseCosts = [1, 3]
  • toppingCosts = [1, 2]
  • target = 5

Step 1: Depth-First Search for Topping Combinations

We start by generating all possible topping sums:

  • Starting with a total of '0', we can either add '0', '1', or '2' of the first topping. This results in possible sums of 0, 1 and 2 for the first topping.
  • For each of these sums, we again can add '0', '1', or '2' of the second topping, resulting in sums of 0, 2, 4 when combined with the first topping. So for each sum coming from the first topping, we again get three possibilities.

The resulting sums are: 0, 1, 2, 2, 3, 4, 4, 5, and 6. After removing duplicates and sorting, we have arr = [0, 1, 2, 3, 4, 5, 6].

Step 2: Iterate over Base Costs and Topping Combinations

We now start with the first base cost, 1, and iterate through arr.

Step 3: Binary Search with bisect_left

When looking for the closest sum in arr to the target - base cost, we are effectively searching for target - 1 = 4. The bisect_left function will give us the index of the first topping sum in arr that is not less than 4, which is 4 itself, at index 4.

Step 4: Evaluating the Closest Sum

We then check the sum at the found index 4 and the sum before it. In arr these are 4 and 3. The total cost with the base of 1 would then be 5 and 4. Since both are equally close to the target of 5, but 4 is less expensive, we would prefer the total cost of 4.

We perform the same operations for the second base cost, 3, looking for target - 3 = 2. bisect_left will result in the index of 2, which points to the sum of 2 exactly in arr. Checking the sums 2 and 1 (from index 2 and 1, respectively) gives us total costs of 5 and 4, again both exactly 1 unit away from the target 5. Since we already have 4 as our current best, we do not need to update our answer.

Step 5: Returning the Closest Cost

Finally, after considering all base costs, the closest possible cost is 4, which is the cheapest dessert cost closest to the target of 5.

Using DFS, we were able to consider all the topping combinations, and with binary search, we quickly identified the closest totals to the target. In the end, we have efficiently arrived at the best possible solution for the problem with a total cost of '4'.

Solution Implementation

1from bisect import bisect_left
2from math import inf
3from typing import List
4
5class Solution:
6    def closestCost(self, base_costs: List[int], topping_costs: List[int], target: int) -> int:
7        # Helper function to perform depth-first search and accumulate costs of toppings
8        def dfs(index, total_cost):
9            # If we have considered all toppings, add the current total_cost to the list
10            if index >= len(topping_costs):
11                possible_toppings_costs.append(total_cost)
12                return
13            # Skip current topping
14            dfs(index + 1, total_cost)
15            # Include current topping once
16            dfs(index + 1, total_cost + topping_costs[index])
17
18        # List to store all possible combinations of toppings' costs
19        possible_toppings_costs = []
20        # Start depth-first search to fill possible_toppings_costs
21        dfs(0, 0)
22        # Sort the list of possible toppings costs for binary search
23        possible_toppings_costs.sort()
24      
25        # Initialize variables to keep track of the difference and the closest cost
26        closest_difference = inf
27        closest_cost = inf
28        # Iterate through all base costs
29        for base_cost in base_costs:
30            # Iterate through all possible toppings costs
31            for toppings_cost in possible_toppings_costs:
32                # Calculate the combined cost of base and toppings
33                current_cost = base_cost + toppings_cost
34                # Find the index of the element in the toppings_costs array
35                # that would make the combined cost as close as possible to the target
36                index = bisect_left(possible_toppings_costs, target - current_cost)
37                # Check both the current and the previous index in the array, if possible
38                for check_index in (index, index - 1):
39                    if 0 <= check_index < len(possible_toppings_costs):
40                        # Calculate the total cost
41                        total_cost = current_cost + possible_toppings_costs[check_index]
42                        # Calculate the absolute difference from the target
43                        difference = abs(total_cost - target)
44                        # Update the closest if this is the smallest difference found
45                        # or if the difference is the same but the total_cost is smaller
46                        if closest_difference > difference or (closest_difference == difference and closest_cost > total_cost):
47                            closest_difference = difference
48                            closest_cost = total_cost
49
50        # Return the closest cost to the target cost
51        return closest_cost
52
1import java.util.ArrayList;
2import java.util.Collections;
3import java.util.List;
4
5class Solution {
6    private List<Integer> toppingCombinations = new ArrayList<>(); // List to store all possible topping combinations
7    private int[] toppings; // Array to store the toppings
8    private final int INF = 1 << 30; // Large number used to for initial values
9
10    // Method to find the closest cost to the target by choosing from base and topping costs
11    // baseCosts: Array of base pizza costs
12    // toppingCosts: Array of topping costs
13    // target: Target cost to achieve
14    public int closestCost(int[] baseCosts, int[] toppingCosts, int target) {
15        toppings = toppingCosts;
16        findAllToppingCombinations(0, 0); // Calls the function to find all combinations of toppings
17        Collections.sort(toppingCombinations); // Sorts the topping combinations for binary search
18      
19        int minDifference = INF, closestTotalCost = INF; // Initializing variables to track the closest cost
20        for (int baseCost : baseCosts) { // Loop through each base cost
21            for (int toppingCombinationCost : toppingCombinations) { // Loop through each topping combination
22                int position = binarySearchClosestPosition(target - baseCost - toppingCombinationCost); // Binary search for closest topping combination
23                for (int idx : new int[] {position, position - 1}) { // Check the current and previous position
24                    if (idx >= 0 && idx < toppingCombinations.size()) {
25                        int currentTotalCost = baseCost + toppingCombinationCost + toppingCombinations.get(idx);
26                        int currentDifference = Math.abs(currentTotalCost - target);
27                        if (minDifference > currentDifference || (minDifference == currentDifference && closestTotalCost > currentTotalCost)) {
28                            minDifference = currentDifference; // Update minimum difference if a closer cost is found
29                            closestTotalCost = currentTotalCost; // Update the closest cost
30                        }
31                    }
32                }
33            }
34        }
35        return closestTotalCost; // Return the cost closest to target, as obtained
36    }
37
38    // Method to perform a binary search to find the closest position in the list
39    private int binarySearchClosestPosition(int x) {
40        int left = 0, right = toppingCombinations.size();
41        while (left < right) {
42            int mid = (left + right) >> 1; // Calculate mid-point for the binary search
43            if (toppingCombinations.get(mid) >= x) { 
44                right = mid; // Narrow the search space to the left half
45            } else {
46                left = mid + 1; // Narrow the search space to the right half
47            }
48        }
49        return left; // Return the position of closest value that is >= x
50    }
51
52    // Recursive method to find all possible combinations of toppings
53    private void findAllToppingCombinations(int index, int total) {
54        if (index >= toppings.length) { // Base case: if all toppings have been considered
55            toppingCombinations.add(total); // Add the total to the list of topping combinations
56            return;
57        }
58        // Recursive case: choose the current topping 0 or more times (up to 2 times)
59        findAllToppingCombinations(index + 1, total); // Skip the current topping
60        findAllToppingCombinations(index + 1, total + toppings[index]); // Add the current topping once
61        findAllToppingCombinations(index + 1, total + 2 * toppings[index]); // Add the current topping twice
62    }
63}
64
1class Solution {
2public:
3    int closestCost(vector<int>& baseCosts, vector<int>& toppingCosts, int target) {
4        vector<int> possibleToppingCombinations;
5        // Recursive function to explore all topping combinations
6        function<void(int, int)> exploreToppings = [&](int index, int totalCost) {
7            // If we've considered all toppings, add this total to the possible combinations
8            if (index >= toppingCosts.size()) {
9                possibleToppingCombinations.push_back(totalCost);
10                return;
11            }
12            // Recursive cases: do not add the current topping, add it once, and add it twice
13            exploreToppings(index + 1, totalCost);
14            exploreToppings(index + 1, totalCost + toppingCosts[index]);
15            exploreToppings(index + 1, totalCost + 2 * toppingCosts[index]);
16        };
17      
18        // Starting the recursive function with the initial index and cost
19        exploreToppings(0, 0);
20
21        // Sort possible topping combinations for binary searching
22        sort(possibleToppingCombinations.begin(), possibleToppingCombinations.end());
23
24        int closestDifference = INT_MAX;
25        int closestCost = INT_MAX;
26      
27        // Iterate over each base cost
28        for (int base : baseCosts) {
29            for (int toppingCombo : possibleToppingCombinations) {
30                // Look for a topping combination that, along with this base, gets closest to the target cost
31                auto it = lower_bound(possibleToppingCombinations.begin(), possibleToppingCombinations.end(), target - base - toppingCombo);
32                for (int k = -1; k <= 1; k++) { // Check both the lower bound and the element before it
33                    int idx = distance(possibleToppingCombinations.begin(), it) + k;
34                    if (idx >= 0 && idx < possibleToppingCombinations.size()) {
35                        int currentCost = base + toppingCombo + possibleToppingCombinations[idx];
36                        int currentDiff = abs(currentCost - target);
37                        // Update closest cost if a closer or equally close but cheaper cost is found
38                        if (closestDifference > currentDiff || (closestDifference == currentDiff && closestCost > currentCost)) {
39                            closestDifference = currentDiff;
40                            closestCost = currentCost;
41                        }
42                    }
43                }
44            }
45        }
46        return closestCost;
47    }
48};
49
1// Define the types for better clarity and maintainability.
2type ToppingsVector = number[];
3type LowerBoundFunction = (vec: number[], target: number) => number;
4
5// Recursive function to explore all topping combinations
6const exploreToppings = (toppingCosts: ToppingsVector, index: number, totalCost: number, possibleToppingCombinations: ToppingsVector): void => {
7    // If we've considered all toppings, add this total to the possible combinations
8    if (index >= toppingCosts.length) {
9        possibleToppingCombinations.push(totalCost);
10        return;
11    }
12    // Recursive cases: do not add the current topping, add it once, and add it twice
13    exploreToppings(toppingCosts, index + 1, totalCost, possibleToppingCombinations);
14    exploreToppings(toppingCosts, index + 1, totalCost + toppingCosts[index], possibleToppingCombinations);
15    exploreToppings(toppingCosts, index + 1, totalCost + 2 * toppingCosts[index], possibleToppingCombinations);
16};
17
18// Function to find a close lower bound for a value in a sorted array
19const lowerBound: LowerBoundFunction = (vec, target) => {
20    let left = 0;
21    let right = vec.length;
22
23    while (left < right) {
24        let mid = Math.floor((left + right) / 2);
25        if (vec[mid] < target) left = mid + 1;
26        else right = mid;
27    }
28    return right;
29};
30
31// Main function to compute the closest cost
32const closestCost = (baseCosts: ToppingsVector, toppingCosts: ToppingsVector, target: number): number => {
33    const possibleToppingCombinations: ToppingsVector = [];
34
35    // Generate all possible topping combinations
36    exploreToppings(toppingCosts, 0, 0, possibleToppingCombinations);
37
38    // Sort possible topping combinations for binary searching
39    possibleToppingCombinations.sort((a, b) => a - b);
40
41    let closestDifference = Number.MAX_SAFE_INTEGER;
42    let closestCostValue = Number.MAX_SAFE_INTEGER;
43
44    // Iterate over each base cost
45    baseCosts.forEach(base => {
46        // Look for a topping combination that, along with this base, gets closest to the target cost
47        possibleToppingCombinations.forEach(toppingCombo => {
48            const index = lowerBound(possibleToppingCombinations, target - base - toppingCombo);
49            for (let k = -1; k <= 1; k++) { // Check both the lower bound and the element before it
50                const idx = index + k;
51                if (idx >= 0 && idx < possibleToppingCombinations.length) {
52                    const currentCost = base + toppingCombo + possibleToppingCombinations[idx];
53                    const currentDiff = Math.abs(currentCost - target);
54                    // Update closest cost if a closer or equally close but cheaper cost is found
55                    if (closestDifference > currentDiff || (closestDifference === currentDiff && closestCostValue > currentCost)) {
56                        closestDifference = currentDiff;
57                        closestCostValue = currentCost;
58                    }
59                }
60            }
61        });
62    });
63    return closestCostValue;
64};
65
Not Sure What to Study? Take the 2-min Quiz:

Problem: Given a list of tasks and a list of requirements, compute a sequence of tasks that can be performed, such that we complete every task once while satisfying all the requirements.

Which of the following method should we use to solve this problem?

Time and Space Complexity

The given Python code aims to find the closest cost to a target by choosing a base cost and adding zero or more topping costs. The approach taken here is a mix of depth-first search (DFS) for generating all possible topping combinations and binary search for finding the closest sum to the target.

Time Complexity:

  • The dfs function is called to generate all possible combinations of topping costs, this results in a DFS tree where each node can either include the current topping or not. The time complexity of this part is O(2^T) where T is the number of topping types, since each topping can be used at most once, leading to 2^T combinations.
  • Sorting the array arr has a time complexity of O(M log M) where M is the number of combinations generated by the DFS, which is also 2^T.
  • The outer loop iterating through the base costs runs B times where B is the number of base costs.
  • Inside the outer loop, a binary search is performed using bisect_left. This has a time complexity of O(log M) for each of the base costs.
  • The inner loop runs a constant number of times (2 times, for indices i and i-1).

Combining these factors, the overall time complexity is O(B * (2^T + M log M)) where B is the number of base costs and M = 2^T is the number of combinations from the toppings.

In the worst-case scenario, this simplifies to O(B * 2^T + B * T * 2^T) taking into account both the DFS and the sorting.

Space Complexity:

  • The space complexity is driven by the storage required for the arr which contains all possible topping combinations, so O(M) where M is the number of combinations, which equals 2^T.
  • Additionally, there is a recursive stack space due to the dfs call which could go as deep as T levels, thus the space complexity for the call stack is O(T).

This means the overall space complexity is O(2^T + T), which simplifies to O(2^T) since 2^T is expected to be the dominant term as compared to the linear term T.

Note: inf is a constant imported from the math module representing infinity used for comparison, and does not impact the computational complexity.

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

Fast Track Your Learning with Our Quick Skills Quiz:

What is the worst case running time for finding an element in a binary search tree(not necessarily balanced) of size n?


Recommended Readings


Got a question? Ask the Teaching Assistant anything you don't understand.

Still not clear? Ask in the Forum,  Discord or Submit the part you don't understand to our editors.


TA 👨‍🏫