当前位置: 首页 > news >正文

【动态规划】详解多重背包问题

目录

  • 1. 前置文章
  • 2. 多重背包
  • 3. 多重背包朴素代码和空间压缩
  • 4. 二进制优化
  • 5.洛谷 P1833 樱花
    • 5.1 题目
    • 5.2 代码
  • 6. 单调队列优化思想
  • 7. 单调队列优化代码 - 二维动态规划
  • 8. 单调队列优化代码 - 空间优化版本
  • 9. 整体代码
  • 10.小结

1. 前置文章

本文前置文章:

  • 【动态规划】详解 0-1背包问题
  • 【动态规划】详解完全背包问题
  • 【动态规划】详解分组背包问题

下面是三种背包模式的区别:

  • 0 - 1 背包 是说:有 n 个物品和一个重量为 t 的背包,这 n 个物品中第 i 个物品的重量为 w[i],价值为 v[i],那么这个背包能装下的物品最大价值是多少,注意一个物品只能选一次。
  • 完全背包 是说:有 n 个物品和一个重量为 t 的背包,这 n 个物品中第 i 个物品的重量为 w[i],价值为 v[i],那么这个背包能装下的物品最大价值是多少,注意一个物品可以选无数次。
  • 分组背包 是说:有 n 组物品和一个重量为 t 的背包,每个物品都有自己的体积,价值和组号,代表这个物品属于哪一组,要求在不超过背包容量的前提下,从每组物品中最多选择一个物品,使得背包中物品的总价值最大。

2. 多重背包

多重背包是指:有 n 个物品和一个重量为 t 的背包,这 n 个物品中第 i 个物品的重量为 w[i],价值为 v[i],数量为 c[i],那么这个背包能装下的物品最大价值是多少,注意一个物品可以选择的次数限制为 c[i]。

多重背包和完全背包很像,只是多重背包每一个物品都有数量限制,我们设定 dp[i][j] 为 1-i 号物品自有选择,每种物品数据不超过限制,容量也不超过 j 的前提下获取到的最大价值

  • 如果不需要第 i 号商品,那么 dp[i][j] = dp[i-1][j]
  • 如果需要第 i 号商品,那么
    • 如果需要 1 个商品, d p [ i ] [ j ] = d p [ i − 1 ] [ j − w [ i ] ] + v [ i ] dp[i][j] = dp[i-1][j - w[i]] + v[i] dp[i][j]=dp[i1][jw[i]]+v[i]
    • 如果需要 2 个商品, d p [ i ] [ j ] = d p [ i − 1 ] [ j − 2 ∗ w [ i ] ] + 2 ∗ v [ i ] dp[i][j] = dp[i-1][j - 2 * w[i]] + 2 * v[i] dp[i][j]=dp[i1][j2w[i]]+2v[i]

3. 多重背包朴素代码和空间压缩

那么我们以洛谷的 P1776 为例子,这道题可以看作是多重背包模板了。

  • 洛谷的 P1776

import java.util.Scanner;

public class Main {

    static int n = 0, t = 0;
    static int[] v, w, c;

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        // 物品数
        n = scanner.nextInt();
        // 背包重量
        t = scanner.nextInt();
        // 物品的价值
        v = new int[n + 1];
        // 物品的重量
        w = new int[n + 1];
        // 物品的数量
        c = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            v[i] = scanner.nextInt();
            w[i] = scanner.nextInt();
            c[i] = scanner.nextInt();
        }

        System.out.println(computeV1());
    }

    // O(n * t * 物品平均数)
    public static int computeV1() {
        int[][] dp = new int[n + 1][t + 1];
        // 遍历物品
        for (int i = 1; i <= n; i++) {
            // 遍历背包
            for (int j = 0; j <= t; j++) {
                // 不选当前物品
                dp[i][j] = dp[i - 1][j];
                // 选当前物品,遍历数量,最多 c[i] 件物品,或者当前背包放不下这么多物品了
                for (int k = 1; k <= c[i] && w[i] * k <= j; k++) {
                    dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k * w[i]] + k * v[i]);
                }
            }
        }
        return dp[n][t];
    }

    // 空间压缩版本
    public static int computeV2() {
        int[] dp = new int[t + 1];
        // 遍历物品
        for (int i = 1; i <= n; i++) {
            // 遍历背包
            for (int j = t; j >= 0; j--) {
                for (int k = 1; k <= c[i] && w[i] * k <= j; k++) {
                    dp[j] = Math.max(dp[j], dp[j - k * w[i]] + k * v[i]);
                }
            }
        }
        return dp[t];
    }

}

