DP系列1-背包问题

302 阅读11分钟

DP系列1——背包问题

01背包问题

1. 思路

1.1 状态表示

确定状态是用一维 f(i) 还是 二维 f(i, j) 表示

1.1.1 集合

每个状态表示一个集合,表示所有选法。每种选法需要满足两个条件:

  • 只从前 i 个物品中选,每个物品只有1个
  • 总体积满足 <= j
1.1.2 属性:集合里面存放的值,如最大值Max,最小值Min

1.2 状态计算——集合划分

DP优化:一般是是对 DP 代码或 DP 方程做等价变形

集合划分分为两种情况:

1.2.1 不含 i :从1~i-1中选择总体积 <= j的情况的集合:Max=f(i-1, j)
1.2.2 包含 i :从1~i中选择总体积 <= j的情况的集合:Max=f(i, j)

w(i)表示 i 的价值,v(i) 表示 i的体积

状态转移方程:f[i,j] = f[i-1,j-v[i]] + w[i]

遵循两个原则:

  • 不漏任何一种选择
  • 不重复选择

如果背包无法装下第i件物品,则包含i的集合可能为空集

1.3. 无后效性

为了保证计算子问题能够按照顺序、不重复地进行,动态规划要求已经求解的子问题不受后续阶段的影响。这个条件也被叫做「无后效性」。换言之,动态规划对状态空间的遍历构成一张有向无环图,遍历就是该有向无环图的一个拓扑序。有向无环图中的节点对应问题中的「状态」,图中的边则对应状态之间的「转移」,转移的选取就是动态规划中的「决策」。

2. 题目解析

2.1 题目描述:01背包问题

有 N 件物品和一个容量为 V 的背包,每件物品有各自的价值且只能被选择一次,要求在有限的背包容量下,装入的物品总价值最大。

「0-1 背包」是较为简单的动态规划问题,也是其余背包问题的基础。

动态规划是不断决策求最优解的过程,「0-1 背包」即是不断对第 i 个物品做出决策,「0-1」正好代表不选与选两种决定。

2.2 题解代码

2.2.1 版本1 二维

(1)状态f[i][j] 定义:前 i个物品,背包容量 j 下的最优解(最大价值):

当前的状态依赖于之前的状态,可以理解为从初始状态f[0][0] = 0开始决策,有 N 件物品,则需要 N 次决策,每一次对第 i 件物品的决策,状态f[i][j]不断由之前的状态更新而来。

(2)当前背包容量不够(j < v[i]),没得选,因此前 i 个物品最优解即为前 i−1 个物品最优解:

对应代码:f[i][j] = f[i - 1][j]

(3)当前背包容量够,可以选,因此需要决策选与不选第 i 个物品:

选:f[i][j] = f[i - 1][j - v[i]] + w[i]。 不选:f[i][j] = f[i - 1][j]。 我们的决策是如何取到最大价值,因此以上两种情况取 max() 。代码如下:

