【算法笔记】动态规划基础(二):背包dp

目录

01背包

例题

2. 01背包问题

状态表示

分析问题,首先需要遍历每一个物品,题中要求最大价值,限制条件是最大体积,要遍历所有可能的体积,因此要开两维: d p [ i ] [ j ] dp[i][j] dp[i][j]

  • 集合 :所有从前 i i i个物品中选,所选物品总体积不超过 j j j的方案
  • 属性:(所选物品总价值的)最大值

综上 : d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有从前 i i i个物品中选,所选物品总体积不超过 j j j的最大价值。

状态计算

集合划分:将这个集合划分成几个不同的集合,就要找一个不同的步骤。

最后一步就不同,有点 d f s dfs dfs指数级枚举的感觉,第 i i i个物品可以选、也可以不选。

如果不选第 i i i个物品,跟看第 i i i个物品前相比,总体积不变、总价值不变,也就是 d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i−1][j]

如果选第 i i i个物品,跟看第 i i i个物品前相比,体积增加了 v [ i ] v[i] v[i],价值增加了 w [ i ] w[i] w[i],当前的总体积不超过 j j j,那选之前的体积就不超过 j − v [ i ] j - v[i] j−v[i],也就是 d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] dp[i - 1][j - v[i]] + w[i] dp[i−1][j−v[i]]+w[i],这里可以看出,要保证 j − v [ i ] ≥ 0 j - v[i] \ge 0 j−v[i]≥0,也就是 j ≥ v [ i ] j \ge v[i] j≥v[i](选的物品总体积不能超过 j j j, j j j还小于 v [ i ] v[i] v[i], 那第 i i i个物品当然不能选了...)

可以得到状态转移方程:
d p [ i ] [ j ] = m a x ( d p [ i ] [ j ] , d p [ i − 1 ] [ j ] ) dp[i][j] = max(dp[i][j], dp[i - 1][j]) dp[i][j]=max(dp[i][j],dp[i−1][j])
d p [ i ] [ j ] = m a x ( d p [ i ] [ j ] , d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] ) ( j ≥ v [ i ] ) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]) (j \ge v[i]) dp[i][j]=max(dp[i][j],dp[i−1][j−v[i]]+w[i])(j≥v[i])

初始化

求最大价值,并且总价值也是从0开始加的,直接将所有位置初始化成0即可。

AC代码

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;

int dp[N][N];
int v[N], w[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> w[i];
    }
    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] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]);
        } 
    }
    cout << dp[n][m] << endl;
    return 0;
}

完全背包

例题

3. 完全背包问题

状态表示

和01背包分析方式相同,唯一不同的是,这次每一个物品可以选任意次,如果从 d f s dfs dfs的角度想,就是把选和不选的两种状态变成了选多少个,就是加了一层 f o r for for循环, d p dp dp也是一样的道理。还是两维,

和01背包相同:

d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有从前 i i i个物品中选,所选物品总体积不超过 j j j的最大价值。

状态计算

多了一重 f o r for for循环遍历每个物品选多少个,在01背包选的基础上,选多少个也就是减多少个 v [ i ] v[i] v[i]、加多少个 w [ i ] w[i] w[i],状态转移方程也就有了。

d p [ i ] [ j ] = max ⁡ 0 ≤ k ≤ t ( d p [ i ] [ j ] , d p [ i − 1 ] [ j − k ⋅ v [ i ] ] + k ⋅ w [ i ] ) , t = ⌊ j v [ i ] ⌋ dp[i][j]=\max\limits_{0\leq k \leq t} (dp[i][j], dp[i-1][j-k \cdot v[i]]+k \cdot w[i]),t=\lfloor \frac{j}{v[i]} \rfloor dp[i][j]=0≤k≤tmax(dp[i][j],dp[i−1][j−k⋅v[i]]+k⋅w[i]),t=⌊v[i]j⌋

初始化

求最大价值,并且总价值也是从0开始加的,直接将所有位置初始化成0即可。

TLE代码

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;

int dp[N][N];
int v[N], w[N];

int main(){
    int n, m;
    cin >> n >> m;
    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] = max(dp[i][j], dp[i - 1][j - k * v[i]] + k * w[i]);
            }
        }
    }
    cout << dp[n][m] << endl;
    return 0;
}

多重背包

例题

4. 多重背包问题 I

状态表示

和完全背包相同,就是加了个限制:第 i i i个物品选的数量不能超过 s [ i ] s[i] s[i],就是第3重 f o r for for循环&&上这个限制即可,其他都相同:

d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有从前 i i i个物品中选,所选物品总体积不超过 j j j的最大价值。

状态计算

d p [ i ] [ j ] = max ⁡ 0 ≤ k ≤ t , k ≤ s [ i ] ( d p [ i ] [ j ] , d p [ i − 1 ] [ j − k ⋅ v [ i ] ] + k ⋅ w [ i ] ) , t = ⌊ j v [ i ] ⌋ dp[i][j]=\max\limits_{0\leq k \leq t,k \leq s[i]} (dp[i][j], dp[i-1][j-k \cdot v[i]]+k \cdot w[i]),t=\lfloor \frac{j}{v[i]} \rfloor dp[i][j]=0≤k≤t,k≤s[i]max(dp[i][j],dp[i−1][j−k⋅v[i]]+k⋅w[i]),t=⌊v[i]j⌋