上面是完全背包朴素写法,整体代码的时间复杂度是 O(n * t * 物品的平均数),空间优化和不优化的版本都是,但是很可惜时间复杂度太高了,没办法通过用例。
在这里插入图片描述


4. 二进制优化

上面版本的多重背包会超时,是因为整体时间复杂度 O(n * t * 物品平均数) 其中平均物品数量太大了,看题目的限制:
在这里插入图片描述
m 的总和不超过 1 0 5 10^5 105,如果是 100 件物品,总和假设是 1 0 5 10^5 105,这样算起来物品平均就是 1 0 3 10^3 103,这样一相乘 n ∗ t ∗ 平均数 n * t * 平均数 nt平均数 就有 1 0 9 10^9 109 次方了。

其实对于多重背包而言,我们可以从二进制的角度去看问题,假设现在有一件物品,这件物品的总数是 19,物品的重量为 4, 价值为 5,我们可以使用二进制对这个物品进行拆分,下面是拆分后的各个组:

  1. 组合 1,物品数: 2 0 = 1 2^0 = 1 20=1,价值 5,重量 4,剩余物品数 18
  2. 组合 2,物品数: 2 1 = 2 2^1 = 2 21=2,价值 10,重量 8,剩余物品数 16
  3. 组合 3,物品数: 2 2 = 4 2^2 = 4 22=4,价值 20,重量 16,剩余物品数 12
  4. 组合 4,物品数: 2 3 = 8 2^3 = 8 23=8,价值 40,重量 32,剩余物品数 4
  5. 组合 5,物品数: 4 4 4,价值 20,重量 16,剩余物品数 0

为什么要拆分成这几个组呢?假设最大价值的物品包含了 x 个上述物品,1 <= x <= 19,对于朴素算法,需要从 1 遍历到 19,然后一个一个算价值,而拆分过后,可以任取里面几个组合成 x,举个例子:

  • 假设 x = 1,直接取组合 1
  • 假设 x = 2,直接取组合 2
  • 假设 x = 3,直接取组合 1 + 组合 2

通过上面的组合就可以拼出 x 来,为什么可以这样呢?其实对于二进制,如果你现在有 1248,那么通过这几个数我们可以随意组合出 1 ~ 15 的任意一个数字,这个是二进制的性质,最后加上组合 5,我们就能随意组合出 1 ~ 19 的任意一个数字。

将上述组合转换成新的物品,假设上述物品下标是 0,经过转换之后结果如下:

