Facebook Pixel

638. Shopping Offers

Problem Description

You are shopping in a store that has n different items, each with its own price. The store offers special bundles where you can buy multiple items together at a discounted price.

Given:

  • price[i]: the individual price of the i-th item
  • needs[i]: the exact quantity you need to buy of the i-th item
  • special: a list of special offers, where each offer special[i] contains:
    • special[i][0] to special[i][n-1]: the quantity of each item included in this bundle
    • special[i][n]: the total price for this bundle

Your task is to find the minimum cost to buy exactly the items you need. You can:

  • Buy items individually at their regular prices
  • Use any special offer multiple times
  • Mix and match individual purchases with special offers

However, you cannot buy more items than needed, even if it would result in a lower total cost.

For example, if you need [2, 2] of two items priced at [2, 5] each, and there's a special offer [3, 0, 5] (3 of item 0 for $5), you cannot use this offer since you only need 2 of item 0, not 3.

The goal is to determine the optimal combination of individual purchases and special offers to minimize the total cost while getting exactly the quantities specified in needs.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • No: The problem doesn't involve nodes and edges or graph traversal. It's about finding the minimum cost to purchase items with special offers.

Need to solve for kth smallest/largest?

  • No: We're not looking for the kth smallest or largest element. We need to find the minimum total cost.

Involves Linked Lists?

  • No: The problem deals with arrays representing prices, needs, and special offers, not linked lists.

Does the problem have small constraints?

  • Yes: The problem explicitly states that n ≤ 6 (at most 6 types of items) and each item quantity needed is at most 10. These are very small constraints that make exhaustive search feasible.

Brute force / Backtracking?

  • Yes: With small constraints, we can explore all possible combinations of using special offers and buying items individually. We need to try different combinations of special offers (which can be used multiple times) and backtrack when a combination doesn't work or isn't optimal.

Conclusion: The flowchart correctly leads us to use a Backtracking approach. The small constraints (n ≤ 6 items, quantities ≤ 10) make it feasible to explore all possible ways to fulfill the shopping needs. We recursively try each special offer if applicable, backtrack, and compare with buying items individually to find the minimum cost.

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

Intuition

When we look at this shopping problem, we need to find the cheapest way to buy exactly what we need. At each decision point, we have multiple choices: buy items individually at regular prices, or use one of the special offers. This creates a decision tree where we need to explore different combinations.

The key insight is that this is an optimization problem with choices at each step. We can think of it as being in a certain "state" - having a specific shopping list of items we still need to buy. From this state, we can:

  1. Buy all remaining items individually (base case)
  2. Try each special offer that doesn't exceed our needs, creating a new state with reduced needs

Since special offers can be used multiple times, we might revisit similar states through different paths. For example, using offer A twice might lead to the same remaining needs as using offer B once. This suggests we should remember (memoize) the minimum cost for each state to avoid recalculating.

The small constraints (n ≤ 6 items, quantities ≤ 10) make another optimization possible: state compression. Instead of using a list to represent our needs, we can encode the entire shopping state into a single integer. With 4 bits per item (supporting quantities 0-15), we only need 24 bits total for 6 items. This makes memoization more efficient - we can use the compressed state as a direct key.

The backtracking nature emerges from trying each valid special offer, recursively solving the subproblem with reduced needs, then "backtracking" to try other offers. We compare all these paths plus the option of buying everything individually to find the minimum cost. The recursion naturally handles the fact that offers can be used multiple times - when we recursively call with reduced needs, that subproblem might use the same offer again if it's still valid.

Learn more about Memoization, Dynamic Programming, Backtracking and Bitmask patterns.

Solution Approach

The solution uses state compression and memoization search (DFS with caching) to efficiently explore all possible ways to fulfill the shopping needs.

State Compression: Since we have at most 6 items and each item's quantity doesn't exceed 10, we can use 4 bits to represent each item's quantity (4 bits can represent 0-15). The entire shopping list is compressed into a single integer mask where:

  • Item 0's quantity is stored in bits 0-3
  • Item 1's quantity is stored in bits 4-7
  • Item i's quantity is stored in bits i * 4 to (i + 1) * 4 - 1