初始化

求最大价值,并且总价值也是从0开始加的,直接将所有位置初始化成0即可。

AC代码

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 105;

int dp[N][N];
int v[N], w[N], s[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> w[i] >> s[i];
    }
    for(int i = 1; i <= n; i++){
        for(int j = 0; j <= m; j++){
            for(int k = 0; k <= s[i] && k * v[i] <= j; k++){
                dp[i][j] = max(dp[i][j], dp[i - 1][j - k * v[i]] + k * w[i]);
            }
        }
    }
    cout << dp[n][m] << endl;
    return 0;
}

分组背包

例题

9. 分组背包问题

状态表示

分组背包问题是每组中有若干物品,每组只能不选或从中选一个,那是不是可以将01背包的第一重 f o r for for循环遍历每个物品变成遍历每组物品,再一重 f o r for for循环遍历一下组中的所有物品,看选哪个,剩下的就和01背包完全相同了。

d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有从前 i i i组物品中选,所选物品总体积不超过 j j j的最大价值。

状态计算

d p [ i ] [ j ] = max ⁡ ( d p [ i − 1 ] [ j ] , max ⁡ 1 ≤ k ≤ s [ i ] ( d p [ i − 1 ] [ j − w [ i ] [ k ] ] + v [ i ] [ k ] ) ) dp[i][j]=\max (dp[i - 1][j],\max\limits_{1\leq k \leq s[i]} (dp[i - 1][j-w[i][k]]+v[i][k])) dp[i][j]=max(dp[i−1][j],1≤k≤s[i]max(dp[i−1][j−w[i][k]]+v[i][k]))

初始化

求最大价值,并且总价值也是从0开始加的,直接将所有位置初始化成0即可。

AC代码

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 105;

int s[N], v[N][N], w[N][N];
int dp[N][N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> s[i];
        for(int j = 1; j <= s[i]; j++){
            cin >> v[i][j] >> w[i][j];
        }
    }
    for(int i = 1; i <= n; i++){
        for(int j = 0; j <= m; j++){
            dp[i][j] = max(dp[i][j], dp[i - 1][j]);
            for(int k = 1; k <= s[i]; k++){
                if(j >= v[i][k]) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i][k]] + w[i][k]);
            }
        }
    }
    cout << dp[n][m] << endl;
    return 0;
}

二维费用背包

例题

8. 二维费用的背包问题

状态表示

二维费用背包就是在标准的01背包的基础上,又加了一层限制,怎么对待体积限制的就怎么对待重量限制,就是多了一重 f o r for for循环遍历所有可能的重量,数组也对应加了一维,因此要开三维: d p [ i ] [ j ] [ k ] dp[i][j][k] dp[i][j][k]

  • 集合 :所有从前 i i i个物品中选,所选物品总体积不超过 j j j且总重量不超过 k k k的方案
  • 属性:(所选物品总价值的)最大值

综上 : d p [ i ] [ j ] [ k ] dp[i][j][k] dp[i][j][k]表示所有从前 i i i个物品中选,所选物品总体积不超过 j j j且总重量不超过 k k k的最大价值。

状态计算

其他和01背包相同,也是看选不选第 i i i个物品,不选的话总体积、总重量、总价值都不变,选的话总体积在原来的基础上增加 v [ i ] v[i] v[i],那选之前的最大体积就应该是 j − v [ i ] j - v[i] j−v[i],总重量在原来的基础上增加 m [ i ] m[i] m[i],那选之前的最大体积就应该是 k − m [ i ] k - m[i] k−m[i],总价值也加 w [ i ] w[i] w[i],能选的前提是 j ≥ v [ i ] & & k ≥ m [ i ] j \ge v[i] \&\& k \ge m[i] j≥v[i]&&k≥m[i]。

可得状态转移方程:
d p [ i ] [ j ] [ k ] = m a x ( d p [ i ] [ j ] [ k ] , d p [ i − 1 ] [ j ] [ k ] ) dp[i][j][k] = max(dp[i][j][k], dp[i - 1][j][k]) dp[i][j][k]=max(dp[i][j][k],dp[i−1][j][k])
d p [ i ] [ j ] [ k ] = m a x ( d p [ i ] [ j ] [ k ] , d p [ i − 1 ] [ j − v [ i ] ] [ k − m [ i ] ] + w [ i ] ) ( j ≥ v [ i ] & & k ≥ m [ i ] ) dp[i][j][k] = max(dp[i][j][k], dp[i - 1][j - v[i]][k - m[i]] + w[i]) (j \ge v[i] \&\& k \ge m[i]) dp[i][j][k]=max(dp[i][j][k],dp[i−1][j−v[i]][k−m[i]]+w[i])(j≥v[i]&&k≥m[i])

初始化

求最大价值,并且总价值也是从0开始加的,直接将所有位置初始化成0即可。

AC代码

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 105;

int dp[1010][N][N];
int v[1010], m[1010], w[1010];