物品 0 , w [ 0 ] = 4 , v [ 0 ] = 5 , c [ 0 ] = 19 = > { 物品 01 , w [ 0 ] = 4 , v [ 0 ] = 5 物品 02 , w [ 0 ] = 8 , v [ 0 ] = 10 物品 03 , w [ 0 ] = 16 , v [ 0 ] = 20 物品 04 , w [ 0 ] = 32 , v [ 0 ] = 40 物品 05 , w [ 0 ] = 16 , v [ 0 ] = 20 物品 0,w[0] = 4,v[0] = 5,c[0] = 19 =>\left\{ \begin{aligned} 物品 01,w[0] = 4,v[0] = 5\\ 物品 02,w[0] = 8,v[0] = 10\\ 物品 03,w[0] = 16,v[0] = 20\\ 物品 04,w[0] = 32,v[0] = 40\\ 物品 05,w[0] = 16,v[0] = 20\\ \end{aligned} \right. 物品0w[0]=4v[0]=5c[0]=19=> 物品01w[0]=4v[0]=5物品02w[0]=8v[0]=10物品03w[0]=16v[0]=20物品04w[0]=32v[0]=40物品05w[0]=16v[0]=20
这就意味者,我们可以将所有物品通过二进制转换成衍生的物品,然后通过 0-1 背包算法进行计算。

所以我们来改造下上面的方法,将每一个物品的数量都拆分成二进制表达的形式:

import java.util.Scanner;

public class Main {

    static int MAX = 2000;
    static int n = 0, t = 0;
    static int[] v, w, c;
    static int index = 0;

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        // 物品数
        n = scanner.nextInt();
        // 背包重量
        t = scanner.nextInt();
        // 物品的价值
        v = new int[MAX];
        // 物品的重量
        w = new int[t + 1];
        // 物品的数量
        c = new int[MAX];
        for (int i = 1; i <= n; i++) {
            int value = scanner.nextInt();
            int weight = scanner.nextInt();
            int count = scanner.nextInt();
            // 二进制分组,将每一个物品通过二进制分组转换成新的物品
            for (int k = 1; k <= count; k <<= 1) {
                v[index] = k * value;
                w[index] = k * weight;
                count -= k;
                index++;
            }
            if (count > 0) {
                v[index] = count * value;
                w[index] = count * weight;
                index++;
            }
        }

        // 使用 0-1 背包进行计算
        System.out.println(compute());
    }

    // 0-1 背包
    public static int compute() {
        int[] dp = new int[t + 1];
        for (int i = 0; i < index; i++) {
            for (int j = t; j >= w[i]; j--) {
                dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
            }
        }
        return dp[t];
    }
}

在这里插入图片描述


5.洛谷 P1833 樱花

洛谷 P1833 樱花。

5.1 题目

题目背景

《爱与愁的故事第四弹·plant》第一章。

题目描述

爱与愁大神后院里种了 n n n 棵樱花树,每棵都有美学值 C i ( 0 ≤ C i ≤ 200 ) C_i(0 \le C_i \le 200) Ci(0Ci200)。爱与愁大神在每天上学前都会来赏花。爱与愁大神可是生物学霸,他懂得如何欣赏樱花:一种樱花树看一遍过,一种樱花树最多看 P i ( 0 ≤ P i ≤ 100 ) P_i(0 \le P_i \le 100) Pi(0Pi100) 遍,一种樱花树可以看无数遍。但是看每棵樱花树都有一定的时间 T i ( 0 ≤ T i ≤ 100 ) T_i(0 \le T_i \le 100) Ti(0Ti100)。爱与愁大神离去上学的时间只剩下一小会儿了。求解看哪几棵樱花树能使美学值最高且爱与愁大神能准时(或提早)去上学。

输入格式

n + 1 n+1 n+1行:

1 1 1 行:现在时间 T s T_s Ts(几时:几分),去上学的时间 T e T_e Te(几时:几分),爱与愁大神院子里有几棵樱花树 n n n。这里的 T s T_s Ts T e T_e Te 格式为:hh:mm,其中 0 ≤ h h ≤ 23 0 \leq hh \leq 23 0hh23 0 ≤ m m ≤ 59 0 \leq mm \leq 59 0mm59,且 h h , m m , n hh,mm,n hh,mm,n 均为正整数。

2 2 2 行到第 n + 1 n+1 n+1 行,每行三个正整数:看完第 i i i 棵树的耗费时间 T i T_i Ti,第 i i i 棵树的美学值 C i C_i Ci,看第 i i i 棵树的次数 P i P_i Pi P i = 0 P_i=0 Pi=0 表示无数次, P i P_i Pi 是其他数字表示最多可看的次数 P i P_i Pi)。

输出格式

只有一个整数,表示最大美学值。

输入输出样例 #1

输入 #1

6:50 7:00 3
2 1 0
3 3 1
4 5 4

输出 #1

11

说明/提示

100 % 100\% 100% 数据: T e − T s ≤ 1000 T_e-T_s \leq 1000 TeTs1000(即开始时间距离结束时间不超过 1000 1000 1000 分钟), n ≤ 10000 n \leq 10000 n10000。保证 T e , T s T_e,T_s Te,Ts 为同一天内的时间。

