这里写目录标题
- 总览
- dp问题的优化
- 01背包问题
- 概述
- 算法思想
- 算法思想中的注意点
- 例题+代码
- 等效为一维数组
- 完全背包问题
- 概述
- 算法思想
- 例题+代码
- 等效为二维数组
- 等效为一维数组
- 多重背包问题
- 概述
- 算法思想
- 例题+代码
- 优化(采用二进制的方式)
- 基本思想
- 时间复杂度
- 例题+代码
- 分组背包问题
- 概述
- 算法思想
总览
dp问题的优化
要清楚:dp问题的优化一般是对dp问题的代码或者计算方程做一个等效变形
有了这个前提,我们在写dp问题时,要先将基本的代码写出来,之后再做优化
01背包问题
概述
假设我们有N个物品,我们的背包的体积是V,
N个物品每个物品有两个属性,分别是v体积、和w价值,或者说权重,每个物品要么不选,如果选的话,只能选一次
我们的目标是:要选出一些物品,在总体积能装的下的情况下(不一定必须装满),争取价值之和最大化
算法思想
Dp问题,要考虑两个问题,一个是状态表示,一个是状态计算
对于01背包问题,
状态表示:
我们先来看状态表示,因为大前提是N个物品和V的体积,也就是不算物品属性的话,我们有两个参数,所以,状态表示f,就有两个参数,那他就是二维的状态表示,f(i,j)
而对于一个状态表示 f,我们要清楚,他是一个集合,那么一个集合就会有属性,一个集合有三种属性,根据不同的题设,选择不同的属性,三种属性分别是max(元素最大值)、min(元素最小值)、数量(元素数量),本题根据题设,是属性选定为max,表示求最大价值
那这个集合的元素表示什么呢,该集合的元素表示在所有的选法中,只从前 i 个物品选择,总体积小于等于 j 的选法
总结:状态表示就是将题意数学化,将题设信息数学化为 f(i,j)
状态计算:
状态计算就是对上面的 f(i,j)进行计算,主要用到集合划分的思想
首先将集合划分为两部分,
第一个部分是 f 集合中所有不含 i 的选法的最大值,那么就是从1~i-1中选,总体积不超过 j ,也及时 f(i-1,j)
第二个部分是 f 集合中所有含 i 的选法的最大值,那么我们先将 i 排除,求得不算 i 时剩余的值最大的选法,因为排除了一个 i ,那么体积限制也跟着缩小,所以是从1~i-1中选,总体积不超过 j-vi 的选法的元素的最大值,即 f (i-1,j-vi)
最后,将两部分取maxAPI,求得 f(i,j),注意,因为第二部分是将 i 排除之后计算得出的,所以,在取max时,第二个参数要加上i的价值wi,即第二个参数为 f(i-1,j-vi)+ wi
如下三张图
算法思想中的注意点
首先,f(i,j)表示在前i个物品中选,总体积不超过 j 的选法,最大价值的值
这里的i 和 j 是通量,相当于将题设扩展成普遍性了,不是题设的最终量,也就是 i 和 j在代码中是变化的,最后是要输出f(N,V)
其次,对于状态计算,不含i的部分一定会出现,含i部分未必一定会出现,所以代码中要加一个判断,代码中会体现,注意留意
再者,代码的循环存在从0到等于终点,而不是0到小于终点或者1到等于终点,这点要注意,只不过有些循环的0初始值就是0,所以循环从1开始到等于终点,具体循环的范围,要根据代码原理、功能具体分析,不要想当然,要实事求是
例题+代码
n,m分别表示有n个物品,总体积是m
v[N],w[N]数组,分别存储每个物品的体积和价值
f[N][N],是状态表示,也就是前i个物品中选,总体积不超过j的选法中,价值最大的值
main函数里,
首先输入n和m
然后for循环,依次向v,和w中输入值(注意,i从1开始到等于n,因为这里是赋值下标物品的数据,没有第0个物品,所以i从1开始到等于n)
之后,双重循环 i 从1到等于n,j从0到等于m(至于为什么i从1开始而不从0开始,是因为f[0][0~m],表示前0个物品中,选出体积不超过 j的选法,因为物品是0,所以总价值也是0,所以f[0][0到m]都是0,又因为int定义自动初始化为0,所以不用管 i =0的情况)
循环内,f[i][j] = f[i-1][j],先将这个赋值给f[i][j]
之后判断第二部分,因为第二部分只有在 j>=v[i]时,才会出现,所以加上if判断之后,f[i][j] = max( f[i][j] , f[i-1][ j-v[i] ] + w[i] )
(因为第一步直接将值赋给了f[i][j],所以这里是用f[i][j]进行对比,这样做的好处是,将第一部分与第二部分隔离开,因为第二部分需要特判)
等效为一维数组
首先我们将 i 维直接去掉,接下来代码中的 i 维也跟着去掉,这样的话,双重循环中的第一行代码就变成了上图所示,由于f[ j ] = f[ j ] 是恒等式,所以可以直接去掉
之后,由于此时仅剩第二条代码,所以可以对 j 的循环范围进行更改,将if去掉,因为只有 j >= v[i] 才会执行第二条语句,所以,直接将j的循环范围改成 j=v[i] ; j <= m ; j++,然后将if去掉
然后直接将这行代码的 i 维去掉,就变成了上图所示,但是直接去掉是不合法的,应该进行一些其他变动
原理:(选择性查看)但是这样的话,从一维数组考虑,由于f[ j - v[i] ] 中的 j - v[i] 肯定小于 f[ j ] 中的 j ,而 j 是从小到大递增的,所以可以知道,f[ j - v[i] ] 在 f[ j ]被计算之前就被计算了,所以当前他已经有结果了,可以放在当前层,所以等效为上图箭头所示,而我们原来的代码中,第二个参数应该是f[i-1][j-v[i]+w[i]],所以,直接去掉是不合法的,应该进行一些其他变动
我们可以将j的范围掉头,让其从大到小进行遍历,就合法了
原理:(选择性查看)这样的话,从一维的角度来看,f[ j - v[i]] 在 f[j] 被计算之后才会被进行计算,也就不会在i层,而是等待被计算,又因为j-v[i]小,i从小到大遍历,所以这时等效为上图所示,f[i-1] [j - v[i] ] ,这时,就完成了等效
最终的代码等效为了上图,这就是01背包代码的终极形态
完全背包问题
概述
完全背包问题,是每个物品有无限个,每个物品都可以选无限次
算法思想
仍然是那几步,只不过这次前 i 个物品可以用无限次
主要区别在于状态计算,这里的划分依据是,对于第i个物品,选了几次
如果是选了0次,那么就是 f[ i-1 , j ]
如果选了k次,那么还是根据曲线救国的思想,先将k个i去掉,剩余部分求最大值,最后加回来k个i的价值
即 f[i-1 , j-kv[i] ] + kw[i]
(之所以还是 i -1 ,是因为 f 的含义是前i个物品,总体积不超过 j ,并没有强调前i个物品的次数,现在只是去掉了一个物品 i ,并没有涉及到 i 的次数)
例题+代码
这里又加了第三重循环,是加入k,表示物品 i 用了k次,因为又总体积和物品 i 的单位体积存在,所以k是有限的,k初始化为0,条件为 k * v[i] <= j
之后代码中直接用f[i][j] , 和 f[i - 1][j - k *v[i]] + k *w[i],进行max,这里之所以没有01背包中的两行代码是因为这里的划分,统一用的是一个公式,仅一个公式就包括了所有的划分情况(即 f[i - 1][j - k *v[i]] + k *w[i]这个公式),所以,直接一行代码即可,没有其他特殊情况
等效为二维数组
根据公式,当k从0开始取不同值的时候,我们可以得到第一行式子
假如我们求一下 f [i,j-v] ,k也从0开始取不同值,我们可以得到第二行式子,发现第二行式子加上w,就是第一行式子中,k!=0的结果,所以可以根据这个关系,将k的for循环删去,等效为双重循环
所以,等效之后的代码与01背包的朴素算法长的极为相似,唯一不同的是,在if后面的代码,这里是 f[i][j-v[i]]+w[i],而前面是f[i-1][j-v[i]]+w[i]
等效为一维数组
由于上面已经优化成二维数组了,且和01背包的代码仅仅差别在max的第二个参数中 f 数组的第一个参数,完全背包是 f[i][j-v[i]]-w[i],而01背包是 f[i-1][j-v[i]]+w[i]
完全背包的代码正好符合01背包向一维数组转换时,等效完 for循环中 j 的范围之后的代码,也就是完全背包问题,在转为一维时,还是跟01极为相似,不同的是,完全背包问题不用将 j 改为从大向小递减,其他均一致
多重背包问题
概述
多重背包问题,是每个物品的个数不一样,也就是每个物品的可选次数不一样
算法思想
状态表示仍然是那个,不同的是状态计算
而状态计算与上一个:完全背包问题极为相似,上面完全背包问题是每种物品都有无限次可以选,唯一的限制是体积不能超过总体积,
而这个多重背包问题,因为每组可选的次数为s[i],不再是无限次,所以,这里的状态计算是:第i个物品选了k次,k从0到s[i]
所以,代码唯一与完全背包问题不同的是,k的限制条件+1:k <= s[i];且多了一个s[ ]数组,在输入的时候,将物品i能选几次输入到数组s[i]中
例题+代码
优化(采用二进制的方式)
基本思想
原来的代码中,对于每个物品i,k都是从0开始递增循环,现在我们可以优化这个k的循环次数,让他不再一次一次循环
假如说,目前物品 i 的s[i] 是200,那么按照常规理解,k就要从0一直循环到200,但是,根据进制数的启发,对于一个数(例如200),他可以表示成十进制,也可以表示成二进制,如上图所示,0~127,都可以用2的0次方 到 2的6次方 加起来得到,因为一个二进制转化为十进制就是这样转化的:由几个二进制的基础数加起来得到,但是因为642=128,1282-1已经超过了200,所以128不能使用,最后差多少就补上这个差值,这样,我们就可以用几个数表示出来0到200的所有数了
而有了这个思想,我们可以将这些次数与当前物品的v和w进行结合,也就是将其打包成一个物品,一个物品有s次,可以被打包成好几个大物品,将所有的物品进行打包完之后,就可以转化成01背包问题了,这样即符合v和w,而且次数也对应可以凑出来,直接拿着打包好的这些物品,进行01背包的算法即可
时间复杂度
通过这样的优化,时间复杂度由NVS,优化到了NVlogS,其中logS是以2为底的对数的缩写
这时候带入数据量,如果算出来数据量最终结果为1e7~1e8,那么就可以在1s内算出来
例题+代码
使用该二进制进行数据打包时,特别注意的是,N开到了25000(如下图),而原始数据是1000,因为这里的1000仅仅是小物品的种类数,而每个物品有s个,但是我们进行打包时,一个物品 有s个 ,一个物品可以打包成logS个,所以就是1000*logS,带入S=2000,再加上空余量,大概就是25000
这里都是在打包物品,(这里将视野放在一个物品的打包即可,其他的交给for循环)
首先输入n和m
之后定义一个cnt=0,用来给打包好的物品编号(后续称为大物品)
for循环从1到等于n
定义 a b s 用来接收每个基础物品(后续称为小物品)的体积价值和次数
之后 定义k=1(用来记录2的进制数)
while循环,k <= s(为什么是k <= s,我们可以看到第27、28行,首先对于当前这个小物品,每次while循环都会将s进行更新,也就是每次s表示的都是减去目前最大的二进制之后,剩余的次数,而如果2的k次方已经是极限,则剩余的部分肯定不够2的k+1次方,所以,就有k <= s )
之后cnt++;(编号++)
v[cnt] = a * k
w[cnt] = b * k
这里就是将s个小物品打包成大物品的过程
之后s -= k
k *= 2
while循环结束后,有一个if判断,判断当s > 0 时,表示有剩余,最后将剩余次数进行打包即可
cnt++
v[cnt] = a * s;
w[cnt] = b * s;
最后将n更新为cnt,表示目前有cnt个大物品
最后将这些大物品进行一遍01背包的代码即可
分组背包问题
概述
分组背包问题,是会将物品进行分组,每一组最多只能选择改组内的一个物品,要么这个组不选,如果想选择这个组里的物品,只能选择改组内的一个物品且一次
算法思想
注意 这里的状态表示,从前i个变成了前i组,
状态计算,划分的时候,划分依据是,选择第i组的第k个,k可以为0(表示不选第 i 组)
如果k=0,那么就是第 i 组不选,那么就是f[i-1 , j ]
其他的 如果选第 i 组的第 k 个,那么就是 f [ i-1 , j - v[i, k]] + w[i,k]
与01背包差不多,只不过这里的 i 表示组,组中一旦有某个物品选了,那么该组就不可以第二次被选了,v、w用二维数组精确到组内成员
首先,s[N]是用来存储每一组有多少个种类的
输入n、m
之后for循环i从1到等于n,输入s[i]
且二重循环for,j从0到小于s[i],输入每个物品的v、w,因为i表示组,所以是w[i][j]、v[i][j]
(注意,因为这里对v和w赋值时用到的 j 是从0开始到小于s[i],那么之后再使用v和w时,那个循环变量也要从0到小于s[i])
其次,开始01背包一维代码的套用,
首先一个for,1从1到小于等于n
之后for,j从大到小,j从m到大于等于0,j–(其实这里的j>=0,本来对应的是 j >= v[i][k],但是k在第三重循环,所以这里改为0,将条件加到三重循环里面)
在之后三重循环for,k从0到小于s[i]
if ( j >= v[i][k]) f[ j ] = max (f[j] , f[j-v[i][k]]+w[i][k]);
最后输出 f[m]即可