public class T2 {
    private static int n; //物品数量
    private static int m; //背包容量
    private static int[] v; //物品体积
    private static int[] w; //物品价值
    /**
     * 二维状态表示
     */
    public static int two_dimension(){
        int[][] opt = new int[n + 1][m + 1];
        for (int i = 1; i <= n; i++) {
            for (int j = 0; j <= m; j++) {
                opt[i][j] = opt[i - 1][j];
                if(j >= v[i]){
                    opt[i][j] = Math.max(opt[i][j], opt[i - 1][j - v[i]] + w[i]);
                }
            }
        }
        return opt[n][m];
    }

2.2.2 版本2 一维

将状态f[i][j]优化到一维f[j],需要通过滚动数组做一个等价变形。

为什么可以这样变形呢?我们定义的状态f[i][j]可以求得任意合法的ij最优解,但题目只需要求得最终状态f[n][m],因此我们只需要一维的空间来更新状态。

(1)状态f[j]定义:N 件物品,背包容量 j 下的最优解。

(2)注意枚举背包容量j必须从m开始。

(3)为什么一维情况下枚举背包容量需要逆序?在二维情况下,状态f[i][j]是由上一轮i - 1的状态得来的,f[i][j]f[i - 1][j]是独立的。而优化到一维后,如果我们还是正序,则由f[较小体积]更新到f[较大体积],则有可能本应该用第i-1轮的状态却用的是第i轮的状态。

(4)例如,一维状态第i轮对体积为 3 的物品进行决策,则f[7]f[4]更新而来,这里的f[4]正确应该是f[i - 1][4],但从小到大枚举j这里的f[4]在第 i 轮计算却变成了f[i][4]。当逆序枚举背包容量j时,我们求f[7]同样由f[4]更新,但由于是逆序,这里的f[4]还没有在第i轮计算,所以此时实际计算的f[4]仍然是f[i - 1][4]

(5)简单来说,一维情况正序更新状态f[j]需要用到前面计算的状态已经被「污染」,逆序则不会有这样的问题。

状态转移方程为:f[j] = max(f[j], f[j - v[i]] + w[i])

for(int i = 1; i <= n; i++){
    for(int j = m; j >= 0; j--){
        if(j < v[i]){
          //f[i][j] = f[i - 1][j];  // 优化前
            f[j] = f[j];            // 优化后,该行自动成立,可省略。
        } else{
          //f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + w[i]);  // 优化前
            f[j] = max(f[j], f[j - v[i]] + w[i]);                   // 优化后
        }    
    }   
} 

实际上,只有当枚举的背包容量 >= v[i]时才会更新状态,因此我们可以修改循环终止条件进一步优化。

for(int i = 1; i <= n; i++)
{
    for(int j = m; j >= v[i]; j--) {
        f[j] = max(f[j], f[j - v[i]] + w[i]);
    }  
}

关于状态f[j]的补充说明:

二维下的状态定义f[i][j]是前 i 件物品,背包容量 j 下的最大价值。一维下,少了前 i 件物品这个维度,我们的代码中决策到第 i 件物品(循环到第i轮),f[j]就是前i轮已经决策的物品且背包容量 j 下的最大价值。

因此当执行完循环结构后,由于已经决策了所有物品,f[j]就是所有物品背包容量 j 下的最大价值。即一维f[j]等价于二维f[n][j]

完全背包问题

1. 思路

1.1 状态表示f[i,j]

1.1.1 集合

每个状态表示一个集合,表示所有选法。每种选法需要满足两个条件:

  • 只从前 i 个物品中选,每个物品有无限个
  • 总体积满足 <= j
1.1.2 属性:集合里面存放的值,如最大值Max,最小值Min

以“曲线救国”的方式计算 Max:

a. 去掉 k 个物品 i (k >= 0)

b. 求 Max = f[i-1, j-k*v[i]] + k*w[i]

1.2 状态计算——集合划分

集合划分分为 k 种情况:

image-20220713083610483

1.2.1 不含 i :从1~i-1中选择总体积 <= j的情况的集合:Max=f(i-1, j)
1.2.2 包含 k 个i :从1~i中选择总体积 <= j的情况的集合:Max=f(i-1, j-k*v[i]) + k * w[i]

w(i)表示 i 的价值,v(i) 表示 i的体积

状态转移方程:f[i,j] = f[i-1, j-v[i]*k] + w[i]*k

2.题目解析

2.1 题目描述:完全背包问题

有 N种物品和一个容量是 V 的背包,每种物品都有无限件可用。

第 i 种物品的体积是 vi,价值是 wi。

求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。 输出最大价值。

输入格式

第一行两个整数,N,V,用空格隔开,分别表示物品数量和背包容积。

接下来有 N 行,每行两个整数 vi, wi,用空格隔开,分别表示第 i 件物品的体积和价值。

输出格式

输出一个整数,表示最大价值。

数据范围

0 <N, V≤ 1000

0 <vi, wi≤ 1000

输入样例

4 5
1 2
2 4
3 4
4 5

输出样例

10

2.2 题解代码

// 变量
private static int n;
private static int m;
private static int[] v;
private static int[] w;
private static final Scanner sc = new Scanner(System.in);
2.2.1 基本框架

这是朴素版本的,时间复杂度 O(nm^2),TLE了

// 三维状态表示
public static void func_1(){
    int[][] dp = new int[n + 1][m + 1];
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= m; j++) {
            for (int k = 0; k * v[i] <= j; k++) {
                dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - k * v[i]] + k * w[i]);
            }
        }
    }
    System.out.println(dp[n][m]);
}
2.2.2 第一次优化:三维——>二维