样例解释:赏第一棵樱花树一次,赏第三棵樱花树 2 2 2 次。


5.2 代码

这道题就是多重背包问题,虽然说 P i = 0 P_i = 0 Pi=0 可以看无数次,但是有时间限制,所以哪怕在规定时间内都看这种树,也会有一个上限的,下面给出代码。

import java.util.Scanner;

public class Main {

    static int MAX = 100001;
    static int[] v = new int[MAX], w = new int[MAX];
    static int n = 0, t = 0;
    static int index = 0;


    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        String line = scanner.nextLine();
        String[] str = line.split(" ");
        // 物品数
        n = Integer.parseInt(str[2]);
        // 多少分钟
        String start = str[0];
        String end = str[1];
        String[] ss = start.split(":");
        String[] es = end.split(":");
        // 计算时间,就是背包重量
        t = Integer.parseInt(es[0]) * 60 + Integer.parseInt(es[1]) - Integer.parseInt(ss[0]) * 60 - Integer.parseInt(ss[1]);

        for (int i = 0; i < n; i++) {
            int weight = scanner.nextInt();
            int value = scanner.nextInt();
            int count = scanner.nextInt();
            if (count == 0) {
                count = t / weight;
            }
            // 二进制解构
            for (int k = 1; k <= count; k <<= 1) {
                v[index] = k * value;
                w[index] = k * weight;
                count -= k;
                index++;
            }
            if(count > 0){
                v[index] = count * value;
                w[index] = count * weight;
                index++;
            }
        }
        System.out.println(compute());
    }

    // 0-1 背包
    public static int compute() {
        int[] dp = new int[t + 1];
        for (int i = 0; i < index; i++) {
            for (int j = t; j >= w[i]; j--) {
                dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
            }
        }
        return dp[t];
    }
}

6. 单调队列优化思想

单调队列优化是多重背包优化的最终版本,性能比二进制的要好很多。那首先我们来看一下什么是单调队列优化,下面把最初版本的代码给粘贴过来。

public static int computeV1() {
    int[][] dp = new int[n + 1][t + 1];
    // 遍历物品
    for (int i = 1; i <= n; i++) {
        // 遍历背包
        for (int j = 0; j <= t; j++) {
            // 不选当前物品
            dp[i][j] = dp[i - 1][j];
            // 选当前物品,遍历数量,最多 c[i] 件物品,或者当前背包放不下这么多物品了
            for (int k = 1; k <= c[i] && w[i] * k <= j; k++) {
                dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k * w[i]] + k * v[i]);
            }
        }
    }
    return dp[n][t];
}

我们观察最里面的 k 的 for 循环,这个循环其实就是在找当前重量 j 依赖上一行的哪些物品,比如下面有一个例子,假设物品 i 的重量是 3,价值是 5,数量是 4,假设现在处理第 i 行的 dp 表,且当前 j = 20,下面就是依赖关系。
在这里插入图片描述
可以看到上面 dp[i][20] 依赖的是 i - 1 行这 5 个,为什么是 5 个呢?因为这个物品只能选 4 个,所以只能选到 dp[i-1][8]。那么我们再来看当 j = 19 和 j = 21 的依赖关系,如下:
在这里插入图片描述

不知道大家发现上面的关系没有:

  • 19、16、13、10、7 对 3 取余数 = 1
  • 20、17、14、11、8 对 3 取余数 = 2
  • 21、18、15、12、9 对 3 取余数 = 0

也就是说我们其实可以把重量 j 根据余数 0、1、2 分为三组,每一组在计算 dp 的时候都只会依赖这一组里面的其他值,不依赖其他组,啥意思,就是在计算 dp[i-1][19] 的时候是不会依赖到 dp[i-1][17] 或者 dp[i-1][18] 的,因为依赖公式是 dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k * w[i]] + k * v[i],j 只会依赖 j - w[i] * k 的值,这些值我们就可以根据余数进行分组。

好了,有了余数分组这个概念之后,我们再来看传统方法里面求 dp 的代码。