For example, if needs = [1, 2, 1], then mask = 0b0001 0010 0001 (in binary).

DFS Function: The core function dfs(cur) calculates the minimum cost to fulfill the shopping list represented by state cur. Here's how it works:

  1. Base cost calculation: First, calculate the cost of buying all remaining items individually:

    ans = sum(p * (cur >> (i * bits) & 0xF) for i, p in enumerate(price))

    This extracts each item's quantity from cur using bit operations and multiplies by its price.

  2. Try each special offer: For each special offer, check if it's valid (doesn't exceed current needs):

    for offer in special:
        nxt = cur
        for j in range(len(needs)):
            if (cur >> (j * bits) & 0xF) < offer[j]:
                break  # Offer exceeds needs, invalid
            nxt -= offer[j] << (j * bits)  # Subtract offer quantities
  3. Recursive calculation: If the offer is valid (the loop completes without breaking), recursively calculate the cost with the reduced needs:

    else:  # Python's for-else: executes if loop didn't break
        ans = min(ans, offer[-1] + dfs(nxt))

    The total cost is the offer's price plus the minimum cost for the remaining needs.

  4. Memoization: The @cache decorator automatically memoizes results, preventing redundant calculations for states we've already solved.

Initialization: The algorithm starts by converting the initial needs list into the compressed state mask:

bits, mask = 4, 0
for i, need in enumerate(needs):
    mask |= need << (i * bits)

Then calls dfs(mask) to find the minimum cost for the initial shopping list.

The beauty of this approach is that it naturally handles:

  • Using offers multiple times (through recursion)
  • Finding the optimal combination of offers and individual purchases
  • Avoiding redundant calculations through memoization
  • Efficient state representation through bit manipulation

Ready to land your dream job?

Unlock your dream job with a 3-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a small example to illustrate the solution approach.

Example:

  • price = [2, 5] (Item 0 costs 2,Item1costs2, Item 1 costs 5)
  • needs = [3, 2] (Need 3 of Item 0, 2 of Item 1)
  • special = [[1, 1, 3], [2, 0, 4]]
    • Offer 1: Buy 1 of Item 0 + 1 of Item 1 for $3
    • Offer 2: Buy 2 of Item 0 for $4

Step 1: State Compression Convert needs = [3, 2] into a bitmask:

  • Item 0 (quantity 3): stored in bits 0-3 as 0011
  • Item 1 (quantity 2): stored in bits 4-7 as 0010
  • Initial mask = 0010 0011 (binary) = 35 (decimal)

Step 2: DFS Exploration from dfs(35)

Starting with state 35 (needs [3, 2]):

  1. Calculate base cost (buying individually):

    • Extract quantities: Item 0 = 3, Item 1 = 2
    • Cost = 3 × 2+2×2 + 2 × 5 = $16
  2. Try Offer 1 [1, 1, 3]:

    • Check validity: Need 3 ≥ 1 (Item 0) ✓, Need 2 ≥ 1 (Item 1) ✓
    • New state after using offer:
      • Item 0: 3 - 1 = 2 (bits 0-3: 0010)
      • Item 1: 2 - 1 = 1 (bits 4-7: 0001)
      • New mask = 0001 0010 = 18
    • Cost = $3 + dfs(18)
  3. Try Offer 2 [2, 0, 4]:

    • Check validity: Need 3 ≥ 2 (Item 0) ✓, Need 2 ≥ 0 (Item 1) ✓
    • New state after using offer:
      • Item 0: 3 - 2 = 1 (bits 0-3: 0001)
      • Item 1: 2 - 0 = 2 (bits 4-7: 0010)
      • New mask = 0010 0001 = 33
    • Cost = $4 + dfs(33)

Step 3: Recursive Calls