int main(){
    int n, m1, m2;
    cin >> n >> m1 >> m2;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> m[i] >> w[i];
    }
    for(int i = 1; i <= n; i++){
        for(int j = 0; j <= m1; j++){
            for(int k = 0; k <= m2; k++){
                dp[i][j][k] = max(dp[i][j][k], dp[i - 1][j][k]);
                if(j >= v[i] && k >= m[i]) dp[i][j][k] = max(dp[i][j][k], dp[i - 1][j - v[i]][k - m[i]] + w[i]);
            }
        }
    }
    cout << dp[n][m1][m2] << endl;
    return 0;
}

混合背包问题

例题

7. 混合背包问题

状态表示

观察上面的几种背包问题可以看出,01背包、完全背包、多重背包其实都是一样的,都是:

d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有从前 i i i个物品中选,所选物品总体积不超过 j j j的最大价值。

状态计算

就是将上面三个背包的状态计算结合到一起,先判断一下是什么背包,然后用对应的方程转移即可。

cpp 复制代码
for(遍历所有物品){
  if(是01背包) 
  	套用01背包代码;
  else if(是完全背包)
    套用完全背包代码;
  else if(是多重背包)
    套用多重背包代码;
}

初始化

求最大价值,并且总价值也是从0开始加的,直接将所有位置初始化成0即可。

TLE代码

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;

int dp[N][N];
int v[N], w[N], s[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> w[i] >> s[i];
    }
    for(int i = 1; i <= n; i++){
        for(int j = 0; j <= m; j++){
            if(s[i] == -1 && j >= v[i]) dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]);
            else if(s[i] == 0){
                for(int k = 0; k * v[i] <= j; k++) dp[i][j] = max(dp[i][j], dp[i - 1][j - k * v[i]] + k * w[i]);
            }
            else{
                for(int k = 0; k <= s[i] && k * v[i] <= j; k++) dp[i][j] = max(dp[i][j], dp[i - 1][j - k * v[i]] + k * w[i]);
            }
        }
    }
    cout << dp[n][m] << endl;
    return 0;
}

TLE的原因是朴素的分组背包复杂度太高,需要用二进制优化(下文)

背包问题求方案数

对于给定的一个背包容量、物品费用、其他关系等的问题,求装到一定容量的方案总数。

这种问题就是把求最大值换成求和即可。

比如下面这题--01背包求方案数

例题

278. 数字组合

状态表示

和01背包相同,就是将总体积不超过 j j j变成了总体积恰好为 j j j,把总价值换成了方案数。

  • 集合 :所有从前 i i i个物品中选,所选物品总体积恰好为 j j j的方案
  • 属性:方案数

d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有从前 i i i个物品中选,所选物品总体积恰好为 j j j的方案数。

状态计算

求方案数就是累加,其他和01背包相同。

d p [ i ] [ j ] + = d p [ i − 1 ] [ j ] dp[i][j] += dp[i - 1][j] dp[i][j]+=dp[i−1][j]
d p [ i ] [ j ] + = d p [ i − 1 ] [ j − v [ i ] ] ( j ≥ v [ i ] ) dp[i][j] += dp[i - 1][j - v[i]](j \ge v[i]) dp[i][j]+=dp[i−1][j−v[i]](j≥v[i])

初始化

d p [ 1 ] [ 0 ] dp[1][0] dp[1][0]是从 d p [ 0 ] [ 0 ] dp[0][0] dp[0][0]转移过来的, d p [ 0 ] [ 0 ] dp[0][0] dp[0][0]是什么都不选,只有这一种方案,方案数为1。

AC代码

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 10005;

int dp[105][N];
int v[105];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i];
    }
    dp[0][0] = 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] += dp[i - 1][j - v[i]];
        }
    }
    cout << dp[n][m] << endl;
    return 0;
}

背包问题求具体方案

例题

12. 背包问题求具体方案

分析

一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的方案,可以参照一般动态规划问题输出方案的方法:记录下每个状态的最优值是由状态转移方程的哪一项推出来的,换句话说,记录下它是由哪一个策略推出来的。便可根据这条策略找到上一个状态,从上一个状态接着向前推即可。

cpp 复制代码
int v = V;  // 记录当前的存储空间

// 因为最后一件物品存储的是最终状态,所以从最后一件物品进行循环
for (从最后一件循环至第一件) {
  if (g[i][v]) {
    选了第 i 项物品;
    v -= 第 i 项物品的重量;
  } else {
    未选第 i 项物品;
  }
}

而对于求字典序最小的方案,可以从状态转移来分析一下:

对于从 1 1 1到 n n n中的每个物品,有三种情况:

  • 只能选,则必须选。
  • 不能选,则必不选。
  • 可选课不选,则必须选。(在前面的物品能选的情况下优先选择前面的物品)

为了满足上面的条件,可以从第 n n n个物品遍历到第 1 1 1个物品,每次求出当前背包的最大总价值 d p [ 1 ] [ m ] dp[1][m] dp[1][m]

