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 thei-th
itemneeds[i]
: the exact quantity you need to buy of thei-th
itemspecial
: a list of special offers, where each offerspecial[i]
contains:special[i][0]
tospecial[i][n-1]
: the quantity of each item included in this bundlespecial[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.
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:
- Buy all remaining items individually (base case)
- 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:
-
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. -
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
-
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.
-
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 EvaluatorExample Walkthrough
Let's walk through a small example to illustrate the solution approach.
Example:
price = [2, 5]
(Item 0 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]):
-
Calculate base cost (buying individually):
- Extract quantities: Item 0 = 3, Item 1 = 2
- Cost = 3 × 5 = $16
-
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
- Item 0: 3 - 1 = 2 (bits 0-3:
- Cost = $3 + dfs(18)
-
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
- Item 0: 3 - 2 = 1 (bits 0-3:
- Cost = $4 + dfs(33)
Step 3: Recursive Calls
For dfs(18) (needs [2, 1]):
- Base cost: 2 × 5 = $9
- Try Offer 1: Valid → New state = 1 (needs [1, 0]) → Cost = $3 + dfs(1)
- Try Offer 2: Valid → New state = 16 (needs [0, 1]) → Cost = $4 + dfs(16)
- Return minimum
For dfs(33) (needs [1, 2]):
- Base cost: 1 × 5 = $12
- Try Offer 1: Valid → New state = 16 (needs [0, 1]) → Cost = $3 + dfs(16)
- Try Offer 2: Invalid (need 1 < 2 for Item 0)
- 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(3 + 4 + 9, 9) = $5
- dfs(33) = min(3 + 12, 8
- dfs(35) = min(3 + 4 + 16, 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 + 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 alln
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
What's the relationship between a tree and a graph?
Recommended Readings
Memoization Prereq Backtracking problems backtracking Memoization is a fancy word for a simple concept so is the case for a lot of things we learn in school It means saving the previous function call result in a dictionary and reading from it when we do the exact same call again
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Backtracking Template Prereq DFS with States problems dfs_with_states Combinatorial search problems Combinatorial search problems involve finding groupings and assignments of objects that satisfy certain conditions Finding all permutations combinations subsets and solving Sudoku are classic combinatorial problems The time complexity of combinatorial problems often grows rapidly with the size of
Want a Structured Path to Master System Design Too? Don’t Miss This!