For dfs(18) (needs [2, 1]):

  1. Base cost: 2 × 2+1×2 + 1 × 5 = $9
  2. Try Offer 1: Valid → New state = 1 (needs [1, 0]) → Cost = $3 + dfs(1)
  3. Try Offer 2: Valid → New state = 16 (needs [0, 1]) → Cost = $4 + dfs(16)
  4. Return minimum

For dfs(33) (needs [1, 2]):

  1. Base cost: 1 × 2+2×2 + 2 × 5 = $12
  2. Try Offer 1: Valid → New state = 16 (needs [0, 1]) → Cost = $3 + dfs(16)
  3. Try Offer 2: Invalid (need 1 < 2 for Item 0)
  4. Return minimum

Step 4: Base Cases

Eventually reaching simpler states:

  • dfs(1) (needs [1, 0]): Base cost = $2, no valid offers
  • dfs(16) (needs [0, 1]): Base cost = $5, no valid offers

Step 5: Backtracking and Result

Working backward with memoized results:

  • dfs(1) = $2
  • dfs(16) = $5
  • dfs(18) = min(9,9, 3 + 2,2, 4 + 5)=min(5) = min(9, 5,5, 9) = $5
  • dfs(33) = min(12,12, 3 + 5)=min(5) = min(12, 8)=8) = 8
  • dfs(35) = min(16,16, 3 + 5,5, 4 + 8)=min(8) = min(16, 8,8, 12) = $8

Final Answer: $8

The optimal strategy is to use Offer 1 twice:

  • First use: [1, 1, 3] → reduces needs from [3, 2] to [2, 1]
  • Second use: [1, 1, 3] → reduces needs from [2, 1] to [1, 0]
  • Buy remaining 1 of Item 0 individually for $2
  • Total: 3+3 + 3 + 2=2 = 8

Solution Implementation

