参考:
【动态规划/背包问题】那就从 0-1 背包问题开始讲起吧 ...
内容是学习 宫水三叶的刷题日记 公众号专题内容时的笔记,为了方便个人复习整理到这里。建议大家关注该公众号,写的很清楚,有更多内容。
动态规划数组含义:
即第i行代表第i个物品(0表示第一个物品),第j列代表背包容量为j,背包容量从0开始,所以列数为容量+1。
(此部分图片来源:背包问题——01背包|完全背包-CSDN博客)
初始化:背包容量为0时最大价值为0,所以第0列全部为0(int数组初始化为0所以不用处理这个),第一行当容量小于w[0]时值为0,大于等于时值为w[i]
对每一个物品,都只有两种状态,选或者不选:
所以转移方程为:
(把c看成j就好了,懒得自己写公式了)
代码:
class Solution {
public int maxValue(int N, int C, int[] v, int[] w) {
int[][] dp = new int[N][C+1];
// 先处理「考虑第一件物品」的情况
for (int i = 0; i <= C; i++) {
dp[0][i] = i >= v[0] ? w[0] : 0;
}
// 再处理「考虑其余物品」的情况
for (int i = 1; i < N; i++) {
for (int j = 0; j < C + 1; j++) {
// 不选该物品
int n = dp[i-1][j];
// 选择该物品,前提「剩余容量」大于等于「物品体积」
int y = j >= v[i] ? dp[i-1][j-v[i]] + w[i] : 0;
dp[i][j] = Math.max(n, y);
}
}
return dp[N-1][C];
}
}
根据「转移方程」,我们知道计算第 i行格子只需要第 i-1行中的某些值。
也就是计算「某一行」的时候只需要依赖「前一行」。
因此可以用一个只有两行的数组来存储中间结果,根据当前计算的行号是偶数还是奇数来交替使用第 0 行和第 1 行。
这样的空间优化方法称为「滚动数组」,我在 路径问题 第四讲 也曾与你分享过。
这种空间优化方法十分推荐,因为改动起来没有任何思维难度。
只需要将代表行的维度修改成 2,并将dp数组中所有使用行维度的地方从 i改成 i&1或者 i%2 即可(更建议使用 ,& 运算在不同 CPU 架构的机器上要比 % 运算稳定)。
代码:
class Solution {
public int maxValue(int N, int C, int[] v, int[] w) {
int[][] dp = new int[2][C+1];
// 先处理「考虑第一件物品」的情况
for (int i = 0; i <= C; i++) {
dp[0][i] = i >= v[0] ? w[0] : 0;
}
// 再处理「考虑其余物品」的情况
for (int i = 1; i < N; i++) {
for (int j = 0; j < C + 1; j++) {
// 不选该物品
int n = dp[(i-1)&1][j];
// 选择该物品,前提「剩余容量」大于等于「物品体积」
int y = j >= v[i] ? dp[(i-1)&1][j-v[i]] + w[i] : 0;
dp[i&1][j] = Math.max(n, y);
}
}
return dp[(N-1)&1][C];
}
}
(把c看成j就好了,懒得自己写公式了)
不难发现当求解第i行格子的值时,不仅是只依赖第 i-1行,还明确只依赖第 i-1行的第j个格子和第j-v[i]个格子。
所以可以进一步将行这个维度优化掉,直接改为dp[j] = Math.max(dp[j],dp[j-v[i]]+w[i]),但是有一个问题就是我们原来是从左向右去遍历j的,如果从左向右的话那当我们改dp[j]的时候它左边的dp[j-v[i]]应该也已经被改过了,这时候对应的是i而不是i-1,所以我们要更改遍历顺序,j从右向左遍历,因为我们要取下标为j-v[i]的元素,所以循环控制条件为j>=v[i]
代码
class Solution {
public int maxValue(int N, int C, int[] v, int[] w) {
int[] dp = new int[C + 1];
for (int i = 0; i < N; i++) {
for (int j = C; j >= v[i]; j--) {
// 不选该物品
int n = dp[j];
// 选择该物品
int y = dp[j-v[i]] + w[i];
dp[j] = Math.max(n, y);
}
}
return dp[C];
}
}
1.给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5] 输出:true 解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5] 输出:false 解释:数组不能分割成两个元素和相等的子集。
原问题可以看做,从nums中挑选N个元素使得元素总和等于所有元素和的一半。设所有和为sum,所有元素和一半为target。这个问题就可以看做是,背包容量为target,物品重量和价值都为nums[i]的一个背包问题,将最大能容纳的价值求解出来看是不是等于target,就等价于原数组能不能分割成两个子集。
二维数组:dp = new int[N][target+1],初始化第一行,所有容量小于nums[i]的为0,其余为nums[i],转移方程为dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-nums[i]]+nums[i])。
优化一维数组:dp[j] = Math.max(dp[j],dp[j-nums[i]]+nums[i])
代码:
class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length;
//「等和子集」的和必然是总和的一半
int sum = 0;
for (int i : nums) sum += i;
int target = sum / 2;
// 对应了总和为奇数的情况,注定不能被分为两个「等和子集」
if (target * 2 != sum) return false;
// 将「物品维度」取消
int[] f = new int[target + 1];
for (int i = 0; i < n; i++) {
int t = nums[i];
// 将「容量维度」改成从大到小遍历
for (int j = target; j >= 0; j--) {
// 不选第 i 件物品
int no = f[j];
// 选第 i 件物品
int yes = j >= t ? f[j-t] + t : 0;
f[j] = Math.max(no, yes);
}
}
// 如果最大价值等于 target,说明可以拆分成两个「等和子集」
return f[target] == target;
}
}
总结:
可以发现,本题的难点在于「对问题的抽象」,主要考察的是如何将原问题转换为一个「01 背包」问题。
事实上,无论是 DP 还是图论,对于特定问题,大多都有相应的模型或算法。
难是难在如何将问题转化为我们的模型。
至于如何培养自己的「问题抽象能力」?
首先通常需要我们积累一定的刷题量,并对「转换问题的关键点」做总结。
例如本题,一个转换「01 背包问题」的关键点是我们需要将「划分等和子集」的问题等效于「在某个数组中选若干个数,使得其总和为某个特定值」的问题。
间接求解到直接求解的转变:
但这道题到这里还有一个”小问题“。就是我们最后是通过「判断」来取得答案的。
通过判断取得的最大价值是否等于target来决定是否能划分出「等和子集」。
虽然说逻辑上完全成立,但总给我们一种「间接求解」的感觉。
造成这种「间接求解」的感觉,主要是因为我们没有对「01 背包」的「状态定义」和「初始化」做任何改动。
但事实上,我们是可以利用「01 背包」的思想进行「直接求解」的。
当我们与某个模型的「状态定义」进行了修改之后,除了考虑调整「转移方程」以外,还需要考虑修改「初始化」状态。
试考虑,我们创建的数组存储的是布尔类型,初始值都是false,这意味着无论我们怎么转移下去,都不可能产生一个true,最终所有的状态都仍然是false。换句话说,我们还需要一个有效值true来帮助整个过程能递推下去。
通常我们使用「首行」来初始化「有效值」。
将「物品编号」从 0 开始调整为从 1 开始。
原本我们的f[0][x]代表只考虑第一件物品、 f[1][x]代表考虑第一件和第二件物品;调整后我们的 f[0][x]代表不考虑任何物品、 f[1][x]代表只考虑第一件物品 ...
这种技巧本质上还是利用了「哨兵」的思想。
二维代码:
class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length;
//「等和子集」的和必然是总和的一半
int sum = 0;
for (int i : nums) sum += i;
int target = sum / 2;
// 对应了总和为奇数的情况,注定不能被分为两个「等和子集」
if (target * 2 != sum) return false;
// f[i][j] 代表考虑前 i 件物品,能否凑出价值「恰好」为 j 的方案
boolean[][] f = new boolean[n+1][target+1];
f[0][0] = true;
for (int i = 1; i <= n; i++) {
int t = nums[i-1];
for (int j = 0; j <= target; j++) {
// 不选该物品
boolean no = f[i-1][j];
// 选该物品
boolean yes = j >= t ? f[i-1][j-t] : false;
f[i][j] = no | yes;
}
}
return f[n][target];
}
}
一维代码:
class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length;
//「等和子集」的和必然是总和的一半
int sum = 0;
for (int i : nums) sum += i;
int target = sum / 2;
// 对应了总和为奇数的情况,注定不能被分为两个「等和子集」
if (target * 2 != sum) return false;
// 取消「物品维度」
boolean[] f = new boolean[target+1];
f[0] = true;
for (int i = 1; i <= n; i++) {
int t = nums[i-1];
for (int j = target; j >= 0; j--) {
// 不选该物品
boolean no = f[j];
// 选该物品
boolean yes = j >= t ? f[j-t] : false;
f[j] = no | yes;
}
}
return f[target];
}
}
有一堆石头,用整数数组 stones
表示。其中 stones[i]
表示第 i
块石头的重量。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x
和 y
,且 x <= y
。那么粉碎的可能结果如下:
x == y
,那么两块石头都会被完全粉碎;x != y
,那么重量为 x
的石头将会完全粉碎,而重量为 y
的石头新重量为 y-x
。最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0
。
示例 1:
输入:stones = [2,7,4,1,8,1] 输出:1 解释: 组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1], 组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1], 组合 2 和 1,得到 1,所以数组转化为 [1,1,1], 组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。
示例 2:
输入:stones = [31,26,33,21,40] 输出:5
问题转化为:把一堆石头分成较小的A、较大的B两堆,求两堆石头重量差最小值。
进一步分析:要让差值小,两堆石头的重量都要接近sum/2;我们假设两堆分别为A,B,A
所以先用dp数组求解A堆,最后返回B-A值
代码
class Solution {
public int lastStoneWeightII(int[] stones) {
int sum = 0;
for(int s:stones){
sum += s;
}
int target = sum/2;
int[] dp = new int[target+1];
for(int i=0;i=stones[i];j--){
dp[j] = Math.max(dp[j],dp[j-stones[i]]+stones[i]);
}
}
return sum - 2 * dp[target];
}
}
把符合下列属性的数组 arr
称为 山脉数组 :
arr.length >= 3
i
(0 < i < arr.length - 1
),满足
arr[0] < arr[1] < ... < arr[i - 1] < arr[i]
arr[i] > arr[i + 1] > ... > arr[arr.length - 1]
给出一个整数数组 arr
,返回最长山脉子数组的长度。如果不存在山脉子数组,返回 0
。
用left数组动归求从一个点可以向左延伸的长度,right数组动归求该点可以向右延伸的长度。对左右均不为0的位置,认为该点存在山峰,求所有这些山峰点中left+right+1的最大值
lass Solution {
public int longestMountain(int[] arr) {
int n = arr.length;
int[] left = new int[n];
int[] right = new int[n];
if(n==0 || n==1) return 0;
for(int i=1;iarr[i-1]){
left[i] = left[i-1]+1;
}else{
left[i] = 0;
}
}
for(int i=n-2;i>=0;i--){
if(arr[i]>arr[i+1]){
right[i] = right[i+1]+1;
}else{
right[i] = 0;
}
}
int max = 0;
for(int i=0;i