0. 写在前面
“华为机试HJ16:购物单” 是一道 “物品间有依赖关系” 的【01背包问题】,属于经典dp问题的变形。
对于基础薄弱的同学来说,本题的思维难度不低,建议先了解“普通01背包问题”的基本求解思路——bilibili辅助学习视频(预计学习时间15min)
1. 题目描述
王强决定把年终奖用于购物,他把想买的物品分为两类:主件与附件,附件是从属于某个主件的,下表就是一些主件与附件的例子:
主件 | 附件 |
---|---|
电脑 | 打印机,扫描仪 |
书柜 | 图书 |
书桌 | 台灯,文具 |
工作椅 | 无 |
如果要买归类为附件的物品,必须先买该附件所属的主件。每个主件可以有 0个、 1个 或 2个 附件。附件不再有从属于自己的附件。王强想买的东西很多,为了不超出预算,他把每件物品规定了一个重要度,分为 5 等:用整数 1 ~ 5 表示,第 5 等最重要。他还从因特网上查到了每件物品的价格(都是 10 元的整数倍)。他希望在不超过 N 元(可以等于 N 元)的前提下,使每件物品的价格与重要度的乘积的总和最大。
设第 j 件物品的价格为 v[j] ,重要度为 w[j] ,共选中了 k 件物品,编号依次为 j1, j2,……, jk ,则所求的总和为:
v[ j1 ]*w[ j1 ] + v[ j2 ]*w[ j2 ] + … + v[ jk ]*w[ jk ] 。(其中 * 为乘号)
请你帮助王强设计一个满足要求的购物单。
输入描述:
输入的第 1 行,为两个正整数,用一个空格隔开:N m
(其中 N ( <32000 )表示总钱数, m ( <60 )为希望购买物品的个数。)
从第 2 行到第 m+1 行,第 j 行给出了编号为 j-1 的物品的基本数据,每行有 3 个非负整数 v p q(其中 v 表示该物品的价格( v<10000 ), p 表示该物品的重要度( 1 ~ 5 ), q 表示该物品是主件还是附件。如果 q=0 ,表示该物品为主件,如果 q>0 ,表示该物品为附件, q 是所属主件的编号)
输出描述:
输出文件只有一个正整数,为不超过总钱数的物品的价格与重要度乘积的总和的最大值( <200000 )。
示例1
输入:
1000 5
800 2 0
400 5 1
300 5 1
400 3 0
500 2 0
输出:
2200
示例2
输入:
50 5
20 3 5
20 3 5
10 3 0
10 2 0
10 1 0
输出:
130
说明:
由第1行可知总钱数N为50以及希望购买的物品个数m为5;
第2和第3行的q为5,说明它们都是编号为5的物品的附件;
第4~6行的q都为0,说明它们都是主件,它们的编号依次为3~5;
所以物品的价格与重要度乘积的总和的最大值为10*1+20*3+20*3=130
2. 思路梳理 与 题目分析
对于复杂的动态规划(dp)问题,如果我们的基础不够牢固,对于类型题的见识少,经验匮乏,那么我们首先至少应该有一个朴素的暴力递归思维,把这道动态规划题目暴力破解。这大概率会TLE,但是没关系(这是推导dp解法的第一步,也是在竞赛或笔试时破釜沉舟、暴力取分的有效手段)。
这里先简单看一下本题的DFS暴力搜索解法(物品数量达到30时TLE,测试用例通过9/12):
import java.io.*;
public class Main {
public static int max;
public static int amount;
public static int[] gPrice;
public static int[] gHappy;
public static int[] gDepend;
public static int[] gUsed;
public static int minPrice;
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String input = null;
while ((input = br.readLine()) != null) {
String[] s = input.split(" ");
max = 0; // 初始化max
minPrice = 10000;
int money = Integer.parseInt(s[0]); // 拥有金额
amount = Integer.parseInt(s[1]); // 商品总数
gPrice = new int[amount]; // 商品价格
gHappy = new int[amount]; // 商品满意度
gDepend = new int[amount]; // 商品依赖关系
// 递归时,静态gUsed数组需要恢复现场!
gUsed = new int[amount]; // 商品当前状态(0未考虑,1已购买,2不选择购买)
for (int i = 0; i < amount; i++) { // 输入各个参数
String[] param = br.readLine().split(" ");
gPrice[i] = Integer.parseInt(param[0]);
if (gPrice[i] < minPrice) minPrice = gPrice[i];
gHappy[i] = Integer.parseInt(param[1]);
gDepend[i] = Integer.parseInt(param[2]);
}
search(0, money);
System.out.println(max);
}
}
public static void search(int current, int money) { // params:当前满意度,当前余额
if (money < minPrice) return; // 余额已不足以购买任何商品
for (int i = 0; i < amount; i++) {
// 有资格被考虑(已激活&&没被考虑过)
if ((gDepend[i] == 0 || gUsed[gDepend[i] - 1] == 1) && gUsed[i] == 0) {
if (money - gPrice[i] >= 0) { // 还有足够的钱买商品i
gUsed[i] = 1; // 修改商品i状态
int tempCurrent = current + gPrice[i] * gHappy[i];
if (max < tempCurrent) max = tempCurrent; // 更新最大满意度
search(tempCurrent, money - gPrice[i]);
gUsed[i] = 0; // 恢复现场
}
// 不选择购买商品i(没钱||不想买)
gUsed[i] = 2; // 修改商品i状态
search(current, money);
gUsed[i] = 0; // 恢复现场
break; // 此轮递归已考虑过一个商品,可以退出
}
}
}
}
接下来进行第二步:观察在递归过程中,哪些分支是冗余的。在暴力递归解法中,每一件物品都有“取”或“不取”两种情况,所以总共产生了 2n 种可能的结果。显而易见的是,诸如:“全部不取”、“只取一个物品”、“全部都取”…这样的方案都不可能是最优解,但是依旧耗费了大量的运行时间。
进入第三步,动态规划的第一个核心设计点,这也是最关键、最困难的一步:结合题目要求与暴力递归的经验,划分子问题,设计dp数组,确定数组元素含义。(碎碎念:在学习过程中,这一步常常令人沮丧。学习了大佬的思路和代码之后,觉得自己智商被碾压,自己一辈子不可能写得出那么巧妙且精美的代码;But Rome was not built in a day,任何强者都经历过这个痛苦的过程,慢慢积累,终将成功,共勉!)
我们不妨先回忆:
- “最长公共子串” 与 “通配符” 都是【匹配】问题,所以dp[ i ][ j ]的定义:“str1的1 ~ i个字符” 能否与 “str2的1 ~ j个字符”匹配;
- “走方格方案数”是【固定解】问题,所以dp[ i ][ j ]的定义:从坐标 (i , j) 到 坐标 (n , m) 拥有多少个确定的行走方案;
而“背包问题”是一个【最优解】问题,所以我们可以先猜测:dp[ i ][ j ]应该代表着“某一个状态下的最优解”;这与我们暴力递归所总结的经验教训相符合:着眼最优解即可,剔除不必要的搜索分支。
(1)划分子问题:自顶向下分析——题目要求:用N元钱,在M个物品中挑选最大满意度;而我们逐渐将他们分解:{N-1元钱,M个物品}、{N-2元钱,M个物品}…{N元钱,M-1个物品}、{N-1元钱,M-1个物品}…{1元钱,1个物品}。
(2)设计dp数组:根据 “子问题” 以及 dp的“状态转移、状态累积”思想,自底向上转移,我们确定了在“01背包问题”中dp[ i ][ j ]的定义:用 “i元钱”,在 “前j个物品” 中能够购买出的最大满意度!
事成一半,接下来是动态规划的第二个核心设计点,也是设计思路的第四步:双重for遍历dp数组,确定状态转移方程。外层for通常设置为“物品遍历”,内层for设置为“金钱遍历”,最终达成的效果:{1元钱 买 前1个物品}、{2元钱 买 前1个物品}…{n元钱 买 前1个物品}、{1元钱 买 前2个物品}、{2元钱 买 前2个物品}…{n元钱 买 前m个物品}。
在设计状态转移方程之前,试想一点:假设目前遍历到{10元钱 买 前3个物品},那么意味着“用1~n元钱去购买前2个物品”的最佳方案全部已知;那么当10元钱确定能够购买第3个物品的时候,问题来临——购买第3个物品大概率会导致原本购买前2个物品的金钱不足,那么此时的最大满意度是否会更高呢?购买第3个物品,是正向作用,还是负面影响呢?
以上的思考表明了:dp[ i ][ j ]的状态必定来源于两个方向,我们看伪代码:
dp[i][j] = Math.max(dp[i][j - 1], dp[i - 第j个物品的价格v][j - 1] + 第j个物品的满意度);
是否清晰?
- 前者:不购买第j个物品(钱不够 或 有意不买),则dp[ i ][ j ] 继承 {i元钱 买 前j - 1个物品} 的最大满意度dp[ i ][j - 1];
- 后者:确定购买第j个物品后,在dp中寻找{i-v元钱 买 前j-1个物品}的满意度dp[i - v][j - 1],然后加上“第j个物品的满意度”即可;
最后比较两者大小。至此,“普通01背包问题”大功告成!
然而我们最开始提过:本题是一个【物品之间有依赖关系】的01背包问题——如何处理主件与附件的关系又成为了一大问题。
这里先给出一句话(后续上代码深入理解),本题最后一步,也是部分“依赖问题”的处理思路:看到附件就跳过,不单独考虑附件,附件永远作为主件的附属品加以考虑!最精妙的一步(可以回看我在“题目描述”中标红的,关于附件数量描述的部分),在设计“物品”的数据结构时,我们需要对主件所拥有的【附件1、附件2】进行存储;在设计状态转移方程的时候,也需要考虑更多的情况。
3. 代码实现
带着思路梳理、题目解析,以及对“附件”的处理思路,跟随注释深入理解本题,加油!
【牛客OJ:AC,16ms】
import java.io.*;
public class Main {
public static int dw; // 加快运行(物品价格都是十的倍数)
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String input = null;
while ((input = br.readLine()) != null) {
dw = 100; // 先将金钱缩小倍数设定为100
boolean flag = true; // 代表当前“100倍”的缩小比例是否可用
String[] strArr = input.split(" ");
int N = Integer.parseInt(strArr[0]); // 金钱总数
int m = Integer.parseInt(strArr[1]); // 物品总数
Good[] A = new Good[m + 1]; // 定义物品数组(当前单个的A[i]只是索引,并未创建具体对象)
for (int i = 1; i <= m; i++) { // 依次输入m个物品的基本信息(全部数组将0置空)
strArr = br.readLine().split(" ");
int v = Integer.parseInt(strArr[0]); // 售价(10的倍数)
int p = Integer.parseInt(strArr[1]); // 重要度
int q = Integer.parseInt(strArr[2]); // 依赖(0~m)
if (flag) { // 截至上一轮,“100倍”的缩小比例仍然可用
if (v % dw != 0) { // 判断本轮缩小比例是否满足
flag = false; // 100倍弃用
dw = 10; // 缩小比例改用10倍
for (int j = 1; j < i; j++) // i之前的物品全部进行修改
A[j].setV(A[j].v * 10);
}
}
v = v / dw; // 物品i的价格按比例缩小
// 【说明】当“附件编号<主件编号”时,主件对象会提前伴随附件一同创建
if (A[i] != null) { // “说明”中的情况出现,当前物品i已提前创建,并且已被添加附件
A[i].setV(v); // 当前轮次仅需设置物品i的其他属性即可
A[i].setP(p);
A[i].setQ(q);
} else { // 使用带参构造创建物品i对象
A[i] = new Good(v, p, q);
}
if (q > 0) { // 如果当前物品i是附件(把编号数字i存入编号为q的主件当中)
if (A[q] == null) { // 呼应上述“说明”中的情况
A[q] = new Good(); // 提前创建主件的对象
}
if (A[q].a1 == 0) { // 先考虑存a1
A[q].setA1(i);
} else { // 再考虑存a2
A[q].setA2(i);
}
}
}
N = N / dw; // 购买者拥有金钱总数亦等比例缩小
dp(N, A); // 数据准备工作完成,开始dp处理
}
}
public static void dp(int N, Good[] A) { // 【背包问题核心】 1)dp数组的定义 2)状态转移的思路
/**
* dp数组的定义:(假定目前dp[持有金钱i][前j个物品])
* ⭐dp[i][j]:在【持有金钱i】的情况下,当考虑了【前j个物品】过后,所得出的最佳满意度!
* 通常在背包问题中:
* 我们会选择“固定前j个物品”,然后“递增金钱数i”,直到i==总钱数;然后j++,进行下一轮。
* */
int[][] dp = new int[N + 1][A.length]; // 行列的意义可以调转,只需注意遍历顺序!
for (int i = 1; i < A.length; i++) { // 锁定前i个物品(前i-1个物品的最大满意度已存在dp表)
// 价格
int v = A[i].v; // 物品i
int v1 = -1; // 物品i + 附件1
int v2 = -1; // 物品i + 附件2
int v3 = -1; // 物品i + 附件1 + 附件2
// 满意度
int tempdp = v * A[i].p; // 物品i
int tempdp1 = -1; // 物品i + 附件1
int tempdp2 = -1; // 物品i + 附件2
int tempdp3 = -1; // 物品i + 附件1 + 附件2
// 分情况讨论:物品i与其附件的购买方法,为上述变量赋值
// 方法0:只购买物品i,直接用v代表价格、tempdp代表满意度。
if (A[i].a1 != 0) { // 方法1:购买“物品i + 附件1”
v1 = v + A[A[i].a1].v;
tempdp1 = tempdp + A[A[i].a1].v * A[A[i].a1].p;
}
if (A[i].a2 != 0) { // 方法2:购买“物品i + 附件2”
v2 = v + A[A[i].a2].v;
tempdp2 = tempdp + A[A[i].a2].v * A[A[i].a2].p;
}
if (A[i].a1 != 0 && A[i].a2 != 0) { // 方法3:购买“物品i + 附件1 + 附件2”
v3 = v + A[A[i].a1].v + A[A[i].a2].v;
tempdp3 = tempdp + A[A[i].a1].v * A[A[i].a1].p + A[A[i].a2].v * A[A[i].a2].p;
}
for (int j = 1; j <= N; j++) { // 递增金钱数量(此步骤体现了变量dw的作用:10或100倍减少循环次数)
if (A[i].q > 0) { // 若物品i是一个附件,不单独处理,附件永远作为其主件的附属品考虑
dp[j][i] = dp[j][i - 1]; // 继承“总钱数为j时,前i-1个物品”的最大满意度
} else { // 【背包问题核心】随着持有钱数j的递增,状态转移方程设计方法!
// 首先获取“不选择购买物品i”时的满意度(基准)
dp[j][i] = dp[j][i - 1];
/**
* ⭐重点理解【状态转移方程】的两个关键设计:
* ① j >= v:当前持有钱数足以“梭哈”购买物品i
* ② dp[j-v][i-1]:(已决心购买物品i)当持有金额为j-v时,“前i-1个物品的最大满意度”——该数值已在上一轮中计算完成
*
* “普通01背包问题”只需要考虑“v”与“tempdp”一种情况;
* 本题由于附件1、2的存在,情况增加至4种,选择“总体满意度”最大购买策略的即可!
* */
if (j >= v && v != -1) dp[j][i] = Math.max(dp[j][i], dp[j - v][i - 1] + tempdp);
if (j >= v1 && v1 != -1) dp[j][i] = Math.max(dp[j][i], dp[j - v1][i - 1] + tempdp1);
if (j >= v2 && v2 != -1) dp[j][i] = Math.max(dp[j][i], dp[j - v2][i - 1] + tempdp2);
if (j >= v3 && v3 != -1) dp[j][i] = Math.max(dp[j][i], dp[j - v3][i - 1] + tempdp3);
/**
* 至此,可总结【有依赖的01背包问题】的最核心思想:
* ⭐附件不单独考虑,永远作为主件的附属品加以考虑!
* 剩下的处理方法基本与“普通的01背包问题”一质,包括:物品类的设计,dp数组的定义,数据的预处理,双重for的遍历方式等。
* */
}
}
}
System.out.println(dp[N][A.length - 1] * dw); // 最后记得把满意度放大
}
}
class Good { // 定义物品类(由于需要上传OJ,所以不另建Class文件)
int v; // 物品价格
int p; // 物品重要度
int q; // 物品依赖
// 题目要求:单个主件的附件个数为0~2
int a1 = 0; // 附件一编号
int a2 = 0; // 附件二编号
Good(int v, int p, int q) {
this.v = v;
this.p = p;
this.q = q;
}
Good() {
}
public void setV(int v) {
this.v = v;
}
public void setP(int p) {
this.p = p;
}
public void setQ(int q) {
this.q = q;
}
public void setA1(int a1) {
this.a1 = a1;
}
public void setA2(int a2) {
this.a2 = a2;
}
}