【前言】:本文讲解【力扣638 大礼包】的【动态规划】方法及其【记忆化搜索】的改进。
一、题目描述
在 LeetCode 商店中,有 n 件在售的物品。每件物品 i 都有对应的价格,也有一些大礼包以优惠的价格捆绑销售一组物品。
1. 输入
一个整数数组 price 表示物品价格,其中 price [ i ] 是第 i 件物品的价格。
一个整数数组 needs 表示购物清单,其中 needs [ i ] 是需要购买第 i 件物品的数量。
一个数组 special 表示大礼包,special [ i ]的长度为 n + 1,其中 special [ i ][ j ] 表示第 i 个大礼包中内含第 j 件物品的数量,且 special [ i ][ n ] (也就是数组中的最后一个整数)为第 i 个大礼包的价格。
2. 输出
返回满足购物清单所需花费的最低价格。
你可以充分利用大礼包的优惠活动,但不能超出购物清单指定的数量,即使那样会降低整体价格。任意大礼包可无限次购买。
3. 要求
1 <= n <= 6
price.length == needs.length == n
special[i].length == n + 1
1 <= special.length <= 100
0 <= price[i] , needs[i] <= 10
0 <= special[i][j] <= 50
4. 样例
输入: price = [2,5], special = [[3,0,5],[1,2,10]], needs = [3,2]
输出: 14
解释: 有 A 和 B 两种物品,价格分别为 ¥2 和 ¥5 。
大礼包 1 ,你可以以 ¥5 的价格购买 3A 和 0B 。
大礼包 2 ,你可以以 ¥10 的价格购买 1A 和 2B 。
需要购买 3 个 A 和 2 个 B , 所以付 ¥10 购买大礼包 2 、 ¥4 购买 2A 。
输入: price = [2,3,4], special = [[1,1,0,4],[2,2,1,9]], needs = [1,2,1]
输出: 11
解释: A ,B ,C 的价格分别为 ¥2 ,¥3 ,¥4 。
需要买 1A ,2B 和 1C ,付 ¥4 买 1A 和 1B(大礼包 1)、¥3 购买 1B 、¥4 购买 1C 。
不可以购买超出待购清单的物品,尽管购买大礼包 2 更加便宜。
二、动态规划
1. 思路分析
该问题是个典型的动态规划问题,现在我们用一个样例推演来说明它动态规划的潜质,如下图所示。
由图知,现有3个商品,价格分别为¥2、¥3、¥4,需求量分别是6、4、5。礼包A包含3个商品的数量是1、2、3,价格¥16;礼包B包含3个商品的数量是3、1、2,价格¥14;礼包C包含3个商品的数量是4、5、6,价格¥30。
现在我们用记号 f (6, 4, 5) 表示该样例的解,也即购买6、4、5个商品所花费的最小金额。显然,单独购买这3种商品所花费的钱6 * 2 + 4 * 3 + 5 * 4 = 44是 f (6, 4, 5) 的一个可能解。尽管单独购买它们明显不理智,但我们不能排除它——因为当不能使用礼包时,只能这样单独购买,所以这种可能性必须要被考虑。
接下来考虑购买礼包的情况,3种商品的需求6、4、5都大于礼包A中包含的商品量1、2、3,所以礼包A是可以购买至少一次的。尽管我们暂不知道后续的购买情况,但我们已经可以确定在已经购买一次礼包A的情况下, f (5, 2, 2) + 16也是 f (6, 4, 5) 的一个可能解。其中 f (5, 2, 2) 是购买一次礼包A后剩余的需求量,16则是购买礼包A花费的金额。
同理,礼包B也可以至少购买一次,那么 f (3, 3, 3) + 14也是 f (6, 4, 5) 的一个可能解。而礼包C由于第二个商品的数量超过总的需求量而无法购买。由此我们就得到了 f (6, 4, 5) 仅有的3个可能解,只需要选取它们的最小值即可。
而对于 f (5, 2, 2) 和 f (3, 3, 3) 的具体值,仍然可以向上面一样分解所有可能性并选取最小值。这样我们就弄清楚了该问题的动态规划潜质,只需要完成函数模拟上述过程,直到回溯答案即可。
2. 编码实现
由于价格数组price、需求量数组needs和大礼包数组special都是算法过程中频繁访问的,所以我们直接在类里定义3个指针指向它们以便使用,另外定义一个int n存储商品数量,由此我们得到类的大框架如下。
class Solution {
private int n;
private List<Integer> price;
private List<List<Integer>> special;
private List<Integer> need;
public int shoppingOffers(List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
// 初始化全局变量
this.n = needs.size();
this.price = price;
this.special = special;
this.need = needs;
return lowPrice();
}
}
另外根据题干要求,只能按照需求量进行购买、而不能多买,所以需要完成一个辅助方法boolean canBuy ( List<Integer> specialList )来判断在当前need下,传入的礼包specialList能否购买。
// 判断在当前need下,大礼包specialList能否购买
private boolean canBuy(List<Integer> specialList) {
for (int i = 0; i < n; i++)
if (specialList.get(i) > need.get(i))
return false;
return true;
}
完成这两步铺垫就可以写计算函数int lowPrice()了。根据前面的分析讲解,首先要计算出直接购买这些商品所需的金额,然后再对每个大礼包进行判断,能购买则购买一次,并递归地计算出对应金额,算完所有的情况后,return所有可能的最小值就是该问题的解。这里要注意,每次购买礼包后、计算金额前,要将对应的需求量减去,而在计算完后再恢复原本的需求量,以免对后续计算造成影响。
// 计算直接购买和使用大礼包购买的所有情况的最小价格
private int lowPrice() {
// 1. 首先计算不用大礼包,直接购买需要花的钱
int minPrice = 0;
for (int i = 0; i < n; i++)
minPrice += need.get(i) * price.get(i);
// 2. 接着对每份大礼包进行判断,如果可以买,就买下来算一次
for (List<Integer> specialList : special)
if (canBuy(specialList)) {
for (int i = 0; i < n; i++) // 购买礼包,修改需求量
need.set(i, need.get(i) - specialList.get(i));
minPrice = Math.min(minPrice, lowPrice() + specialList.get(n)); // 取所有情况的最小值
for (int i = 0; i < n; i++) // 算完以后,恢复需求量
need.set(i, need.get(i) + specialList.get(i));
}
return minPrice;
}
将以上代码组合起来即可得到完整代码如下,该代码可直接通过该题,但是耗时较多。
class Solution {
private int n;
private List<Integer> price;
private List<List<Integer>> special;
private List<Integer> need;
public int shoppingOffers(List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
// 初始化全局变量
this.n = needs.size();
this.price = price;
this.special = special;
this.need = needs;
return lowPrice();
}
// 计算直接购买和使用大礼包购买的所有情况的最小价格
private int lowPrice() {
// 1. 首先计算不用大礼包,直接购买需要花的钱
int minPrice = 0;
for (int i = 0; i < n; i++)
minPrice += need.get(i) * price.get(i);
// 2. 接着对每份大礼包进行判断,如果可以买,就买下来算一次
for (List<Integer> specialList : special)
if (canBuy(specialList)) {
for (int i = 0; i < n; i++) // 购买礼包,修改需求量
need.set(i, need.get(i) - specialList.get(i));
minPrice = Math.min(minPrice, lowPrice() + specialList.get(n)); // 取所有情况的最小值
for (int i = 0; i < n; i++) // 算完以后,恢复需求量
need.set(i, need.get(i) + specialList.get(i));
}
return minPrice;
}
// 判断在当前need下,大礼包specialList能否购买
private boolean canBuy(List<Integer> specialList) {
for (int i = 0; i < n; i++)
if (specialList.get(i) > need.get(i))
return false;
return true;
}
}
三、改进版 - 记忆化搜索
1. 缺陷分析
上述动态规划过程耗时较多的根本原因在于——
各种情况的计算必然有个先后顺序,但事实上该问题的解与顺序无关!
各种情况的计算必然有个先后顺序,但事实上该问题的解与顺序无关!
各种情况的计算必然有个先后顺序,但事实上该问题的解与顺序无关!
在动态规划的思路分析中,购买礼包A后得到 f (5, 2, 2) + 16,计算 f (5, 2, 2) 时我们会发现还能购买一次礼包B,这样上述式子就变成了 f (2, 1, 0) + 30。于是我们算出 f (2, 1, 0) =7后知道,先购买一次礼包A、再购买一次礼包B、最后单独购买商品,一共花费¥37实现了6、4、5的购买任务。
而在购买礼包B得到 f (3, 3, 3) + 14后,计算 f (3, 3, 3) 时我们会继续购买一次礼包A得到 f (2, 1, 0) + 30,再根据 f (2, 1, 0) =7知道先购买一次礼包B、再购买一次礼包A、最后单独购买商品花费¥37完成任务。
也就是说,先购买A还是先购买B都一样,其实都是在计算 f (2, 1, 0) + 30。购买的路径(或者说顺序)对这个问题的解并没有影响,而先A后B和先B后A这2种等价的情况我们却做了多余的计算。
2. 优化解决
为了优化程序,我们引入【记忆化搜索】的思想。就像人拥有记忆一样,我们在先A后B的路径下已经算出了 f (2, 1, 0) =7, f (5, 2, 2) =21,那么在后续的路径中,一旦要用到 f (2, 1, 0) 和 f (5, 2, 2) 就能直接拿到结果而不用重复计算。
我们使用HashMap来记忆这些计算结果,HashMap可以存储 键key 到 值value 的映射关系,每算出一种需求量情形下的最优结果,我们就把该需求量及其结果存入HashMap以供下次使用。由于需求量被存放在数组 List<Integer> need 中不能直接作为 键值key 使用,所以我们不妨把 need 转成 String 来描述,这样就确定了在类中引入一个全局变量map,存放String 到 Integer 的映射。
private HashMap<String, Integer> map = new HashMap<>();
有了映射关系,有了记忆的载体map,就要把记忆化体现在代码中。这里有个小技巧分享给大家,其实想引入记忆化并不需要对我们已经写好的动态规划函数 int lowPrice() 大兴土木,那样容易把自己绕晕出错,更好的解决办法是对 lowPrice() 再进行一次封装。
写一个新的函数 int memoLowPrice() ,在这个函数中,先去 map 里查询当前的 need 是否有已经求出的最优解,如果有就直接返回;如果没有再去调用 lowPrice() 计算最优解,并把当前 need 及其最优解的映射关系存入 map ,最后再返回最优解。这样的话,无记忆化的 int lowPrice() 和有记忆化的 int memoLowPrice() 在输入输出上是完全等价的,但却巧妙地插入了查询 map 和添加 map 的过程。
// 对lowPrice的封装,先查询map,如果已有结果直接返回,否则调用lowPrice计算后存入map再返回
private int memoLowPrice() {
String keyStr = String.valueOf(need);
if (map.containsKey(keyStr))
return map.get(keyStr);
int temp = lowPrice();
map.put(keyStr, temp);
return temp;
}
完成新函数 memoLowPrice() 后,把代码中凡是调用到 lowPrice() 的地方都替换成 memoLowPrice() 就大功告成。快把新代码交一发试试吧!记忆化后的完整代码如下,可直接通过该题。
class Solution {
private HashMap<String, Integer> map = new HashMap<>();
private int n;
private List<Integer> price;
private List<List<Integer>> special;
private List<Integer> need;
public int shoppingOffers(List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
// 初始化全局变量
this.n = needs.size();
this.price = price;
this.special = special;
this.need = needs;
return lowPrice();
}
// 计算直接购买和使用大礼包购买的所有情况的最小价格
private int lowPrice() {
// 1. 首先计算不用大礼包,直接购买需要花的钱
int minPrice = 0;
for (int i = 0; i < n; i++)
minPrice += need.get(i) * price.get(i);
// 2. 接着对每份大礼包进行判断,如果可以买,就买下来算一次
for (List<Integer> specialList : special)
if (canBuy(specialList)) {
for (int i = 0; i < n; i++) // 购买礼包,修改需求量
need.set(i, need.get(i) - specialList.get(i));
minPrice = Math.min(minPrice, memoLowPrice() + specialList.get(n)); // 取所有情况的最小值
for (int i = 0; i < n; i++) // 算完以后,恢复需求量
need.set(i, need.get(i) + specialList.get(i));
}
return minPrice;
}
// 对lowPrice的封装,先查询map,如果已有结果直接返回,否则调用lowPrice计算后存入map再返回
private int memoLowPrice() {
String keyStr = String.valueOf(need);
if (map.containsKey(keyStr))
return map.get(keyStr);
int temp = lowPrice();
map.put(keyStr, temp);
return temp;
}
// 判断在当前need下,大礼包specialList能否购买
private boolean canBuy(List<Integer> specialList) {
for (int i = 0; i < n; i++)
if (specialList.get(i) > need.get(i))
return false;
return true;
}
}