然后再从第 1 1 1个物品遍历到第 n n n个物品,其中 d p [ i ] [ j ] dp[i][j] dp[i][j]为当前的最优情况,此时若满足:

  • d p [ i ] [ j ] = = d p [ i + 1 ] [ j ] dp[i][j] == dp[i + 1][j] dp[i][j]==dp[i+1][j] ,则表示 d p [ i ] [ j ] dp[i][j] dp[i][j]是由 d p [ i + 1 ] [ j ] dp[i + 1][j] dp[i+1][j]转移过来的。
  • d p [ i ] [ j ] = = d p [ i + 1 ] [ j − v [ i ] ] + w [ i ] dp[i][j] == dp[i + 1][j - v[i]] + w[i] dp[i][j]==dp[i+1][j−v[i]]+w[i],则表示 d p [ i ] [ j ] dp[i][j] dp[i][j]是由 d p [ i + 1 ] [ j − v [ i ] ] dp[i + 1][j - v[i]] dp[i+1][j−v[i]]转移过来的。

这时,如果上面两个只满足一个,是"不选"转移过来的就不用管,是"选"转移过来的就输出,并减掉对应的体积即可。如果两个都满足的话,为了保证字典序最小,也要输出并减掉对应的体积。(可选可不选的时候一定选。)

AC代码

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;
int dp[N][N];
int v[N], w[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> w[i];
    }
    for(int i = n; i >= 1; i--){
    	for(int j = 0; j <= m; j++){
    		dp[i][j] = dp[i + 1][j];
    		if(j >= v[i]) dp[i][j] = max(dp[i][j], dp[i + 1][j - v[i]] + w[i]);
    	}
    }
    int j = m;
    for(int i = 1; i <= n; i++){
    	if(j >= v[i] && dp[i][j] == dp[i + 1][j - v[i]] + w[i]){
    		cout << i << ' ';
    		j -= v[i];
    	}
    }
    cout << endl;
    return 0;
}

背包问题求最优选法方案数

例题

11. 背包问题求方案数

状态表示

和01背包求方案数思路大致相同,但要开两个数组,一个记录最优方案,一个记录方案数。

d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有从前 i i i个物品中选,所选物品总体积恰好为 j j j的最大价值。

c n t [ i ] [ j ] cnt[i][j] cnt[i][j]表示所有从前 i i i个物品中选,所选物品总体积恰好为 j j j,并且价值达到 d p [ i ] [ j ] dp[i][j] dp[i][j]的方案数。

状态计算

首先 d p dp dp数组和01背包的更新完全一致,主要来看 c n t cnt cnt数组是怎么更新的。

这里要注意一个关键点:价值达到 d p [ i ] [ j ] dp[i][j] dp[i][j]的方案数。

那你就要想:它的价值是怎么达到 d p [ i ] [ j ] dp[i][j] dp[i][j]的。

是不是有两种方式,一种是不选( d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i−1][j]),一种是选( d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] dp[i - 1][j - v[i]] + w[i] dp[i−1][j−v[i]]+w[i]),这两个取max,转移来的 d p [ i ] [ j ] dp[i][j] dp[i][j],那是不是 d p [ i ] [ j ] dp[i][j] dp[i][j]一定等于二者中较大的,并且是由较大的那个分支转移过来的。

d p [ i ] [ j ] dp[i][j] dp[i][j]由哪个状态转移过来, c n t [ i ] [ j ] cnt[i][j] cnt[i][j]就要累加哪个分支。

如果 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] dp[i][j]=dp[i - 1][j] dp[i][j]=dp[i−1][j] 且 d p [ i ] [ j ] ≠ d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] dp[i][j]\neq dp[i - 1][j - v[i]] + w[i] dp[i][j]=dp[i−1][j−v[i]]+w[i],说明我们此时不选择 把物品放入背包更优, d p [ i ] [ j ] dp[i][j] dp[i][j]由 d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i−1][j] 转移过来, c n t [ i ] [ j ] + = c n t [ i − 1 ] [ j ] cnt[i][j]+=cnt[i - 1][j] cnt[i][j]+=cnt[i−1][j]

如果 d p [ i ] [ j ] ≠ d p [ i − 1 ] [ j ] dp[i][j]\neq dp[i - 1][j] dp[i][j]=dp[i−1][j] 且 d p [ i ] [ j ] = d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] dp[i][j]=dp[i - 1][j - v[i]] + w[i] dp[i][j]=dp[i−1][j−v[i]]+w[i],说明我们此时选择 把物品放入背包更优, d p [ i ] [ j ] dp[i][j] dp[i][j]由 d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] dp[i - 1][j - v[i]] + w[i] dp[i−1][j−v[i]]+w[i] 转移过来, c n t [ i ] [ j ] + = c n t [ i − 1 ] [ j − v [ i ] ] cnt[i][j] += cnt[i -1][j - v[i]] cnt[i][j]+=cnt[i−1][j−v[i]]

如果 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] dp[i][j]=dp[i - 1][j] dp[i][j]=dp[i−1][j] 且 d p [ i ] [ j ] = d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] dp[i][j]=dp[i - 1][j - v[i]] + w[i] dp[i][j]=dp[i−1][j−v[i]]+w[i],说明放入或不放入 都能取得最优解, d p [ i ] [ j ] dp[i][j] dp[i][j]由 d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i−1][j] 和 d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] dp[i - 1][j - v[i]] +w[i] dp[i−1][j−v[i]]+w[i] 转移过来, c n t [ i ] [ j ] + = c n t [ i − 1 ] [ j ] cnt[i][j]+=cnt[i - 1][j] cnt[i][j]+=cnt[i−1][j], c n t [ i ] [ j ] + = c n t [ i − 1 ] [ j − v [ i ] ] ; cnt[i][j] += cnt[i -1][j - v[i]]; cnt[i][j]+=cnt[i−1][j−v[i]];

