一、最基础的动态规划之一
01背包问题是动态规划中最基础的问题之一,它的解法完美地体现了动态规划的思想和性质。
01背包问题最常见的问题形式是:给定n件物品的体积和价值,将他们尽可能地放入一个体积固定的背包,最大的价值可以是多少。我们可以用费用c和价值v来描述一件物品,再设允许的最大花费为w。只要n稍大,我们就不可能通过搜索来遍查所有组合的可能。运用动态规划的思想,我们把原来的问题拆分为子问题,子问题再进一步拆分直至不可再分(初始值),随后从初始值开始,尽可能地求取每一个子问题的最优解,最终就能求得原问题的解。由于不同的问题可能有相同的子问题,子问题存在大量重叠,我们需要额外的空间来存储已经求得的子问题的最优解。这样,可以大幅度地降低时间复杂度。
有了这样的思想,我们来看01背包问题可以怎样拆分成子问题:
要求解的问题是:在n件物品中最大花费为w能得到的最大价值。显然,对于0 <= i <= n,0 <= j <= w,在前i件物品中最大花费为j能得到的最大价值。
可以使用数组dp[n + 1][w + 1]来存储所有的子问题,dp[i][j]就代表从前i件物品中选出总花费不超过j时的最大价值。
可知dp[0][j]值一定为零。那么,该怎么递推求取所有子问题的解呢。显而易见,要考虑在前i件物品中拿取,首先要考虑前i - 1件物品中拿取的最优情况。
当我们从第i - 1件物品递推到第i件时,我们就要考虑这件物品是拿,还是不拿,怎样收益最大。
①:首先,如果j < c[i],那第i件物品是无论如何拿不了的,dp[i][j] = dp[i - 1][j];
②:如果可以拿,那就要考虑拿了之后收益是否更大。拿这件物品需要花费c[i],除去这c[i]的子问题应该是dp[i - 1][j - c[i]],这时,就要比较dp[i - 1][j]和dp[i - 1][j - c[i]] + v[i],得出最优方案。
细节和代码如下:
int n, w; int c[maxn], v[maxn];//c为费用,v为价值 int dp[maxn][maxw]; int main() { scanf("%d %d", &w, &n);//w为最大费用,n为数量 for(int i = 1;i <= n;i++) { scanf("%d %d", &c[i], &v[i]);//输入,注意这里的下标是从1开始的 } memset(dp, 0, sizeof(dp));//若不涉及多组输入,这一步其实可以省略 //如果下标从0开始,下面也需要稍作修改 for(int i = 1;i <= n;i++) { for(int j = 0;j <= w;j++) { if(c[i] > j) dp[i][j] = dp[i - 1][j];//状态转移,情况① else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - c[i]] + v[i]);//情况② } } printf("%d ", dp[n][w]); }
这样做,时间复杂度和空间复杂度都是o(nw)
二、空间优化 一维数组实现
在上面的解法中,我们把所有子问题的最优解都用数组保存了下来,实际上,这些最优解并不是一直都有用的。从状态转移方程可以看出,对当前的i,只有i - 1时的最优解才是有用的,再之前的已经不会再被使用了。如果能把已经无用的空间节省出来,空间复杂度能够得到非常大的优化,这在有些问题中是非常必要的。
实际上,只需要一个一维的数组dp[m + 1](省去n那一维)就可以完成任务。外层的循环每一轮开始时,这个数组里存的要么是初始值(i = 0的情况),要么是上一轮递推得到的值(i - 1时的情况),符合原本状态转移方程的要求。
不过,这样做就需要更加的注意递推的方向,更新的顺序。在更新前,数组里存储的是上一轮的值(或初始值),更新后,更新了的位置存储的就是这一轮的值了。在01背包的问题里,我们要从i - 1的情况递推上来,所以要倒着更新。这一点需要着重理解(反了的话就变成完全背包了)。
代码如下:
int c[maxn], v[maxn];//c为费用,v为价值 int dp[maxw]; //其余部分略 for(int i = 1;i <= n;i++) { for(int j = w;j >= c[i];j--) //这里要反着更新,否则dp[j - c[i]]会比dp[j]先更新,而更新后它对应的就不是i - 1时的状态了 { dp[j] = max(dp[j], dp[j - c[i]] + v[i]); } }
三、01背包的常见变形
实践中很难遇到如此标准的01背包模型,大多数情况下,我们都需要根据具体问题对上面的算法做出一定的修改。
这些问题都是背包问题常见的,解决方法也都相同或者相似:
1.求取的不是价值的最大值而是最小值:把max换成min即可,原理相同。
2.费用不一定都是正数:负数没法用作数组的下标,可以考虑把所有的费用都先加上一个较大的数再做处理(平移)。
3.费用存在小数:可以把所用费用都先放大一定的倍数,是所有的费用值都为整数(推荐HDU 1864)。
4.要求恰好装满,即选取的物品的费用之和恰等于最大花费:
相比基础的01背包,这里我们需要一个正常情况不可能出现的值来表示状态非法、不可能实现,这个值可以是-1、-INF之类的,视具体情况而定。
在初始化时,除dp[i][0]的值应为0之外,其他所有值都应为非法值。在状态转移时,首先要判断子问题是否非法。
for(int i = 1;i <= w;i++)//初始化 { dp[i] = -INF; } dp[0] = 0; for(int i = 1;i <= n;i++) { for(int j = w;j >= c[i];j--) { if(dp[i - c[i]] != -INF)//判断是否合法,这里其实省去了几种情况,用二维数组实现的话需注意 { dp[j] = max(dp[j], dp[j - c[i]] + v[i]); } } }