// 遍历背包
for (int j = 0; j <= t; j++) {
    // 不选当前物品
    dp[i][j] = dp[i - 1][j];
    // 选当前物品,遍历数量,最多 c[i] 件物品,或者当前背包放不下这么多物品了
    for (int k = 1; k <= c[i] && w[i] * k <= j; k++) {
        dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k * w[i]] + k * v[i]);
    }
}

对于第 i 号物品,在求 dp 的时候显然是需要从 i - 1 行依赖的这几个下标的值中找到最大值,那么传统方法的瓶颈就在这了!!!每一次要求 dp[i][j],都需要从 j 往前去遍历,遍历次数可以达到 c[i],一旦 c[i] 比较大,时间复杂度就很大了,所以有没有什么办法我不需要遍历这么多次,我只需要 O(1) 的时间就能找到最大值呢?当然有了,就是单调队列。

但是现在有一个问题,比如下面的图。

在这里插入图片描述

在求依赖关系的时候 dp[i][22] 依赖的 dp[i-1][19] + 1 * 5,但是左边当 j = 19 的时候,依赖 dp[i-1][19] + 0 * 5,所以如果用单调队列维护这几个依赖关系,那当 j 从 19 变化到 22 的时候,所依赖的数据都要加上一个 5,这就说明单调队列里面的数值是不断变化的。

这种情况就很难办了,我们总不可能每求一个 dp[i][j] 的值都把单调队列里面的数据全部修改一遍吧,所以有没有什么办法能把依赖项后面的 k * w[i] 给删掉呢 (红色部分)

那就从定义来看,在 j 从 19 -> 22 -> 25 的过程中,是不断 + 3 的,所以可以把上面的依赖关系改成下面这样。
在这里插入图片描述

接着我们发现了,当求 dp[i][19] 的时候,依赖的这几项里面都有 19 / 3 * 5 这个公共项,当求 dp[i][22] 的时候,依赖的这几项里面都有 22 / 3 * 5 这个公共项,所以下面进一步改写。
在这里插入图片描述

当依赖项改写成这样之后,我们发现依赖的红色部分在 j 变化的时候是不变的了!!! 所以单调队列里面的元素就按照下面代码这样写,代码表示如下,另外多说一句,单调队列的整体时间复杂度可以优化到 O(n * t)

// 假设 i 号物品重量 3, 价值 5, 个数 4
// 当前 dp[i][j] 依赖 dp[i-1][j] + index * w[i] / v[i]
// 比如 dp[i][20] 依赖
// 1. dp[i-1][20] + 0 * 5
// 2. dp[i-1][17] + 1 * 5
// 3. dp[i-1][14] + 2 * 5
// 4. dp[i-1][11] + 3 * 5
// 5. dp[i-1][8] + 4 * 5
// 单调队列本意就是维护上面 5 个数字的大小关系,从大到小,这样就不需要每次计算 j 都往回倒推
// 但是当求 dp[i][23] 的时候,依赖 dp[i-1][20] + 1 * 5
// 这时候单调队列每一个元素都要加上一个 5,就变化了,所以我们要把这个 5 给消掉
// 方法就是 dp[i][20] 依赖
// 1. dp[i-1][20] - 20 / 3 * 5
// 2. dp[i-1][17] - 17 / 3 * 5
// 3. dp[i-1][14] - 14 / 3 * 5
// 4. dp[i-1][11] - 11 / 3 * 5
// 5. dp[i-1][8] - 8 / 3 * 5
// 最终让最大值加上 20 / 3 * 5,这样结果是不变的,但是单调队列里面维护的信息就不变了,当继续求 dp[i][23] 的时候就加上 23 / 3 * 5
public static int getQueueValue(int[][] dp, int i, int j) {
    return dp[i - 1][j] - j / w[i] * v[i];
}

7. 单调队列优化代码 - 二维动态规划

下面直接给出单调队列优化的二维动态规划代码。