如果理解不了的话,从图论跑最长路、最长路计数的角度想一下:

cpp 复制代码
if(dp[i - 1][j] > dp[i][j]){
	dp[i][j] = dp[i - 1][j]; // dp[i][j] == dp[i - 1][j]就说明是从dp[i - 1][j]转移过来的
	cnt[i][j] += cnt[i - 1][j];
}

if(dp[i - 1][j - v[i]] + w[i] > dp[i][j]{
	dp[i][j] = dp[i - 1][j - v[i]] + w[i]; // dp[i][j] == dp[i - 1][j - v[i]] + w[i]就说明是从dp[i - 1][j - v[i]]转移过来的
	cnt[i][j] += cnt[i - 1][j - v[i]];
}

初始化

c n t [ 1 ] [ 0 ] cnt[1][0] cnt[1][0]是从 c n t [ 0 ] [ 0 ] cnt[0][0] cnt[0][0]转移过来的, c n t [ 0 ] [ 0 ] cnt[0][0] cnt[0][0]是什么都不选,只有这一种方案,方案数为1。

需要注意, d p dp dp数组的初始化和普通的01背包不太一样:普通的01背包的 j j j表示总体积不超过 j j j,是存在"没装满"的情况的,而当前定义的是总体积恰好等于 j j j,一定不存在"没装满"的情况,那怎么从根源上避免这种"没装满"的情况呢?

想一下,所有的那些"没装满"的状态,都是由哪几个"起点"转移过去的,也就是最极限的"没装满"是哪几个状态。

可以看一下下标为0的几个位置,对于所有的 d p [ 0 ] [ j ] dp[0][j] dp[0][j],一个物品都没选,还能有总体积?所有的"没装满"的状态都是由这些"起点"转移过去的,所以给这些点都初始化成负无穷,不让他们转移,就从根源上筛掉了所有的"没装满"的状态。

AC代码1

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;
const int mod = 1e9 + 7;

int dp[N][N], cnt[N][N];
int v[N], w[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> w[i];
    }
    for(int i = 1; i <= m; i++) dp[0][i] = -0x3f3f3f3f;
    cnt[0][0] = 1;
    for(int i = 1; i <= n; i++){
        for(int j = 0; j <= m; j++){
            dp[i][j] = max(dp[i][j], dp[i - 1][j]);
            if(j >= v[i]) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
        }
    }
    for(int i = 1; i <= n; i++){
        for(int j = 0; j <= m; j++){
            if(dp[i][j] == dp[i - 1][j]) cnt[i][j] = (cnt[i][j] + cnt[i - 1][j]) % mod;
            if(j >= v[i] && dp[i][j] == dp[i - 1][j - v[i]] + w[i]) cnt[i][j] = (cnt[i][j] + cnt[i - 1][j - v[i]]) % mod;
        }
    }
    int res = 0;
    for(int i = 0; i <= m; i++){
        if(dp[n][i] == dp[n][m]) res = (res + cnt[n][i]) % mod;
    }
    cout << res << endl;
    return 0;
}

AC代码2

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;
const int mod = 1e9 + 7;

int dp[N][N], cnt[N][N];
int v[N], w[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> w[i];
    }
    for(int i = 1; i <= m; i++) dp[0][i] = -0x3f3f3f3f;
    cnt[0][0] = 1;
    for(int i = 1; i <= n; i++){
        for(int j = 0; j <= m; j++){
        	int no = dp[i - 1][j];
            if(no > dp[i][j]){
            	dp[i][j] = no;
            	cnt[i][j] = cnt[i - 1][j];
            }
            else if(dp[i - 1][j] == dp[i][j]){
            	cnt[i][j] += cnt[i - 1][j];
            }
            if(j >= v[i]){
            	int yes = dp[i - 1][j - v[i]] + w[i];
            	if(yes > dp[i][j]){
            		dp[i][j] = yes;
            		cnt[i][j] = cnt[i - 1][j - v[i]];
            	}
            	else if(yes == dp[i][j]){
            		cnt[i][j] = (cnt[i][j] + cnt[i - 1][j - v[i]]) % mod;
            	}
            }
        }
    }
    int res = 0;
    for(int i = 0; i <= m; i++){
        if(dp[n][i] == dp[n][m]) res = (res + cnt[n][i]) % mod;
    }
    cout << res << endl;
    return 0;
}

图论写法

像这种 d p dp dp求方案数、求方案的题,都可以可以将其转化为图论中在DAG上的最短路的问题,感兴趣可以自己写一下。

小优化

根据贪心原理,当费用相同时,只需保留价值最高的;当价值一定时,只需保留费用最低的;当有两件物品 i i i, j j j 且 i i i 的价值大于 j j j 的价值并且 i i i 的费用小于 j j j 的费用时,只需保留 i i i。

