左程云、代码随想录算法学习(二)
左程云、代码随想录算法学习(二)
回溯算法
回溯算法代码模板
- 回溯算法的核心思想是:
- 递归构造解空间树,在树的每个节点上做出一个选择,并继续探索;若当前选择不符合条件则回退(撤销选择);
- 回溯法抽象为树形结构后,其遍历过程就是:
for
循环横向遍历,递归纵向遍历,回溯不断调整结果集。
- 代码模板:
1 |
|
组合问题
- 组合问题:77.组合
- 思路:
- 当当前路径
path
中的元素数量达到k
,即完成一个组合,加入结果集中,作为终止条件; - 从
startpoint
开始,避免重复元素; - 每次递归往路径中添加一个数字
i
; - 然后递归继续选下一个数(从
i+1
开始); - 最后撤销上一步的选择,进行回溯。
- 当当前路径
- 代码:
1 |
|
- 剪枝优化:
- 在递归遍历部分可以进行剪枝优化,
for
循环至多从n-(k-path.size())+1
这个位置开始才能避免无效的递归遍历。
- 在递归遍历部分可以进行剪枝优化,
- 剪枝优化后代码:
1 |
|
组合总和 III
- 组合总和 III:216.组合总和 III
- 思路:这道题目与上一题较为类似,仅需在遍历过程中加上求和
sum
的过程以及sum
的回溯过程即可。同样可以做剪枝操作:- 当总和
sum
已经大于n
的时候已经没有意义,不再需要继续遍历; for
循环至多从9-(k-path.size())+1
这个位置开始才能避免无效的递归遍历。
- 当总和
- 代码:
1 |
|
电话号码的字母组合
- 电话号码的字母组合:17.电话号码的字母组合
- 思路:这道题目首先需要建立数字与电话号码字母的对照数组,递归终止条件在于字符串
path
的长度和题目输入的digits
字符串的长度相等,在递归逻辑中采用startpoint
来控制选取digits
字符串中的哪个数字,用index
来表示该数字对应的手机中字符串中的字母的数量用于后续for
循环遍历,循环内部为经典回溯算法。 - 代码:
1 |
|
组合总和
- 组合总和:39.组合总和
- 思路:这道题目和之前的组合总和 III 的关键不同点在于组合总和问题能够重复利用当前元素,该条件要建立在
candidates
内的元素是不重复的,这样子遍历出来的解集也不会出现重复的组合。因此代码仅仅需要将迭代递归语句中的i+1
改为i
即可。 - 代码:
1 |
|
组合总和 II
- 组合总和 II:40.组合总和 II
- 思路:这道题和之前的组合总和问题最大区别在于
candidates
数组中的元素是包含相同的重复元素,因此按照先前的遍历方式解集必定会出现重复集合,因此处理难度在于去重操作。具体操作如下:- 先对
candidates
数组进行排序,sort(candidates.begin(), candidates.end());
; - 采用
used
数组来控制在同树层方向去重,而树枝方向不需要去重; - 在迭代遍历逻辑中
true
表示进入下一层时相同元素可以继续用; false
表示进入在层深度一样时相同元素不能继续用了。
- 先对
- 代码:
1 |
|
分割回文串
- 分割回文串:131.分割回文串
- 思路:
- 在递归回溯逻辑中,先判断
[startpoint, i]
区间的子串是否为回文串:- 如果是的话就获取(
s.substr(startpoint, i-startpoint+1)
)该子串,然后将其添加至path
中。 - 如果不是的话,后续不需要在遍历,直接
continue
跳过循环。
- 如果是的话就获取(
- 根据上述循环逻辑,只要
startpoint
能够到达字符串的末尾(if(startpoint>=s.size())
)就说明找到了一种分割方案,可以将path
添加到result
中。 - 判断字符串是否为回文串可以采用双指针法。
- 在递归回溯逻辑中,先判断
- 代码:
1 |
|
复原IP地址
- 复原IP地址:93.复原IP地址
- 思路:
- 这道题同样与前一题类似是分割字符串问题,同样在递归回溯逻辑中,先判断按照
[startpoint, i]
区间分割的 IP 子串是否合法:- 合法则给子串后面加上
'.'
,递归进入下一个子串的遍历; - 不合法则直接
continue
跳过当前循环。
- 合法则给子串后面加上
- 需要注意判断 IP 地址子串是否合法的一些特性,并构成
isVaild()
函数。
- 这道题同样与前一题类似是分割字符串问题,同样在递归回溯逻辑中,先判断按照
- 代码:
1 |
|
子集
- 子集:78.子集
- 思路:如果把子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
- 因此将收获结果的代码放在了终止条件之前,确保将每个节点的结果都收集。
- 代码:
1 |
|
子集 II
- 子集:90.子集 II
- 思路:这道题目与组合总和 II 比较类似,同样是采用
used
数组来控制在同树层方向去重,而树枝方向不需要去重; - 代码:
1 |
|
非递减子序列
- 非递减子序列:491.非递减子序列
- 思路:这道题目和之前需要去重的题目主要区别在于不能够对原始的
nums
数组进行排序,虽然同样是在同树层方向去重,而树枝方向不需要去重,但是不能像之前的题目一样采用used
数组来去重了。此时需要采用unordered_set<int> uset;
来控制每一层的去重,需要注意的是uset
需要写在for
循环的外面。 - 代码:
1 |
|
全排列
- 全排列:46.全排列
- 思路:普通全排列的题目是
nums
数组中没有重复的元素,因此不需要进行去重处理。全排列问题与之前的组合问题最大的区别在于[1,2]
和[2,1]
可以看作是两个不一样的结果,因此全排列的代码不需要startpoint
来控制顺序,但需要used
数组来记录哪些元素在上一个树层中已经用过了。 - 代码:
1 |
|
全排列 II
- 全排列 II:47.全排列 II
- 思路:
- 这道题目相对于上一道普通全排列问题的最大区别在于
nums
数组中存在重复的元素,因此需要进行去重。去重的逻辑与组合问题的去重逻辑基本一致,就是需要先对nums
数组进行排序,然后根据used
数组判断是在树层中重复还是在树枝上重复,最终只需要去掉同树层中重复的情况即可。 - 同样也有不排序去重的方法,和前面的非递减子序列题目类似,采用
unordered_set<int> uset;
来控制每一层的去重,需要注意的是uset
需要写在for
循环的外面,但是该方法更加耗时和占用了额外的内存空间。
- 这道题目相对于上一道普通全排列问题的最大区别在于
- 代码 1(排序去重):
1 |
|
- 代码 2(非排序去重):
1 |
|
N 皇后
- N 皇后:51.N 皇后
- 思路:N 皇后问题的递归回溯函数
backtracking()
函数并不复杂,遍历行row
中的每一列col
,并采用isVaild()
函数判断皇后放置在坐标[row, col]
上是否合法,合法才能继续递归。而isVaild()
函数检查同一列或同一斜线上是否有棋子(递归算法保证了每一行是不重复的),并且需要注意的是只需要往行数减少方向检查即可,行数增大方向并没有放置皇后,因此不需要检查! - 代码:
1 |
|
解数独
- 解数独:37.解数独
- 思路:这道题目与 N 皇后问题的最大区别在于 N 皇后问题只需要遍历行
row
中的每一列col
来寻找一个放置皇后的位置即可,而解数独这道题目需要逐个遍历各个格子,如果发现该格子为字符'.'
则需要遍历填入字符'1' - '9'
,填入一个数字后判断是否合法:- 如果所选的数字合法则继续递归,此时如果后续递归是
true
的话就可以直接返回true
。 - 如果所选的数字不合法则在
for
循环填入下一个数字尝试。 - 或者后续递归返回
false
,就需要回溯,并在for
循环填入下一个数字尝试。 - 如果该空格填入了
9
个数字都不行则返回false
。 - 最终棋盘已经填满,则遍历完棋盘都没有返回
false
,说明找到了合适棋盘位置了,直接返回true
。
- 如果所选的数字合法则继续递归,此时如果后续递归是
- 代码:
1 |
|
贪心算法
贪心算法理论基础
- 贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
- 贪心算法一般分为如下四步:
- 将问题分解为若干个子问题
- 找出适合的贪心策略
- 求解每一个子问题的最优解
- 将局部最优解堆叠成全局最优解
- 这个四步其实过于理论化了,我们平时在做贪心类的题目时,如果按照这四步去思考,真是有点“鸡肋”。做题的时候,只要想清楚局部最优是什么,能否推导出全局最优,其实就够了。
- 总结:贪心没有套路,说白了就是常识性推导加上举反例。
分发饼干
- 分发饼干:455.分发饼干
- 思路:这里的局部最优就是用尽可能接近该小孩胃口的饼干喂饱该小孩,充分利用饼干尺寸喂饱一个小孩,全局最优就是喂饱尽可能多的小孩。
- 代码开始是需要对两个数组进行排序,排序后判断胃口最小的小孩都比最大的饼干大则直接返回
0
。 - 采用
index
控制遍历饼干大小数组的起始位置,当饼干[j]
已经投喂了小孩[i]
之后,小孩[i+1]
就需要用[j+1]
及之后的饼干投喂了。否则容易造成超时问题。
- 代码开始是需要对两个数组进行排序,排序后判断胃口最小的小孩都比最大的饼干大则直接返回
- 代码:
1 |
|
摆动序列
- 摆动序列:376.摆动序列
- 思路一:默认序列至少一个元素是峰值,令
result = 1
,例如只有一个元素或者所有元素全部相等的的序列[2]
和[2,2,2,2]
,元素本身就是峰值。对于序列[1,2,2,2,3,4]
,只有出现峰值的时候才更新pre
的值,这样就能保证当nums[i]
指向最后一个2
的时候出现多加1
的情况。 - 代码:
1 |
|
- 思路二:分别使用
up
和down
两个变量交替记录上升趋势和下降趋势,当nums[i] > nums[i-1]
时,up = down + 1
,当nums[i] < nums[i-1]
时,down = up + 1
,这样当趋势持续上升或者下降的时候只会+1
一次而不会重复记录,最终取up
和down
两个变量的最大值即作为结果。 - 代码:
1 |
|
最大子数组和
- 最大子数组和:53.最大子数组和
- 思路一(贪心解法):贪心解法的核心在于如果此前记录的子数组累加和为负数时,再和数组
nums
中后续的数相加会使得总的子数组和变小,因此子数组累加和为负数时应当置为0
,用后一个数为起点再次累加,每次需要将较大值通过result = max(result, sum)
来记录。 - 代码:
1 |
|
- 思路二(前缀和解法):应当找到一个尽可能小的前缀和
MinPreSum
,然后用当前的总和sum
减去此时最小的前缀和跟目前的最大子数组和比较再取最大值:max(MaxSubSum, sum - MinPreSum)
。 - 代码:
1 |
|
买卖股票的最佳时机 II
- 买卖股票的最佳时机 II:122.买卖股票的最佳时机 II
- 思路:这道题目采用贪心算法的思路非常直观和简单,只要每一天的收益为正便进行股票的买卖从而使得总收益的最大化。局部最优是每一天的收益为正数,全局最优即为总的收益最大。
- 代码:
1 |
|
跳跃游戏
- 跳跃游戏:55.跳跃游戏
- 思路:本题的巧妙之处在于不去纠结每一跳具体是跳几步,只需要计算在
i
这个节点能够跳的范围cover
,并在cover
这个范围内去寻找更大的范围,最终判断能否找到到达最后的范围。 - 代码:
1 |
|
跳跃游戏 II
- 跳跃游戏 II:45.跳跃游戏 II
- 思路:这道题目需要求从起点跳到终点的最短跳跃次数,处理方法和上一题有较大区别。我们需要记录当前访问过的所有节点能够跳到的最远距离
max_cover
,以及现阶段的覆盖距离now_cover
,要从现阶段的覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最少步数!
- 代码:
1 |
|
K 次取反后最大化的数组和
- K次取反后最大化的数组和:1005.K次取反后最大化的数组和
- 思路:
- 贪心的思路:
- 当数组有正数和负数时,局部最优:让绝对值大的负数变为正数,当前数值达到最大。整体最优:整个数组和达到最大。
- 当数组只有正数和
0
后,局部最优:只让最小的数连续翻转,耗尽K
值。整体最优:整个数组和达到最大。
- 贪心的思路:
- 代码:
1 |
|
加油站
- 加油站:134.加油站
- 思路:如果总油量减去总消耗大于等于零那么一定可以跑完一圈,说明各个站点的加油站剩油量相加一定是要大于等于零的。那么每个加油站的剩余量
rest[i]
为gas[i] - cost[i]
。i
从0
开始累加rest[i]
,和记为curSum
,一旦curSum
小于零,说明[0, i]
区间都不能作为起始位置,因为这个区间选择任何一个位置作为起点,到i
这里都会断油,那么起始位置从i+1
算起,再从0
计算curSum
。 - 代码:
1 |
|
分发糖果
- 分发糖果:135.分发糖果
- 思路:
- 这道题需要采用两次贪心策略:
- 先确定右边评分大于左边的情况(也就是从前向后遍历)。此时局部最优:只要右边评分比左边大,右边的孩子就多一个糖果,全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果。
- 再确定左孩子大于右孩子的情况(从后向前遍历)。局部最优:取
candy[i+1]+1
和candy[i]
最大的糖果数量,保证第i
个小孩的糖果数量既大于左边的也大于右边的。全局最优:相邻的孩子中,评分高的孩子获得更多的糖果。 - 在确定左孩子大于右孩子的情况,需要用上之前的右边孩子的比较情况,因此需要从后向前遍历。
- 这道题需要采用两次贪心策略:
- 代码:
1 |
|
柠檬水找零
- 柠檬水找零:860.柠檬水找零
- 思路:
- 有如下三种情况:
- 情况一:账单是
5
,直接收下; - 情况二:账单是
10
,消耗一个5
,增加一个10
; - 情况三:账单是
20
,优先消耗一个10
和一个5
,如果不够,再消耗三个5
。
- 情况一:账单是
- 贪心策略:优先找零
10
元钞票,因为美元10
只能给账单20
找零,而美元5
可以给账单10
和账单20
找零,美元5
更万能!
- 有如下三种情况:
- 代码:
1 |
|
根据身高重建队列
- 根据身高重建队列:406.根据身高重建队列
- 思路:按照身高排序之后,优先按身高高的
people
的k
来插入,后序插入节点也不会影响前面已经插入的节点,最终按照k
的规则完成了队列。在按照身高从大到小排序后:- 局部最优:优先按身高高的
people
的k
来插入。插入操作过后的people
满足队列属性; - 全局最优:最后都做完插入操作,整个队列满足题目队列属性。
- 局部最优:优先按身高高的
- 代码:
1 |
|
用最少数量的箭引爆气球
- 用最少数量的箭引爆气球:452.用最少数量的箭引爆气球
- 思路:
- 局部最优:当气球出现重叠,一起射,所用弓箭最少。
- 全局最优:把所有气球射爆所用弓箭最少。
- 怎么寻找重叠区域呢?首先按照各个气球的左坐标
left_idx
进行对points
数组进行排序,遍历取当前气球的的左坐标和left_idx
的较大值,同理,取当前气球右坐标和right_idx
的较小值组成重叠区域。 - 初始化要射箭的总数:
ans = points.size()
。 - 怎么判断是否重叠?如果
left_idx <= right_idx
说明当前气球和之前的气球重叠,需要射的箭的数量减一ans--
,否则不重叠并更新left_idx
和right_idx
的值。
- 代码:
1 |
|
无重叠区间
- 无重叠区间:435.无重叠区间
- 思路:
- 关键点:判断到
intervals[i][0] < intervals[i-1][1]
说明i
区间和i-1
区间重叠了,在下一个循环就要判断i+1
区间是否和i
区间和i-1
区间重叠的部分重叠,也就是intervals[i][1] = min(intervals[i][1], intervals[i-1][1])
。
- 关键点:判断到
- 代码:
1 |
|
划分字母区间
- 划分字母区间:763.划分字母区间
- 思路:在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。
- 具体分为以下两步:
- 统计每一个字符最后出现的位置;
- 从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点。
- 具体分为以下两步:
- 代码:
1 |
|
合并区间
- 合并区间:56.合并区间
- 思路:
- 首先按照区间起点升序排列,如果起点相同,按终点升序排列。
- 从第二个区间开始,与前一个区间比较:
- 如果重叠:
intervals[i][0] <= intervals[i-1][1]
→ 合并为一个区间,更新当前区间的起点和终点,intervals[i]
代表合并后的区间。 - 如果不重叠:直接把前一个区间放进
result
,继续循环。 - 循环结束后,把最后一个区间加入结果。
- 如果重叠:
- 代码:
1 |
|
单调递增的数字
- 单调递增的数字:738.单调递增的数字
- 思路:
- 例如:
98
,一旦出现strNum[i - 1] > strNum[i]
的情况(非单调递增),首先想让strNum[i - 1]--
,然后strNum[i]
给为9
,这样这个整数就是89
,即小于98
的最大的单调递增整数; - 从后向前遍历,就可以重复利用上次比较得出的结果了,从后向前遍历
332
的数值变化为:332 -> 329 -> 299
; flag
的作用,对于1000
而言,只有到最后10
才会变成0900
,不符合题意,应该将flag
标记为10
中0
的位置,将后面的数都变为9
。
- 例如:
- 代码:
1 |
|
监控二叉树
- 监控二叉树:968.监控二叉树
- 思路:
- 每个节点可能有三种状态:
0
该节点无覆盖
1
本节点有摄像头2
本节点有覆盖
- 为了让摄像头数量最少,我们要尽量让叶子节点的父节点安装摄像头,这样才能摄像头的数量最少。所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了。
- 单层逻辑处理主要有如下四类情况:
- 情况 1:左右节点至少有一个无覆盖的情况;
- 情况 2:左右节点至少有一个有摄像头;
- 情况 3:左右节点都有覆盖;
- 情况 4:根结点没有覆盖。
- 每个节点可能有三种状态:
- 代码:
1 |
|
动态规划
动态规划理论基础
- 动态规划,英文:
Dynamic Programming
,简称DP
,如果某一问题有很多重叠子问题,使用动态规划是最有效的。所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。 - 动态规划五部曲:
- 确定
dp
数组(dp table
)以及下标的含义 - 确定递推公式
dp
数组如何初始化- 确定遍历顺序
- 举例推导
dp
数组
- 确定
- 动规的题目,写代码之前一定要把状态转移在
dp
数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。debug
的过程可以把程序中的dp
数组打印出来与自己推导的结果进行对照来找问题。
斐波那契树
- 斐波那契树:509.斐波那契树
- 思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i]
的定义为:第i
个数的斐波那契数值是dp[i]
; - 确定递推公式:状态转移方程
dp[i] = dp[i-1] + dp[i-2];
; dp
数组如何初始化:dp[1] = 1;
和dp[2] = 1;
;- 确定遍历顺序:从前往后遍历;
- 举例推导
dp
数组符合要求。
- 确定
- 按照动态规划五部曲进行分析:
- 代码:
1 |
|
爬楼梯
- 爬楼梯:70.爬楼梯
- 思路:
- 爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。那么第一层楼梯再跨两步就到第三层,第二层楼梯再跨一步就到第三层。所以到第三层楼梯的状态可以由第二层楼梯和到第一层楼梯状态相加推导出来,那么就可以想到动态规划了。
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i]
的定义为:爬到第i
层楼梯,有dp[i]
种方法; - 确定递推公式:状态转移方程
dp[i] = dp[i-1] + dp[i-2];
; dp
数组如何初始化:dp[1] = 1;
和dp[2] = 2;
;- 确定遍历顺序:从前往后遍历;
- 举例推导
dp
数组符合要求。
- 确定
- 代码:
1 |
|
使用最小花费爬楼梯
- 使用最小花费爬楼梯:746.使用最小花费爬楼梯
- 思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i]
的定义为:爬到第i
层楼梯的最小花费是dp[i]
; - 确定递推公式:状态转移方程
dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2]);
; dp
数组如何初始化:dp[0] = 0;
和dp[1] = 0;
;- 确定遍历顺序:从前往后遍历;
- 举例推导
dp
数组符合要求。
- 确定
- 按照动态规划五部曲进行分析:
- 代码:
1 |
|
不同路径
- 不同路径:62.不同路径
- 思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i][j]
的定义为:表示从(1,1)
出发,到(i,j)
有dp[i][j]
条不同的路径; - 确定递推公式:状态转移方程
dp[i][j] = dp[i][j-1] + dp[i-1][j];
; dp
数组如何初始化:dp[1][1] = 1;
,其他初始化为0
;- 确定遍历顺序:从上往下、从前往后遍历;
- 举例推导
dp
数组符合要求。
- 确定
- 按照动态规划五部曲进行分析:
- 代码:
1 |
|
不同路径 II
- 不同路径 II:63.不同路径 II
- 思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i][j]
的定义为:表示从(1,1)
出发,到(i,j)
有dp[i][j]
条不同的路径,和上一题的主要区别在于增加了障碍,在有障碍的点要令dp[i][j]=0
; - 确定递推公式:状态转移方程
dp[i][j] = dp[i][j-1] + dp[i-1][j];
; dp
数组如何初始化:dp[1][1] = 1;
,其他初始化为0
;- 确定遍历顺序:从上往下、从前往后遍历;
- 举例推导
dp
数组符合要求。
- 确定
- 按照动态规划五部曲进行分析:
- 代码:
1 |
|
整数拆分
- 整数拆分:343.整数拆分
- 思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i]
的定义为:拆分整数i
后所得到的最大乘积为dp[i]
; - 确定递推公式:
- 正整数
i
可以拆分成j*(i-j)
、j*dp[i-j]
这两种方式,此时能够覆盖所有的拆分情况,取出最大值即可。 - 状态转移方程
dp[i] = max(max(j*(i-j), j*dp[i-j]), dp[i]);
;
- 正整数
dp
数组如何初始化:dp[0] = 0;
、dp[1] = 0;
、dp[2] = 1;
;- 确定遍历顺序:从前往后遍历;
- 举例推导
dp
数组符合要求。
- 确定
- 按照动态规划五部曲进行分析:
- 代码:
1 |
|
不同的二叉搜索树
- 不同的二叉搜索树:96.不同的二叉搜索树
- 思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i]
的定义为:由i
个节点组成的二叉搜索树的种类数量为dp[i]
种; dp
数组如何初始化:dp[0] = 1;
;- 确定遍历顺序:从前往后遍历;
- 举例推导
dp
数组符合要求。 - 确定递推公式:
- 二叉搜索树一旦形状确定,其内部节点的填充方法就是唯一确定的(从下方投影看,左到右依次增大),所以这题就是求不同形状的二叉搜索树数量,进而转换成左子树形状种类✖️右子树形状种类;
- 确定
- 按照动态规划五部曲进行分析:
- 状态转移方程:
1 |
|
- 代码:
1 |
|
0 - 1背包理论基础
- 携带研究材料:46. 携带研究材料
- 思路:
- 二维递推公式:
i
:表示0
到i
的物品任取放入容量j
的背包中j
:表示此时的背包容量dp[i][j]
: 背包在容量为j
的情况下从0
到i
的物品中选择装入得到的最大价值。
- 一维递推公式:
i
:表示0
到i
的物品任取放入容量j
的背包中j
:表示此时的背包容量dp[j]
: 背包在容量为j
的情况下从0
到i
的物品中选择装入得到的最大价值。
- 二维递推公式:
- 一维递推公式在
j
上要倒序遍历,避免一个物品重复添加。例子:- 举一个例子:物品
0
的重量weight[0] = 1
,价值value[0] = 15
- 如果正序遍历,此时
i=0
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
- 此时
dp[2]
就已经是30
了,意味着物品0
,被放入了两次,所以不能正序遍历。 - 为什么倒序遍历,就可以保证物品只放入一次呢?此时
i=0
- 倒序就是先算
dp[2]
dp[2] = dp[2 - weight[0]] + value[0] = 15
(dp
数组已经都初始化为0
)dp[1] = dp[1 - weight[0]] + value[0] = 15
- 所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
- 举一个例子:物品
- 二维递推公式代码:
1 |
|
- 一维递推公式代码:
1 |
|
分割等和子集
- 分割等和子集:416.分割等和子集
- 思路:
- 这道题目可以看成是
0 - 1
背包问题的变式,题目只要求将一个数组分割成两个子集,使得两个子集的元素和相等。那么可以将背包容量设定为数组总和sum
的一半,即sum/2
,各个物品的重量和价值都是nums
数组的元素,若能刚好装下sum/2
容量的背包则返回true
,否则返回false
。 - 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[j]
的定义为:背包在容量为j
的情况下从0
到i
的物品中选择装入得到的最大重量。 - 确定递推公式(与
0 - 1
背包问题一致):- 状态转移方程
dp[j] = max(dp[j], dp[j-nums[i]]+nums[i]);
;
- 状态转移方程
dp
数组如何初始化:全部初始化成0
;- 确定遍历顺序:在
i
上正序遍历,在j
上倒序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 这道题目可以看成是
- 代码:
1 |
|
最后一块石头的重量II
- 最后一块石头的重量 II:1049.最后一块石头的重量II
- 思路:
- 这道题目同样可以看成是
0 - 1
背包问题的变式,上一题是求背包是否正好装满,而本题是求背包最多能装多少。那么可以将背包容量设定为数组总和sum
的一半,即target = sum/2
,各个物品的重量和价值都是stones
数组的元素,得到在该容量下能装的最大重量,最后sum - dp[target] - dp[target]
即得到最小的结果。 - 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[j]
的定义为:背包在容量为j
的情况下从0
到i
的物品中选择装入得到的最大重量。 - 确定递推公式(与
0 - 1
背包问题一致):- 状态转移方程
dp[j] = max(dp[j], dp[j-nums[i]]+nums[i]);
;
- 状态转移方程
dp
数组如何初始化:全部初始化成0
;- 确定遍历顺序:在
i
上正序遍历,在j
上倒序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 这道题目同样可以看成是
- 代码:
1 |
|
目标和
- 目标和:494.目标和
- 思路:
- 这道题目同样可以看成是
0 - 1
背包问题的变式,假设加法的总和为pos
,那么减法对应的总和就是neg = pos - sum
。所以我们要求的是pos + neg = target
,pos = (target + sum)/2
,此时问题就转化为,用nums
装满容量为pos
的背包,有几种方法。 - 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[j]
的定义为:dp[j]
表示装满容量为j
的背包有dp[j]
种方法。 - 确定递推公式:
dp[j]
由所有dp[j-nums[i]]
相加得到,后续要装满容量为j
的背包有多少种方法都是用该递推公式。- 状态转移方程
dp[j] += dp[j-nums[i]];
;
dp
数组如何初始化:dp[0] = 1;
、其余初始化成0
;- 确定遍历顺序:在
i
上正序遍历,在j
上倒序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 这道题目同样可以看成是
- 代码:
1 |
|
一和零
- 一和零:474.一和零
- 思路:
- 这道题目同样可以看成是
0 - 1
背包问题的变式,只不过这个背包有两个维度,一个是m
一个是n
,而不同长度的字符串就是不同大小的待装物品。 - 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i][j]
:最多有i
个0
和j
个1
的strs
的最大子集的大小为dp[i][j]
。 - 确定递推公式:
dp[i-x][j-y]+1
的含义是容量为[i-x][j-y]
的背包加上有x
个0
和y
个1
的字符串后子集的元素数量要+1
。- 状态转移方程
dp[i][j] = max(dp[i][j], dp[i-x][j-y]+1);
;
dp
数组如何初始化:全部初始化成0
;- 确定遍历顺序:在
i
上正序遍历,在j
上倒序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 这道题目同样可以看成是
- 代码:
1 |
|
完全背包理论基础
- 携带研究材料:52.携带研究材料
- 思路:
- 唯一和
0 - 1
背包不同之处在于遍历背包重量j
采用正序。
- 唯一和
- 代码:
1 |
|
零钱兑换 II
- 零钱兑换 II:518.零钱兑换 II
- 思路:
- 这道题目是完全背包问题的变式,求的是装满容量为
j
的背包有多少种方式。 - 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[j]
:装满容量为j
的背包有dp[j]
种方式。 - 确定递推公式:
dp[j]
由所有dp[j-nums[i]]
相加得到,后续要装满容量为j
的背包有多少种方法都是用该递推公式。- 状态转移方程
dp[j] += dp[j-nums[i]];
; - 需要注意的是要加上
if (dp[j] < INT_MAX - dp[j - coins[i]])
来防止相加数据超int
。
dp
数组如何初始化:dp[0] = 1;
、其余初始化成0
;- 确定遍历顺序:在
i
上正序遍历,在j
上正序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 这道题目是完全背包问题的变式,求的是装满容量为
- 代码:
1 |
|
组合总和 Ⅳ
- 组合总和 Ⅳ:377.组合总和 Ⅳ
- 思路:
- 这道题目是完全背包问题的变式,求的是装满容量为
j
的背包有多少种方式。但是和上一题不同的是不同的遍历顺序有不同的结果:- 先背包再物品,求的是排列数;
- 先物品再背包,求的是组合数。
- 可以这么理解,先遍历物品的话
0
号物品将不会出现在后面,因此求的是组合数。 - 本题求的是排列数,因此采用先背包再物品的遍历顺序。
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[j]
:装满容量为j
的背包有dp[j]
种方式。 - 确定递推公式:
dp[j]
由所有dp[j-nums[i]]
相加得到,后续要装满容量为j
的背包有多少种方法都是用该递推公式。- 状态转移方程
dp[j] += dp[j-nums[i]];
; - 需要注意的是要加上
if (dp[j] < INT_MAX - dp[j - coins[i]])
来防止相加数据超int
。
dp
数组如何初始化:dp[0] = 1;
、其余初始化成0
;- 确定遍历顺序:在
i
上正序遍历,在j
上正序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 这道题目是完全背包问题的变式,求的是装满容量为
- 代码:
1 |
|
爬楼梯(进阶版)
- 爬楼梯(进阶版):56.爬楼梯(进阶版)
- 思路:
- 这道题和上一道题目完全一样,注意求的是排列数,因此采用先背包再物品的遍历顺序。
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[j]
:装满容量为j
的背包有dp[j]
种方式。 - 确定递推公式:
dp[j]
由所有dp[j-nums[i]]
相加得到,后续要装满容量为j
的背包有多少种方法都是用该递推公式。- 状态转移方程
dp[j] += dp[j-nums[i]];
; - 需要注意的是要加上
if (dp[j] < INT_MAX - dp[j - coins[i]])
来防止相加数据超int
。
dp
数组如何初始化:dp[0] = 1;
、其余初始化成0
;- 确定遍历顺序:在
i
上正序遍历,在j
上正序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 代码:
1 |
|
零钱兑换
- 零钱兑换:322.零钱兑换
- 思路:
- 题目中说每种硬币的数量是无限的,可以看出是典型的完全背包问题。
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[j]
:凑足总额为j
所需钱币的最少个数为dp[j]
。 - 确定递推公式:
dp[j] = min(dp[j], dp[j-coins[i]]+1);
dp
数组如何初始化:dp[0] = 0;
、其余初始化成INT_NAX
;- 确定遍历顺序:在
i
上正序遍历,在j
上正序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 代码:
1 |
|
完全平方数
- 完全平方数:279.完全平方数
- 思路:
- 该题与上一题的思路完全类似。
- 代码:
1 |
|
单词拆分
- 单词拆分:139.单词拆分
- 思路:
- 单词就是物品,字符串
s
就是背包,单词能否组成字符串s
,就是问物品能不能把背包装满。拆分时可以重复使用字典中的单词,说明就是一个完全背包! - 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i]
: 字符串长度为i
的话,dp[i]
为true
,表示可以拆分为一个或多个在字典中出现的单词。 - 确定递推公式:如果确定
dp[j]
是true
,且[j, i]
这个区间的子串出现在字典里,那么dp[i]
一定是true
。 dp
数组如何初始化:dp[0] = true;
、其余初始化成false
;- 确定遍历顺序:在
i
上正序遍历,在j
上正序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 注意代码中的
unordered_set<string>
和word_dict.find(s.substr(j, i-j)) != word_dict.end()
相关代码的使用!
- 单词就是物品,字符串
- 代码:
1 |
|
打家劫舍
- 打家劫舍:198.打家劫舍
- 思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i]
: 考虑下标i
(包括i
)以内的房屋,最多可以偷窃的金额为dp[i]
。 - 确定递推公式:
dp[i] = max(dp[i-2]+nums[i], dp[i-1]);
。- 最大金额要么是
dp[i-2]
家的金额加上nums[i]
,要么是不偷第i
家,最大金额是dp[i-1]
,这两者取最大值即可。
- 最大金额要么是
dp
数组如何初始化:dp[0] = nums[0];
、dp[1] = max(dp[0], nums[1]);
、其余初始化成false
;- 确定遍历顺序:在
i
上正序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 按照动态规划五部曲进行分析:
- 代码:
1 |
|
打家劫舍II
- 打家劫舍 II:213.打家劫舍II
- 思路:
- 这一道题目和上一题差不多,唯一区别就是成环了。
- 情况一:考虑不包含首尾元素
- 情况二:考虑包含首元素,不包含尾元素
- 情况三:考虑包含尾元素,不包含首元素
- 由于情况二和情况三都包含了情况一了,所以只考虑情况二和情况三就可以了。因此分别求情况二和情况三的值,返回其中的最大值即可。
- 代码:
1 |
|
打家劫舍III
- 打家劫舍 III:337.打家劫舍III
- 思路:
- 1、确定递归函数的参数和返回值:这里我们要求一个节点偷与不偷的两个状态所得到的金钱,那么返回值就是一个长度为
2
的数组。 - 2、确定终止条件:在遍历的过程中,如果遇到空节点的话,很明显,无论偷还是不偷都是
0
,所以就返回{0, 0}
。 - 3、确定遍历顺序:
- 首先明确的是使用后序遍历。因为要通过递归函数的返回值来做下一步计算。
- 通过递归左节点,得到左节点偷与不偷的金钱。
- 通过递归右节点,得到右节点偷与不偷的金钱。
- 4、确定单层递归的逻辑:
- 如果是偷当前节点,那么左右孩子就不能偷,
dp[1] = root->val + val_1[0] + val_2[0];
- 如果不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,所以:
dp[0] = max (val_1[0], val_1[1]) + max (val_2[0], val_2[1]);
- 最后当前节点的状态就是
{dp[0], dp[1]}
。即:{ 不偷当前节点得到的最大金钱,偷当前节点得到的最大金钱 }。
- 如果是偷当前节点,那么左右孩子就不能偷,
- 1、确定递归函数的参数和返回值:这里我们要求一个节点偷与不偷的两个状态所得到的金钱,那么返回值就是一个长度为
- 代码:
1 |
|
买卖股票的最佳时机
- 买卖股票的最佳时机:121.买卖股票的最佳时机
- 贪心思路:
- 因为股票就买卖一次,那么贪心的想法很自然就是取最左最小值,取最右最大值,那么得到的差值就是最大利润。
- 贪心思路代码:
1 |
|
- 动规思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i][0]
表示不持有股票,dp[i][1]
表示持有股票。 - 确定递推公式:
- 第
i
天如果不持有股票:dp[i][0] = max(dp[i-1][0], dp[i-1][1]+prices[i]);
- 第
i
天如果持有股票:dp[i][1] = max(dp[i-1][1], 0-prices[i]);
- 第
dp
数组如何初始化:dp[0][0] = 0;
、dp[0][1] = 0-prices[0];
、其余初始化成0
;- 确定遍历顺序:在
i
上正序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 按照动态规划五部曲进行分析:
- 动规思路代码:
1 |
|
买卖股票的最佳时机II
- 买卖股票的最佳时机 II:122.买卖股票的最佳时机II
- 思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i][0]
表示不持有股票,dp[i][1]
表示持有股票。 - 确定递推公式:
- 第
i
天如果不持有股票:dp[i][0] = max(dp[i-1][1]+prices[i], dp[i-1][0]);
- 第
i
天如果持有股票:dp[i][1] = max(dp[i-1][0]-prices[i], dp[i-1][1]);
- 第
dp
数组如何初始化:dp[0][0] = 0;
、dp[0][1] = 0-prices[0];
、其余初始化成0
;- 确定遍历顺序:在
i
上正序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 按照动态规划五部曲进行分析:
- 代码:
1 |
|
买卖股票的最佳时机 III
- 买卖股票的最佳时机 III:123.买卖股票的最佳时机III
- 思路:
- 按照动态规划五部曲进行分析:
- 确定
dp
数组及下标的含义:dp[i][0]
表示不操作,dp[i][1]
表示第一次持有股票,dp[i][2]
表示第一次卖出股票,dp[i][3]
表示第二次持有股票,dp[i][4]
表示第二次卖出股票; - 确定递推公式:
- 第
i
天如果操作:dp[i][0] = dp[i-1][0];
- 第
i
天如果第一次持有股票:dp[i][1] = max(dp[i-1][1], dp[i-1][0]-prices[i]);
- 第
i
天如果第一次卖出股票:dp[i][2] = max(dp[i-1][2], dp[i-1][1]+prices[i]);
- 第
i
天如果第二次持有股票:dp[i][3] = max(dp[i-1][3], dp[i-1][2]-prices[i]);
- 第
i
天如果第二次卖出股票:dp[i][4] = max(dp[i-1][4], dp[i-1][3]+prices[i]);
- 第
dp
数组如何初始化:dp[0][0] = 0;
、dp[0][1] = 0-prices[0];
、dp[0][2] = 0;
、dp[0][3] = 0-prices[0];
、dp[0][4] = 0;
、其余初始化成0
;- 确定遍历顺序:在
i
上正序遍历; - 举例推导
dp
数组符合要求。
- 确定
- 按照动态规划五部曲进行分析:
- 代码:
1 |
|
左程云、代码随想录算法学习(二)
http://example.com/2025/03/07/zuo_algorithm_2/