public static int compute() {
    int[][] dp = new int[n + 1][t + 1];
    for (int i = 1; i <= n; i++) {
        // 同余分组计算,t 是整体重量,其中假设当前物品重量是 3,那么下标 0,3,6,9 ... 为一组
        // 1,4,7,10 ... 为一组, 2,5,8,11,14 ... 为一组
        for (int mod = 0; mod <= Math.min(t, w[i] - 1); mod++) {
            l = r = 0;
            for (int j = mod; j <= t; j += w[i]) {
                while (l < r && getQueueValue(dp, i, queue[r - 1]) <= getQueueValue(dp, i, j)) {
                    r--;
                }
                // 找到第一个大于当前 j 求出来的值的位置
                queue[r++] = j;
                // 删除过期的节点,比如当前重量是 23,由于物品数是 4,所以单调队列里面就是
                // 23,20,17,14,11,剩下的 8 就要移除了
                if (queue[l] == j - w[i] * (c[i] + 1)) {
                    l++;
                }
                dp[i][j] = getQueueValue(dp, i, queue[l]) + j / w[i] * v[i];
            }
        }
    }
    return dp[n][t];
}

public static int getQueueValue(int[][] dp, int i, int j) {
    return dp[i - 1][j] - j / w[i] * v[i];
}

代码有三个 for 循环,最外层的循环就是在遍历物品,第二层 for 循环是在遍历余数,为什么要 mod <= Math.min(t, w[i] - 1),因为物品重量假设是 100,但是背包重量才 20,这种情况下 mod 最高就遍历到 19 就行了。

接着 for (int j = mod; j <= t; j += w[i]) 就是在遍历每一个分组,也就是上面说的 0,3,6,9 ...1,4,7,10 ...2,5,8,11,14 ... 一组一组来求。

下面就是维护单调队列了,维护的代码也不难,单调队列是从大到小,这个单调队列是提前创建好的数组 => static int[] queue = new int[40001],代码逻辑是首先从右往左找到第一个比要加入的数据大的下标。

while (l < r && getQueueValue(dp, i, queue[r - 1]) <= getQueueValue(dp, i, j)) {
    r--;
}

接着设置当前的值到单调队列里面。

queue[r++] = j;

最后删除过期的数据,比如当前重量 j 从 20 变成 23,由于物品数是 4,所以单调队列里面就维护 23,20,17,14,11,剩下的 8 就要移除了。

if (queue[l] == j - w[i] * (c[i] + 1)) {
    l++;
}

下面是图解维护单调队列的流程,注意下面单调队列里面的值
在这里插入图片描述


8. 单调队列优化代码 - 空间优化版本

// 空间压缩
public static int computeV2() {
    int[] dp = new int[t + 1];
    for (int i = 1; i <= n; i++) {
        for (int mod = 0; mod <= Math.min(t, w[i] - 1); mod++) {
            l = r = 0;
            // 先把一维数组的c[i]个值入队列
            int cnt = 1;
            for (int j = t - mod; j >= 0 && cnt <= c[i]; j -= w[i], cnt++) {
                while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, j)) {
                    r--;
                }
                queue[r++] = j;
            }
            // 开始从后往前遍历
            for (int j = t - mod, last = j - w[i] * c[i]; j >= 0; j -= w[i], last -= w[i]) {
                // 设置最后一个应该放的位置
                if(last >= 0){
                    while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, last)) {
                        r--;
                    }
                    queue[r++] = last;
                }
                // 计算 dp[j]
                dp[j] = getQueueValue2(dp, i, queue[l]) + j / w[i] * v[i];
                // 判断是否过期,因为是从后往前遍历,所以 j 就是要过期的元素
                if (queue[l] == j) {
                    l++;
                }
            }
        }
    }
    return dp[t];
}

public static int getQueueValue2(int[] dp, int i, int j) {
    return dp[j] - j / w[i] * v[i];
}

空间压缩的版本和二维 dp 的版本遍历是一样的,但是由于当前 dp[i][j] 依赖的是上一层的左上的节点,所以需要先把 c[i] 个值放到队列里面。

在这里插入图片描述

比如求 dp[19] 的时候先把 10-19 这四个入队,那剩下的 7 呢?别急,先入队 4 个,然后从后往前遍历的时候把 dp[7]-7/3*5 也入队,然后调整队列,再删除过期值。