1class Solution:
2    def shoppingOffers(
3        self, price: List[int], special: List[List[int]], needs: List[int]
4    ) -> int:
5        from functools import cache
6        from typing import List
7      
8        @cache
9        def calculate_min_cost(current_state: int) -> int:
10            # Calculate the cost of buying remaining items individually
11            individual_cost = 0
12            for item_idx, item_price in enumerate(price):
13                # Extract quantity needed for this item from the bit-packed state
14                quantity_needed = (current_state >> (item_idx * BITS_PER_ITEM)) & 0xF
15                individual_cost += item_price * quantity_needed
16          
17            min_cost = individual_cost
18          
19            # Try applying each special offer
20            for offer in special:
21                next_state = current_state
22                can_apply_offer = True
23              
24                # Check if we can apply this offer (enough items needed)
25                for item_idx in range(len(needs)):
26                    current_need = (current_state >> (item_idx * BITS_PER_ITEM)) & 0xF
27                  
28                    # If we need fewer items than the offer provides, skip this offer
29                    if current_need < offer[item_idx]:
30                        can_apply_offer = False
31                        break
32                  
33                    # Subtract the offer quantities from the next state
34                    next_state -= offer[item_idx] << (item_idx * BITS_PER_ITEM)
35              
36                # If offer is applicable, calculate cost with this offer
37                if can_apply_offer:
38                    offer_price = offer[-1]  # Last element is the offer's price
39                    min_cost = min(min_cost, offer_price + calculate_min_cost(next_state))
40          
41            return min_cost
42      
43        # Pack the needs into a single integer using 4 bits per item
44        BITS_PER_ITEM = 4
45        initial_state = 0
46        for item_idx, quantity_needed in enumerate(needs):
47            initial_state |= quantity_needed << (item_idx * BITS_PER_ITEM)
48      
49        return calculate_min_cost(initial_state)
50
1class Solution {
2    // Number of bits used to store each item's quantity in the bitmask
3    private final int BITS_PER_ITEM = 4;
4    // Number of different items
5    private int numItems;
6    // Individual prices for each item
7    private List<Integer> itemPrices;
8    // Special offers (each offer contains quantities for each item and the offer price at the end)
9    private List<List<Integer>> specialOffers;
10    // Memoization map: key is the bitmask representing current needs, value is minimum cost
11    private Map<Integer, Integer> memo = new HashMap<>();
12
13    /**
14     * Finds the lowest price to fulfill the shopping needs using special offers
15     * @param price List of prices for each item
16     * @param special List of special offers (each offer: [item1_qty, item2_qty, ..., offer_price])
17     * @param needs List of quantities needed for each item
18     * @return Minimum cost to buy all needed items
19     */
20    public int shoppingOffers(
21        List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
22        numItems = needs.size();
23        this.itemPrices = price;
24        this.specialOffers = special;
25      
26        // Encode the needs into a bitmask where each item's quantity occupies 4 bits
27        int needsMask = 0;
28        for (int i = 0; i < numItems; ++i) {
29            needsMask |= needs.get(i) << (i * BITS_PER_ITEM);
30        }
31      
32        return dfs(needsMask);
33    }
34
35    /**
36     * Recursive DFS with memoization to find minimum cost for current needs
37     * @param currentNeeds Bitmask representing current shopping needs
38     * @return Minimum cost to fulfill the current needs
39     */
40    private int dfs(int currentNeeds) {
41        // Check if we've already computed the result for this state
42        if (memo.containsKey(currentNeeds)) {
43            return memo.get(currentNeeds);
44        }
45      
46        // Calculate base cost: buy all remaining items at individual prices
47        int minCost = 0;
48        for (int i = 0; i < numItems; ++i) {
49            // Extract quantity for item i from the bitmask (use 0xf to get 4 bits)
50            int quantity = (currentNeeds >> (i * BITS_PER_ITEM)) & 0xf;
51            minCost += itemPrices.get(i) * quantity;
52        }
53      
54        // Try each special offer to see if it reduces the cost
55        for (List<Integer> offer : specialOffers) {
56            int remainingNeeds = currentNeeds;
57            boolean canApplyOffer = true;
58          
59            // Check if we can apply this offer (have enough of each item)
60            for (int j = 0; j < numItems; ++j) {
61                int currentQuantity = (currentNeeds >> (j * BITS_PER_ITEM)) & 0xf;
62                int offerQuantity = offer.get(j);
63              
64                if (currentQuantity < offerQuantity) {
65                    canApplyOffer = false;
66                    break;
67                }
68                // Subtract the offer quantities from remaining needs
69                remainingNeeds -= offerQuantity << (j * BITS_PER_ITEM);
70            }
71          
72            // If offer is applicable, calculate cost with this offer
73            if (canApplyOffer) {
74                int offerPrice = offer.get(numItems); // Last element is the offer price
75                minCost = Math.min(minCost, offerPrice + dfs(remainingNeeds));
76            }
77        }
78      
79        // Store the result in memo and return
80        memo.put(currentNeeds, minCost);
81        return minCost;
82    }
83}
84
1class Solution {
2public:
3    int shoppingOffers(vector<int>& price, vector<vector<int>>& special, vector<int>& needs) {
4        // Constants for bit manipulation
5        const int BITS_PER_ITEM = 4;  // Each item quantity uses 4 bits (max value 15)
6        int numItems = needs.size();
7      
8        // Memoization map: encoded state -> minimum cost
9        unordered_map<int, int> memo;
10      
11        // Encode initial needs into a bitmask
12        // Each item's quantity occupies 4 bits in the mask
13        int initialMask = 0;
14        for (int i = 0; i < numItems; ++i) {
15            initialMask |= needs[i] << (i * BITS_PER_ITEM);
16        }
17      
18        // Recursive function with memoization to find minimum cost
19        function<int(int)> findMinCost = [&](int currentState) {
20            // Check if this state has been computed before
21            if (memo.contains(currentState)) {
22                return memo[currentState];
23            }
24          
25            // Base case: buy all remaining items at regular price
26            int minCost = 0;
27            for (int i = 0; i < numItems; ++i) {
28                // Extract quantity of item i from current state
29                int quantity = (currentState >> (i * BITS_PER_ITEM)) & 0xf;
30                minCost += price[i] * quantity;
31            }
32          
33            // Try applying each special offer
34            for (const auto& offer : special) {
35                int nextState = currentState;
36                bool canApplyOffer = true;
37              
38                // Check if we have enough items to apply this offer
39                for (int j = 0; j < numItems; ++j) {
40                    int currentQuantity = (currentState >> (j * BITS_PER_ITEM)) & 0xf;
41                    int offerRequirement = offer[j];
42                  
43                    if (currentQuantity < offerRequirement) {
44                        canApplyOffer = false;
45                        break;
46                    }
47                  
48                    // Update next state by subtracting offer quantities
49                    nextState -= offerRequirement << (j * BITS_PER_ITEM);
50                }
51              
52                // If offer is applicable, calculate cost with this offer
53                if (canApplyOffer) {
54                    int offerPrice = offer[numItems];  // Last element is the offer price
55                    minCost = min(minCost, offerPrice + findMinCost(nextState));
56                }
57            }
58          
59            // Store result in memoization map
60            memo[currentState] = minCost;
61            return minCost;
62        };
63      
64        // Start recursion with initial needs
65        return findMinCost(initialMask);
66    }
67};
68
1/**
2 * Calculates the minimum cost to fulfill shopping needs using special offers
3 * @param price - Array of individual item prices
4 * @param special - Array of special offers, each offer contains quantities and final price
5 * @param needs - Array of required quantities for each item
6 * @returns Minimum cost to buy exactly the needed items
7 */
8function shoppingOffers(price: number[], special: number[][], needs: number[]): number {
9    // Number of bits used to encode each item's quantity in bitmask
10    const BITS_PER_ITEM = 4;
11    const itemCount = needs.length;
12  
13    // Memoization cache: maps encoded state to minimum cost
14    const memoCache: Map<number, number> = new Map();
15
16    // Encode initial needs into a bitmask
17    // Each item's quantity occupies 4 bits in the mask
18    let initialState = 0;
19    for (let i = 0; i < itemCount; i++) {
20        initialState |= needs[i] << (i * BITS_PER_ITEM);
21    }
22
23    /**
24     * Recursive function to find minimum cost for a given state
25     * @param currentState - Bitmask encoding current needs for each item
26     * @returns Minimum cost to fulfill the needs represented by currentState
27     */
28    const findMinimumCost = (currentState: number): number => {
29        // Check if result is already computed
30        if (memoCache.has(currentState)) {
31            return memoCache.get(currentState)!;
32        }
33      
34        // Base case: buy all remaining items at regular price
35        let minimumCost = 0;
36        for (let i = 0; i < itemCount; i++) {
37            // Extract quantity for item i from bitmask
38            const quantity = (currentState >> (i * BITS_PER_ITEM)) & 0xf;
39            minimumCost += price[i] * quantity;
40        }
41      
42        // Try each special offer
43        for (const offer of special) {
44            let nextState = currentState;
45            let canApplyOffer = true;
46          
47            // Check if current needs can accommodate this offer
48            for (let j = 0; j < itemCount; j++) {
49                const currentQuantity = (currentState >> (j * BITS_PER_ITEM)) & 0xf;
50                const offerQuantity = offer[j];
51              
52                // Cannot apply offer if it requires more than we need
53                if (currentQuantity < offerQuantity) {
54                    canApplyOffer = false;
55                    break;
56                }
57              
58                // Update next state by subtracting offer quantities
59                nextState -= offerQuantity << (j * BITS_PER_ITEM);
60            }
61          
62            // If offer is applicable, consider using it
63            if (canApplyOffer) {
64                const offerPrice = offer[itemCount]; // Last element is the offer's price
65                minimumCost = Math.min(minimumCost, offerPrice + findMinimumCost(nextState));
66            }
67        }
68      
69        // Cache and return the result
70        memoCache.set(currentState, minimumCost);
71        return minimumCost;
72    };
73
74    return findMinimumCost(initialState);
75}
76

