【算法基础】 背包dp问题:背包九讲

这篇博客详细介绍了背包问题的动态规划解决方案,包括01背包、完全背包、多重背包、混合背包、二维背包等类型的分析、状态转移、优化技巧和具体实现。针对每个类型,博主探讨了思路、状态初始化、状态转移方程,并提供了优化方法,如二维转一维和循环反向。此外,还讨论了如何处理有依赖关系的背包问题和求解具体方案的策略。
摘要由CSDN通过智能技术生成

背包九讲学习笔记

这个博客是对动态规划的背包问题专题的学习笔记,记录了背包问题的九个经典类型。
参考视频:视频链接
题目来自视频中使用的oj中的例题。

话不多说,咱们开始

01背包

题目

分析问题

题目的意思很好理解:
有N个物品,体积为V的背包。每个物品有自己的价值和体积,由于是01背包问题,所以每件物品仅有可以选或者不选两种选项,问我们可以向背包中装下的价值的最大值。

题意很简单,接着就让我们展开思路想办法解决它。

两个可能会产生的思路

笔者个人补充,着急学习正解的同学可以跳过这里

思路一:枚举

考虑到每件物品仅有选或者不选两种状态,那么不难发现状态数是确定的,共有2^n种状态。于是就可以枚举每一种状态,在判断其体积合乎规则后,统计答案即可。

这样的思想当然可以,但是问题就在于时间复杂度上,上面说了状态数共有2n种,就算是在枚举的过程中顺带累积了体积和价值,那么复杂度也是O(2n),这个复杂度对于大于25的数据范围就有些吃力了。对于1000这种范围,显然是不可饶恕的,予以TLE警告。

然后25的范围也足以体现出这种算法的效率之低。所以这种想法,我们还是趁早打消的好。

思路二:贪心

最开始接触这个问题的我反应是这样的。想要通过计算物品的“性价比”作为标准排序进行选取。
但是转念一想后发现这样的想法是不可取的,一个单位性价比较高的物品放进背包中不一定会使答案最优。问题的原因就在于题目中的物品是不可分隔的,所以仅考虑“性价比”在当前体积还有足够的剩余时还可以保证其正确性,但在接近放满时就会出现问题,导致这种解法的不正确性就出现在这里了。
由于可能产生剩余的空间,不能再放下其他的物品,背包中就会产生空余。这些空余就会使已经放入背包中的“性价比”贬值,小于未加入背包的物品,从而导致选出的答案不是最优解。
换句话说我们不知道是否需要将已经放入背包中的那些物品拿出来换成其它的物品。这导致我们的这个思路无法解决问题。

值得注意的是,这种贪心的思想在该问题环境下破产的根本原因是物品选取是离散的,不可分割的。
所以如果物品可以分割,那么这种贪心的思想就可以保证其正确,这道题就是这样

所以两种思路都是不可取的,既然是正经的动态规划中的背包问题板子题,那这就让我们走回到正轨上来。

状态转移

设状态dp[i][j]表示在第i件物品,j的体积下的最大价值。

那么由于该件物品仅有选或不选两种情况,那么这个状态就可能会从两种状态转移而来

当不选时,前一状态为dp[i - 1][j],当前物品贡献为0;当选择时,前一状态为dp[i - 1][j - v[i]]当前物品贡献为w[i]。

我们从两者中取较大者作为当前状态的最优解。即为dp[i][j] = max(dp[i - 1][j],dp[i - 1][j - v[i]] + w[i]);
(一定不要忘记在选择物品的状态转移时加上当前物品的价值,当体积不足以放下当前物品时,直接照抄上一行的值。)
伪代码:

for(int i = 1;i <= n;i++)
		{
   
			v = scan.nextInt();
			w = scan.nextInt();
			for(int j = V;j >= 0;j--)
			{
   
				if(j < v)
				{
   
					dp[i][j] = dp[i - 1][j];
				}
				else
				{
   
					dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - v] + w);
				}
			}
		}

状态初始化

有了转移方程,我们就要确定边界,即确定初始状态和最终答案。

需要初始化的初始状态应该是一个物品都没有考虑的状态,这个(些)初始状态我们是已知的这里有两种初始化的方法,分别对应着对状态的不同理解:

1.将dp[i][j]看作是对于第i个物品,占用体积 恰好为j 时的最优解。初始状态为:没有放置任何物品的背包价值和占用体积都为0,即dp[0][0] = 0;其余体积都是不可能的,赋初值为负无穷

2.将dp[i][j]看作是对于第i个物品,占用体积 不大于j 时的最优解。初始状态为:没有放置任何物品时,总体积为零,不大于任何体积,所以所有体积的初值都为0.即dp[0][j] = 0

答案

对于该问题,最终的答案一定出现在在我们考虑了每一个物品后,即在数组中的最后一行dp[n]

不过具体在哪个位置,就和刚刚的初始化有关,关系如下:

初始化为负无穷,答案需要统计(某一总体积价值最大)
初始化为零,答案为最后一位(小于等于最大体积价值最大)

对于第一种初始化的方法,需要遍历最后一行,找出那个最大的价值方案,即ans=max{dp[n][i]}(1 ≤i≤V)
对于第二种初始化的方法,最优答案出现在体积最大的情况下,即ans=dp[n][V]

对01背包的优化

从上述状态转移方程中,不难发现其实第i个物品的所有状态都来自于第i-1个物品,所以为了求取最大的价值并没有必要保留所有的状态
当然,由于转移仅涉及上一行,所以可以采取滚动数组的优化方案来优化空间复杂度。但是我们仍有更优的优化方案,可以将数组直接从二维优化至一维。

二维转一维