滚动数组优化dp

什么是滚动数组

d p dp dp的问题最常用的优化之一:滚动数组 优化 d p dp dp状态表示,主要用来优化空间。

滚动数组优化了什么

对于有的题, d p dp dp的状态有很多个, d p dp dp数组也要开很多维,空间严重超限 (比如 d p [ 1000 ] [ 1000 ] [ 1000 ] dp[1000][1000][1000] dp[1000][1000][1000]),这个时候,如果状态转移的某一维满足某些条件,就可以用滚动数组将 d p dp dp数组的其中一维优化成 2 2 2 (比如 d p [ 2 ] [ 1000 ] [ 1000 ] dp[2][1000][1000] dp[2][1000][1000]),甚至有的时候还可以直接优化掉一维

滚动数组优化01背包

下面用01背包问题为例。

01背包的状态转移方程:
d p [ i ] [ j ] = m a x ( d p [ i ] [ j ] , d p [ i − 1 ] [ j ] ) dp[i][j] = max(dp[i][j], dp[i - 1][j]) dp[i][j]=max(dp[i][j],dp[i−1][j])
d p [ i ] [ j ] = m a x ( d p [ i ] [ j ] , d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] ) ( j ≥ v [ i ] ) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]) (j \ge v[i]) dp[i][j]=max(dp[i][j],dp[i−1][j−v[i]]+w[i])(j≥v[i])

可以看到,对于每次状态转移,都是 d p [ i − 1 ] [ . . . ] dp[i - 1][...] dp[i−1][...]向 d p [ i ] [ . . . ] dp[i][...] dp[i][...]转移 ,也就是每次转移时, d p dp dp的第一维用到的空间只有 2 2 2 ,那可不可以每次让 d p dp dp第一维只开 2 2 2,然后让现在的 i i i变成在这两维之间来回滚动呢?

观察一下,对于所有的 i i i和 i + 1 i + 1 i+1,是不是只要 i i i是奇数, i + 1 i + 1 i+1就必然是偶数;只要 i i i是偶数, i + 1 i + 1 i+1就必然是奇数

那在每次表示和转移 d p dp dp数组时都给第一维 % 2 \%2 %2,或者给第一维 & 1 \&1 &1 ,是不是就实现了在 0 0 0和 1 1 1之间反复滚动?

拿 i % 2 i \% 2 i%2举例子:

  • 如果 i i i是奇数, i % 2 = 1 i \% 2 = 1 i%2=1, ( i − 1 ) % 2 = 0 (i - 1) \% 2 = 0 (i−1)%2=0,实际上就是 d p [ 0 ] [ . . . ] dp[0][...] dp[0][...]向 d p [ 1 ] [ . . . ] dp[1][...] dp[1][...]转移,这时 i + 1 i + 1 i+1是偶数,下一次又是 d p [ 1 ] [ . . . ] dp[1][...] dp[1][...]向 d p [ 0 ] [ . . . ] dp[0][...] dp[0][...]转移,这时的 d p [ 0 ] [ . . . ] dp[0][...] dp[0][...]已经是 ( i + 1 ) − 2 (i + 1) - 2 (i+1)−2的结果了,覆盖不会影响 i + 1 i + 1 i+1的结果,所以可以这么做。
  • 如果 i i i是偶数也同理。

就是这样让 d p dp dp数组的某一维在 0 0 0和 1 1 1之间来回滚动,从而优化掉一维空间,这种优化方式就叫滚动数组。

ACcode

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;

int dp[2][N];
int v[N], w[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> w[i];
    }
    for(int i = 1; i <= n; i++){
        for(int j = 0; j <= m; j++){
            dp[i & 1][j] = dp[i - 1 & 1][j];
            if(j >= v[i]) dp[i & 1][j] = max(dp[i - 1 & 1][j], dp[i - 1 & 1][j - v[i]] + w[i]);
        } 
    }
    cout << dp[n & 1][m] << endl;
    return 0;
}

滚动数组直接彻底优化掉01背包的一维

观察上面的代码,容易发现:在每个阶段开始时,实际上执行了一次从 d p [ i − 1 ] [ ] dp[i - 1][] dp[i−1][]到 d p [ i ] [ ] dp[i][] dp[i][]的拷贝操作( d p [ i & 1 ] [ j ] = d p [ i − 1 & 1 ] [ j ] dp[i \& 1][j] = dp[i - 1 \& 1][j] dp[i&1][j]=dp[i−1&1][j]),这提示我们可以进一步省略掉 d p dp dp数组的第一维,只用一维数组,即当外层循环到第 i i i个物品时, d p [ j ] dp[j] dp[j]表示背包中放的物品的总体积不超过 j j j的最大价值和。

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;

int dp[N];
int v[N], w[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> w[i];
    }
    for(int i = 1; i <= n; i++){
        for(int j = m; j >= v[i]; j--){
            dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
        } 
    }
    cout << dp[m] << endl;
    return 0;
}

但如果直接优化掉一维的话,遍历体积的一维一定要倒序循环,为什么呢?