// 先把一维数组的c[i]个值入队列
int cnt = 1;
for (int j = t - mod; j >= 0 && cnt <= c[i]; j -= w[i], cnt++) {
    while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, j)) {
        r--;
    }
    queue[r++] = j;
}

上面代码就是将 c[i] 个值入队,大家要注意如果物品个数有 c[i],那么队列里面的元素就是 c[i] + 1 个,因为还有一个是 dp[i-1][j] - j / 3 * 5。

j = t - mod 表示从后往前遍历,因为是依赖上一层的左边和上边的节点,所以需要从后往前遍历。

下面 for 循环才是真正从后往前遍历的逻辑,上面只是提前设置好 c[i] 个元素到单调队列。

last = j - w[i] * c[i] 就是接下来要入队的节点了,上面不是入队了 4 个节点吗,下面把 dp[7] - 7 / 3 * 5 也入队了,但是要注意 last 必须大于 0 才可以入队,否则小于 0 就不行了。

注意上面在解释的时候都是按照重量为 3,价值为 5,个数为 4 来解析的。

最后判断是否过期直接判断 queue[l] == j,因为 j 是从后往前遍历的,假设当前 j = 22,那么当处理完 dp[j] 往前遍历到 dp[19] 的时候,如果 quue[l] == 22,那么就说明这个最大值过期了。

if (queue[l] == j) {
    l++;
}

9. 整体代码

下面直接给出整体的代码。

import java.util.Scanner;

public class Main {

    static int MAX = 101;
    static int n = 0, t = 0;
    static int[] v = new int[MAX], w = new int[MAX], c = new int[MAX];
    static int[] queue = new int[40001];

    static int index = 0;
    // 单调队列的两个指针
    static int l = 0, r = 0;

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        // 物品数
        n = scanner.nextInt();
        // 背包重量
        t = scanner.nextInt();

        for (int i = 1; i <= n; i++) {
            v[i] = scanner.nextInt();
            w[i] = scanner.nextInt();
            c[i] = scanner.nextInt();
        }

        System.out.println(computeV2());
    }

    public static int compute() {
        int[][] dp = new int[n + 1][t + 1];
        for (int i = 1; i <= n; i++) {
            // 同余分组计算,t 是整体重量,其中假设当前物品重量是 3,那么下标 0,3,6,9 ... 为一组
            // 1,4,7,10 ... 为一组, 2,5,8,11,14 ... 为一组
            for (int mod = 0; mod <= Math.min(t, w[i] - 1); mod++) {
                l = r = 0;
                for (int j = mod; j <= t; j += w[i]) {
                    while (l < r && getQueueValue(dp, i, queue[r - 1]) <= getQueueValue(dp, i, j)) {
                        r--;
                    }
                    // 找到第一个大于当前 j 求出来的值的位置
                    queue[r++] = j;
                    // 删除过期的节点,比如当前重量是 23,由于物品数是 4,所以单调队列里面就是
                    // 23,20,17,14,11,剩下的 8 就要移除了
                    if (queue[l] == j - w[i] * (c[i] + 1)) {
                        l++;
                    }
                    dp[i][j] = getQueueValue(dp, i, queue[l]) + j / w[i] * v[i];
                }
            }
        }
        return dp[n][t];
    }

    // 假设 i 号物品重量 3, 价值 5, 个数 4
    // 当前 dp[i][j] 依赖 dp[i-1][j] + index * w[i] / v[i]
    // 比如 dp[i][20] 依赖
    // 1. dp[i-1][20] + 0 * 5
    // 2. dp[i-1][17] + 1 * 5
    // 3. dp[i-1][14] + 2 * 5
    // 4. dp[i-1][11] + 3 * 5
    // 5. dp[i-1][8] + 4 * 5
    // 单调队列本意就是维护上面 5 个数字的大小关系,从大到小,这样就不需要每次计算 j 都往回倒推
    // 但是当求 dp[i][23] 的时候,依赖 dp[i-1][20] + 1 * 5
    // 这时候单调队列每一个元素都要加上一个 5,就变化了,所以我们要把这个 5 给消掉
    // 方法就是 dp[i][20] 依赖
    // 1. dp[i-1][20] - 20 / 3 * 5
    // 2. dp[i-1][17] - 17 / 3 * 5
    // 3. dp[i-1][14] - 14 / 3 * 5
    // 4. dp[i-1][11] - 11 / 3 * 5
    // 5. dp[i-1][8] - 8 / 3 * 5
    // 最终让最大值加上 20 / 3 * 5,这样结果是不变的,但是单调队列里面维护的信息就不变了,当继续求 dp[i][23] 的时候就加上 23 / 3 * 5
    public static int getQueueValue(int[][] dp, int i, int j) {
        return dp[i - 1][j] - j / w[i] * v[i];
    }

    // 空间压缩
    public static int computeV2() {
        int[] dp = new int[t + 1];
        for (int i = 1; i <= n; i++) {
            for (int mod = 0; mod <= Math.min(t, w[i] - 1); mod++) {
                l = r = 0;
                // 先把一维数组的c[i]个值入队列
                int cnt = 1;
                for (int j = t - mod; j >= 0 && cnt <= c[i]; j -= w[i], cnt++) {
                    while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, j)) {
                        r--;
                    }
                    queue[r++] = j;
                }
                // 开始从后往前遍历
                for (int j = t - mod, last = j - w[i] * c[i]; j >= 0; j -= w[i], last -= w[i]) {
                    // 设置最后一个应该放的位置
                    if(last >= 0){
                        while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, last)) {
                            r--;
                        }
                        queue[r++] = last;
                    }
                    // 计算 dp[j]
                    dp[j] = getQueueValue2(dp, i, queue[l]) + j / w[i] * v[i];
                    // 判断是否过期,因为是从后往前遍历,所以 j 就是要过期的元素
                    if (queue[l] == j) {
                        l++;
                    }
                }
            }
        }
        return dp[t];
    }

    public static int getQueueValue2(int[] dp, int i, int j) {
        return dp[j] - j / w[i] * v[i];
    }
}

