1.0-1背包问题
给你一个可装载重量为W
的背包和N
个物品,每个物品有重量和价值两个属性。其中第i
个物品的重量为wt[i]
,价值为val[i]
,现在让你用这个背包装物品,最多能装的价值是多少?
举个简单的例子,输入如下:
N = 3, W = 4
wt = [2, 1, 3]
val = [4, 2, 3]
算法返回 6,选择前两件物品装进背包,总重量 3 小于W
,可以获得最大价值 6。
这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。这也许就是 0-1 背包这个名词的来历。解决这个问题没有什么排序之类巧妙的方法,只能穷举所有可能,按照套路,直接走流程就行了。
动规标准套路:
1.想明白状态有哪些,选择是什么:
状态,如何才能描述一个问题局面?只要给定几个可选物品和一个背包的容量限制,就形成了一个背包问题,对不对?所以状态有两个,就是「背包的容量」和「可选择的物品」。再说选择,也很容易想到啊,对于每件物品,你能选择什么?选择就是「装进背包」或者「不装进背包」嘛。
题目中出现了 载重量w ,物品n ,每个物品有重量和价值,题目问的是,在一定载重量w的限定下, 最多能装的价值是多少。动态规划问题都是求最值的问题,这里求的是价值的最值,也就是说动态规划的结果就是价值的最值,再换句话说,就是dp[][]的含义一定是价值,那么剩下的就是限重w和可选择的物品,这也就是状态,当然对于每个物品的选择都是 装 or 不装进,而动态规划之中对于不同的选择就是之间穷举取极值。
在明确了状态和选择之后,动态规划的基本套路就是:
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 择优(选择1,选择2...)
2.明确dp数组的定义:
dp
数组是什么?其实就是描述问题局面的一个数组。换句话说,我们刚才明确问题有什么「状态」,现在需要用dp
数组把状态表示出来。首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维dp
数组,一维表示可选择的物品,一维表示背包的容量。
dp[i][w]
的定义如下:对于前i
个物品,当前背包的容量为w
,这种情况下可以装的最大价值是dp[i][w]
。比如说,如果 dp[3][5] = 6,其含义为:对于给定的一系列物品中,若只对前 3 个物品进行选择,当背包容量为 5 时,最多可以装下的价值为 6。
PS:为什么要这么定义?便于状态转移,或者说这就是套路,记下来就行了。
根据这个定义,我们想求的最终答案就是****dp[N][W]
。base case 就是dp[0][..] = dp[..][0] = 0
,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0。
int dp[N+1][W+1]
dp[0][..] = 0
dp[..][0] = 0
for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
把物品 i 装进背包,
不把物品 i 装进背包
)
return dp[N][W]
3.确定状态转移方程:
简单说就是,上面伪码中「把物品i
装进背包」和「不把物品i
装进背包」怎么用代码体现出来呢?这一步要结合对dp
数组的定义和我们的算法逻辑来分析:
dp[i][w]
表示:对于前i
个物品,当前背包的容量为w
时,这种情况下可以装下的最大价值是dp[i][w]
。
如果你没有把这第i
个物品装入背包,那么很显然,最大价值dp[i][w]
应该等于dp[i-1][w]
。你不装嘛,那就继承之前的结果。
如果你把这第i
个物品装入了背包,那么dp[i][w]
应该等于dp[i-1][w-wt[i-1]] + val[i-1]
。
首先,由于i
是从 1 开始的,所以对val
和wt
的取值是i-1
。
而dp[i-1][w-wt[i-1]]
也很好理解:你如果想装第i
个物品,你怎么计算这时候的最大价值?换句话说,在装第****i
个物品的前提下,背包能装的最大价值是多少?
显然,你应该寻求剩余重量w-wt[i-1]
限制下能装的最大价值,加上第i
个物品的价值val[i-1]
,这就是装第i
个物品的前提下,背包可以装的最大价值。
综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码:
for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
dp[i-1][w],
dp[i-1][w - wt[i-1]] + val[i-1]
)
return dp[N][W]
最后一步****,把伪码翻译成代码,处理一些边界情况。
int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
// vector 全填入 0,base case 已初始化
vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
for (int i = 1; i <= N; i++) {
for (int w = 1; w <= W; w++) {
if (w - wt[i-1] < 0) {
// 当前背包容量装不下,只能选择不装入背包
dp[i][w] = dp[i - 1][w];
} else {
// 装入或者不装入背包,择优
dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1],
dp[i - 1][w]);
}
}
}
return dp[N][W];
}
2.0-1背包问题的变体:
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
首先回忆一下背包问题大致的描述是什么:
给你一个可装载重量为W
的背包和N
个物品,每个物品有重量和价值两个属性。其中第i
个物品的重量为wt[i]
,价值为val[i]
,现在让你用这个背包装物品,最多能装的价值是多少?
那么对于这个问题,我们可以先对集合求和,得出sum
,把问题转化为背包问题:
给一个可装载重量为sum/2
的背包和N
个物品,每个物品的重量为nums[i]
。现在让你装物品,是否存在一种装法,能够恰好将背包装满?
第一步要明确两点,「状态」和「选择」。状态就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。
第二步要明确dp
数组的定义。dp[i][j] = x
表示,对于前i
个物品,当前背包的容量为j
时,若x
为true
,则说明可以恰好将背包装满,若x
为false
,则说明不能恰好将背包装满。比如说,如果dp[4][9] = true
,其含义为:对于容量为 9 的背包,若只是用前 4 个物品,可以有一种方法把背包恰好装满。或者说对于本题,含义是对于给定的集合中,若只对前 4 个数字进行选择,存在一个子集的和可以恰好凑出 9。
根据这个定义,我们想求的最终答案就是dp[N][sum/2]
,base case 就是dp[..][0] = true
和dp[0][..] = false
,因为背包没有空间的时候,就相当于装满了,而当没有物品可选择的时候,肯定没办法装满背包。
第三步,根据「选择」,思考状态转移的逻辑。如果不把nums[i]
算入子集,或者说你不把这第i
个物品装入背包,那么是否能够恰好装满背包,取决于上一个状态dp[i-1][j]
,继承之前的结果。如果把nums[i]
算入子集,或者说你把这第i
个物品装入了背包,那么是否能够恰好装满背包,取决于状态dp[i - 1][j-nums[i-1]]
。首先,由于i
是从 1 开始的,而数组索引是从 0 开始的,所以第i
个物品的重量应该是nums[i-1]
,这一点不要搞混。dp[i - 1][j-nums[i-1]]
也很好理解:你如果装了第i
个物品,就要看背包的剩余重量j - nums[i-1]
限制下是否能够被恰好装满。换句话说,如果j - nums[i-1]
的重量可以被恰好装满,那么只要把第i
个物品装进去,也可恰好装满j
的重量;否则的话,重量j
肯定是装不满的。
class Solution {
public boolean canPartition(int[] nums) {
int sum=0;
for(int i=0;i<nums.length;i++){
sum=sum+nums[i];
}
if(sum%2!=0){return false;}
int target=sum/2;
boolean[][] dp=new boolean[nums.length+1][target+1];
for (int i = 0; i <= nums.length; i++){
dp[i][0] = true;
}
for(int i=1;i<=nums.length;i++){
for(int j=1;j<=target;j++){
if(j-nums[i-1]<0){
dp[i][j]=dp[i-1][j];
}else{
dp[i][j]=dp[i-1][j] | dp[i-1][j-nums[i-1]];
}
}
}
return dp[nums.length][target];
}
}
3.完全背包问题:
leet518. 零钱兑换问题:
有一个背包,最大容量为amount
,有一系列物品coins
,每个物品的重量为coins[i]
,每个物品的数量无限。请问有多少种方法,能够把背包恰好装满?
这个问题和我们前面讲过的两个背包问题,有一个最大的区别就是,每个物品的数量是无限的,这也就是传说中的「完全背包问题」,没啥高大上的,无非就是状态转移方程有一点变化而已。
第一步要明确两点,「状态」和「选择」。这部分都是背包问题的老套路了,我还是啰嗦一下吧:状态有两个,就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 计算(选择1,选择2...)
第二步要明确dp
数组的定义。首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维dp
数组。
dp[i][j]
的定义如下:若只使用前i
个物品,当背包容量为j
时,有dp[i][j]
种方法可以装满背包。换句话说,翻译回我们题目的意思就是:若只使用coins
中的前i
个硬币的面值,若想凑出金额j
,有dp[i][j]
种凑法。
base case 为dp[0][..] = 0, dp[..][0] = 1
。因为如果不使用任何硬币面值,就无法凑出任何金额;如果凑出的目标金额为 0,那么“无为而治”就是唯一的一种凑法。最终想得到的答案就是dp[N][amount]
,其中N
为coins
数组的大小.
第三步,根据「选择」,思考状态转移的逻辑。注意,我们这个问题的特殊点在于物品的数量是无限的,所以这里和之前写的背包问题文章有所不同。如果你不把这第i
个物品装入背包,也就是说你不使用coins[i]
这个面值的硬币,那么凑出面额j
的方法数dp[i][j]
应该等于dp[i-1][j]
,继承之前的结果。如果你把这第i
个物品装入了背包,也就是说你使用coins[i]
这个面值的硬币,那么dp[i][j]
应该等于dp[i][j-coins[i-1]]
。
首先由于i
是从 1 开始的,所以coins
的索引是i-1
时表示第i
个硬币的面值。dp[i][j-coins[i-1]]
也不难理解,如果你决定使用这个面值的硬币,那么就应该关注如何凑出金额j - coins[i-1]
。比如说,你想用面值为 2 的硬币凑出金额 5,那么如果你知道了凑出金额 3 的方法,再加上一枚面额为 2 的硬币,不就可以凑出 5 了嘛。综上就是两种选择,而我们想求的dp[i][j]
是「共有多少种凑法」,所以dp[i][j]
的值应该是以上两种选择的结果之和:
class Solution {
public int change(int amount, int[] coins) {
int[][] dp=new int[coins.length+1][amount+1];
for(int i=0;i<=coins.length;i++){
dp[i][0]=1;
}
for(int i=1;i<=coins.length;i++){
for(int j=1;j<=amount;j++){
if(j-coins[i-1]<0){
dp[i][j]=dp[i-1][j];
}else{
dp[i][j]=dp[i-1][j]+dp[i][j-coins[i-1]];
}
}
}
return dp[coins.length][amount];
}
}