Facebook Pixel

2431. Maximize Total Tastiness of Purchased Fruits 🔒

Problem Description

You have access to a fruit market where each fruit has a specific price and tastiness value. Given two arrays of the same length n:

  • price[i] represents the cost of the i-th fruit
  • tastiness[i] represents how tasty the i-th fruit is

You have a budget of maxAmount money and maxCoupons discount coupons to spend.

The goal is to buy fruits to maximize the total tastiness while staying within your budget.

Rules for purchasing:

  1. Each fruit can be bought at most once (you can't buy multiple of the same fruit)
  2. For any fruit, you can choose to:
    • Buy it at full price: costs price[i]
    • Buy it with a coupon at half price: costs price[i] // 2 (integer division, rounded down)
    • Not buy it at all
  3. Each coupon can only be used once, and you have at most maxCoupons coupons total
  4. The total spending cannot exceed maxAmount

Example scenarios:

  • If a fruit costs 10 and you use a coupon, you pay 5
  • If a fruit costs 11 and you use a coupon, you pay 5 (rounded down from 5.5)
  • You can choose to buy some fruits with coupons, some at full price, and skip others entirely

The problem asks you to return the maximum possible sum of tastiness values you can achieve by strategically selecting which fruits to buy and which ones to apply coupons to, while respecting all constraints.

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

Intuition

This problem presents us with multiple choices at each step - for every fruit, we need to decide whether to buy it or not, and if we buy it, whether to use a coupon. This decision-making pattern naturally suggests a dynamic programming approach.

Think of it as exploring all possible purchasing strategies. At each fruit, we're at a decision point with three options:

  1. Skip this fruit entirely
  2. Buy it at full price (if we have enough money)
  3. Buy it with a coupon (if we have enough money for half price AND have coupons left)

The key insight is that our decision for the current fruit depends on:

  • Which fruit we're considering (position i)
  • How much money we have left (j)
  • How many coupons we have left (k)

These three values completely define our "state" at any point in the decision process. Once we know these three things, the best decision from that point forward is always the same - this is the optimal substructure property that makes dynamic programming applicable.

We can visualize this as a tree of decisions. Starting from the first fruit with full budget and all coupons, we branch into different scenarios based on our choices. Many of these branches will lead to the same state (same fruit index, same remaining money, same remaining coupons), which means we're solving the same subproblem multiple times. This overlap in subproblems is why memoization is effective here.

The recursive function dfs(i, j, k) captures this beautifully - it answers the question: "What's the maximum tastiness I can get starting from fruit i, with j money and k coupons remaining?" By caching the results, we avoid recalculating the same scenarios and efficiently explore all possible purchasing strategies to find the optimal one.

The base case is when we've considered all fruits (i == len(price)), at which point no more tastiness can be gained, so we return 0.

Learn more about Dynamic Programming patterns.

Solution Approach

The solution implements a top-down dynamic programming approach using memoization search. Let's break down the implementation:

Function Definition: We define a recursive function dfs(i, j, k) that represents:

  • i: the index of the current fruit we're considering
  • j: the amount of money remaining
  • k: the number of coupons remaining
  • Returns: the maximum total tastiness achievable from fruit i onwards with the given constraints

Base Case:

if i == len(price):
    return 0

When we've considered all fruits (reached the end of the array), no more tastiness can be gained, so we return 0.

Recursive Cases: For each fruit at position i, we explore three choices:

  1. Skip the current fruit:

    ans = dfs(i + 1, j, k)

    We move to the next fruit without spending money or coupons, keeping the same budget and coupon count.

  2. Buy at full price (if affordable):

    if j >= price[i]:
        ans = max(ans, dfs(i + 1, j - price[i], k) + tastiness[i])

    If we have enough money (j >= price[i]), we can buy this fruit at full price. We recursively solve for the remaining fruits with reduced money (j - price[i]) and add the current fruit's tastiness to the result.

  3. Buy with a coupon (if affordable and coupons available):

    if j >= price[i] // 2 and k:
        ans = max(ans, dfs(i + 1, j - price[i] // 2, k - 1) + tastiness[i])

    If we have enough money for half price (j >= price[i] // 2) AND have at least one coupon (k > 0), we can use a coupon. We recursively solve with reduced money (j - price[i] // 2) and one less coupon (k - 1), adding the current fruit's tastiness.

Memoization: The @cache decorator automatically memoizes the function results. When dfs(i, j, k) is called with the same parameters again, it returns the cached result instead of recalculating. This prevents redundant computation of overlapping subproblems.

Final Answer:

return dfs(0, maxAmount, maxCoupons)

We start the recursion from fruit 0 with the full budget and all coupons available.

Time Complexity: O(n × maxAmount × maxCoupons) where n is the number of fruits, as each unique state is computed only once.

Space Complexity: O(n × maxAmount × maxCoupons) for the memoization cache plus O(n) for the recursion stack depth.

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:

Input:

  • price = [10, 20, 20]
  • tastiness = [5, 8, 8]
  • maxAmount = 20
  • maxCoupons = 1

Goal: Find the maximum total tastiness we can achieve.

Let's trace through the recursive calls starting from dfs(0, 20, 1):

Step 1: dfs(0, 20, 1) - Considering fruit 0 (price=10, tastiness=5)

  • Option 1: Skip fruit 0 → dfs(1, 20, 1)
  • Option 2: Buy at full price (20 ≥ 10) → dfs(1, 10, 1) + 5
  • Option 3: Buy with coupon (20 ≥ 5 and have 1 coupon) → dfs(1, 15, 0) + 5

Step 2: Explore dfs(1, 20, 1) - Considering fruit 1 (price=20, tastiness=8)

  • Option 1: Skip → dfs(2, 20, 1)
  • Option 2: Buy at full price (20 ≥ 20) → dfs(2, 0, 1) + 8
  • Option 3: Buy with coupon (20 ≥ 10 and have 1 coupon) → dfs(2, 10, 0) + 8

Step 3: Explore dfs(1, 10, 1) - From buying fruit 0 at full price

  • Option 1: Skip → dfs(2, 10, 1)
  • Option 2: Can't buy at full price (10 < 20)
  • Option 3: Buy with coupon (10 ≥ 10 and have 1 coupon) → dfs(2, 0, 0) + 8

Step 4: Explore dfs(1, 15, 0) - From buying fruit 0 with coupon

  • Option 1: Skip → dfs(2, 15, 0)
  • Option 2: Can't buy at full price (15 < 20)
  • Option 3: Can't use coupon (have 0 coupons)

Continue exploring leaf nodes...

When we reach dfs(2, 10, 0) from the path where we bought fruit 1 with a coupon:

  • We can buy fruit 2 with the remaining 10 money (price=20, half price=10)
  • But we have no coupons left, so we can only skip it

Backtracking and Computing Results:

Key paths to consider:

  1. Buy nothing → tastiness = 0
  2. Buy fruit 0 with coupon (cost 5), then buy fruit 1 with remaining money at full price (can't afford) → tastiness = 5
  3. Buy fruit 1 with coupon (cost 10), then buy fruit 2 at full price (can't afford with remaining 10) → tastiness = 8
  4. Buy fruit 0 at full price (cost 10), then buy fruit 1 with coupon (cost 10) → tastiness = 5 + 8 = 13 ✓

The maximum tastiness achieved is 13 by buying fruit 0 at full price and fruit 1 with the coupon.

The memoization ensures that when we encounter the same state (i, j, k) again (like dfs(2, 10, 0) might be reached through different paths), we don't recalculate it.

Solution Implementation

1class Solution:
2    def maxTastiness(
3        self, price: List[int], tastiness: List[int], maxAmount: int, maxCoupons: int
4    ) -> int:
5        """
6        Find the maximum total tastiness achievable when buying fruits.
7      
8        Args:
9            price: List of fruit prices
10            tastiness: List of fruit tastiness values
11            maxAmount: Maximum amount of money available
12            maxCoupons: Maximum number of 50% discount coupons available
13      
14        Returns:
15            Maximum total tastiness achievable
16        """
17      
18        @cache
19        def dfs(fruit_index: int, remaining_amount: int, remaining_coupons: int) -> int:
20            """
21            Dynamic programming helper function to calculate maximum tastiness.
22          
23            Args:
24                fruit_index: Current index in the fruit arrays
25                remaining_amount: Remaining money available
26                remaining_coupons: Remaining coupons available
27          
28            Returns:
29                Maximum tastiness achievable from current state
30            """
31            # Base case: no more fruits to consider
32            if fruit_index == len(price):
33                return 0
34          
35            # Option 1: Skip current fruit
36            max_tastiness = dfs(fruit_index + 1, remaining_amount, remaining_coupons)
37          
38            # Option 2: Buy current fruit at full price (if affordable)
39            if remaining_amount >= price[fruit_index]:
40                max_tastiness = max(
41                    max_tastiness,
42                    dfs(fruit_index + 1, remaining_amount - price[fruit_index], remaining_coupons) + tastiness[fruit_index]
43                )
44          
45            # Option 3: Buy current fruit with 50% coupon (if affordable and coupons available)
46            half_price = price[fruit_index] // 2
47            if remaining_amount >= half_price and remaining_coupons > 0:
48                max_tastiness = max(
49                    max_tastiness,
50                    dfs(fruit_index + 1, remaining_amount - half_price, remaining_coupons - 1) + tastiness[fruit_index]
51                )
52          
53            return max_tastiness
54      
55        # Start DFS from first fruit with full budget and all coupons
56        return dfs(0, maxAmount, maxCoupons)
57
1class Solution {
2    // Memoization table: dp[itemIndex][remainingAmount][remainingCoupons]
3    private int[][][] dp;
4    private int[] price;
5    private int[] tastiness;
6    private int n;
7
8    /**
9     * Finds the maximum tastiness achievable with given budget and coupons.
10     * Each item can be bought at full price or half price (using a coupon).
11     * 
12     * @param price Array of item prices
13     * @param tastiness Array of item tastiness values
14     * @param maxAmount Maximum amount of money available
15     * @param maxCoupons Maximum number of coupons available
16     * @return Maximum total tastiness achievable
17     */
18    public int maxTastiness(int[] price, int[] tastiness, int maxAmount, int maxCoupons) {
19        this.n = price.length;
20        this.price = price;
21        this.tastiness = tastiness;
22        // Initialize memoization table with dimensions for all states
23        this.dp = new int[n][maxAmount + 1][maxCoupons + 1];
24      
25        // Start DFS from first item with full budget and all coupons
26        return dfs(0, maxAmount, maxCoupons);
27    }
28
29    /**
30     * Recursive DFS with memoization to find maximum tastiness.
31     * 
32     * @param itemIndex Current item index being considered
33     * @param remainingAmount Remaining money available
34     * @param remainingCoupons Remaining coupons available
35     * @return Maximum tastiness achievable from current state
36     */
37    private int dfs(int itemIndex, int remainingAmount, int remainingCoupons) {
38        // Base case: no more items to consider
39        if (itemIndex == n) {
40            return 0;
41        }
42      
43        // Return memoized result if already computed
44        if (dp[itemIndex][remainingAmount][remainingCoupons] != 0) {
45            return dp[itemIndex][remainingAmount][remainingCoupons];
46        }
47      
48        // Option 1: Skip current item
49        int maxTastiness = dfs(itemIndex + 1, remainingAmount, remainingCoupons);
50      
51        // Option 2: Buy current item at full price if affordable
52        if (remainingAmount >= price[itemIndex]) {
53            maxTastiness = Math.max(
54                maxTastiness, 
55                dfs(itemIndex + 1, remainingAmount - price[itemIndex], remainingCoupons) + tastiness[itemIndex]
56            );
57        }
58      
59        // Option 3: Buy current item at half price using a coupon if affordable and coupons available
60        if (remainingAmount >= price[itemIndex] / 2 && remainingCoupons > 0) {
61            maxTastiness = Math.max(
62                maxTastiness, 
63                dfs(itemIndex + 1, remainingAmount - price[itemIndex] / 2, remainingCoupons - 1) + tastiness[itemIndex]
64            );
65        }
66      
67        // Memoize and return the result
68        dp[itemIndex][remainingAmount][remainingCoupons] = maxTastiness;
69        return maxTastiness;
70    }
71}
72
1class Solution {
2public:
3    int maxTastiness(vector<int>& price, vector<int>& tastiness, int maxAmount, int maxCoupons) {
4        int itemCount = price.size();
5      
6        // Initialize memoization table to 0
7        // dp[i][j][k] represents max tastiness starting from item i with j amount and k coupons
8        memset(dp, 0, sizeof(dp));
9      
10        // Define recursive function with memoization
11        function<int(int, int, int)> calculateMaxTastiness;
12      
13        calculateMaxTastiness = [&](int itemIndex, int remainingAmount, int remainingCoupons) -> int {
14            // Base case: no more items to consider
15            if (itemIndex == itemCount) {
16                return 0;
17            }
18          
19            // Return memoized result if already computed
20            if (dp[itemIndex][remainingAmount][remainingCoupons] != 0) {
21                return dp[itemIndex][remainingAmount][remainingCoupons];
22            }
23          
24            // Option 1: Skip current item
25            int maxValue = calculateMaxTastiness(itemIndex + 1, remainingAmount, remainingCoupons);
26          
27            // Option 2: Buy item at full price if we have enough money
28            if (remainingAmount >= price[itemIndex]) {
29                maxValue = max(maxValue, 
30                              calculateMaxTastiness(itemIndex + 1, 
31                                                   remainingAmount - price[itemIndex], 
32                                                   remainingCoupons) + tastiness[itemIndex]);
33            }
34          
35            // Option 3: Buy item with coupon (half price) if we have enough money and coupons
36            int halfPrice = price[itemIndex] / 2;
37            if (remainingAmount >= halfPrice && remainingCoupons > 0) {
38                maxValue = max(maxValue, 
39                              calculateMaxTastiness(itemIndex + 1, 
40                                                   remainingAmount - halfPrice, 
41                                                   remainingCoupons - 1) + tastiness[itemIndex]);
42            }
43          
44            // Memoize and return result
45            dp[itemIndex][remainingAmount][remainingCoupons] = maxValue;
46            return maxValue;
47        };
48      
49        // Start recursion from first item with full budget and all coupons
50        return calculateMaxTastiness(0, maxAmount, maxCoupons);
51    }
52
53private:
54    // Memoization table: [item index][remaining amount][remaining coupons]
55    // Constraints: max 100 items, max 1000 amount, max 5 coupons
56    int dp[101][1001][6];
57};
58
1// Memoization table: [item index][remaining amount][remaining coupons]
2// Constraints: max 100 items, max 1000 amount, max 5 coupons
3let dp: number[][][] = [];
4
5function maxTastiness(price: number[], tastiness: number[], maxAmount: number, maxCoupons: number): number {
6    const itemCount = price.length;
7  
8    // Initialize memoization table with -1 to indicate uncomputed states
9    // Using -1 instead of 0 since 0 could be a valid result
10    dp = Array(101).fill(null).map(() => 
11        Array(1001).fill(null).map(() => 
12            Array(6).fill(-1)
13        )
14    );
15  
16    // Recursive function to calculate maximum tastiness with memoization
17    const calculateMaxTastiness = (itemIndex: number, remainingAmount: number, remainingCoupons: number): number => {
18        // Base case: no more items to consider
19        if (itemIndex === itemCount) {
20            return 0;
21        }
22      
23        // Return memoized result if already computed
24        if (dp[itemIndex][remainingAmount][remainingCoupons] !== -1) {
25            return dp[itemIndex][remainingAmount][remainingCoupons];
26        }
27      
28        // Option 1: Skip the current item
29        let maxValue = calculateMaxTastiness(itemIndex + 1, remainingAmount, remainingCoupons);
30      
31        // Option 2: Buy the item at full price if we have enough money
32        if (remainingAmount >= price[itemIndex]) {
33            maxValue = Math.max(
34                maxValue,
35                calculateMaxTastiness(
36                    itemIndex + 1,
37                    remainingAmount - price[itemIndex],
38                    remainingCoupons
39                ) + tastiness[itemIndex]
40            );
41        }
42      
43        // Option 3: Buy the item with a coupon (half price) if we have enough money and coupons
44        const halfPrice = Math.floor(price[itemIndex] / 2);
45        if (remainingAmount >= halfPrice && remainingCoupons > 0) {
46            maxValue = Math.max(
47                maxValue,
48                calculateMaxTastiness(
49                    itemIndex + 1,
50                    remainingAmount - halfPrice,
51                    remainingCoupons - 1
52                ) + tastiness[itemIndex]
53            );
54        }
55      
56        // Memoize and return the result
57        dp[itemIndex][remainingAmount][remainingCoupons] = maxValue;
58        return maxValue;
59    };
60  
61    // Start recursion from the first item with full budget and all coupons
62    return calculateMaxTastiness(0, maxAmount, maxCoupons);
63}
64

Time and Space Complexity

Time Complexity: O(n × maxAmount × maxCoupons)

The time complexity is determined by the memoized recursive function dfs(i, j, k):

  • Parameter i ranges from 0 to n-1 (where n = len(price))
  • Parameter j ranges from 0 to maxAmount
  • Parameter k ranges from 0 to maxCoupons

Due to the @cache decorator, each unique combination of (i, j, k) is computed only once. The total number of unique states is n × (maxAmount + 1) × (maxCoupons + 1). For each state, the function performs constant time operations (comparisons and arithmetic), resulting in O(n × maxAmount × maxCoupons) time complexity.

Space Complexity: O(n × maxAmount × maxCoupons)

The space complexity consists of:

  • Memoization cache: The @cache decorator stores all computed states, which requires O(n × maxAmount × maxCoupons) space
  • Recursion call stack: In the worst case, the recursion depth is O(n) (when processing all items sequentially)

Since the memoization cache dominates the space usage, the overall space complexity is O(n × maxAmount × maxCoupons).

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

Common Pitfalls

1. Incorrect Coupon Usage Strategy

A common mistake is assuming that coupons should always be used on the most expensive items to maximize savings. This greedy approach can lead to suboptimal solutions.

Why it's wrong: The optimal strategy isn't just about saving the most money—it's about maximizing tastiness within budget constraints. Sometimes using a coupon on a cheaper item allows you to buy more total items, resulting in higher overall tastiness.

Example scenario:

  • Budget: 10, Coupons: 1
  • Fruit A: price=8, tastiness=5
  • Fruit B: price=6, tastiness=4
  • Fruit C: price=4, tastiness=3

If you use the coupon on the most expensive (Fruit A), you pay 4, leaving 6 for one more fruit (B), total tastiness = 9. If you use the coupon on Fruit B, you pay 3, leaving 7 for Fruit C (4), total tastiness = 7. But if you buy B at full price (6) and use coupon on C (2), you spend 8 total and get tastiness = 7. The first approach (coupon on most expensive) happens to be optimal here, but this isn't always the case.

Solution: The dynamic programming approach correctly explores all possibilities rather than making greedy assumptions about coupon usage.

2. Integer Division Confusion

When calculating half price with a coupon, using floating-point division instead of integer division can cause errors.

Incorrect:

half_price = price[fruit_index] / 2  # Results in float
if remaining_amount >= half_price:    # Float comparison

Correct:

half_price = price[fruit_index] // 2  # Integer division
if remaining_amount >= half_price:    # Integer comparison

Why it matters: If a fruit costs 11, the half price should be 5 (not 5.5). Using float division could lead to budget calculation errors and incorrect feasibility checks.

3. State Space Explosion Without Memoization

Without the @cache decorator, the recursive solution would have exponential time complexity, making it impractical for larger inputs.

Problem without memoization:

def dfs(fruit_index, remaining_amount, remaining_coupons):
    # Same logic but no caching
    ...

This would recompute the same subproblems multiple times. For instance, you might reach state (i=3, money=5, coupons=2) through many different purchasing paths.

Solution: Always use memoization (@cache or manual dictionary) to ensure each unique state is computed only once.

4. Forgetting to Clear Cache Between Test Cases

When running multiple test cases in the same execution context, the @cache decorator persists across calls, potentially returning incorrect cached results from previous test cases.

Fix:

class Solution:
    def maxTastiness(self, price, tastiness, maxAmount, maxCoupons):
        @cache
        def dfs(fruit_index, remaining_amount, remaining_coupons):
            # ... implementation ...
      
        result = dfs(0, maxAmount, maxCoupons)
        dfs.cache_clear()  # Clear cache after solving
        return result

5. Off-by-One Errors in Coupon Check

A subtle bug can occur when checking coupon availability:

Incorrect:

if remaining_amount >= half_price and remaining_coupons >= 0:  # Wrong!

Correct:

if remaining_amount >= half_price and remaining_coupons > 0:  # Must have at least 1

The condition should be remaining_coupons > 0 (or just remaining_coupons in Python), not >= 0, because you need at least one coupon to use it.

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

Which algorithm should you use to find a node that is close to the root of the tree?


Recommended Readings

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

Load More