背包问题是一类经典的动态规划问题,但在具体的算法考察过程中几乎不会直接问你背包问题原型,往往都是给出一个具体情景,需要你通过分析判定出问题是否符合背包问题的特征,从而是否能够使用动态规划去解决,所以对背包问题原型的熟悉程度很关键,今天我们就先来看看背包问题的“万恶之源”——0/1背包问题。
注:本文记录时候参考微信公共号代码随想录中Carl大佬的分析和学习思路,感兴趣的小伙伴可以自行搜索相关内容进行进一步学习,附一个Carl哥的知乎链接:
咱就把0-1背包问题讲个通透! - 知乎 (zhihu.com)
先给出最经典的0/1背包问题的大致原型描述:
给你一个背包,容量为W
你面前有一组物品(n_1, n_2, … n_i),共计n个物品
每个物品ni具有二维属性(w_i, v_i), 分别表示当前物品的容量和物品本身的价值
每当把一个物品n_i放入背包中,会占用掉w_i的背包容量并获得v_i的价值
问题:求解该背包装入这些物品能获得的最大价值V_max
假设当前有一个背包,容量W为3,现有3个物品,描述如下表:
重量(容量) | 价值 | |
---|---|---|
物品1 | 2 | 8 |
物品2 | 1 | 4 |
物品3 | 3 | 10 |
那么根据问题要求:选择装入物品1和物品2是最佳方案,能够获得8+4=12的最大价值,且所耗容量为2 + 1 = 3 <= W
以上便是0/1背包问题的原型,那么其他多种多样的背包问题是如何描述的呢?差别在哪里?
其实在0/1背包问题中,物品有一个属性被隐藏掉了,其实物品应该具备三个属性**(c_i, w_i, v_i)**,其中多出来的c_i代表物品的数量,显然在0/1背包中,物品数量永远为1件,而如果物品数量无限制,则为完全背包问题,如果各个物品数量不相同,这又叫多重背包问题。
这里参考Carl哥的总结,给出背包问题的基本分类:
各种背包问题的根本都是0/1背包问题,所以今天我们只看0/1背包问题的解决方法!
很简单,穷举所有的物品组合情况,回溯求解,在求解过程中不断更新全局最大值
时间复杂度分析:很简单,假设有n个物品,每个物品要么选择放入背包,要么不放,方案数有2*n个,时间复杂度为O(2 * n),指数级别
根据个人经验,leetcode还好基本属于最慢的情况,排名后10%差不多,但是涉及到应试几乎无一例外都会超时,ac情况惨不忍睹,建议如果不是时间完全不够或者动态规划方法不会的情况下,千万别暴力做!!!
给出本人的一个回溯代码吧,以Java为例,思想很简单:
public class Solution {
/**
* 全局最优解
*/
static int res = 0;
public static void main(String[] args) {
int[][] nums = new int[3][2];
nums[0][0] = 2;
nums[0][1] = 8;
nums[1][0] = 1;
nums[1][1] = 4;
nums[2][0] = 3;
nums[2][1] = 10;
process(nums, 0, 0, 3, 0);
System.out.println(res);
}
/**
* @param nums 二维数组 序号代表第i个物品 nums[i][0] nums[i][1] 分别代表第i个物品的容量和价值
* @param curW 当前已占用的背包容量
* @param curV 当前已获得的累计价值
* @param W 背包的最大容量
* @param index 控制物品编号选择的下标 从0开始往后走
*/
public static void process(int[][] nums, int curW, int curV, int W, int index) {
//nums.length个物品求子集
for (int i = index; i < nums.length; i++) {
//选择第i个物品
//不超重,可选择
if (curW + nums[i][0] <= W) {
curW += nums[i][0];
curV += nums[i][1];
//更新全局最优值
res = Math.max(res, curV);
//dfs 继续选择
process(nums, curW, curV, W, i + 1);
//回溯
curV -= nums[i][1];
curW -= nums[i][0];
}
}
}
}
暴力法下如何得到具体的方案:
很简单,因为是暴力回溯,只需要用一个路径记录一下做出的选择即可,比如用一个list对每次选择的物品号做记录,同时维持一个全局最佳方案,一旦发现curV > res时,我们把全局最优方案更新成当前这个list的内容即可,同时也要注意对这个list进行回溯!
二维,一个是物品,一个是容量
令dp[i ] [j ] 表示,当前已经考虑了前i个物品的选择情况了,并且当前背包容量为j 情况下所获得的最大价值
这里一定要明确这二维状态的定义方式!!!
动态规划的本质就是:求解一个问题的最优解,我们通过求它子问题的最优解,然后一步步构造出整个问题的最优解
所以核心一定是明确子问题是什么?
这里有两个状态终点分别是物品数n 和容量W,那么子问题的个数就是 n * W个!
即第一维表示我当前先缩小问题的规模,先看前1个物品的选择情况,再看前2个物品的选择情况,再看前3个物品的选择情况,再看前i个物品的选择情况,一直处理到前n个物品的选择情况;而针对每一种上述情况,我们考虑背包的容量从0开始递增到W,即对于先看前1个物品的选择情况时,背包容量为0时最大价值是多少? 为1时最大价值是多少?一直到为w时最大价值为多少?
那么这个问题要求解的结果很容易理解为是 dp[n ] [W ] , 即已经考虑了前n个物品的选择情况,并且当前背包容量为W情况下的最大价值
针对一个物品,如果当前背包的容量不能够容纳放入它,则当前无法进行任何选择;如果容量够,能放下它 ,那么可以有两种选择:要么放入背包,要么不放入,非常容易找到选择。
有了状态和选择,动态转移就来了! 即对于每一个子问题状态,做全部的选择尝试,然后从中选结果最优的那个选择更新出的状态作为新状态! 这就是状态转移的推导!
假如当前正要求 dp[i ] [j ] ,即 目前要考虑第 i 个物品的选择情况了 (即前i - 1个物品的选择情况,选了谁,不选谁已经确定好了!)
详细说一下状态的转移:
不放入第i个物品: dp[i ] [j ] = dp[i - 1] [j ]
当前第i个物品不放,必然第二个状态j根本不会变,第一个状态只需要继承一个i - 1时候的状态即可,同时最大价值不会有任何变化
放入第i 个物品 : dp[i ] [j ] = dp[i - 1] [j - w[i] ] + v[i]
首先第一个状态肯定还是继承 i - 1 时候的状态,当第二个状态是谁转移过来的?
由于对第i个物品我们选择是放入,则当前容量j 肯定是加上了物品i的容量了!所以把它减掉不就找到之前的状态,即 j - w[i]
同时最大价值因为放入了第i个物品,所以要加 v[i]
状态初始化非常非常重要,一定要明白状态的是怎么更新的,否则得不到正确的结果,甚至是对于动态规划的任何优化,比如空间优化,遍历顺序的优化等都是基于对状态更新过程的完全掌握!
以上述例子说明一下dp矩阵的状态初始化,数组下标从0开始,所以物品编号从0开始!
熟悉动态规划的小伙伴应该有感觉:往往先求一下第0行和第0列的初始结果作为边界,然后动态规划,这是为什么呢?
其实这个并不固定,是根据你状态的转化过程决定的!!
什么是转化过程? 简单地理解就是要求**dp[i ] [j ]**这个子问题的结果 ,我必须要知道哪些其他子问题的结果!!而如何得知呢?要紧抓状态转移方程!!
例如:求解dp[1 ] [2 ] 根据我们的状态转移方程,dp[0 ] [2]是不是需要知道? 是不是 dp[0 ] [2 - w[i]] 需要知道?
dp[0 ] [2] 很具体了,那dp[0 ] [2 - w[i]]是谁? 是不是就是dp[0 ] [0 ]或者dp[0 ] [1 ] 中的一个,即dp[i ] [j ] 的上一行从0开始一直到dp[i ] [j ]左上角的结果为止,任意一个结果都有可能成为求解dp[i ] [j ]所需要的状态,所以我们要知道它们每一个的结果!!
因此状态转化过程可以理解为,从dp[i - 1] [0] ,dp[i - 1] [1] , dp[i - 1] [2]…dp[i - 1] [j ] 出发,能够确定出 dp[i ] [j ]的值
体现在矩阵中如下:
因此,对于次问题的dp矩阵,我们将第0行和第0列作为边界,就可以推导出其他任意处的结果!
除了边界,其他位置怎么确定初始化值?
还是回归到状态转移方程中去:
很明显,可以看到某一个位置的结果求解跟自己的初始值没有任何关系,所有其他位置可以初始化成任意值,那这里我们就默认为初始化0值即可!
显然我们在求解这n * W个子问题的结果时需要对dp矩阵进行遍历,遍历是双重循环:可以先对物品序号1 - n进行遍历,再对容量从0 - W进行遍历,也可以反过来遍历,这两个遍历顺序都是正确的,因为都符合我们的状态转化过程!! 具体可以自行分析一下比较容易。
以先遍历物品编号,后遍历容量的顺序给出迭代结果的变化
具体的推导过程不熟悉的小伙伴可以手动推导,这里不再给出,紧抓状态转移方程即可很容易的求解出dp矩阵的全貌!
//dp矩阵构建
int[][] dp = new int[n][W + 1];
//边界初始化 w数组保存物品的容量 v数组保存物品的价值,假设编号都是相互对应的
for (int i = w[0]; i <= W; i++) {
dp[0][i] = v[0];
}
//计算dp矩阵 因为边界已经计算过了,这里两重循环都从1开始进行即可
for (int i = 1; i < n; i++) {
for (int j = 1; j <= W; j++) {
if (j - w[i] < 0) {
//容量不够 不可能放入
dp[i][j] = dp[i - 1][j];
} else {
//容量够 可以放入第i个物品 考虑放不放
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
}
return dp[n - 1][W];
显然为O(n * W),即子问题的规模 乘以 求解每个子问题的开销,而求解每个子问题的时间复杂度仅为O(1) (仅包含比较,求一次和以及赋值这样的常数级别的运算)
显然为dp矩阵的空间大小 为O(n * W) , 我们后续再考虑对问题的空间复杂度进行优化!!!本文暂不予讨论
个人认为:动态规划的每一步都很重要,缺一不可,
只有能确定好状态和选择,才有可能找到正确可行的状态转移方程
只有确定了状态转移方程,才能找到正确的状态转移的具体过程和dp矩阵的初始化值
只有知道状态转移的具体过程,才能知道哪里是边界,以及什么样的遍历顺序是正确的?什么样是错的?
只有上述流程都非常熟悉,优化起来才信手捏来!
初识动规时,其实每一步都是难点和重点,后续将对背包问题的其他类型,完全背包问题以及多重背包问题进行解析并进行空间复杂度的优化,同时以实际场景题目为例进行编程练习! 小伙伴们后续见!