优化思路 我们列举一下更新次序的内部关系:

f[i, j ] = max( f[i-1,j] , f[i-1,j-v]+w ,  f[i-1,j-2*v]+2*w , f[i-1,j-3*v]+3*w , .....)
f[i, j-v]= max(            f[i-1,j-v]   ,  f[i-1,j-2*v] + w , f[i-1,j-3*v]+2*w , .....)
由上两式,可得出如下递推关系: 
                        f[i][j]=max(f[i,j-v]+w , f[i-1][j]) 

有了上面的关系,那么其实k循环可以不要了,可以将代码优化成这样:

// 优化为:二维状态表示
public static void func_2(){
    int[][] dp = new int[n + 1][m + 1];
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= m; j++) {
            dp[i][j] = dp[i - 1][j];
            if(j >= v[i]) {
                dp[i][j] = Math.max(dp[i][j], dp[i][j - v[i]] + w[i]);
            }
        }
    }
    System.out.println(dp[n][m]);
}
2.2.3 第二次优化:二维——>一维(滚动数组)

这个代码和01背包的非优化写法很像,对比一下,下面是01背包的核心代码

for(int i = 1; i <= n; i++){
    for(int j = 0; j <= m; j++)
    {
        f[i][j] = f[i - 1][j];
        if(j - v[i] >= 0)
            f[i][j] = max(f[i][j], f[i - 1][j - v[i]] + w[i]);
    }
}

两个代码其实只有一句不同(注意下标)

f[i][j] = max(f[i][j], f[i-1][j-v[i]]+w[i]);//01背包
f[i][j] = max(f[i][j], f[i][j-v[i]]+w[i]);//完全背包问题

因为和01背包代码很相像,我们很容易想到利用滚动数组将二维优化为一维。可以将代码优化成这样:

// 滚动数组优化
public static void func_3(){
    int[] dp = new int[m + 1];
    for (int i = 1; i <= n; i++) {
        for (int j = v[i]; j <= m; j++) { //注意了,这里的j是从小到大枚举,和01背包不一样
            dp[j] = Math.max(dp[j], dp[j - v[i]] + w[i]);
        }
    }
    System.out.println(dp[m]);
}

多重背包问题

与完全背包问题类似,不同的点在于不同物品的数量有一个最大值s[i],即第 i 个物品的选取区间为0 ~ s[i]

1. 思路

1.1 状态表示:f[i][j]

  1. 集合:从前 i 个物品中选,且总体积不超过 j 的所有方案的集合.
  2. 属性:最大值

1.2 状态计算:

  1. 思想-----集合的划分
  2. 集合划分依据:根据第 i个物品有多少个来划分。含 0 个、含 1 个 ··· 含 k 个。状态表示与完全背包朴素代码一样。

状态转移方程:f[i][j] = max(f[i][j], f[i - 1][j - k * v[i]] + k * w[i])

2. 题目解析

有 N 种物品和一个容量是 V 的背包。

第 i 种物品最多有 s_i 件,每件体积是 v_i,价值是 w_i。

求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。 输出最大价值。

输入格式

第一行两个整数,N,V,用空格隔开,分别表示物品种数和背包容积。

接下来有 N 行,每行三个整数 v_i,w_i, s_i,用空格隔开,分别表示第 i 种物品的体积、价值和数量。

输出格式

输出一个整数,表示最大价值。

数据范围

0< N ≤ 1000