每次 d p dp dp更新时,都是由 d p [ i − 1 ] [ ] dp[i - 1][] dp[i−1][]向 d p [ i ] [ ] dp[i][] dp[i][]更新,每一次用到的一定是上一层的数据。

把 d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] − > d p [ i ] [ j ] dp[i - 1][j - v[i]] + w[i] -> dp[i][j] dp[i−1][j−v[i]]+w[i]−>dp[i][j]换成一维的话,就是 d p [ j − v [ i ] ] + w [ i ] − > d p [ j ] dp[j - v[i]] + w[i] -> dp[j] dp[j−v[i]]+w[i]−>dp[j],当我们从小到大更新时, 因为 j − v [ i ] j - v[i] j−v[i] 是严格小于 j j j 的,所以我们可以举个例子 d p [ 3 ] = m a x ( d p [ 3 ] , d p [ 2 ] + 1 ) dp[3] = max(dp[3], dp[2] + 1) dp[3]=max(dp[3],dp[2]+1) 因为我们是从小到大更新的,所以当更新到 d p [ 3 ] dp[3] dp[3]的时候, d p [ 2 ] dp[2] dp[2]已经更新过了,已经不是上一层的 d p [ 2 ] dp[2] dp[2]。

那这样会产生什么样的效果呢?为什么这样不行呢?想一下,更新 d p dp dp数组,一定是先遍历 i i i、后遍历 j j j,每次遍历 j j j的时候实际上 i i i是确定的,也就是同一个物品。如果 d p [ j − v [ i ] ] dp[j - v[i]] dp[j−v[i]]在前面已经被更新过了,也就是说前面的转移过程是 d p [ j − v [ i ] − v [ i ] ] + w [ i ] − > d p [ j − v [ i ] ] dp[j - v[i] - v[i]] + w[i] -> dp[j - v[i]] dp[j−v[i]−v[i]]+w[i]−>dp[j−v[i]],如果后面再拿 d p [ j − v [ i ] ] dp[j - v[i]] dp[j−v[i]]去更新 d p [ j ] dp[j] dp[j],也就是 d p [ j − v [ i ] ] + w [ i ] − > d p [ j ] dp[j - v[i]] + w[i] -> dp[j] dp[j−v[i]]+w[i]−>dp[j],那最后的效果就是 d p [ j − 2 ∗ v [ i ] ] + 2 ∗ w [ i ] dp[j - 2 * v[i]] + 2 * w[i] dp[j−2∗v[i]]+2∗w[i],那是不是意味着在当前 d p [ j ] dp[j] dp[j]这个状态,第 i i i个物品被选了两次?如果在后面再更新,还会被选更多次,这就和01背包一件物品只能选一次冲突了。

倒序遍历体积就避免了上面的问题。

滚动数组优化完全背包

弄懂滚动数组优化01背包为什么要倒序遍历体积后,在刚才的过程中想一想,如果正序遍历,就会出现一个物品可以选多次的情况,那一个物品选多次,不就是完全背包吗?所以在01背包滚动数组优化成一维的基础上,正序遍历体积,就变成了滚动数组优化完全背包,比如上面的完全背包问题,下面就是它的AC代码。

cpp 复制代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;

int dp[N];
int v[N], w[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        cin >> v[i] >> w[i];
    }
    for(int i = 1; i <= n; i++){
        for(int j = v[i]; j <= m; j++){
            dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
        } 
    }
    cout << dp[m] << endl;
    return 0;
}

二进制优化多重背包

例题

多重背包问题 II

分析

优化的本质就是:用二进制的思想将每个物品可以选的次数 s s s拆分

首先,将多重背包转化成最朴素的01背包 :也就是每种物品有 s s s个,将每一个都看成是01背包中的1个物品,每个物品都只有选 0 0 0个和选 1 1 1个这两种选法。

但这样来选,复杂度太高了,可不可以将某些物品放在一堆呢?

比如第 i i i件物品有 s s s件:

首先,朴素的多重背包问题,是不是要从 1 1 1~ s s s遍历一遍?

如果能将这 s s s个物品分成几堆,通过选这几堆其中的几堆物品,他们的和能凑出来从 1 1 1~ s s s的所有情况,是不是就可以通过遍历这几堆来替代这层枚举了?

进而想到,任何一个数都有它的二进制表示,任何一个数都可以表示成若干二的整数次幂的和

举个例子:对于 s = 1023 s=1023 s=1023:

用 2 0 2^0 20可以凑出来 0 0 0~ 1 1 1的每个数。

用 2 0 2^0 20和 2 1 2^1 21可以凑出来 0 0 0~ 3 3 3的每个数。

用 2 0 2^0 20和 2 1 2^1 21和 2 2 2^2 22可以凑出来 0 0 0~ 7 7 7的每个数。

...

所以用 2 0 2^0 20 ~ 2 9 2^9 29一定可以凑出从 0 0 0~ 1023 1023 1023的每个数。

如果 s s s不是2的整数次幂减一怎么办呢?这个时候只需要在后面再补一个 s − (前面几项 2 k 的和) s-(前面几项2^k的和) s−(前面几项2k的和)就可以了

比如对于 s = 10 s = 10 s=10

用 2 0 2^0 20可以凑出来 0 0 0~ 1 1 1的每个数。

