左程云、代码随想录算法学习(二)
左程云、代码随想录算法学习(二)
回溯算法
回溯算法代码模板
- 回溯算法的核心思想是:
- 递归构造解空间树,在树的每个节点上做出一个选择,并继续探索;若当前选择不符合条件则回退(撤销选择);
- 回溯法抽象为树形结构后,其遍历过程就是:
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 |
|
左程云、代码随想录算法学习(二)
http://example.com/2025/03/07/zuo_algorithm_2/