CSP-背包问题专题(背包dp)
知识概述
背包问题是算法设计中的经典问题,其分支也非常众多,主流分类有如下几种:部分背包、0-1背包、完全背包、多重背包、分组背包等。在贪心算法的学习时,我们了解了部分背包问题可以使用贪心算法进行求解。本文将介绍其余几种背包问题,使用动态规划的思想能够很好的解决背包类问题,求出全局最优解。
这些众多背包问题的根源便是0-1背包问题,掌握到其求解方式后能够通过改变部分求解过程,完成其他的背包问题。首先抛出一个结论:动态规划能够完美解决所有背包问题,其根本原因是因为动态规划能够遍历到所有可能出现的情况,从中找到最优解。
0-1背包问题
:
给定一个背包,有一定的容量,有若干物品,每个物品有其价值和占用的空间,每种物品最多只能选择一次。(每个物品只有0态和1态,0-1背包因此得名)求出在使用固定容量的前提下,能够获得的最大物品价值。
该问题中使用的更新数组dp[i][j],记录了到第i个物品为止,j容量能够容纳下的最大价值。数组w[i]和v[i]分别记录了第i个物品的占用空间和价值。
在进行更新时,我们可以利用动态规划的更新公式:
dp[i][j]=max(dp[i-1][j],dp[i-1][j-wi]+vi)
对该公式的理解:dp[i-1][j]和dp[i-1][j-wi]+vi表示了对第i个物品的不选择or选择两种决策,因为求解的是价值最大值,所以使用max取最优策略。
0-1背包问题优化
:
按照上述的方式进行计算,需要的时间复杂度和空间复杂度均为O(N×a),N是物品的数量,a是背包上限容量。
由于动态规划的本质就是遍历可能的决策,从中找到最优决策,不可避免的是对所有物品和背包容量进行访问,因此时间复杂度O(N×a)无法优化。如果我们只求解最优的结果而没有存储过程的需求,使用单个数组或者是滚动数组是一种更好的抉择。尽管这种优化方式会导致我们并不能够输出具体选择了哪个物品,但是在部分只需求结果的场景中可以作为防止爆空间的手段。
具体优化操作:单个数组存储数据
对上述的公式进行分析可以发现特点—计算 i 维的数据只会用到 i -1的数据,且更新 j 维的数据用到 j 或 j – w 的数据。根据这个特点我们可以将 i 维度去掉,只使用一维数组来更新数据,且由于更新 j 维时使用的数据在 j 或 j – w 位置,采取倒序更新的方式比较合理。
完全背包问题
:
给定一个背包,有一定的容量,有若干物品,每个物品有其价值和占用的空间,每种物品可以被选择无数次。求出在使用固定容量的前提下,能够获得的最大物品价值。
该问题中使用的更新数组dp[i][j],记录了到第i个物品为止,j容量能够容纳下的最大价值。数组w[i]和v[i]分别记录了第i个物品的占用空间和价值。与0-1背包问题不同的是,在更新dp[i][j]时不但可能使用 i-1 维度数据,还有在 i 维数据基础上额外添加第 i 件物品的可能,公式变形成:
dp[i][j]=max(dp[i-1][j],dp[i-1][j-wi]+vi,dp[i][j-wi]+vi)
完全背包问题优化
:
看到这样的公式,可能大部分人会想:这个同时使用 i 维和 i -1维怎么优化空间啊?
但巧妙的是,不需要太复杂的操作,而是只需要将0-1背包的逆序改为正序即可。原因如下:在0-1背包问题我们使用逆序的原因是每个物品只能被选择一次,即 i 维数据只能被 i -1维的数据更新,且必须被更新为 i -1维数据。但在完全背包的情境下,不同的是 i 维数据可以被 i -1 或 i 维数据更新,这种更新原则和正序的更新原则完全一致。综上,完全背包可以使用这种简便的方法进行空间优化。
多重背包
:
给定一个背包,有一定的容量,有若干物品,每个物品有其价值和占用的空间,每种物品可以被选择有限次数。求出在使用固定容量的前提下,能够获得的最大物品价值。
多重背包问题与完全背包看来类似,但是展开分析后又发现区别很大。在完全背包中我们并没有限值每件物品的出现次数,并不符合多重背包的题意。且由于这种限制并不容易实现,不符合动态规划的优美性和便捷性。
解决多重背包更普适性的方法是拆分法,即把多充背包拆成多个0-1背包,再跑0-1背包的程序即可完成求解。但这种暴力拆分的算法并不精巧,会带来诸多的问题:
如果一个物品最多可以被选择1e5次,就要把它拆分成这么多个子物品么?这种处理方式是否会带来激增的时间和空间复杂度呢?
显然答案是会的,这种糟糕的算法会让我们在设计程序时遭遇很大的困难,动态规划看起来没法求解这个问题了。为了简便实际的解决拆分问题,引入二进制拆分。
二进制拆分
熟悉计算机底层知识的同学应该熟悉二进制,这种计算机内部的数据存储方式。二进制具有十分高效的数字表示效率,n位2进制可以表示2
n
个数值,且范围内的全部数值都可以通过二进制不同位上的0-1取值来表示 。这种仅使用0-1表示数据且高效的数据描述方式,为多重背包问题带来了启发。
根据二进制原理,一个任意大的数字,都可以使用二进制表示,且每个位上的数值只能取0-1。值为0表示该位权重无效,值为1代表该位权重有效,每一位的权重可以使用 2
i
计算。
由此二进制拆分的方法总结如下:
多重背包中第 i 项,价值为 v[i] ,占用空间为 w[i] , 最大可选次数为 t 。
1、从m=0开始,依次拆分出2
m
(m++),并拆分出新的价值vv[i]=2
m
×v[i] 和新的空间ww[i]=2
m
×w[i]
2、拆到剩余的 t < 2
m
时,证明已经无法再拆,停止拆分。
3、判断剩下的t,如果t=0说明正好可以拆完,无需处理。如果t>0,说明拆分剩下了一部分,这一部分作为拆分偏移量加入到新的价值数组vv[i]=2
m
×t和空间数组ww[i]=2
m
×t
使用二进制拆分,高效的将多重背包转换为0-1背包,只需对新产生的价值数组vv和空间数组ww进行0-1背包求解即可得到全局最优解。
题目一
题目概述
一家银行计划安装一台用于提取现金的机器。
机器能够按要求的现金量发送适当的账单。
机器使用正好N种不同的面额钞票,例如D_k,k = 1,2,…,N,并且对于每种面额D_k,机器都有n_k张钞票。
例如,
N = 3,
n_1 = 10,D_1 = 100,
n_2 = 4,D_2 = 50,
n_3 = 5,D_3 = 10
表示机器有10张面额为100的钞票、4张面额为50的钞票、5张面额为10的钞票。
东东在写一个 ATM 的程序,可根据具体金额请求机器交付现金。
注意,这个程序计算程序得出的最大现金少于或等于可以根据设备的可用票据供应有效交付的现金。
Input及输入样例
程序输入来自标准输入。 输入中的每个数据集代表特定交易,其格式为:Cash N n1 D1 n2 D2 … nN DN其中0 <= Cash <= 100000是所请求的现金量,0 <= N <= 10是 纸币面额的数量,0 <= nk <= 1000是Dk面额的可用纸币的数量,1 <= Dk <= 1000,k = 1,N。 输入中的数字之间可以自由出现空格。 输入数据正确。
输入样例:
735 3 4 125 6 5 3 350
633 4 500 30 6 100 1 5 0 1
735 0
0 3 10 100 10 50 10 10
Output及输出样例
对于每组数据,程序将在下一行中将结果打印到单独一行上的标准输出中。
输出样例:
735
630
0
0
算法思维
样例解释:
1、第一个数据集指定一笔交易,其中请求的现金金额为 735。 机器包含3种面额的纸币:4张钞票 125、6张钞票 5和3张钞票 350。 机器可以交付所需现金的确切金额。
2、在第二种情况下,机器的票据供应不能满足所要求的确切现金数量。 可以交付的最大现金为 630。 请注意,在机器中组合钞票以匹配交付的现金有多种可能性。
3、在第三种情况下,机器是空的,没有现金交付。 在第四种情况下,请求的现金金额为 0,因此机器不交付现金。
分析题目可以发现,题目是简单的多重背包问题,每种钱币可以选限定次数。但是不同的是,多重背包问题中的 v[i] 和 w[i] 在该道题目中是同一个值,其余算法根据多重背包问题求解即可。
实现源代码(C++)
#include<iostream>
#include<stdio.h>
using namespace std;
const int M=20;
const int N