用 2 0 2^0 20和 2 1 2^1 21可以凑出来 0 0 0~ 3 3 3的每个数。

用 2 0 2^0 20和 2 1 2^1 21和 2 2 2^2 22可以凑出来 0 0 0~ 7 7 7的每个数。

用 2 0 2^0 20和 2 1 2^1 21和 2 2 2^2 22和 3 3 3可以凑出来从 0 0 0~ 10 10 10的每个数。

那这样分堆有什么用呢?

仔细想想,从 1 1 1~ s s s之间的每一个数都能用上面这几个堆的数凑出来,是不是每种凑法就相当于是这个数的一个二进制表示(除了多最后一个数的时候)?

而一个数的二进制表示只有0和1,那是不是问题可以转化为01背包?

事实上,最后多出来那个非 2 k 2^k 2k的数在凑数的时候也只能选0或1次。
那为什么不直接把它也用一个 > s >s >s的 2 k 2^k 2k的数来表示呢?
还是上面的例子:

对于 s = 10 s = 10 s=10

用 2 0 2^0 20可以凑出来 0 0 0~ 1 1 1的每个数。

用 2 0 2^0 20和 2 1 2^1 21可以凑出来 0 0 0~ 3 3 3的每个数。

用 2 0 2^0 20和 2 1 2^1 21和 2 2 2^2 22可以凑出来 0 0 0~ 7 7 7的每个数。

用 2 0 2^0 20和 2 1 2^1 21和 2 2 2^2 22和 3 3 3可以凑出来从 0 0 0~ 10 10 10的每个数。

用 2 0 2^0 20和 2 1 2^1 21和 2 2 2^2 22和 2 3 2^3 23可以凑出来从 0 0 0~ 15 15 15的每个数。
如果最后组数放 2 3 2^3 23个而不是 3 3 3个,就会凑出来选 11 11 11~ 15 15 15次的情况,而实际上是不能选这么多次的,因此这样凑是错的。

把对于每组物品能选 1 1 1~ s s s次,转化成每堆物品只能选 0 0 0或 1 1 1次,01背包的每种选法恰好等同于分组背包的每种选法,内层循环时间复杂度由 O ( s ) O(s) O(s)降到了 O ( l o g s ) O(logs) O(logs)。

AC代码

cpp 复制代码
#include <iostream>
#include <vector>
#define endl '\n'
const int N = 10010, M = 2010;
using namespace std;

int dp[M];

int main(){
    ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
    int n, m;
    cin >> n >> m;
    vector<int> v, w;
    for(int i = 0; i < n; i++){
        int a, b, s;
        cin >> a >> b >> s;
        int t = 1;
        while(t <= s){
            v.push_back(a * t);
            w.push_back(b * t);
            s -= t;
            t *= 2;
        }        
        if(s){
            v.push_back(a * s);
            w.push_back(b * s);
        }
    }
    n = v.size();
    for(int i = 0; i < n; i++){
        for(int j = m; j >= v[i]; j--){
            dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
        }
    }
    cout << dp[m] << endl;
    return 0;
}

为什么这样拆分是对的?

如果直接遍历转化为01背包问题,是每次都拿一个来问,取了好还是不取好 ,假如10个取7个最优,那么在实际的遍历过程中在第7个以后经过状态转移方程其实已经是选择"不取"好了。

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

为什么是这样呢?因为dp本身就是用来比较哪个更优的 ,在状态转移的过程中自然会完成上述询问得出相应结果,这也就是说,无论最终取几个是最优解,用二进制取出来的结果和一次一次问是完全一样的

二进制分堆能不重不漏的表示到所有情况,一个一个遍历也能遍历到所有情况 ,而dp每一步的状态转移都取的是当前的最优解,两者都做到了不重不漏,逐渐递推到全局,也一定是全局的最优解。

一篇写的很好的文章

背包问题九讲 - 崔添翼

相关推荐
Olrookie13 分钟前
若依前后端分离版学习笔记(二十)——实现滑块验证码(vue3)
java·前端·笔记·后端·学习·vue·ruoyi
茉莉玫瑰花茶22 分钟前
floodfill 算法(dfs)
算法·深度优先
请你喝好果汁64123 分钟前
Conda_bashrc 初始化机制学习笔记
笔记·学习·conda
CoderCodingNo1 小时前
【GESP】C++五级考试大纲知识点梳理, (5) 算法复杂度估算(多项式、对数)
开发语言·c++·算法
MYX_3091 小时前
第三章 线型神经网络
深度学习·神经网络·学习·算法
_李小白1 小时前
【Android Gradle学习笔记】第八天:NDK的使用
android·笔记·学习
摇滚侠2 小时前
Spring Boot 3零基础教程,WEB 开发 自定义静态资源目录 笔记31
spring boot·笔记·后端·spring
摇滚侠2 小时前
Spring Boot 3零基础教程,WEB 开发 Thymeleaf 遍历 笔记40
spring boot·笔记·thymeleaf
坚持编程的菜鸟3 小时前
LeetCode每日一题——三角形的最大周长
算法·leetcode·职场和发展
Chloeis Syntax3 小时前
接10月12日---队列笔记
java·数据结构·笔记·队列