直接到洛谷提交也是能通过的。
在这里插入图片描述


10.小结

文章里面的例子都来自左神的视频:算法讲解075【必备】背包dp-多重背包、混合背包,讲的确实很清楚,后面还有一些混合背包的内容下一篇文章再单独说下。





如有错误,欢迎指出!!!

相关文章:

  • Java求101-200之间有多少素数
  • 西门子200smart之modbus_TCP(做主站与第三方设备)通讯
  • Siddon 算法学习中的疑问
  • asp.net 4.5在医院自助系统中使用DeepSeek帮助医生分析患者报告
  • Git错误: Updates were rejected because the remote contains work that you do nothint: have locally.
  • Redis分布式锁如何实现——简单理解版
  • 2025信创即时通讯排行:安全合规与生态适配双轮驱动
  • oracle事务的组成
  • uniapp vue3使用uniapp的生命周期
  • 借助AI Agent实现数据分析
  • 触动精灵对某东cookie读取并解密--记lua调用C语言
  • 基于粒子群算法(PSO)栅格地图移动机器人路径规划
  • MySQL错误 “duplicate entry ‘1‘ for key ‘PRIMARY‘“ 解决方案
  • Axure大屏可视化模板:赋能多领域,开启数据展示新篇章
  • AF3 quat_multiply 和 quat_multiply_by_vec 函数解读
  • PostgreSQL用SQL实现俄罗斯方块
  • EasyRTC轻量级Webrtc音视频通话SDK,助力带屏IPC在嵌入式设备中的应用
  • 密码协议与网络安全——引言
  • UE5.5 Niagara 渲染器
  • 从 0 到 1 构建 Python 分布式爬虫,实现搜索引擎全攻略
  • 结束北京队与总决赛十年之痒的,为何会是一度被群嘲的许利民
  • 三亚回应“游客骑摩托艇出海遇暴雨”:未失联,已引导申请先行赔付
  • 魔都眼|石库门里看车展,五一来张园体验城市“漫时光”
  • 美国第一季度经济环比萎缩0.3%
  • 神十九乘组安全顺利出舱
  • 浙商银行一季度净赚超59亿微增0.61%,非息净收入降逾22%