华为机试HJ16:购物单 系统的动态规划设计思路 剖析Java最优解代码

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行可知总钱数N50以及希望购买的物品个数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;
    }
}
  • 12
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值