背包问题解析
背包定义
那么什么样的问题可以被称作为背包问题?换言之,我们拿到题目如何透过题目的不同包装形式看到里面背包问题的不变内核呢?
我对背包问题定义的理解:
给定一个背包容量target,再给定一个数组nums(物品),能否按一定方式选取nums中的元素得到target
注意:
1、背包容量target和物品nums的类型可能是数,也可能是字符串
2、target可能题目已经给出(显式),也可能是需要我们从题目的信息中挖掘出来(非显式)(常见的非显式target比如sum/2等)
3、选取方式有常见的一下几种:每个元素选一次/每个元素选多次/选元素进行排列组合
背包问题分类:
常见的背包类型主要有以下几种:
1、0/1背包问题:每个元素最多选取一次
2、完全背包问题:每个元素可以重复选择
3、组合背包问题:背包中的物品要考虑顺序
4、分组背包问题:不止一个背包,需要遍历每个背包
而每个背包问题要求的也是不同的,按照所求问题分类,又可以分为以下几种:
1、最值问题:要求最大值/最小值
2、存在问题:是否存在…………,满足…………
3、组合问题:求所有满足……的排列组合
首先是背包分类的模板:
1、0/1背包:外循环nums,内循环target,target倒序且target>=nums[i];
2、完全背包:外循环nums,内循环target,target正序且target>=nums[i];
3、组合背包:外循环target,内循环nums,target正序且target>=nums[i];
4、分组背包:这个比较特殊,需要三重循环:外循环背包bags,内部两层循环根据题目的要求转化为1,2,3三种背包类型的模板
然后是问题分类的模板:
1、最值问题: dp[i] = max/min(dp[i], dp[i-nums]+1)或dp[i] = max/min(dp[i], dp[i-num]+nums);
2、存在问题(bool):dp[i]=dp[i]||dp[i-num];
3、组合问题:dp[i]+=dp[i-num];
509. 斐波那契数 - 力扣(LeetCode)
class Solution {
public int fib(int n) {
if(n<=1) return n;
else{
//确定dp数组及其含义
int[] dp=new int[n+1];//注意是n+1
dp[0]=0;
dp[1]=1;
for(int i=2;i<=n;i++)
{
dp[i]=dp[i-1]+dp[i-2];//状态转移方程及初始化 顺序是从前向后
}
return dp[n];
}
}
}
70. 爬楼梯 - 力扣(LeetCode)
class Solution {
public int climbStairs(int n) {
if(n==1) return 1;
if(n==2) return 2;
int[] stair=new int[n+1];
stair[1]=1;
stair[2]=2;//声明完这两个就可以了
for(int i=3;i<=n;i++)
{
stair[i]=stair[i-1]+stair[i-2];
}
return stair[n];
}
}
优化空间复杂度
// 用变量记录代替数组
class Solution {
public int climbStairs(int n) {
if(n <= 2) return n;
int a = 1, b = 2, sum = 0;
for(int i = 3; i <= n; i++){
sum = a + b; // f(i - 1) + f(i - 2)
a = b; // 记录f(i - 1),即下一轮的f(i - 2)
b = sum; // 记录f(i),即下一轮的f(i - 1)
}
return b;
}
}
字符串使用的是一个方法(.length()
),而对于数组使用的是一个属性(.length
),没有括号
01背包
746. 使用最小花费爬楼梯 - 力扣(LeetCode)
// 方式一:第一步不支付费用
class Solution {
public int minCostClimbingStairs(int[] cost) {
int len = cost.length;
int[] dp = new int[len + 1];
// 从下标为 0 或下标为 1 的台阶开始,因此支付费用为0
dp[0] = 0;
dp[1] = 0;
// 计算到达每一层台阶的最小费用
for (int i = 2; i <= len; i++) {
dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
return dp[len];
}
}
62. 不同路径 - 力扣(LeetCode)
这道题主要在于领悟,每到一个位置(除了最靠左和最上面一排的位置之外,它的位置必然只可以从它的上一个格子和左边格子到达
机器人从(0 , 0) 位置出发,到(m - 1, n - 1)终点。
按照动规五部曲来分析:
1.确定dp数组(dp table)以及下标的含义
dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。
2.确定递推公式
想要求dp[i][j],只能有两个方向来推导出来,即dp[i - 1][j] 和 dp[i][j - 1]。
此时在回顾一下 dp[i - 1][j] 表示啥,是从(0, 0)的位置到(i - 1, j)有几条路径,dp[i][j - 1]同理。
那么很自然,dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来。
3.dp数组的初始化
如何初始化呢,首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。
所以初始化代码为:
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
4.确定遍历顺序
这里要看一下递推公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1],dp[i][j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。
这样就可以保证推导dp[i][j]的时候,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值的。
5.举例推导dp数组
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp=new int[m][n];
//机器人每次只能向右或者向下移动一步 可以推出dp[i][j]=dp[i-1][j]+dp[i][j-1]
if(m==n&&m==1) return 1;
for(int i=0;i<m;i++)
{
for(int j=0;j<n;j++)
{
if(i==0&&j==0)
{
dp[i][j]=0;
continue;
}
if(i!=0&&j!=0) dp[i][j]=dp[i-1][j]+dp[i][j-1];
//防止访问边界
if(i==0)
dp[i][j]=1;
if(j==0)
dp[i][j]=1;
System.out.println(dp[i][j]);
}
System.out.println("/n");
}
return dp[m-1][n-1];
}
}
63. 不同路径 II - 力扣(LeetCode)
1.确定dp数组(dp table)以及下标的含义
dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。
2.确定递推公式
递推公式和62.不同路径一样,dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。
但这里需要注意一点,因为有了障碍,(i, j)如果就是障碍的话应该就保持初始状态(初始状态为0)。
所以代码为:
if (obstacleGrid[i][j] == 0) { // 当(i, j)没有障碍的时候,再推导dp[i][j]
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
3.dp数组如何初始化
在62.不同路径 (opens new window)不同路径中我们给出如下的初始化:
vector<vector<int>> dp(m, vector<int>(n, 0)); // 初始值为0
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
因为从(0, 0)的位置到(i, 0)的路径只有一条,所以dp[i][0]一定为1,dp[0][j]也同理。
但如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i][0]应该还是初始值0。
注意代码里for循环的终止条件,一旦遇到obstacleGrid[i][0] == 1的情况就停止dp[i][0]的赋值1的操作,dp[0][j]同理
for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) {
dp[i][0] = 1;
}
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
dp[0][j] = 1;
}
4.确定遍历顺序
从递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1] 中可以看出,一定是从左到右一层一层遍历,这样保证推导dp[i][j]的时候,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值。
代码如下:
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
int[][] dp = new int[m][n];
//如果在起点或终点出现了障碍,直接返回0
if (obstacleGrid[m - 1][n - 1] == 1 || obstacleGrid[0][0] == 1) {
return 0;
}
for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) {
dp[i][0] = 1;
}
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
dp[0][j] = 1;
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = (obstacleGrid[i][j] == 0) ? dp[i - 1][j] + dp[i][j - 1] : 0;
}
}
return dp[m - 1][n - 1];
}
}
但就算是做过62.不同路径,在做本题也会有感觉遇到障碍无从下手。
其实只要考虑到,遇到障碍dp[i][j]保持0就可以了。
也有一些小细节,例如:初始化的部分,很容易忽略了障碍之后应该都是0的情况。
二维动态规划数组 (dp[i][j]
)
在二维DP数组中,dp[i][j]
表示考虑前 i
个物品时,对于容量为 j
的背包,可以获得的最大价值。因为每个 dp[i][j]
都是独立计算的,它仅依赖于 dp[i-1][...]
的状态,我们可以首先遍历物品,然后遍历容量,或者先遍历容量,再遍历物品。两种顺序都是可行的,因为在计算 dp[i][j]
时,所有依赖的状态(即 dp[i-1][...]
)都已经被计算过了。
一维动态规划数组 (dp[j]
)
一维DP数组中,dp[j]
表示对于容量为 j
的背包,考虑当前处理的物品,可以获得的最大价值。这里,状态是累积的,当前物品的状态直接在前一物品的基础上更新。因此,必须先遍历物品,然后对于每个物品,再从大到小遍历背包容量。这样做是为了保证每个物品只被考虑一次(即防止同一个物品被重复添加到背包中)。
如果在一维DP数组中先遍历背包容量,再遍历物品,那么在处理每个容量时,可能会重复考虑同一个物品,这就违反了01背包问题中每个物品只能选取一次的规则。
343. 整数拆分 - 力扣(LeetCode)
动规五部曲,分析如下:
1.确定dp数组(dp table)以及下标的含义
dp[i]:分拆数字i,可以得到的最大乘积为dp[i]。
dp[i]的定义将贯彻整个解题过程,下面哪一步想不懂了,就想想dp[i]究竟表示的是啥!
2.确定递推公式
可以想 dp[i]最大乘积是怎么得到的呢?
其实可以从1遍历j,然后有两种渠道得到dp[i].
一个是j * (i - j) 直接相乘。
一个是j * dp[i - j],相当于是拆分(i - j),对这个拆分不理解的话,可以回想dp数组的定义。
外层循环遍历得到dp[i]数组每个值,内层循环是暴力枚举值,比较得出结论
class Solution {
public int integerBreak(int n) {
//dp[i] 为正整数 i 拆分后的结果的最大乘积
int[] dp = new int[n+1];
dp[1]=1;
dp[2]=1;
for(int i = 3; i <= n; i++) {
for(int j = 1; j < i;j++) {
//如果在比较最大值的时候不包括dp[i],那有可能越比越小,倒把一开始找到的最大值给弄丢了
dp[i] = Math.max(dp[i], Math.max(j*(i-j), j*dp[i-j]));
// j * (i - j) 是单纯的把整数 i 拆分为两个数 也就是 i,i-j ,再相乘
//而j * dp[i - j]是将 i 拆分成两个以及两个以上的个数,再相乘。
}
}
return dp[n];
}
}
因为拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的。
例如 6 拆成 3 * 3, 10 拆成 3 * 3 * 4。 100的话 也是拆成m个近似数组的子数 相乘才是最大的。
只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。
那么 j 遍历,只需要遍历到 n/2 就可以,后面就没有必要遍历了,一定不是最大值。
for (int i = 3; i <= n ; i++) {
for (int j = 1; j <= i / 2; j++) {
dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
}
}
96. 不同的二叉搜索树 - 力扣(LeetCode)
n为1的时候有一棵树,n为2有两棵树,这个是很直观的。
来看看n为3的时候,有哪几种情况。
当1为头结点的时候,其右子树有两个节点,看这两个节点的布局,是不是和 n 为2的时候两棵树的布局是一样的啊!
当3为头结点的时候,其左子树有两个节点,看这两个节点的布局,是不是和n为2的时候两棵树的布局也是一样的啊!
当2为头结点的时候,其左右子树都只有一个节点,布局是不是和n为1的时候只有一棵树的布局也是一样的啊!
发现到这里,其实我们就找到了重叠子问题了,其实也就是发现可以通过dp[1] 和 dp[2] 来推导出来dp[3]的某种方式。
思考到这里,这道题目就有眉目了。
dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]
如图所示:
1.确定dp数组(dp table)以及下标的含义
dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]。
也可以理解是i个不同元素节点组成的二叉搜索树的个数为dp[i] ,都是一样的。
以下分析如果想不清楚,就来回想一下dp[i]的定义
2.确定递推公式
在上面的分析中,其实已经看出其递推关系, dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
j相当于是头结点的元素,从1遍历到i为止。
所以递推公式:dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
3.dp数组如何初始化
初始化,只需要初始化dp[0]就可以了,推导的基础,都是dp[0]。
那么dp[0]应该是多少呢?
从定义上来讲,空节点也是一棵二叉树,也是一棵二叉搜索树,这是可以说得通的。
从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。
所以初始化dp[0] = 1
4.确定遍历顺序
首先一定是遍历节点数,从递归公式:dp[i] += dp[j - 1] * dp[i - j]可以看出,节点数为i的状态是依靠 i之前节点数的状态。
class Solution {
public int numTrees(int n) {
//dp的含义是有i个节点的二叉搜索树个数
int[] dp=new int[n+1];
//外层用于遍历,内层用于切分 和整数积这道题很像
dp[0]=1;//避免*0变成0
dp[1]=1;
for(int i=2;i<=n;i++)
{
//以j为头节点
for(int j=1;j<=i;j++)
{
dp[i]+=dp[j-1]*dp[i-j];//j-1是左边节点个数 j-i是右面节点个数
}
}
return dp[n];
}
}
0-1背包问题
46. 携带研究材料(第六期模拟笔试)
1. 确定dp数组以及下标的含义
我们需要使用二维数组,为什么呢?
因为有两个维度需要分别表示:物品 和 背包容量
如图,二维数组为 dp[i][j]。
动态规划的思路是根据子问题的求解推导出整体的最优解。
我们先看把物品0 放入背包的情况:
再看把物品1 放入背包:
背包容量为 3,上一行同一状态,背包只能放物品0,这次也可以选择物品1了,背包可以放物品1 或者 物品0,物品1价值更大,背包里的价值为20。
上图中,我们看 dp[1][4] 表示什么意思呢。
任取 物品0,物品1 放进容量为4的背包里,最大价值是 dp[1][4]。
通过这个举例,我们来进一步明确dp数组的含义。
即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的,如果哪里看懵了,就来回顾一下i代表什么,j又代表什么。
2. 确定递推公式
这里在把基本信息给出来:
对于递推公式,首先我们要明确有哪些方向可以推导出 dp[i][j]。
这里我们dp[1][4]的状态来举例:
求取 dp[1][4] 有两种情况:
- 放物品1
- 还是不放物品1
如果不放物品1, 那么背包的价值应该是 dp[0][4] 即 容量为4的背包,只放物品0的情况。
推导方向如图:
如果放物品1, 那么背包要先留出物品1的容量,目前容量是4,物品1 的容量(就是物品1的重量)为3,此时背包剩下容量为1。
容量为1,只考虑放物品0 的最大价值是 dp[0][1],这个值我们之前就计算过。
所以 放物品1 的情况 = dp[0][1] + 物品1 的价值,推导方向如图:
两种情况,分别是放物品1 和 不放物品1,我们要取最大值(毕竟求的是最大价值)
dp[1][4] = max(dp[0][4], dp[0][1] + 物品1 的价值)
以上过程,抽象化如下:
-
不放物品i:背包容量为j,里面不放物品i的最大价值是dp[i - 1][j]。
-
放物品i:背包空出物品i的容量后,背包容量为j - weight[i],dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]且不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3. dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如图:
在看其他情况。
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
那么很明显当 j < weight[0]
的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。
当j >= weight[0]
时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。
初始化背包为0和第一个物品放入的情形。
for (int i = 1; i < weight.length; i++) { // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。
dp[i][0] = 0;
}
// 正序遍历
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
4. 确定遍历顺序
在如下图中,可以看出,有两个遍历的维度:物品与背包重量
要理解递归的本质和递推的方向。
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。
dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向),那么先遍历物品,再遍历背包的过程如图所示:
做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!
注意易错点:
1.j具有实际容量的意义,需要访问到n,所以声明dp时候列长为n+1.
2.放入物品i的情况是j-w[i],给物品i腾地方
import java.util.*;
public class Main{
public static void main(String[] args)
{
Scanner scanner=new Scanner(System.in);
int m=scanner.nextInt();//材料种类
int n=scanner.nextInt();//行李箱大小
int[] w=new int[m];//所含空间
int[] v=new int[m];//价值
for(int i=0;i<m;i++)
{
w[i]=scanner.nextInt();
}
for(int i=0;i<m;i++)
{
v[i]=scanner.nextInt();
}
//dp表示在0-i个物品中挑选,容量为j时候最大价值,因为我们需要考虑访问dp[i][n]也就是容量为n的情况,所以大小必须声明为n+1
int[][] dp=new int[m][n+1];
//初始化第一个物品的情况(因为递推公式是左上方/上方推导
for(int j=w[0];j<=n;j++)
{
dp[0][j]=v[0];
}
//我习惯先遍历物品再遍历背包
for(int i=1;i<m;i++)
{
for(int j=1;j<=n;j++)
{
if(j>=w[i])
{
//可以放得下当前物品的情况,可以选择不放或者放 放的话
dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
}else{
//放不下当前物品,直接继承上一行的情况
dp[i][j]=dp[i-1][j];
}
}
}
System.out.println(dp[m-1][n]);
}
}
使用了二维数组 dp
。dp[i][j]
表示考虑到第 i
个物品时,背包容量为 j
时的最大价值。这种方法更直观地反映了每个阶段的状态:
- 外层循环遍历每个物品。
- 内层循环遍历所有可能的容量值(从 0 到
N
)。这里不需要从后向前更新,因为每次计算都是基于上一个物品的状态,不存在前面提到的覆盖问题。 - 如果当前背包容量可以容纳物品
i
,代码比较了两种情况:不包括当前物品i
,或包括当前物品i
。选择其中价值最大的一个。 - 如果背包容量不足以容纳物品
i
,那么就保持与上一个物品相同的状态。
滚动数组简化空间复杂度:
-
外层循环 (
for (int i = 0; i < M; i++)
): 这个循环遍历所有的物品。对于每个物品i
,我们决定是将其加入背包还是不加入。 -
内层循环 (
for (int j = N; j >= spaces[i]; j--)
): 这个循环遍历所有从N
到spaces[i]
的容量值。我们从大到小遍历,以确保在处理每个物品时,较小容量的值不会受到影响(因为一旦改变了较小容量的值,它可能会影响到处理较大容量时的决策)。 -
状态更新 (
dp[j] = Math.max(dp[j], dp[j - spaces[i]] + values[i]);
): 这里是动态规划的关键所在。对于每个容量j
,我们有两个选择:- 不将物品
i
加入背包。这种情况下,背包的总价值保持不变,即dp[j]
。 - 将物品
i
加入背包。加入物品i
后,背包的总价值等于物品i
的价值 (values[i]
) 加上剩余容量 (j - spaces[i]
) 下的最大价值 (dp[j - spaces[i]]
)。
我们要在这两个选择中选择一个使得
dp[j]
最大的。这就是Math.max(dp[j], dp[j - spaces[i]] + values[i])
的作用。 - 不将物品
简而言之,这段代码在每次考虑加入一个新物品时,都会检查每个可能的背包容量,遍历dp[j]并更新在该容量下能达到的最大价值。通过这样做,我们可以确保无论背包的容量是多少,我们都知道在当前可选的物品范围内,能够获得的最大价值是多少。最终,dp[N]
就会告诉我们,在容量为 N
的背包中能够装下的物品的最大总价值。
经过观察可以发现,dp【i】【j】的更新只与dp【i-1】的状态有关,那么我们可以像斐波那契数列那样优化,只保留两行就够了,每一次根据第一行来更新第二行的信息,然后再把第二行替换为第一行。
针对于物品i,dp【1】【j】 = max(dp【0】【j】,dp【0】【j-weight_i】+value_i)
还能进一步压缩空间吗,显然是可以,再仔细观察我们发现,dp【i】【j】的更新只与dp【i-1】【j】和dp【i-1】【j-weight_i】左上角这两个数据有关,而与右边的数据无关,那么从右向左遍历,遍历时左边的数据还是上一行的数据没有更新, 这样子用一行数组很好的实现了我们的最终目的。
看到这里,你肯定是明白为什么可以用一维数组,以及为什么要从后向前遍历了。
还不明白,快,私信我,我手把手教你[doge]
import java.util.*;
public class Main{
public static void main(String[] args)
{
Scanner scanner=new Scanner(System.in);
int m=scanner.nextInt();//材料种类
int n=scanner.nextInt();//行李箱大小
int[] w=new int[m];//所含空间
int[] v=new int[m];//价值
for(int i=0;i<m;i++)
{
w[i]=scanner.nextInt();
}
for(int i=0;i<m;i++)
{
v[i]=scanner.nextInt();
}
//dp表示在0-i个物品中挑选,容量为j时候最大价值,因为我们需要考虑访问dp[i][n]也就是容量为n的情况,所以大小必须声明为n+1
int[] dp=new int[n+1];
//初始化第一个物品的情况(因为递推公式是左上方/上方推导
for(int j=w[0];j<=n;j++)
{
dp[j]=v[0];
}
//空间上可以简化为一维滚动数组 因为更新的所有状态都来自于上一行
for(int i=1;i<m;i++)
{
for(int j=n;j>=w[i];j--)
{
//可以放得下当前物品的情况,可以选择不放或者放 放的话
dp[j]=Math.max(dp[j],dp[j-w[i]]+v[i]);
//放不下当前物品,直接继承上一行的情况
}
}
System.out.println(dp[n]);
}
}
这段代码使用了一维数组 dp
。dp[j]
存储的是在背包容量为 j
时能够达到的最大价值。它通过两层循环来实现:
- 外层循环遍历每个物品。
- 内层循环从后往前遍历所有可能的容量值(从
N
到spaces[i]
)。这样做是为了保证每个物品只被计算一次。从后向前更新可以确保当计算dp[j]
时,dp[j - spaces[i]]
还没有被当前物品更新,即它仍然代表不包含当前物品的状态。 - 在每个
j
值上,代码比较了两种情况:不放入当前物品,或者放入当前物品。选择其中的最大值作为dp[j]
的新值。
416. 分割等和子集 - 力扣(LeetCode)
做这道题需要做一个等价转换:是否可以从输入数组中挑选出一些正整数,使得这些数的和等于整个数组元素的和的一半。
容易知道:数组的和一定得是偶数。
class Solution {
public boolean canPartition(int[] nums) {
//暴力枚举?
int result=0;
for(int i=0;i<nums.length;i++)
{
result+=nums[i];
}
// 如果总和是奇数,直接返回 false
if (result % 2 != 0) return false;
result=result/2;
int[] dp=new int[result+1];
dp[0]=0;
//dp[j]数组含义:容量为j的背包刚好放满
for(int i=0;i<nums.length;i++)
{
//遍历每个物品
for(int j=result;j>=nums[i];j--)
{
dp[j]=Math.max(dp[j],dp[j-nums[i]]+nums[i]);
// System.out.println(dp[j]);
}
if(dp[result]==result)
{
return true;
}
// System.out.println("\n");
}
return false;
}
}
1049. 最后一块石头的重量 II - 力扣(LeetCode)
为 stones 中的每个数字添加 +/−,使得形成的「计算表达式」结果绝对值最小。
与(题解)494. 目标和 类似,需要考虑正负号两边时,其实只需要考虑一边就可以了,使用总和 sum 减去决策出来的结果,就能得到另外一边的结果。
同时,由于想要「计算表达式」结果绝对值,因此我们需要将石子划分为差值最小的两个堆。
class Solution {
public int lastStoneWeightII(int[] stones) {
//思路:为 stones 中的每个数字添加 +/−,使得形成的「计算表达式」结果绝对值最小。分成+堆和-堆
// d与(题解)494. 目标和 类似,需要考虑正负号两边时,其实只需要考虑一边就可以了,使用总和 sum 减去决策出来的结果,就能得到另外一边的结果。
int sum=0;
for(int i=0;i<stones.length;i++)
{
sum+=stones[i];
}
int target=sum/2;//越接近这个数越好
int[] dp=new int[sum+1];
for(int i=0;i<stones.length;i++)
{
for(int j=target;j>=stones[i];j--)
{
//加不加这块石头的重量 加
dp[j]=Math.max(dp[j-stones[i]]+stones[i],dp[j]);
}
}
return Math.abs(sum-2*dp[target]);
}
}
494. 目标和 - 力扣(LeetCode)
这里 dp[0] = 1
表示没有选择任何元素时,总和为 0
的方法只有一种。
现在,遍历 nums
中的每个元素(每个元素都是 1
):
-
处理第一个
1
:- 从
j = 4
到1
向后遍历:- 当
j = 4
时:dp[4] += dp[4 - 1]
。由于dp[3]
目前是0
,所以dp[4] = 0 + 0 = 0
。 - 当
j = 3
时:dp[3] += dp[3 - 1]
。由于dp[2]
目前是0
,所以dp[3] = 0 + 0 = 0
。 - 当
j = 2
时:dp[2] += dp[2 - 1]
。由于dp[1]
目前是0
,所以dp[2] = 0 + 0 = 0
。 - 当
j = 1
时:dp[1] += dp[1 - 1]
。由于dp[0]
是1
,所以dp[1] = 0 + 1 = 1
。
- 当
第一个
1
处理完后,dp
数组更新为[1, 1, 0, 0, 0]
。 - 从
-
处理第二个
1
:- 同样的过程。从
j = 4
到1
向后遍历:- 当
j = 4
时:dp[4] += dp[4 - 1]
。由于dp[3]
目前是0
,所以dp[4] = 0 + 0 = 0
。 - 当
j = 3
时:dp[3] += dp[3 - 1]
。由于dp[2]
目前是0
,所以dp[3] = 0 + 0 = 0
。 - 当
j = 2
时:dp[2] += dp[2 - 1]
。由于dp[1]
目前是1
,所以dp[2] = 0 + 1 = 1
。 - 当
j = 1
时:dp[1] += dp[1 - 1]
。由于dp[0]
是1
,所以dp[1] = 1 + 1 = 2
。
- 当
第二个
1
处理完后,dp
数组更新为[1, 2, 1, 0, 0]
。 - 同样的过程。从
-
以此类推,处理剩下的每个
1
。经过处理所有的
1
后,dp[4]
将给出使用nums
中的数字达到和为4
的不同方法数。在这个例子中,最终dp
数组可能看起来像[1, 5, 10, 10, 5]
。
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int num : nums) {
sum += num;
}
// 如果 sum + target 是奇数或者 target 大于 sum,没有方案
if ((sum + target) % 2 != 0 || target > sum) return 0;
int bigTarget = (sum + target) / 2;
// 检查 bigTarget 是否在合理范围内
if (bigTarget < 0) return 0;
int[] dp = new int[bigTarget + 1];
dp[0] = 1; // 初始化,表示和为0的方式有一种,即不选择任何元素
for (int num : nums) {
for (int j = bigTarget; j >= num; j--) {
dp[j] += dp[j - num];
}
}
return dp[bigTarget];
}
}
474. 一和零 - 力扣(LeetCode)
在01背包问题中,从大到小遍历背包容量是为了确保每个物品只被计算一次。这个策略是为了避免同一物品被重复添加。我们用一个具体的例子来解释这个概念:
假设你有一个背包,其最大容量为 5
单位,你有两件物品可以选择,物品A(占用3单位空间,价值10)和物品B(占用2单位空间,价值6)。我们要计算不超过背包最大容量时的最大价值。
我们用一个数组 dp
来记录每个容量下的最大价值。dp[j]
表示容量为 j
的背包所能达到的最大价值。
不使用从大到小遍历的后果
如果我们从小到大遍历背包容量:
for (物品)
{
for (int j = 物品体积; j <= 背包容量; j++)
{
dp[j] = Math.max(dp[j], dp[j - 物品体积] + 物品价值);
}
}
当我们处理物品A时,假设我们更新了 dp[3]
为10(只装物品A)。当我们继续处理到 j = 6
时(假设我们没有容量限制),我们会再次考虑物品A,因为 dp[6 - 3]
已经包括了物品A,这就意味着我们又把物品A加入了背包,这违反了01背包的规则(每个物品只能使用一次)。
使用从大到小遍历
for (物品) {
for (int j = 背包容量; j >= 物品体积; j--)
{
dp[j] = Math.max(dp[j], dp[j - 物品体积] + 物品价值);
} }
当我们处理物品A时,我们首先更新 dp[5]
,此时只考虑物品A的情况。然后我们更新 dp[4]
,再更新 dp[3]
。由于我们是从大到小遍历,当我们处理较小的容量时,我们使用的是尚未加入当前物品的 dp
值。这样,每个物品只被计算一次,符合01背包的规则。
完全背包
import java.util.*;
public class Main{
public static void main(String[] args)
{
Scanner sc=new Scanner(System.in);
int N=sc.nextInt();
int V=sc.nextInt();
int[] weight=new int[N+1];
int[] value=new int[N+1];
for(int i=1;i<=N;i++)
{
weight[i]=sc.nextInt();
value[i]=sc.nextInt();
}
int[] dp=new int[V+1];
dp[0]=0;
for(int i=1;i<=N;i++)
{
//内层背包容量
for(int j=weight[i];j<=V;j++)
{
dp[j]=Math.max(dp[j-weight[i]]+value[i],dp[j]);
}
}
System.out.println(dp[V]);
}
}
518. 零钱兑换 II - 力扣(LeetCode)
class Solution {
public int coinChange(int[] coins, int amount) {
//完全背包 先遍历背包再遍历物品
//dp[i]的含义为要装满i的最少硬币个数
int[] dp=new int[amount+1];
Arrays.fill(dp,Integer.MAX_VALUE);
dp[0]=0;//装满0需要0个硬币
for(int i=1;i<=amount;i++)
{
for(int value:coins)
{
if(value<=i&&dp[i-value]!=Integer.MAX_VALUE)
dp[i]=Math.min(dp[i],dp[i-value]+1);
}
}
return dp[amount]==Integer.MAX_VALUE?-1:dp[amount];
}
}
本题求的是装满这个背包的物品组合数是多少。
因为每一种面额的硬币有无限个,所以这是完全背包。
但本题和纯完全背包不一样,纯完全背包是凑成背包最大价值是多少,而本题是要求凑成总金额的物品组合个数!
注意题目描述中是凑成总金额的硬币组合数,为什么强调是组合数呢?
例如示例一:
5 = 2 + 2 + 1
5 = 2 + 1 + 2
这是一种组合,都是 2 2 1。
如果问的是排列数,那么上面就是两种排列了。
组合不强调元素之间的顺序,排列强调元素之间的顺序。 其实这一点我们在讲解回溯算法专题的时候就讲过。
那我为什么要介绍这些呢,因为这和下文讲解遍历顺序息息相关!
本题其实与我们讲过 494. 目标和 (opens new window)十分类似。
494. 目标和 (opens new window)求的是装满背包有多少种方法,而本题是求装满背包有多少种组合。
这有啥区别?
求装满背包有几种方法其实就是求组合数。 不过 494. 目标和 (opens new window)是 01背包,即每一类物品只有一个。
以下动规五部曲:
#1、确定dp数组以及下标的含义
定义二维dp数值 dp[i][j]:使用 下标为[0, i]范围的coins[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种组合方法。
01背包理论基础 (opens new window),中二维DP数组的递推公式为:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
完全背包理论基础 (opens new window)详细讲解了完全背包二维DP数组的递推公式为:
dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i])
看去完全背包 和 01背包的差别在哪里?
在于01背包是 dp[i - 1][j - weight[i]] + value[i]
,完全背包是 dp[i][j - weight[i]] + value[i])
主要原因就是 完全背包单类物品有无限个。
如果 j 可以整除 物品0,那么装满背包就有1种组合方法。
初始化代码:初始化第一行第一列
dp[0][j] += dp[0][j - coins[0]]
的含义
-
dp[0][j]
表示只使用第一种硬币时,凑成金额j
的组合数。 -
dp[0][j - coins[0]]
表示凑成金额j - coins[0]
的组合数。
3. 逻辑解释
假设我们只有一种硬币(coins[0]
),要凑成金额 j
,有两种情况:
-
不使用当前硬币:
如果金额j
不是coins[0]
的倍数,那么无法凑成j
,组合数为 0。 -
使用当前硬币:
如果金额j
是coins[0]
的倍数,那么可以使用若干个coins[0]
凑成金额j
。
例如:-
如果
coins[0] = 2
,要凑成金额j = 4
,可以使用两个2
。 -
要凑成金额
j = 6
,可以使用三个2
。
-
因此,凑成金额 j
的组合数等于凑成金额 j - coins[0]
的组合数。换句话说:
-
如果我们可以用若干个
coins[0]
凑成金额j - coins[0]
,那么再加上一个coins[0]
,就可以凑成金额j
。 -
for (int j = 0; j <= bagSize; j++) {
if (j % coins[0] == 0) dp[0][j] = 1;
}
最左列如何初始化呢?
dp[i][0] 的含义:用物品i(即coins[i]) 装满容量为0的背包 有几种组合方法。
都有一种方法,即不装。
所以 dp[i][0] 都初始化为1
#4. 确定遍历顺序
class Solution {
public int change(int amount, int[] coins) {
//二维数组做法
//使用 下标为[0, i]范围的coins[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种组合方法。
int[][] dp=new int[coins.length][amount+1];//因为要访问到amount
//初始化dp数组第一列,表示金额为0时只有一种情况,也就是什么都不装
for(int i=0;i<coins.length;i++)
{
dp[i][0]=1;
}
//因为后面要访问i-1 所以第一行也要初始化
for(int j=coins[0];j<=amount;j++)
{
//不放物品0和放物品0凑成j
dp[0][j] = dp[0][j - coins[0]]; // 明确状态转移
}
for(int i=1;i<coins.length;i++)
{
for(int j=1;j<=amount;j++)
{
//j<coins[i]的情况不能省略哦
if(j < coins[i]) dp[i][j] = dp[i-1][j];
else dp[i][j] = dp[i][j-coins[i]] + dp[i-1][j];//组合数
}
}
return dp[coins.length-1][amount];
}
}
在使用一维数组实现的动态规划中,内层循环的方向(从前往后或从后向前)取决于状态转移方程的依赖关系。对于零钱兑换问题,内层循环从前往后是正确的,因为每个 dp[j]
的值依赖于 dp[j - coins[i]]
的值,而 j - coins[i]
总是小于 j
。
如果内层循环从后向前,那么在更新 dp[j]
时,dp[j - coins[i]]
可能已经包含了当前硬币 coins[i]
的贡献,这会导致重复计算。因此,内层循环必须从前往后,以确保每次更新 dp[j]
时,dp[j - coins[i]]
只包含之前硬币的贡献。
1. 从前向后(从小到大)
适用场景:当状态转移依赖于之前已经计算过的状态,并且每个状态可以多次使用时,通常从前向后遍历。
特点
-
依赖关系:
dp[j]
的更新依赖于dp[j - coins[i]]
,而j - coins[i]
总是小于j
。 -
重复使用:允许重复使用同一个硬币(或状态),因为每次更新
dp[j]
时,dp[j - coins[i]]
已经包含了之前所有硬币的贡献。
2. 从后向前(从大到小)
适用场景:当状态转移依赖于之前已经计算过的状态,但每个状态只能使用一次时,通常从后向前遍历。
特点
-
依赖关系:
dp[j]
的更新依赖于dp[j - coins[i]]
,但为了避免重复计算,需要确保dp[j - coins[i]]
在更新后不会被再次使用。 -
避免重复:从后向前遍历时,更新后的状态不会影响之前的状态,从而避免重复计算。
class Solution {
public int change(int amount, int[] coins) {
int[] dp=new int[amount+1];//dp[j]的含义是装满j的方法数
dp[0]=1;
for(int i=0;i<coins.length;i++)
{
for(int j=coins[i];j<=amount;j++)
{
dp[j]+=dp[j-coins[i]];
}
}
return dp[amount];
}
}
377. 组合总和 Ⅳ - 力扣(LeetCode)
class Solution {
public int combinationSum4(int[] nums, int target) {
int[] dp=new int[target+1];//dp[j]的含义是装满j的排列数
dp[0]=1;
for(int i=1;i<=target;i++)
{
// 背包容量从1开始
//遍历物品
for(int j=0;j<nums.length;j++)
{
if(i>=nums[j])
{
dp[i]+=dp[i-nums[j]];
System.out.println("dp["+i+"]="+dp[i]);
}
}
System.out.println("/n");
}
return dp[target];
}
}
import java.util.*;
public class Main{
public static void main (String[] args) {
/* code */
Scanner sc=new Scanner(System.in);
int n=sc.nextInt();
int m=sc.nextInt();
int[] dp=new int[n+1];
dp[0]=1;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
if(i>=j)
{
dp[i]+=dp[i-j];
}
}
}
System.out.println(dp[n]);
}
}
多重背包
其实就是0,1背包的变体
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int c = sc.nextInt();
int n = sc.nextInt();
int[] weight = new int[n];
int[] value = new int[n];
int[] numbers = new int[n];
for (int i = 0;i < n;i++) {
weight[i] = sc.nextInt();
}
for (int i = 0;i < n;i++) {
value[i] = sc.nextInt();
}
for (int i = 0;i < n;i++) {
numbers[i] = sc.nextInt();
}
int[] dp = new int[c+1];
for (int i = 0;i < n;i++) {
for (int j = c;j >= weight[i];j--) {
for (int k = 1;k <= numbers[i] && (j - k * weight[i]) >= 0;k++) {
dp[j] = Math.max(dp[j],dp[j-weight[i] * k]+ k * value[i]);
}
}
}
System.out.println(dp[c]);
}}
背包总结
打家劫舍!
198. 打家劫舍 - 力扣(LeetCode)
-
在动态规划的开始阶段需要特别处理。如果只有一个房屋,那么直接偷这个房屋;如果有两个房屋,就选择偷取价值更大的那个。
-
循环的起始条件:从第三个房屋开始循环,逐步计算当前状态下的最大值。在循环过程中,每次考虑当前房屋偷与不偷两种情况,选择其中的较大值更新
dp
数组。class Solution { public int rob(int[] nums) { if (nums == null || nums.length == 0) return 0; if (nums.length == 1) return nums[0]; int[] dp=new int[nums.length+1]; dp[0]=nums[0]; dp[1]=Math.max(dp[0],nums[1]); for(int i=2;i<nums.length;i++) { dp[i]=Math.max(dp[i-1],dp[i-2]+nums[i]); System.out.println(dp[i]); } return dp[nums.length-1]; } }
买卖股票
309. 买卖股票的最佳时机含冷冻期 - 力扣(LeetCode)
容易写错的地方:
dp[i][0]=Math.max(dp[i-1][0],dp[i-1][3]-prices[i]);
这样写是不对的,忽略了dp[i-1][1]-price[i]
class Solution {
public int maxProfit(int[] prices) {
if(prices.length<=1) return 0;
int[][] dp=new int[prices.length][4];
//0表示持有 1表示保持卖出的状态 2表示当天卖出 3表示冷冻期 的最大利润
dp[0][0]=-prices[0];
dp[1][0]=Math.max(dp[0][0],-prices[1]);
dp[1][2]=Math.max(dp[0][0]+prices[1],0);
dp[1][3]=0;
for(int i=2;i<prices.length;i++)
{
dp[i][0]=Math.max(Math.max(dp[i-1][1]-prices[i],dp[i-1][0]),dp[i-1][3]-prices[i]);
dp[i][1]=Math.max(dp[i-1][1],dp[i-1][3]);
dp[i][2]=dp[i-1][0]+prices[i];
dp[i][3]=dp[i-1][2];
}
return Math.max(dp[prices.length-1][1],Math.max(dp[prices.length-1][2],dp[prices.length-1][3]));
}
}
递增子序列
前缀判断做法
不连续递增子序列的跟前0-i 个状态有关,连续递增的子序列只跟前一个状态有关
1143. 最长公共子序列 - 力扣(LeetCode)
- 对于每个
i
和j
,如果text1[i-1] == text2[j-1]
,这意味着这两个字符在两个字符串中都出现且按相同顺序出现。因此,当前字符是LCS的一部分,我们将此位置的LCS长度设置为dp[i-1][j-1] + 1
。 - 如果字符不匹配,我们查看不包含
text1
当前字符或不包含text2
当前字符情况下的LCS长度,取两者的较大值作为当前位置的LCS长度。这是因为LCS可以通过删除一个字符串中的字符而不改变另一个字符串来获得。
1035. 不相交的线 - 力扣(LeetCode)
直线不能相交,这就是说明在字符串A中 找到一个与字符串B相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,链接相同数字的直线就不会相交。
115. 不同的子序列 - 力扣(LeetCode)
dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]。
// dp数组中存储需要删除的字符个数
class Solution {
public int minDistance(String word1, String word2) {
int[][] dp = new int[word1.length() + 1][word2.length() + 1];
for (int i = 0; i < word1.length() + 1; i++) dp[i][0] = i;
for (int j = 0; j < word2.length() + 1; j++) dp[0][j] = j;
for (int i = 1; i < word1.length() + 1; i++) {
for (int j = 1; j < word2.length() + 1; j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
}else{
dp[i][j] = Math.min(dp[i - 1][j - 1] + 2,
Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
}
}
}
return dp[word1.length()][word2.length()];
}
}