本节内容
1049.最后一块石头的重量II
494.目标和
474.一和零
1049.最后一块石头的重量II※ 建议 :
题目链接: https://leetcode.cn/problems/last-stone-weight-ii/ 文章讲解: https://programmercarl.com/1049.%E6%9C%80%E5%90%8E%E4%B8%80%E5%9D%97%E7%9F%B3%E5%A4%B4%E7%9A%84%E9%87%8D%E9%87%8FII.html 视频讲解: https://www.bilibili.com/video/BV14M411C7oV/
题目分析
方案一 本题的解题关键是:将这一堆石头可能的分成两堆,分成重量最相近时候的两堆,然后拿着两堆石头相撞,剩下的就是最小的最后一块石头的重量了。
所以说此问题就转变为与 416. 分割等和子集 问题了,尽可能的将石头分割好,以此来解决此问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution { public int lastStoneWeightII (int [] stones) { int sum = Arrays.stream(stones).sum(); int mind = sum / 2 ; int [] dp = new int [mind + 1 ]; for (int i = stones[0 ]; i < mind + 1 ; i++) { dp[i] = stones[0 ]; } for (int i = 1 ; i < stones.length; i++) { for (int j = mind; j >= stones[i]; j--) { dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]); } } return sum - 2 * dp[mind]; } }
结果 解答成功: 执行耗时:3 ms,击败了43.54% 的Java用户 内存消耗:38.9 MB,击败了65.85% 的Java用户
分析 时间复杂度: O( n * sum( stones ) )
空间复杂度: O( sum( stones ) )
代码随想录 https://programmercarl.com/1049.%E6%9C%80%E5%90%8E%E4%B8%80%E5%9D%97%E7%9F%B3%E5%A4%B4%E7%9A%84%E9%87%8D%E9%87%8FII.html
思路 本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了 。
本题物品的重量为stones[i],物品的价值也为stones[i]。
对应着01背包里的物品重量weight[i]和 物品价值value[i]。
接下来进行动规五步曲:
确定dp数组以及下标的含义
**dp[j]表示容量(这里说容量更形象,其实就是重量)为j的背包,最多可以背最大重量为dp[j]**。
可以回忆一下01背包中,dp[j]的含义,容量为j的背包,最多可以装的价值为 dp[j]。
相对于 01背包,本题中,石头的重量是 stones[i],石头的价值也是 stones[i] ,可以 “最多可以装的价值为 dp[j]” == “最多可以背的重量为dp[j]”
确定递推公式
01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
本题则是:dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
一些同学可能看到这dp[j - stones[i]] + stones[i]中 又有- stones[i] 又有+stones[i],看着有点晕乎。
大家可以再去看 dp[j]的含义。
dp数组如何初始化
既然 dp[j]中的j表示容量,那么最大容量(重量)是多少呢,就是所有石头的重量和。
因为提示中给出1 <= stones.length <= 30,1 <= stones[i] <= 1000,所以最大重量就是30 * 1000 。
而我们要求的target其实只是最大重量的一半,所以dp数组开到15000大小就可以了。
当然也可以把石头遍历一遍,计算出石头总重量 然后除2,得到dp数组的大小。
此处就直接用15000了。
接下来就是如何初始化dp[j]呢,因为重量都不会是负数,所以dp[j]都初始化为0就可以了,这样在递归公式dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]); 中dp[j]才不会初始值所覆盖。
代码为:
1 vector<int > dp (15001 , 0 ) ;
确定遍历顺序
如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!
代码如下:
1 2 3 4 5 6 for (int i = 0 ; i < stones.size (); i++) { for (int j = target; j >= stones[i]; j--) { dp[j] = max (dp[j], dp[j - stones[i]] + stones[i]); } }
举例推导dp数组
举例,输入:[2,4,1,1],此时target = (2 + 4 + 1 + 1)/2 = 4 ,dp数组状态图如下:
最后dp[target]里是容量为target的背包所能背的最大重量。
那么分成两堆石头,一堆石头的总重量是dp[target],另一堆就是sum - dp[target]。
在计算target的时候,target = sum / 2 因为是向下取整,所以sum - dp[target] 一定是大于等于dp[target]的 。
那么相撞之后剩下的最小石头重量就是 (sum - dp[target]) - dp[target]。
以上分析完毕,C++代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution {public : int lastStoneWeightII (vector<int >& stones) { vector<int > dp (15001 , 0 ) ; int sum = 0 ; for (int i = 0 ; i < stones.size (); i++) sum += stones[i]; int target = sum / 2 ; for (int i = 0 ; i < stones.size (); i++) { for (int j = target; j >= stones[i]; j--) { dp[j] = max (dp[j], dp[j - stones[i]] + stones[i]); } } return sum - dp[target] - dp[target]; } };
时间复杂度:O(m × n) , m是石头总重量(准确的说是总重量的一半),n为石头块数
空间复杂度:O(m)
代码实现 二维数组版本(便于理解)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class Solution { public int lastStoneWeightII (int [] stones) { int sum = 0 ; for (int s : stones) { sum += s; } int target = sum / 2 ; int [][] dp = new int [stones.length][target + 1 ]; for (int j = stones[0 ]; j <= target; j++) { dp[0 ][j] = stones[0 ]; } for (int i = 1 ; i < stones.length; i++) { for (int j = 1 ; j <= target; j++) { if (j >= stones[i]) { dp[i][j] = Math.max(dp[i - 1 ][j], dp[i - 1 ][j - stones[i]] + stones[i]); } else { dp[i][j] = dp[i - 1 ][j]; } } } System.out.println(dp[stones.length - 1 ][target]); return (sum - dp[stones.length - 1 ][target]) - dp[stones.length - 1 ][target]; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution { public int lastStoneWeightII (int [] stones) { int sum = 0 ; for (int i : stones) { sum += i; } int target = sum >> 1 ; int [] dp = new int [target + 1 ]; for (int i = 0 ; i < stones.length; i++) { for (int j = target; j >= stones[i]; j--) { dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]); } } return sum - 2 * dp[target]; } }
494.目标和※ 建议 :
题目链接: https://leetcode.cn/problems/target-sum/ 文章讲解: https://programmercarl.com/0494.%E7%9B%AE%E6%A0%87%E5%92%8C.html 视频讲解: https://www.bilibili.com/video/BV1o8411j73x/
题目分析
方案一:回溯 简单粗暴,比较容易想到。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution { private int result = 0 ; public int findTargetSumWays (int [] nums, int target) { backtracking(nums, target, 0 , 0 ); return result; } private void backtracking (int [] nums, int target, int start, int sum) { if (nums.length == start) { if (target == sum) result++; return ; } backtracking(nums, target, start + 1 , sum + nums[start]); backtracking(nums, target, start + 1 , sum - nums[start]); } }
结果 解答成功: 执行耗时:513 ms,击败了22.50% 的Java用户 内存消耗:38.8 MB,击败了98.60% 的Java用户
分析 时间复杂度: O( 2 ^ n )
空间复杂度: O( 1 )
方案二:动态规划 太难想到其可以和 01背包 问题打上交道,还需要积累经验。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution { public int findTargetSumWays (int [] nums, int target) { int sum = Arrays.stream(nums).sum(); if ((sum + target) % 2 == 1 || sum < -target) return 0 ; int flag = Math.abs((sum + target) / 2 ); int [] dp = new int [flag + 1 ]; dp[0 ] = 1 ; for (int i = 0 ; i < nums.length; i++) { for (int j = flag; j >= nums[i]; j--) { dp[j] += dp[j - nums[ ]]; } } return dp[flag]; } }
结果 解答成功: 执行耗时:3 ms,击败了64.63% 的Java用户 内存消耗:39.1 MB,击败了69.44% 的Java用户
分析 时间复杂度: O( n × m )
空间复杂度: O( m )
代码随想录 https://programmercarl.com/0494.%E7%9B%AE%E6%A0%87%E5%92%8C.html
思路 这道题目咋眼一看和动态规划背包啥的也没啥关系。
本题要如何使表达式结果为target,
既然为target,那么就一定有 left组合 - right组合 = target。
left + right = sum,而sum是固定的。right = sum - left
公式来了, left - (sum - left) = target 推导出 left = (target + sum)/2
。
target是固定的,sum是固定的,left就可以求出来。
此时问题就是在集合nums中找出和为left的组合。
回溯算法 组合总和问题
此时可以套组合总和的回溯法代码,几乎不用改动。
当然,也可以转变成序列区间选+ 或者 -,使用回溯法,那就是另一个解法。
我也把代码给出来吧,大家可以了解一下,回溯的解法,以下是本题转变为组合总和问题的回溯法代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 class Solution {private : vector<vector<int >> result; vector<int > path; void backtracking (vector<int >& candidates, int target, int sum, int startIndex) { if (sum == target) { result.push_back (path); } for (int i = startIndex; i < candidates.size () && sum + candidates[i] <= target; i++) { sum += candidates[i]; path.push_back (candidates[i]); backtracking (candidates, target, sum, i + 1 ); sum -= candidates[i]; path.pop_back (); } }public : int findTargetSumWays (vector<int >& nums, int S) { int sum = 0 ; for (int i = 0 ; i < nums.size (); i++) sum += nums[i]; if (S > sum) return 0 ; if ((S + sum) % 2 ) return 0 ; int bagSize = (S + sum) / 2 ; result.clear (); path.clear (); sort (nums.begin (), nums.end ()); backtracking (nums, bagSize, 0 , 0 ); return result.size (); } };
当然以上代码超时了。
也可以使用记忆化回溯,但这里我就不在回溯上下功夫了,直接看动规
动态规划 如何转化为01背包问题呢。
假设加法的总和为x,那么减法对应的总和就是sum - x。
所以我们要求的是 x - (sum - x) = target
x = (target + sum) / 2
此时问题就转化为,装满容量为x的背包,有几种方法 。
这里的x,就是bagSize,也就是我们后面要求的背包容量。
大家看到(target + sum) / 2 应该担心计算的过程中向下取整有没有影响。
这么担心就对了,例如sum 是5,S是2的话其实就是无解的,所以:
1 2 (C++代码中,输入的S 就是题目描述的 target)if ((S + sum) % 2 == 1 ) return 0 ;
同时如果 S 的绝对值已经大于sum,那么也是没有方案的。
1 2 (C++代码中,输入的S 就是题目描述的 target)if (abs (S) > sum) return 0 ;
再回归到01背包问题,为什么是01背包呢?
因为每个物品(题目中的1)只用一次!
这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少。
本题则是装满有几种方法。其实这就是一个组合问题了。
确定dp数组以及下标的含义
dp[j] 表示:填满 j(包括j)这么大容积的包,有dp[j]种方法
其实也可以使用二维dp数组来求解本题,dp[i][j]:使用 下标为[0, i]的nums[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种方法。
下面我都是统一使用一维数组进行讲解, 二维降为一维(滚动数组),其实就是上一层拷贝下来。
确定递推公式
有哪些来源可以推出dp[j]呢?
只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。
例如:dp[j],j 为5,
已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 容量为5的背包。
已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 容量为5的背包。
已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 容量为5的背包
已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 容量为5的背包
已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 容量为5的背包
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。
所以求组合类问题的公式,都是类似这种:
1 dp[j] += dp[j - nums[i]]
这个公式在后面在讲解背包解决排列组合问题的时候还会用到!
dp数组如何初始化
从递推公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递推结果将都是0。
这里有录友可能认为从dp数组定义来说 dp[0] 应该是0,也有录友认为dp[0]应该是1。
其实不要硬去解释它的含义,咱就把 dp[0]的情况带入本题看看应该等于多少。
如果数组[0] ,target = 0,那么 bagSize = (target + sum) / 2 = 0。 dp[0]也应该是1, 也就是说给数组里的元素 0 前面无论放加法还是减法,都是 1 种方法。
所以本题我们应该初始化 dp[0] 为 1。
可能有同学想了,那 如果是 数组[0,0,0,0,0] target = 0 呢。
其实 此时最终的dp[0] = 32,也就是这五个零 子集的所有组合情况,但此dp[0]非彼dp[0],dp[0]能算出32,其基础是因为dp[0] = 1 累加起来的。
dp[j]其他下标对应的数值也应该初始化为0,从递推公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来。
确定遍历顺序
对于01背包问题一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。
举例推导dp数组
输入:nums: [1, 1, 1, 1, 1], S: 3
bagSize = (S + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
C++代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution {public : int findTargetSumWays (vector<int >& nums, int S) { int sum = 0 ; for (int i = 0 ; i < nums.size (); i++) sum += nums[i]; if (abs (S) > sum) return 0 ; if ((S + sum) % 2 == 1 ) return 0 ; int bagSize = (S + sum) / 2 ; vector<int > dp (bagSize + 1 , 0 ) ; dp[0 ] = 1 ; for (int i = 0 ; i < nums.size (); i++) { for (int j = bagSize; j >= nums[i]; j--) { dp[j] += dp[j - nums[i]]; } } return dp[bagSize]; } };
时间复杂度:O(n × m),n为正数个数,m为背包容量
空间复杂度:O(m),m为背包容量
本题还是有点难度,大家也可以记住,在求装满背包有几种方法的情况下,递推公式一般为:
1 dp[j] += dp[j - nums[i]];
后面我们在讲解完全背包的时候,还会用到这个递推公式!
代码实现 易于理解的二维数组版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 class Solution { public int findTargetSumWays (int [] nums, int target) { int sum = 0 ; for (int i = 0 ; i < nums.length; i++) { sum += nums[i]; } if (sum < Math.abs(target)){ return 0 ; } if ((sum + target) % 2 != 0 ) { return 0 ; } int left = (sum + target) / 2 ; int [][] dp = new int [nums.length][left + 1 ]; for (int j = 0 ; j <= left; j++) { if (nums[0 ] == j) { dp[0 ][j] = 1 ; } } int numZeros = 0 ; for (int i = 0 ; i < nums.length; i++) { if (nums[i] == 0 ) { numZeros++; } dp[i][0 ] = (int ) Math.pow(2 , numZeros); } for (int i = 1 ; i < nums.length; i++) { for (int j = 1 ; j <= left; j++) { if (nums[i] > j) { dp[i][j] = dp[i - 1 ][j]; } else { dp[i][j] = dp[i - 1 ][j] + dp[i - 1 ][j - nums[i]]; } } } return dp[nums.length - 1 ][left]; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution { public int findTargetSumWays (int [] nums, int target) { int sum = 0 ; for (int i = 0 ; i < nums.length; i++) sum += nums[i]; if ( target < 0 && sum < -target) return 0 ; if ((target + sum) % 2 != 0 ) return 0 ; int size = (target + sum) / 2 ; if (size < 0 ) size = -size; int [] dp = new int [size + 1 ]; dp[0 ] = 1 ; for (int i = 0 ; i < nums.length; i++) { for (int j = size; j >= nums[i]; j--) { dp[j] += dp[j - nums[i]]; } } return dp[size]; } }
474.一和零※ 建议 :
题目链接: https://leetcode.cn/problems/ones-and-zeroes/ 文章讲解: https://programmercarl.com/0474.%E4%B8%80%E5%92%8C%E9%9B%B6.html 视频讲解:
题目分析
方案一 此问题想起来和01背包问题很相似,但是去滚动数组似乎需要升维,将一维升为二维
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Solution { public int findMaxForm (String[] strs, int m, int n) { int [][] dp = new int [m + 1 ][n + 1 ]; for (int i = 0 ; i < strs.length; i++) { String str = strs[i]; int num0 = 0 , num1 = 0 ; for (int j = 0 ; j < str.length(); j++) { if ('0' == str.charAt(j)) num0++; else num1++; } for (int j = m; j >= num0; j--) { for (int k = n; k >= num1; k--) { dp[j][k] = Math.max(dp[j][k], dp[j - num0][k - num1] + 1 ); } } } return dp[m][n]; } }
结果 解答成功: 执行耗时:21 ms,击败了55.43% 的Java用户 内存消耗:39.5 MB,击败了84.92% 的Java用户
分析 时间复杂度: O( n * m * lenth * strLength )
空间复杂度: O( n * m )
代码随想录 https://programmercarl.com/0474.%E4%B8%80%E5%92%8C%E9%9B%B6.html
思路 这道题目,还是比较难的,也有点像程序员自己给自己出个脑筋急转弯,程序员何苦为难程序员呢。
来说题,本题不少同学会认为是多重背包,一些题解也是这么写的。
其实本题并不是多重背包,再来看一下这个图,捋清几种背包的关系
多重背包是每个物品,数量不同的情况。
本题中strs 数组里的元素就是物品,每个物品都是一个!
而m 和 n相当于是一个背包,两个维度的背包 。
理解成多重背包的同学主要是把m和n混淆为物品了,感觉这是不同数量的物品,所以以为是多重背包。
但本题其实是01背包问题!
只不过这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品。
开始动规五部曲:
确定dp数组(dp table)以及下标的含义
**dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]**。
确定递推公式
dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1。
dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 1。
然后我们在遍历的过程中,取dp[i][j]的最大值。
所以递推公式:dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
此时大家可以回想一下01背包的递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。
这就是一个典型的01背包! 只不过物品的重量有了两个维度而已。
dp数组如何初始化
01背包的dp数组初始化为0就可以。
因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖。
确定遍历顺序
01背包一般是外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历!
那么本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n。
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 for (string str : strs) { int oneNum = 0 , zeroNum = 0 ; for (char c : str) { if (c == '0' ) zeroNum++; else oneNum++; } for (int i = m; i >= zeroNum; i--) { for (int j = n; j >= oneNum; j--) { dp[i][j] = max (dp[i][j], dp[i - zeroNum][j - oneNum] + 1 ); } } }
有同学可能想,那个遍历背包容量的两层for循环先后循序有没有什么讲究?
没讲究,都是物品重量的一个维度,先遍历哪个都行!
举例推导dp数组
以输入:[“10”,”0001”,”111001”,”1”,”0”],m = 3,n = 3为例
最后dp数组的状态如下所示:
以上动规五部曲分析完毕,C++代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution {public : int findMaxForm (vector<string>& strs, int m, int n) { vector<vector<int >> dp (m + 1 , vector <int > (n + 1 , 0 )); for (string str : strs) { int oneNum = 0 , zeroNum = 0 ; for (char c : str) { if (c == '0' ) zeroNum++; else oneNum++; } for (int i = m; i >= zeroNum; i--) { for (int j = n; j >= oneNum; j--) { dp[i][j] = max (dp[i][j], dp[i - zeroNum][j - oneNum] + 1 ); } } } return dp[m][n]; } };
时间复杂度: O(kmn),k 为strs的长度
空间复杂度: O(mn)
总结
不少同学刷过这道题,可能没有总结这究竟是什么背包。
此时我们讲解了0-1背包的多种应用,
纯 0 - 1 背包 是求 给定背包容量 装满背包 的最大价值是多少。
分割等和子集 是求 给定背包容量,能不能装满这个背包。
最后一块石头的重量 II 是求 给定背包容量,尽可能装,最多能装多少
目标和 是求 给定背包容量,装满背包有多少种方法。
本题是求 给定背包容量,装满背包最多有多少个物品。
所以在代码随想录中所列举的题目,都是 0-1背包不同维度上的应用,大家可以细心体会!
代码实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class Solution { public int findMaxForm (String[] strs, int m, int n) { int [][] dp = new int [m + 1 ][n + 1 ]; int oneNum, zeroNum; for (String str : strs) { oneNum = 0 ; zeroNum = 0 ; for (char ch : str.toCharArray()) { if (ch == '0' ) { zeroNum++; } else { oneNum++; } } for (int i = m; i >= zeroNum; i--) { for (int j = n; j >= oneNum; j--) { dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1 ); } } } return dp[m][n]; } }