重新分析状态和转移,当进行到第i个物品体积为j时,两个用来转移状态分别来自于上一物品占用体积较小的某个状态dp[i - 1][j - v[i]]和当前体积状态dp[i][j]。

所以可以将这个二选一的过程看作是先原封不动的复制上一行同一位置的答案,在将其与另一个状态选优。进而可以发现,这样的做法其实并不需要我们利用新的空间。也就是说可以直接将第j位同第j-v[i]位加上价值进行比较,产生的答案放置在第j位上。方程:dp[j]=max{dp[j],dp[j - v[i]] +w[i]}
那么这样一来,我们就不需要第二个维度了,进行新的物品的状态转移,仅需要在同一个数组上按照方程扫一遍即可。

不过,仅考虑到这里并没有结束,上述过程仍旧存在问题,需要我们对体积的枚举进行改造。

循环反向

刚刚的问题就在于,如果仍旧按照二维数组的正向循环的顺序去做,那么在利用前面元素进行转移时,就不能保证这个状态是来自上一个物品的了。
所以为了避免这个问题,在枚举体积时我们选择避开选取状态的方向,反向进行选取。
(这里可以参考拿着大拖把清洁狭长的走廊时,都是拉着拖把走,这样就可以避免把刚刚拖干净的地面踩脏)

代码:

import java.util.Scanner;

public class Main {
   
	public static void main(String[] args)
	{
   
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int V = scan.nextInt();
		int[] dp = new int[V + 1];
		int v,w;
		for(int i = 1;i <= n;i++)
		{
   
			v = scan.nextInt();
			w = scan.nextInt();
			for(int j = V;j >= v;j--)
			{
   
				dp[j] = Math.max(dp[j], dp[j - v] + w);
			}
		}
		System.out.print(dp[V]);
	}
}

一点关于空间优化的想法:

笔者私货,可略过

在刚刚的分析和优化中背包问题出现了两种空间上的优化:

 先是将空间从n行优化为滚动数组的两行。
 再是将空间从两行优化为一行

这里的两次优化分别对应了背包问题转移时的两种性质,对于其他的动态规划题满足其中性质就可以做相应的优化。它们分别是:

1.用来转移的子状态全部来源于前一行
2.子状态全部集中分布在同一侧
下限行数优化

对于第一种性质,可以参考数列,就像是数列中的一类递推公式,每一个元素仅与前一个元素有关,例如ak = 5ak-1。这样的数列在根据递推式计算某一位时可以仅存储相邻的两个值。每一次根据公式计算出下一个值,覆盖掉两者中的靠前的那一位。这样的操作,在数组中,对应的就是滚动数组。

当然,数列可以看做是这种转移的特殊情况:
数组列数为1
转移时仅进行计算不涉及选择(相较于状态转移,这里更应该称之为递推)。

不过相信你已经发现了,那样的数列在计算时仅需要一个值不断更迭就好。其实正如我们将两行优化为一行一样,数列的递推满足了下面这条性质,所以在递推过程中可以对空间进行进一步的优化。

行数减一优化

进行这种优化需要满足前两种性质,也就是说,这个转移或者递归的过程,每一行的状态或是值,仅有其前面确定数量的行递推得到。其次,就是子状态必须在一行中集中分布在当前状态的一侧(这里可以包含当前状态所处的位置)。

例如,对于背包问题,子状态在它的一行中就集中分布在当前状态的左侧。从二维数组的角度看,假设得到的新数组位于最下方,那么所有子状态都分布在当前状态的左上方。

这样一来,就可以将滚动数组再优化掉一行。数列的情况与之类似,上一个数字就在新的数字的正上方,满足这个性质,所以在滚动的过程中可以再减少一行。

这样看来,其实从二维优化至一维,是两行滚动数组再优化掉一行所致。本质上还是滚动的数组。
(在补充一点,集中分布在一侧,其实是为了在覆盖的时候不会影响后续,保证用到的子状态都是原数组的。所以,如果通过其他的选取子状态的方式保证使转移时不发生干扰,那么其实不必要求集中分布在一侧,也不必要求从后向前循环)

完全背包

题目

分析问题

大背景同01背包问题一样,本题作为01背包问题的一种扩展,不同于01背包仅有选或者选一个两种情况,完全背包的每件物品可以选取任意整数数量,可看做01…n背包。

状态转移

仿照01背包的思路,每个状态的转移需要枚举所有可能出现的选取方式,最少选0个,为dp[j];最多选k=j/v个,为dp[j - v[i] * k]+ k * w[i]

不过这样的转移方式,会导致时间复杂度直接多了一个k,k最大为V,则上限变成了O(NV2)。爆炸

所以,我们需要再进行优化。

循环方向(反转正)

其实这里的优化很简单,就是将刚刚的01背包中的循环反向即可。反向的操作使得我们可以利用刚刚考虑放过一定数量的物品的最优解继续计算是否需要继续放。这句话可能有一些绕,大致的意思就是,由于是正向循环,所以当体积枚举到j时,进行转移,就相当于在体积为j-vi的基础上判断是否再加上一个该物品或者不加。由于是加上一个,所以就相当于在体积允许的情况下选取若干个。
值得注意的是,01背包中我们反向循环正是为了避免这个问题(你品,你细品)
代码:

import java.util.Scanner;

public class Main {
   
	public static void main(String[] args)
	{
   
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int V = scan.nextInt();
		int[] dp = new int[V + 1];
		int v,w;
		for(int i = 1;i <= n;i++)
		{
   
			v = scan.nextInt();
			w = scan.nextInt();
			for(int j = V;j >= v;j--)
			{
   
				dp[j] = Math.max(dp[j], dp[j - v] + w);
			}
		}
		System.out.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值