0< V ≤ 2000

0< v_i,w_i, s_i ≤ 2000

输入样例

4 5
1 2 3
2 4 1
3 4 3
4 5 2

输出样例

10

2.1 分析

当 s_i = 1 时,相当于 01背包中的一件物品

当 s_i > 1 时,相当于01背包中的多个一件物品 故我们可以死拆(把多重背包拆成01背包),

只是状态转移方程所在的for循环在判断结束时需要附加 k <= s[i]

2.2 题解代码

2.2.1 基本框架

这是朴素版本的,时间复杂度 O(nms),TLE了

private static int n; //物品数量
private static int m; //背包容量
private static int[] v;//物品体积
private static int[] w;//物品价值
private static int[] s;//物品数量上限
public static int dp_1() {
    int[][] opt = new int [n + 1][m + 1];
    for(int i = 1; i <= n; ++i){
        for(int j = 0; j <= m; ++j){
            for(int k = 0; k * v[i] <= j && k <= s[i]; ++k){
                opt[i][j] = Math.max(opt[i][j], opt[i - 1][j - k * v[i]] + k * w[i]);
            }
        }
    }
    return opt[n][m];
}
2.2.2 二进制优化方法

在完全背包中,通过两个状态转移方程:

f[i, j ] = max( f[i-1,j] , f[i-1,j-v]+w , f[i-1,j-2v]+2w , f[i-1,j-3v]+3w, .....)
f[i, j-v]= max( f[i-1,j-v] , f[i-1,j-2v] + w, f[i-1,j-2v]+2w , .....)

通过上述比较,可以得到 f[i][j] = max(f[i - 1][j],f[i][j - v] + w)

再来看下多重背包,

f[i, j ] = max( f[i-1,j], f[i-1,j-v]+w, f[i-1,j-2v]+2w, ....., f[i-1,j-Sv] + Sw, )
f[i, j-v]= max(           f[i-1,j-v],   f[i-1,j-2v]+w, .....,  f[i-1,j-Sv] + (S-1)w, f[i-1,j-(S+1)v]+Sw )

怎么比完全背包方程比较就多出了一项? 其实,一般从实际含义出发来考虑即可,这里是在分析f[i,j-w]这个状态的表达式,首先这个状态的含义是 从前 i 个物品中选,且总体积不超过 j - w 的最大价值, 我们现在最多只能选s个物品,因此如果我们选 s 个第 i 个物品,那么体积上就要减去 s * v,价值上就要加上s * w,那更新到状态中去就是 f[i - 1, j - v - s * v] + s * w

那为什么完全背包不会有最后一项? 完全背包由于对每种物品没有选择个数的限制,所以只要体积够用就可以一直选,没有最后一项。

二进制优化,它为什么正确,为什么合理,凭什么可以这样分??  我们首先确认三点:

(1)我们知道转化成01背包的基本思路就是:判断每件物品是否应该选择。

(2)我们知道任意一个实数可以由二进制数来表示,也就是2^0 - 2^k 其中一项或几项的和。

(3)这里多重背包问的就是每件物品取多少件可以获得最大价值。

分析:

如果直接遍历转化为01背包问题,是每次都拿一个来问,取了好还是不取好。那么根据数据范围,这样的时间复杂度是O(n^3),也就是1e+9,这样是毫无疑问是会 TLE 的。

假如 10 个取 7 个好,那么在实际的遍历过程中在第7个以后经过状态转移方程其实已经是选择“不取”好了。现在,用二进制思想将其分堆,分成 k + 1 个分别有2^k个的堆,然后拿这一堆一堆去问,我是取了好呢,还是不取好呢,经过dp选择之后,结果和拿一个一个来问的结果是完全一样的,因为dp选择的是最优结果,而根据第二点任意一个实数都可以用二进制来表示,如果最终选出来10个取7个是最优的在分堆的选择过程中分成了2^0=1,2^1=2,2^2=4,10 - 7 = 3 这四堆,然后去问四次,也就是拿去走dp状态转移方程,走的结果是第一堆1个,取了比不取好,第二堆2个,取了比不取好,第三堆四个,取了比不取好,第四堆3个,取了还不如不取,最后依旧是取了1+2+4=7个。

