题目地址:
https://leetcode.com/problems/shopping-offers/
给定 n n n个物品的价格 A A A,再给定一个数组 B B B, B [ i ] B[i] B[i]表示某个“套餐”,它是一个长 n + 1 n+1 n+1的数组,其前 B [ i ] [ j ] B[i][j] B[i][j]表示下标为 j j j的物品有多少个, B [ i ] [ n ] B[i][n] B[i][n]表示这些物品的套餐的价格。再给定一个长 n n n数组 C C C代表需求,表示要求买的各个物品数量,问恰好买这么多物品最低花费是多少。注意,必须是恰好买这么多,不能多买也不能少买。物品也是可以单独买的,即不参加套餐。每个套餐买的个数不受限制。物品个数不超过 6 6 6个。
思路是记忆化搜索。我们可以按照最后一步选了什么套餐来分类(当然什么套餐都不选也是种方案),然后进行DFS。对于每个需求,我们可以开个哈希表做记忆化。这里需要解释一下java语言的一些特性,在java里,List的哈希方式,是将其哈希成开头加个 1 1 1后的 31 31 31进制整数,例如对于列表 [ 1 , 2 , 3 ] [1,2,3] [1,2,3],java会将其哈希成 31 31 31进制下的 1123 1123 1123,也就是哈希成 1 × 3 1 3 + 1 × 3 1 2 + 2 × 31 + 3 = 30817 1\times 31^3+1\times 31^2+2\times 31+3=30817 1×313+1×312+2×31+3=30817,而List里判断是否equals,是逐个比较列表里的值,如果值全相等就返回true。所以在记忆化的时候,可以直接把key设为是List类型的。但是,如果将key设为List类型的,要注意尽量不要修改其值,即使修改了,也要记得在操作哈希表之前还原回来。当然最好的选择是将List转为字符串后存入哈希表里,这样不用怕key被修改。代码如下:
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Solution {
public int shoppingOffers(List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
return dfs(needs, new HashMap<>(), price, special);
}
private int dfs(List<Integer> needs, Map<List<Integer>, Integer> map, List<Integer> price, List<List<Integer>> special) {
// 如果对于当前的needs之前已经算出过结果了,则直接返回之
if (map.containsKey(needs)) {
return map.get(needs);
}
int res = 0;
// 先考虑什么套餐都不用的情况,也就是每个商品都单独买
for (int i = 0; i < price.size(); i++) {
res += needs.get(i) * price.get(i);
}
// 接着考虑每个套餐
for (int i = 0; i < special.size(); i++) {
List<Integer> sp = special.get(i);
// 如果当前套餐可以买,也就是买了不会超出需求,那么就尝试之
if (fit(needs, sp)) {
// 将套餐的商品扣掉
for (int j = 0; j < needs.size(); j++) {
needs.set(j, needs.get(j) - sp.get(j));
}
res = Math.min(res, sp.get(price.size()) + dfs(needs, map, price, special));
// 尝试完了要恢复现场
for (int j = 0; j < needs.size(); j++) {
needs.set(j, needs.get(j) + sp.get(j));
}
}
}
// 算完了做一下记忆化
map.put(needs, res);
return res;
}
// 判断一下套餐sp是否超出needs
private boolean fit(List<Integer> needs, List<Integer> sp) {
for (int i = 0; i < needs.size(); i++) {
if (needs.get(i) < sp.get(i)) {
return false;
}
}
return true;
}
}
时间复杂度 O ( l B n ∏ C ) O(l_Bn\prod C) O(lBn∏C),但实际上并不是每个小于 C C C的需求都会被遍历到,所以实际时间是比较小的,空间 O ( n ∏ C ) O(n\prod C) O(n∏C)。
当然此题也可以用背包的递推公式来做记忆化搜索,并且使用状态压缩记录每个物品有多少个。由于最多有 6 6 6种物品,每个物品最多 6 6 6个,所以可以用个 6 × 3 6\times 3 6×3位二进制数来表示每个物品有多少个。代码如下:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Solution {
public int shoppingOffers(List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
int n = price.size();
// 开一个记忆化数组,dp[i][s]表示如果只考虑前i个套餐的话,要买到s这个状态,至少需要多少花费
int[][] dp = new int[special.size() + 1][1 << n * 3];
// 先初始化为-1
for (int[] row : dp) {
Arrays.fill(row, -1);
}
// 求一下needs代表的状态,这里target的最低3位表示的是下标是0的商品要买多少个,以此类推
int target = 0;
for (int i = needs.size() - 1; i >= 0; i--) {
target = (target << 3) + needs.get(i);
}
return dfs(special.size(), target, special, price, dp);
}
// 返回的是,如果只考虑前count个套餐的话,要达到state这个状态的最小花费(当然单买也是考虑的)
private int dfs(int count, int state, List<List<Integer>> special, List<Integer> price, int[][] dp) {
// 如果之前已经算出来过,则直接返回
if (dp[count][state] != -1) {
return dp[count][state];
}
int n = price.size();
// 如果一个套餐都不考虑,那就是全单买
if (count == 0) {
dp[count][state] = 0;
// 求一下每个商品买多少个,然后累加一下花费
for (int i = 0; i < n; i++) {
int c = state >> i * 3 & 7;
dp[count][state] += c * price.get(i);
}
return dp[count][state];
}
// 考虑不选第count个套餐的情况
dp[count][state] = dfs(count - 1, state, special, price, dp);
// 考虑选第count个套餐的情况
List<Integer> sp = special.get(count - 1);
// 存一下考虑完当前套餐后的需求状态
int nextState = 0;
// 逆序遍历是为了方便nextState的计算
for (int i = n - 1; i >= 0; i--) {
int c = state >> i * 3 & 7;
// 小了,说明当前套餐是不能选的,标记为-1并退出循环
if (c < sp.get(i)) {
nextState = -1;
break;
}
nextState = (nextState << 3) + c - sp.get(i);
}
// 如果当前套餐能选,再算一下选了当前套餐的情况下的最小花费
if (nextState != -1) {
dp[count][state] = Math.min(dp[count][state], sp.get(n) + dfs(count, nextState, special, price, dp));
}
return dp[count][state];
}
}
时空复杂度一样。