Time and Space Complexity

Time Complexity: O(n × k × m^n)

The algorithm uses dynamic programming with memoization to explore different purchasing options. The state is encoded as an integer where each item's quantity occupies 4 bits. With n types of items and maximum demand m for each item, there are at most m^n possible states (combinations of quantities). For each state, the algorithm:

  • Calculates the direct purchase cost: O(n) operations
  • Iterates through k special offers, and for each offer, checks if it's applicable by examining all n items: O(k × n) operations

Therefore, the overall time complexity is O(m^n × (n + k × n)) = O(n × k × m^n).

Space Complexity: O(n × m^n)

The space is primarily consumed by:

  • The memoization cache which stores up to m^n states (all possible combinations of item quantities)
  • Each recursive call uses O(n) space for local variables and iteration
  • The recursion depth can be at most O(m^n) in the worst case

Since the cache dominates the space usage and each cached entry requires O(n) bits to represent the state, the total space complexity is O(n × m^n).

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

Common Pitfalls

1. Incorrect Bit Extraction and Manipulation

One of the most common mistakes is incorrectly extracting or updating item quantities in the bit-packed state. Developers often make errors with bit shift operations or masks.

Pitfall Example:

# Wrong: Using wrong bit mask or shift amount
quantity = current_state >> (item_idx * 4) & 0xFF  # Using 0xFF instead of 0xF
# or
quantity = current_state & (0xF << (item_idx * 4))  # Incorrect order of operations