这样利用二进制优化,时间复杂度就从 O(nms)降到O(nmlogs)。

private static int n; //物品数量
private static int m; //背包容量
private static int[] v;//物品体积
private static int[] w;//物品价值
private static int[] s;//物品数量上限
​
private static final int N = 12000; // n * l
private static int[] v_ = new int[N];
private static int[] w_ = new int[N];
​
private static final BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
​
public static int dp_1(){
    int index = 0;
    for(int i = 1; i <= n; ++i){
        int k = 1;
        while(k <= s[i]){
            ++ index;
            v_[index] = k * v[i];
            w_[index] = k * w[i];
            s[i] -= k;
            k *= 2;
        }
        if(s[i] > 0){
            ++ index;
            v_[index] = v[i] * s[i];
            w_[index] = w[i] * s[i];
        }
    }
    n = index;
    int[] opt = new int[m + 1];
    for(int i = 1; i <= n; ++i){
        for(int j = m; j >= v_[i]; --j){
            opt[j] = Math.max(opt[j], opt[j - v_[i]] + w_[i]);
        }
    }
    return opt[m];
}

分组背包问题

1. 问题定义

有 N 组物品和一个容量是 M 的背包。每组物品有若干个不同的物品,同一组内的物品最多只能选一个。每件物品的体积是 v_{ik},价值是 w_{ik},数量上限是其中 i 是组号,k 是组内编号。求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。输出最大价值。

这个问题变成了每组物品有若干种策略:是选择本组的某一件,还是一件都不选。也就是说设 f[i][j] 表示前 i 组物品总价值能取得的最大权值,则有:

image.png 分组背包问题可以看作是01背包问题的变形:在01背包的问题上增加条件—第 i 组有 s[i]个物品,每组只能选一个。

2. 题目解析

题目描述

有 N 组物品和一个容量是 V 的背包。

每组物品有若干个,同一组内的物品最多只能选一个。 每件物品的体积是 v_{ij},价值是 w_{ij},其中 i 是组号,j 是组内编号。

求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。

输出最大价值

输入格式

第一行有两个整数 N,V,用空格隔开,分别表示物品组数和背包容量。

接下来有 N 组数据:

每组数据第一行有一个整数 S_i,表示第 i 个物品组的物品数量; 每组数据接下来有 S_i 行,每行有两个整数 v_{ij}, w_{ij},用空格隔开,分别表示第 i 个物品组的第 j 个物品的体积和价值;

输出格式

输出一个整数,表示最大价值。

数据范围

0<N,V≤100,

0<Si≤100

0<v_{ij},w_{ij}≤100

输入样例

3 5
2
1 2
2 4
1
3 4
1
4 5

输出样例:

8
public class T9 {
    private static int n; //物品组数
    private static int m; //背包容量
    private static int[][] v = new int[105][105];//物品体积
    private static int[][] w = new int[105][105];//物品价值
    private static int[] s = new int[105];//每组物品种数
    private static final Scanner sc = new Scanner(System.in);
​
    public static int dp_1(){
        int[] opt = new int[m + 1];
        for(int i = 1; i <= n; ++i){
            for(int j = m; j >= 0; --j){
                for(int k = 0; k < s[i]; ++k){
                    if(v[i][k] <= j){
                        opt[j] = Math.max(opt[j], opt[j - v[i][k]] + w[i][k]);
                    }
                }
            }
        }
        return opt[m];
    }
    public static void main(String[] args) {
        n = sc.nextInt();
        m = sc.nextInt();
        for (int i = 1; i <= n; i++) {
            s[i] = sc.nextInt();
            for(int j = 0; j < s[i]; ++j){
                v[i][j] = sc.nextInt();
                w[i][j] = sc.nextInt();
            }
        }
        System.out.println(dp_1());
    }
}