The Shopping Offers problem asks you to help a shopper buy a list of items at the lowest possible price. You are given three inputs:
price
: An array where each element represents the price of a single unit of an item.special
: A list of special offers. Each offer is an array where the first N elements indicate how many units of each item are included, and the last element is the total price for the offer.needs
: An array representing how many units of each item the shopper wants to buy.needs
using any combination of single purchases and special offers. Each offer can be used multiple times as long as it doesn't exceed the needs
. The solution must ensure that the offers are applied in valid combinations and that no more items than needed are purchased.
At first glance, the problem seems to invite a brute-force approach: try every combination of offers and single purchases, and pick the cheapest. However, this quickly becomes infeasible as the number of items and offers increases, due to the exponential number of combinations.
The key insight is to recognize that the problem can be broken down recursively: for a given set of needs
, try every offer that can be applied, recursively solve the subproblem for the remaining needs, and take the minimum. Since many subproblems will repeat (e.g., the same needs
state reached via different offer paths), memoization (caching) can help avoid redundant computations.
This approach shifts from brute-force enumeration to a more efficient recursive search with memoization, similar to dynamic programming.
We use a recursive Depth-First Search (DFS) with memoization to solve the problem efficiently. Here are the steps:
needs
as its parameter and returns the minimum cost to satisfy those needs.We use a hash map (dictionary) for memoization, because lookups and inserts are O(1), which greatly improves performance.
Suppose:
price = [2, 5]
(item 0 costs $2, item 1 costs $5)special = [[3,0,5],[1,2,10]]
(offer 1: 3 of item 0 for $5; offer 2: 1 of item 0 and 2 of item 1 for $10)needs = [3,2]
(need 3 of item 0 and 2 of item 1)The recursive function explores all valid combinations, and memoization ensures that repeated states like [2,0] or [0,2] are not recomputed.
Brute-force: The number of combinations is exponential in the number of items and the maximum need per item. If n
is the number of item types and k
is the maximum quantity needed for any item, the state space is O(k^n).
Optimized (DFS + Memoization): Each unique needs
tuple is computed at most once. Thus, the time and space complexity are both O(k^n), where k
is the maximum need and n
is the number of items. This is feasible for small n
(typically ≤ 6).
The Shopping Offers problem is a classic example of recursive search with memoization. By breaking the problem into subproblems and caching results, we avoid redundant work and efficiently find the minimum cost. The elegance lies in recognizing the subproblem structure and using a hash map to remember solutions to previously seen states.
from typing import List
class Solution:
def shoppingOffers(self, price: List[int], special: List[List[int]], needs: List[int]) -> int:
memo = {}
def dfs(current_needs):
needs_tuple = tuple(current_needs)
if needs_tuple in memo:
return memo[needs_tuple]
# buy all items at regular price
min_cost = sum(p * n for p, n in zip(price, current_needs))
for offer in special:
new_needs = []
for i in range(len(price)):
if offer[i] > current_needs[i]:
break
new_needs.append(current_needs[i] - offer[i])
else:
# offer can be used
min_cost = min(min_cost, offer[-1] + dfs(new_needs))
memo[needs_tuple] = min_cost
return min_cost
return dfs(needs)
class Solution {
public:
int shoppingOffers(vector<int>& price, vector<vector<int>>& special, vector<int>& needs) {
unordered_map<string, int> memo;
return dfs(price, special, needs, memo);
}
int dfs(vector<int>& price, vector<vector<int>>& special, vector<int> needs, unordered_map<string, int>& memo) {
string key = serialize(needs);
if (memo.count(key)) return memo[key];
int min_cost = 0;
for (int i = 0; i < needs.size(); ++i)
min_cost += needs[i] * price[i];
for (auto& offer : special) {
vector<int> new_needs = needs;
int i = 0;
for (; i < needs.size(); ++i) {
if (offer[i] > new_needs[i])
break;
new_needs[i] -= offer[i];
}
if (i == needs.size()) {
min_cost = min(min_cost, offer.back() + dfs(price, special, new_needs, memo));
}
}
memo[key] = min_cost;
return min_cost;
}
string serialize(vector<int>& needs) {
string res;
for (int n : needs) res += to_string(n) + ",";
return res;
}
};
import java.util.*;
class Solution {
Map<List<Integer>, Integer> memo = new HashMap<>();
public int shoppingOffers(List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
if (memo.containsKey(needs)) return memo.get(needs);
int minCost = 0;
for (int i = 0; i < price.size(); ++i)
minCost += price.get(i) * needs.get(i);
for (List<Integer> offer : special) {
List<Integer> newNeeds = new ArrayList<>();
int i = 0;
for (; i < price.size(); ++i) {
if (offer.get(i) > needs.get(i))
break;
newNeeds.add(needs.get(i) - offer.get(i));
}
if (i == price.size()) {
minCost = Math.min(minCost, offer.get(offer.size() - 1) + shoppingOffers(price, special, newNeeds));
}
}
memo.put(needs, minCost);
return minCost;
}
}
/**
* @param {number[]} price
* @param {number[][]} special
* @param {number[]} needs
* @return {number}
*/
var shoppingOffers = function(price, special, needs) {
const memo = new Map();
function dfs(currentNeeds) {
const key = currentNeeds.join(',');
if (memo.has(key)) return memo.get(key);
let minCost = 0;
for (let i = 0; i < price.length; ++i) {
minCost += price[i] * currentNeeds[i];
}
for (const offer of special) {
let valid = true;
const newNeeds = [];
for (let i = 0; i < price.length; ++i) {
if (offer[i] > currentNeeds[i]) {
valid = false;
break;
}
newNeeds.push(currentNeeds[i] - offer[i]);
}
if (valid) {
minCost = Math.min(minCost, offer[offer.length - 1] + dfs(newNeeds));
}
}
memo.set(key, minCost);
return minCost;
}
return dfs(needs);
};