前言
在了解线性动态规划之前,先讲一下什么是动态规划,动态规划是一种表格处理法,将原问题缩小成为已解的子问题并记录子问题的结果在表格中,再由子问题自底向上的求解问题的方法,并且原问题不会影响到已经求解出来的子问题(既无后效性)。所以我们在求解动态规划的时候一般会开一个状态数组(表格),这个数组(表格)就是记录当前状态的子问题的解决方案,在求解往后问题的时候直接可以通过该数组建立状态转移方程调用子问题的结果从而解出问题。动态规划的好处在于可以减少重复计算(和递归相比这点就非常明显),那么如何确定状态转移方程就是动态规划中最重要的一点。确定状态转移方程一般有以下三步:
1、确定状态
解动态规划问题我们一般会开一个数组,这个数组是我们解题的关键,数组中的每个值f[i],f[i][j]其实都是代表了i ,j 在当前情况下的状态,那么确定状态需要两步:
举个例子:用最少要用多少个面值为ak = 2,5,7的硬币拼出27块钱
-最后一步
我们假设有m块硬币可以拼出27块钱,假设最后一枚硬币为ak,虽然我们不知道最优策略是什么,但是我们知道a1,a2...am这m块硬币如果可以拼出27块钱,那么我们知道的是m-1块硬币如果拼出了最优策略,那么我们只要在最后一块硬币找到最优值那么就是拼出27块钱的最优策略
-子问题
现在的问题就由如何拼出27块钱转为如何拼出27-ak块钱,那么拼出27-ak块钱的问题就是拼出27块钱的子问题
2、确定转移方程
根据上一步对状态的分析我们不难得到状态方程
我们设f[x] = 最少用多少硬币拼出27块钱,变量x记录当前拼出的金额x
f[x] = min{f[x-2]+1,f[x-5]+1,f[x-7]+1}
由此可得我们可以由f[1],f[2]......这样递推到f[27]
3、确定初始条件和边界情况
我们通过一直x-2,x-5,x-7来缩小问题规模,那么我们如果x-2小于0怎么办?那么我们该什么时候停下来呢?
-初始条件
f[2] = min{f[0]+1,f[-3]+1,f[-5]+1},那么此时f[0]就是我们的初始值,代表拼出0块钱所需要的硬币,那必然为0,所以只要f[0] = 0就是第一步初始条件
-边界情况
比如f[1] = min{f[-1]+1,f[-4]+1,f[-6]+1}那么此时我们可以发现,我们不能用该硬币拼出1块钱,那么此时我们就应该将f[1]取无穷大,当我们不能拼出来这个面值时,我们就将当前状态f[x]赋值无穷大。 这就是边界情况,我们可以通过if进行判断赋值。
因此在面对动态规划问题一般分三个步骤
1、通过最后一步,子问题分析出状态,并开设状态数组
2、由状态得到状态转移方程
3、确定初始条件和边界情况
那么什么问题可以用到动态规划?
1、具有最优子结构
既原问题的最优解包含子问题的最优解 ,因为我们在求解动态规划的时候我们一般先求解小的子问题,然后在解决大的问题的时候用小的子问题的解,如果原问题的解和子问题的解没有关系那么就不能使用动态规划,假设一个学校有十个班级,如果要求学校的最大成绩,那么就要把十个班级的成绩求出来,在其中找到学校的最大成绩,这就是将一个问题解析成了10小问题求解
2、子问题重叠
相对于递归而言动态规划可以避免很多重复计算,因为每个子结果都是在表格里记录的,每次要用的时候就像查表一样这样可以避免很多重复的计算也同时是一个优化手段
3、无后效性
当前状态只跟之前的状态有关,跟后面的状态没有关系
-------------前面(已解)-------------------当前状态--------------后面(未解)------------
以上是对动态规划的个人理解,现在讲一下线性动态规划,所谓线性动态规划其实就是以线性数据结构为基础,通过某种递推关系(状态转移方程)得到最终方程的算法,一般分为一维和二维。当然还有以树为数据结构的动态规划,比如树形dp
01背包问题
01背包的分析
01背包问题可以说是最经典的背包问题,也是最基础的背包问题,01背包最重要的点在于在不大于背包容量的情况下,如何计算背包里面所能取到的最大价值,并且每个物品只能取一次,并且在选取物品的时候要考虑价值最优。这题的递推思路跟上一题的思路是一致的,但是01背包的不同点在于每个物品只能取一次,并且要考虑价值问题。我们可以用三步骤来分析这个问题
第一步
最后一步
假设最后一个物品是体积为ak,价值为vk,我们不知道之前V-ak里面放的是什么物品,但我知道的是v-ak里面放的物品价值一定是最大的,那么假设v-ak的价值为m,那么对于最后一步而言我们只要在最后一个物品中选一个价值最大的物品这样就构成了整体价值最大。
子问题
那么现在就将问题缩为,如何在v-ak的体积中找到价值最大值,其实ak就是背包中的物品,也就是说我们可以枚举每一个物品,在每个物品中求出最大值
第二步
状态转移方程
f[v体积] =Max{v体积时取这个物品的价值,v体积不取该物品的价值}取一个最大值
第三步
初始条件和边界情况
当v-ak<0的时候,就说明背包容量不够就直接从vi开始枚举体积,这就是边界情况
当v-ak =0的时候,说明背包刚刚好放下物品,也就是我们的初始化条件f[0] = 0
package 动态规划问题.背包问题;
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sr = new Scanner(System.in);
int N = sr.nextInt();//N件物品
int V = sr.nextInt();//V的容量
int v[] = new int[N+1];//体积数组
int w[] = new int[N+1];//价值数组
int dp[][] = new int[N+1][V+1];//二维状态数组 dp[i][j]中i代表第i个物品,j代表容量,dp的状态值代表第i个物品在j容量时候的价值
for(int i=1;i<=N;++i) {//输入
v[i] = sr.nextInt();
w[i] = sr.nextInt();
}
//二维做法
//先从二维开始讲起
for(int i=1;i<=N;++i) {//枚举每一个物品
for(int j = v[i];j<=V;++j) {//枚举背包重量,这里的j从vi一直枚举到V
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-v[i]]+w[i]);
//这里用j容量情况下取到i物品背包的价值与不取该物品j容量的价值进行比较取最大值
}
}
System.out.print(dp[N][V]);
}
}
滚动数组优化
这是二维的01背包,空间复杂度为O(mn),最坏情况是O(n^2),我们每次更新i层数据只需要i-1层数据,也就是说只跟上一层数据有关,那么我们可以使用滚动数组优化一下空间复杂度讲空间复杂度降低到线性
int N = sr.nextInt();//N件物品
int V = sr.nextInt();//V的容量
int v[] = new int[N+1];//体积数组
int w[] = new int[N+1];//价值数组
//int dp[] = new int[V+1];//一维状态数组
for(int i=1;i<=N;++i) {//输入
v[i] = sr.nextInt();
w[i] = sr.nextInt();
}
//方法一
for(int i=1;i<=N;++i){
for(int j=V;j>=v[i];--j){//这里注意枚举方式是从V开始枚举到wi
dp[j] = Math.max(dp[j],dp[j-v[i]]+w[i]);
}
}
//这里之所以不像二维一样从wi一直枚举到V是因为每次往前枚举j的时候都要用到上一层的数据,如果用一维这样递推的话,之前的数据会被之后的数据所覆盖那么就会错误,所以只能由后往前,但也可以用如下的方式开设滚动数组,就避免了如下问题
//方法二
也可以开一个二维滚动数组,但要理解i%2的意思,其实原理还是一样的
int[][] dp = new int[2][N+1]
for(int i=1;i<=N;++i){
int now = i%2;//滚动值
for(int j = v[i];j<=V;++j){
dp[now][j] = Math.max(dp[1-now][j],dp[now][j-v[i]]+w[i]);
}
}
完全背包问题
不同于01背包只能取一次物品,完全背包可以无限取物品,其实思考状态方程的方式跟01背包是一样的,但是递推的方式跟01背包有所不同
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sr = new Scanner(System.in);
int N = sr.nextInt();
int V = sr.nextInt();
int[] v = new int[N+1];//体积数组
int[] w = new int[N+1];//价值数组
for(int i=1;i<=N;++i) {
v[i] = sr.nextInt();
w[i] = sr.nextInt();
}
int[] dp = new int[V+1];//状态数组
for(int i =1;i<=N;++i) {//这里必须要理解为什么j要从vi递推到V,j=v[i]的时候就决策了是否要拿i物品,当j = 2*vi 的时候且dp[vi]+w[i]>dp[j]的情况下,我们就拿了两次i物品
//j一直往前递推我们就可能可以取到无限次i物品
for(int j=v[i];j<=V;++j) {
dp[j] = Math.max(dp[j], dp[j-v[i]]+w[i]);
}
}
System.out.println(dp[V]);
}
}
多重背包
多重背包和完全背包的不同点在于,多重背包不能无限取物品,他只能取有限次物品,那么在被限制的物品选取的条件下,我们要找到放入背包的最大价值,其实大体的递推思路跟01背包和完全背包差不多,所以要懂多重背包就得懂01和完全背包,相对于他们而言,多重背包就是要再枚举能拿的最大物品数目,那么如何优化枚举物品数是优化多重背包的关键
暴力枚举法
public class 动态规划多重背包问题 {
public static void main(String[] args){
Scanner sr = new Scanner(System.in);
int N = sr.nextInt();//种数
int V = sr.nextInt();//体积
int []v = new int[N+1];//体积
int []w = new int[N+1];//价值
int []s = new int[N+1];//个数
for(int i=1;i<=N;++i) {
v[i] = sr.nextInt();
w[i] = sr.nextInt();
s[i] = sr.nextInt();
}
int dp[] = new int[V+1];
for(int i=1;i<=N;++i) {//暴力法
for(int j =V;j>=v[i];--j) {//枚举体积
for(int k=1;k<=s[i] && j>=k*v[i];++k) {//枚举个数且k*w[i] 不能大于j,这里要从1开始枚举,一直枚举到s[i],那么可以在这层循环里面寻找优化方法
dp[j] = Math.max(dp[j], dp[j-k*v[i]]+k*w[i]);
}
}
}
System.out.print(dp[V]);
}
}
暴力枚举法的时间复杂度是O(NVK),那么我们可以通过二进制优化来降低枚举物品的数目
枚举二进制
二进制优化的好处在于,任何数都可以用二进制表达,一个数通过二进制拆分枚举的次数就会变少。其实不止是二进制拆分, 三进制,其他进制其实是一样的。那么我们通过二进制将多重背包的物品拆分开,以此来降低枚举物品的数量。举个例子有五十个苹果,要取n个苹果(n<=50),那么最普通的方法就是将一个一个拿出来,要拿50次。但如果我们在每个箱子放2^k个苹果的话,那么也就要放1,2,4,8,16,19个苹果就可以了,取任意一个苹果只要推出几个箱子就可以了。
其实多重背包就是用这个思路优化的,在1,2...s[i]中选取最大价值物品,将其转化为01背包问题
多重背包的二进制优化
public class 动态规划多重背包问题_二进制优化 {
public static void main(String[] args) {
Scanner sr = new Scanner(System.in);
int N = sr.nextInt();//种数
int V = sr.nextInt();//体积
int []v = new int[N+1];//体积
int []w = new int[N+1];//价值
int []s = new int[N+1];//个数
for(int i=1;i<=N;++i) {//输入
v[i] = sr.nextInt();
w[i] = sr.nextInt();
s[i] = sr.nextInt();
}
int dp[] = new int[V+1];
for(int i=1;i<=N;++i){//枚举物品数
if(s[i]*v[i]>=V) {//如果s[i]*v[i]>=V 说明直接可以用完全背包来枚举
for(int j=v[i];j<=V;++j) {//正推
dp[j] = Math.max(dp[j], dp[j-v[i]]+w[i]);
}
}else {//否则使用二进制枚举物品数
for(int k =1;s[i]>0;k<<=0) {//1 2 4...s[i]-2^p,只用枚举p+2件物品
int x = Math.min(k, s[i]);
for(int j = V;j>=x*v[i];--j) {//当 01 背包枚举物品
dp[j] = Math.max(dp[j], dp[j-x*v[i]]+x*w[i]);
}
s[i]-=k;
}
}
}
System.out.print(dp[V]);
}
}
多重背包单调队列优化
不管是二进制优化还是多重背包优化其实本质上是拆分思想,二进制拆分物品数量将n件物品通过二进制拆成log2n件数量,单调队列拆分的是背包容量m,根据v(第i个物品的体积)将f[0..m]拆成v个类,并在O(m)时间内完成状态更新。从时间复杂度来看二进制时间复杂度为O(mnlog2n)而单调队列的时间复杂度为O(mn),因此在时间复杂度上单调队列要更有优势。如果没看过单调队列最好先看一下滑动窗口再来。这里利用单调队列维护一个价值单调递增的队列,而队头存储的就是最大价值,这里要注意的是队头不能是划出窗口的元素,那么如何判断队头是否出队呢?
当我们计算容量为m时的最大价值,由于我们在m容量下只能放入s个物品,因此我们利用这个条件来判断单调队列的窗口范围,既当dl[head] < m-s*v 时候头队列已经划出窗口,队头出队。因此,f[m]是由前面不超过数量s的同类值递推得到的。这就相当于从宽度为s的窗口中选出最大值来更新当前值,因此我们就需要顺序更新数组,这里就需要拷贝上个物品的状态方程(这里我们假设拷贝数组为g),这样就不会因为顺序更新数组而影响当前物品的状态方程。因为f[m]通过前面的旧值g[dl[head]]来更新,所以窗口是在g数组上进行滑动。其中(m-dl[head])/v*w代表还能放入物品的值,也就是说f[m]=窗口中的最大价值+还能放入物品的价值来更新的
例如,m=12,v=3,s=2
我们按v将物品容量分为三类
f[0],f[3],f[6],f[9],f[12]
f[1],f[4],f[7],f[10]
f[2],f[5],f[8],f[11]
当我们更新容量为6时的状态,我们的窗口的左边界为6-2*3=0,也就是窗口的范围是0-3之间,假设当前队列的队头为dl[head]=0,那么在更新f[6]时我们要考虑三点:
1、队头是否在窗口中
2、队头的价值是否大于当前容量的旧值g[m]
3、当前物品价值是否大于队尾价值(g[dl[tail]]),大于则队尾出队
最后将当前容量(m)状态存入队列,并且将窗口往右移动一个单位
import java.util.Scanner;
public class 多重背包_单调队列优化1 {
public static void main(String[] args) {
Scanner sr = new Scanner(System.in);
int N = sr.nextInt();//数量为N
int V = sr.nextInt();//容量为V
int [] vi = new int[N+1];//体积数组
int [] wi = new int[N+1];//价值数组
int [] si = new int[N+1];//数量数组
int [] dl = new int[V+1];//队列数组
int [] dp1 = new int[V+1];//dp[v]表示体积v情况的最大价值
int [] dp2 = new int[V+1];
for(int i=1;i<=N;++i) {
vi[i] = sr.nextInt();
wi[i] = sr.nextInt();
si[i] = sr.nextInt();
}
for(int i=1;i<=N;++i) {//遍历背包
System.arraycopy(dp1, 1, dp2, 1, V);//拷贝数组作为上一个物品的状态量
for(int m=0;m<vi[i];++m) {//按照第i个物品的容量将物品分为vi个类
int h = 0,t = -1;//定义队列的头尾指针,且 h>t 时为空队列,将h=0,t=-1来重置队列
for(int n=m;n<=V;n+=vi[i]) {//按类进行滑动窗口
//当h > t 时表示空队列,这种情况就直接pass
if(h<=t && dl[h] < n-si[i]*vi[i])h++;//第一种情况是队头已经划出窗口直接h++
if(h<=t)dp1[n] = Math.max(dp2[n], dp2[dl[h]]+(n-dl[h])/vi[i]*wi[i]);//n容量下的上一个物品的价值和队头存储的物品价值进行比较,取最大价值并更新当前容量状态
while(h<=t && dp2[n] >= dp2[dl[t]]+(n-dl[t])/vi[i]*wi[i])t--;//当前物品价值和队尾价值进行比较,若大于队尾价值队尾出队,既t--
dl[++t] = n;//将该状态存入队列
}
}
}
System.out.println(dp1[V]);
}
}