Solution: Always use parentheses to ensure correct order of operations and use the right mask (0xF for 4 bits):

quantity = (current_state >> (item_idx * BITS_PER_ITEM)) & 0xF

2. Integer Overflow with Large State Values

When dealing with 6 items, the state requires 24 bits. If not careful with bit operations, especially subtraction, you might encounter unexpected behavior.

Pitfall Example:

# Potential issue if subtraction makes state negative
next_state = current_state
for j in range(len(needs)):
    next_state -= offer[j] << (j * BITS_PER_ITEM)  # Could go negative if not checked

Solution: Always validate that the offer can be applied before performing subtraction:

# Check feasibility first
for item_idx in range(len(needs)):
    if (current_state >> (item_idx * BITS_PER_ITEM)) & 0xF < offer[item_idx]:
        can_apply_offer = False
        break
# Only subtract if offer is valid
if can_apply_offer:
    for item_idx in range(len(needs)):
        next_state -= offer[item_idx] << (item_idx * BITS_PER_ITEM)

3. Forgetting to Filter Invalid Special Offers

Some special offers might be inherently invalid (e.g., offering more items than maximum needs). Not filtering these can lead to unnecessary computation.

Pitfall Example:

# Processing all offers without validation
for offer in special:
    # Directly trying to apply without checking if it's ever usable

Solution: Pre-filter offers that exceed any item's needs:

# Filter out offers that are never applicable
valid_specials = []
for offer in special:
    is_valid = all(offer[i] <= needs[i] for i in range(len(needs)))
    if is_valid:
        valid_specials.append(offer)

4. Not Handling Edge Cases

Missing edge cases like empty needs, no special offers, or zero-priced items can cause issues.

Pitfall Example:

# Not handling empty needs or special offers
if not needs:  # This check is missing
    return 0

Solution: Add proper edge case handling:

# Handle edge cases
if not needs or all(n == 0 for n in needs):
    return 0
if not special:
    return sum(p * n for p, n in zip(price, needs))

5. Inefficient State Representation

Using more bits than necessary or not considering the constraints can lead to memory issues.

Pitfall Example:

# Using 8 bits per item when 4 would suffice
BITS_PER_ITEM = 8  # Wasteful if max quantity is 10

Solution: Calculate the minimum bits needed based on constraints:

# Calculate bits needed based on max quantity
max_quantity = max(needs) if needs else 0
BITS_PER_ITEM = max_quantity.bit_length() if max_quantity > 0 else 1
# But for this problem, 4 bits is sufficient since max is 10
BITS_PER_ITEM = 4
Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

What's the relationship between a tree and a graph?


Recommended Readings

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

Load More