前言:
算法训练系列是做《代码随想录》一刷,个人的学习笔记和详细的解题思路,总共会有60篇博客来记录,计划用60天的时间刷完。
内容包括了面试常见的10类题目,分别是:数组,链表,哈希表,字符串,栈与队列,二叉树,回溯算法,贪心算法,动态规划,单调栈。
博客记录结构上分为 思路,代码实现,复杂度分析,思考和收获,四个方面。
如果这个系列的博客可以帮助到读者,就是我最大的开心啦,一起LeetCode一起进步呀;)
目录
LeetCode1049. 最后一块石头的重量II
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
LeetCode494. 目标和
0. 总体思路
方法一:回溯解法
方法二:动态规划
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
LeetCode474. 一和零
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
链接:1049. 最后一块石头的重量 II - 力扣(LeetCode)
本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了,和前一道题 9.9 分割等和子集 很像;
本题物品的重量为stones[i],物品的价值也为stones[i];对应着01背包里的物品重量weight[i]和 物品价值value[i]。
接下来进行动规五步曲:
dp[j]表示容量(这里说容量更形象,其实就是重量)为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]呢,因为重量都不会是负数,所以dp[j]都初始化为0就可以了,这样在递归公式dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);中dp[j]才不会初始值所覆盖;
dp数组的长度怎么考虑呢?
如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!
举例,输入:[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]。
# time:O(n*target);space:O(target)
class Solution(object):
def lastStoneWeightII(self, stones):
"""
:type stones: List[int]
:rtype: int
"""
# 初始化
target = sum(stones)//2
dp = [0]*(target+1)
# 遍历顺序
for i in range(len(stones)):
for j in range(target,stones[i]-1,-1):
# 递推公式
dp[j] = max(dp[j],dp[j-stones[i]]+stones[i])
return sum(stones) - dp[target] -dp[target]
时间复杂度:O(n*target)
其中n为数组的长度(石头个数),target是整个数组的元素和的一半;需要计算出所有的状态,每个状态计算的时间复杂度为O(1);
空间复杂度:O(target)
其中 target是整个数组的元素和的一半;空间复杂度取决于dp数组,数组长度就是target+1;
Reference: 代码随想录 (programmercarl.com)
本题学习时间:30分钟。
链接:494. 目标和 - 力扣(LeetCode)
这道题目咋眼一看和动态规划背包啥的也没啥关系。
本题要如何使表达式结果为target,可以将数组中的元素分为两个阵营,正号组和负号组;那么就一定有 left组合 - right组合 = target;而left + right = sum;所以说:left = (target + sum)/2 ;
target是固定的,sum是固定的,left就可以求出来;此时问题就是在集合nums中找出和为left的组合有多少种。
(二刷再看)
在回溯算法系列中,一起学过这道题目**回溯算法:39. 组合总和 (opens new window)**的录友应该感觉很熟悉,这不就是组合总和问题么?
此时可以套组合总和的回溯法代码,几乎不用改动。
当然,也可以转变成序列区间选+ 或者 -,使用回溯法,那就是另一个解法;我也把代码给出来吧,大家可以了解一下,回溯的解法,以下是本题转变为组合总和问题的回溯法代码:
class Solution {
private:
vector> result;
vector path;
void backtracking(vector& candidates, int target, int sum, int startIndex) {
if (sum == target) {
result.push_back(path);
}
// 如果 sum + candidates[i] > target 就终止遍历
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& 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相加的时候要各位小心数值溢出的问题
int bagSize = (S + sum) / 2; // 转变为组合总和问题,bagsize就是要求的和
// 以下为回溯法代码
result.clear();
path.clear();
sort(nums.begin(), nums.end()); // 需要排序
backtracking(nums, bagSize, 0, 0);
return result.size();
}
};
以上代码还可以进一步优化,使用记忆化回溯。
x = (target + sum) / 2;此时问题就转化为,装满容量为x背包,有几种方法。
这里的x,就是bagSize,也就是我们后面要求的背包容量。
大家看到(target + sum) / 2 应该担心计算的过程中向下取整有没有影响?
这么担心就对了,例如sum 是5,S是2的话其实就是无解的,所以:
同时如果 S的绝对值已经大于sum,那么也是没有方案的:
class Solution:
def findTargetSumWays(self, nums: List[int], target: int) -> int:
sumValue = sum(nums)
# 注意边界条件为
# target>sumValue or target<-sumValue or (sumValue + target) % 2 == 1
if abs(target) > sumValue or (sumValue + target) % 2 == 1:
return 0
再回归到01背包问题,为什么是01背包呢?
因为每个物品(题目中的1)只用一次!这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少;本题则是装满有几种方法。其实这就是一个组合问题了。
dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法;
有哪些来源可以推出dp[j]呢?
只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。
例如:dp[j],j 为5,
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。
所以求组合类问题的公式,都是类似这种:
dp[j] += dp[j - nums[i]]
这个公式在后面在讲解背包解决排列组合问题的时候还会用到!
dp[0]
从递归公式可以看出,在初始化的时候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[非零下标]
dp[j]其他下标对应的数值也应该初始化为0,从递归公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来。
我们讲过对于01背包问题一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。
输入:nums: [1, 1, 1, 1, 1], S: 3
bagSize = (S + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
# 动态规划-01背包应用
# time:O(N*left);space:O(left)
class Solution(object):
def findTargetSumWays(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: int
"""
total = sum(nums)
# 这里要写abs(target)>total;Example:[100],-200
if abs(target)>total or (total+target)%2==1:
return 0
bagSize = (total+target)//2
dp = [0]*(bagSize+1)
dp[0] = 1
for i in range(len(nums)):
for j in range(bagSize,nums[i]-1,-1):
dp[j] += dp[j-nums[i]]
return dp[bagSize]
时间复杂度:O(n × m);
其中n为整数个数,m为背包容量,背包问题中有两层for循环;
空间复杂度:O(m)
其中m为背包容量;
代码中边界条件要记得判断abs(target)>total;因为可能出现这种例子:Example:[100],-200;
此时想起,我们之前讲过的**回溯算法:39. 组合总和 (opens new window)是不是应该也可以用dp来做啊?是的,如果仅仅是求个数的话,就可以用dp,但回溯算法:39. 组合总和 (opens new window)**要求的是把所有组合列出来,还是要使用回溯法爆搜的;
本题还是有点难度,大家也可以记住,在求装满背包有几种方法的情况下,递推公式一般为:
dp[j] += dp[j - nums[i]]
后面我们在讲解完全背包的时候,还会用到这个递推公式!
Reference:代码随想录 (programmercarl.com)
本题学习时间:80分钟。
链接:474. 一和零 - 力扣(LeetCode)
这道题目,还是比较难的,也有点像程序员自己给自己出个脑筋急转弯,程序员何苦为难程序员呢。
来说题,本题不少同学会认为是多重背包,一些题解也是这么写的。
其实本题并不是多重背包,再来看一下这个图,捋清几种背包的关系
多重背包是每个物品,数量不同的情况。本题中strs 数组里的元素就是物品,每个物品都是一个!而m 和 n相当于是一个背包,两个维度的背包。 理解成多重背包的同学主要是把m和n混淆为物品了,感觉这是不同数量的物品,所以以为是多重背包。但本题其实是01背包问题!这不过这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品。
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[0][0]肯定只能为0;然后非零下标反正都是要覆盖的,因为递推公式中需要和自己比,,保证递推的时候dp[i][j]不会被初始值覆盖,所以说也全部初始化为0;
一维的01背包一定是外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历!物品就是strs里的字符串,背包容量就是题目描述中的m和n;代码如下:
for str in strs:
ones = str.count('1')
zeros = str.count('0')
# 遍历背包容量且从后向前遍历!
for i in range(m, zeros - 1, -1):
for j in range(n, ones - 1, -1):
dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1)
遍历背包容量的两层for循环先后循序有没有什么讲究?
没讲究,都是物品重量的一个维度,先遍历那个都行!
以输入:["10","0001","111001","1","0"],m = 3,n = 3为例
最后dp数组的状态如下所示:
# time:O(N*m*n);space:O(m*n)
# step1.确定dp数组含义:dp[i][j]表示容量为i和j的容器,最多可以放下多少个元素
# step2. 递推公式: dp[i][j] = max(dp[i][j],dp[i-zeroNum][j-OneNum])
# step3. 初始化dp[0][0] = 0
# step4. 遍历顺序,外层遍历物品,数组中的每个元素,内层逆序遍历两个维度的背包容量
# step5. 打印dp,debug
class Solution(object):
def findMaxForm(self, strs, m, n):
"""
:type strs: List[str]
:type m: int
:type n: int
:rtype: int
"""
dp = [[0]*(n+1) for _ in range(m+1)]
# 遍历物品
for item in strs:
zeroNum = item.count("0")
OneNum = item.count("1")
# 遍历背包容量且从后向前遍历
for i in range(m,zeroNum-1,-1):
for j in range(n,OneNum-1,-1):
dp[i][j] = max(dp[i][j],dp[i-zeroNum][j-OneNum]+1)
return dp[m][n]
时间复杂度:O(Nmn)
其中N为数组strs的长度,m和n为题意中给的0和1的元素个数;
空间复杂度:O(m*n)
其中m和n为题意中给的0和1的元素个数;因为dp数组的大小为(m+1)*(n+1);
Reference:代码随想录 (programmercarl.com)
本题学习时间:60分钟。
本篇学习时间3小时,总结字数7000+;针对01背包的不同层面的应用举了典型的题目,“最后一块石头的重量”是给定背包容量,尽可能装物品,最多能装多少;“目标和”是给定背包容量,问装满背包有多少种方法;“一和零”是给定背包容量,装满背包最多可以有多少物品。(求推荐!)