Leetcode 每日一题和每日一题的下一题刷题笔记 5/30
写在前面
这是我参与更文挑战的第5天,活动详情查看:更文挑战
快要毕业了,才发现自己被面试里的算法题吊起来锤。没办法只能以零基础的身份和同窗们共同加入了力扣刷题大军。我的同学们都非常厉害,他们平时只是谦虚,口头上说着自己不会,而我是真的不会。。。乘掘金鼓励新人每天写博客,我也凑个热闹,记录一下每天刷的前两道题,这两道题我精做。我打算每天刷五道题,其他的题目嘛,也只能强行背套路了,就不发在博客里了。
本人真的只是一个菜鸡,解题思路什么的就不要从我这里参考了,编码习惯也需要改进,各位如果想找刷题高手请教问题我觉得去找 宫水三叶的刷题日记 这位大佬比较好。我在把题目做出来之前尽量不去看题解,以免和大佬的内容撞车。
另外我也希望有得闲的大佬提供一些更高明的解题思路给我,欢迎讨论哈!
好了废话不多说开始第五天的前两道题吧!
2021.6.5 每日一题
今天又是链表,看来这个月应该是不玩前缀和了?
这道题和昨天的每日一题形成了补充。昨天的每日一题是找两个链表里面共同的元素(元素的地址相同,元素的值相同),今天的每日一题是找值相同(输入里的第二个数可以视为只有一个元素的链表,只看它的值这样)。今天这个题没必要用什么哈希集合哈希表之类的,不方便。首先链表里的元素值本来就有可能相等,这个不能放到那种所有元素各不相同的字段上,然后底下找相等用到的就是 count 这个方法,这个找的就是所有元素各不相同的字段上出现没出现和现在这个输入重复的元素。根据昨天的经验,遍历一次链表里的所有元素应该就能完成这道题。因为链表的特殊性,这种数据结构里面没有定义长度,但定义了迭代器,所以用 while 循环来遍历每个元素。要注意一件事,头节点也可能被删掉的,所以应对这种特殊情况,一是自己单独写一个应对头节点的代码,二是往头节点前面放一个外来节点,保证外来节点不会被删掉,这样头节点和后续的中间节点的地位就一样了。
接下来写代码
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode *external_node = new ListNode(0, head);
ListNode *temp = external_node;
while (temp->next != NULL) {
if (temp->next->val == val) {
temp->next = temp->next->next;
} else {
temp = temp->next;
}
}
return external_node->next;
}
};
提示里面写到了第二个输入的取值范围比前面链表元素的值的取值范围多一个 0,所以一开始补上的那个节点的值是 0,之后就不会被删掉。
另外还有一个问题是这个循环迭代的时候出现的,给 temp 赋值时不要赋 external_node->next,不然一上来就是一个空的链表,怎么说?循环里面判断当前这个位置的下一个元素是否为空,值是否和第二个输入相等,这样能在进循环的时候就把空链表拦下来。另外,假如值相等,里面执行的语句只能这么写,往前推一下,必须要让最后一个值进第二个判断,实际上循环从头节点前一个元素(外来节点)开始,只循环到倒数第二个元素,赋值时赋 external_node->next 就和没有设置外来节点的效果是一样的,所以这么赋值是不对的。
看完题解发现我这个解法就是官方题解里面的第二个方法,第一个方法是递归法。递归法认为每次只处理头节点,头节点为空时递归结束。具体代码可以去看官方题解,这里不粘贴了。
2021.6.5 每日一题下面的题
摸奖时间,希望能出一个让我有参考链接的题目
这题有点意思哈,看题面是不是出现 i 和 j 交换位置了?那就往背包问题上靠吧[手动滑稽]。然后看到一个小的范围,嗯,是不是滑动窗口?单调队列是不是呼之欲出了?我自己只看出来滑动窗口,然后再靠百度的联想把单调队列给搜出来了。。。(这样算作弊吗)
然后学习一下单调队列和滑动窗口的概念吧!
单调队列
单调队列就是字面上的意思,不用再瞎联想了,原来的一大堆数升序排序或者降序排序之后的数组就是这里用的单调队列。
滑动窗口
这里有一些生活体验更好,小时候我特别馋商店里那些花里胡哨的文具,有一种尺子上面带一个小滑框,
图片看不了就算了,就是这个百度百科里面的图片
这个尺子原来我记得好像可以算乘法还是珠算来着,还能转换单位。人家带一把算盘我带一把尺子就解决问题了。后来一问发现这个尺子还挺贵的。。。回家跟家长说然后被说教了一晚上败家子。。。结果现在也没买过这种尺子,自己的珠算也忘得一干二净,算盘不会打了。
言归正传嗷,这种尺子上面有个小滑框,滑动窗口的思想和这个框的感觉类似的,在大范围里面给出一个小范围,减少搜索范围,甚至搜索用到的循环次数也能减少。
单调队列和滑动窗口结合
这两个东西结合起来,就是小范围内的单调队列了。比如说现在一个滑窗里面有 [1, -3, 4, -6],要获得这个滑窗里面的单调队列,就要来个小排序,变成 [-6, -3, 1, 4] 这样。然后滑窗往后移动一格,原来的 1 不在滑窗里面了,变成 [-6, -3, 4],进来了一个 -5,变成 [-6, -3, 4, -5],之后再来个小排序,变成 [-6, -5, -3, 4]。如此往复循环,这样可以找出一个滑窗里的最大值和最小值。
现在回到原题,这道题题面上规定子序列里两个相邻元素的下标在滑窗的范围内,又要画图了。
输入:nums = [10,2,-10,5,20], k = 2
输出:37
解释:子序列为 [10, 2, 5, 20] 。
滑窗用“[]”表示
原数组:| +10 | +02 | -10 | +05 | +20 |
循环一:|[+10]| +02 | -10 | +05 | +20 | 窗右最大值:10 + 0,最大值:10
窗口:1 ++++^ 自己 + 循环初始化
循环二:|[+10 | +02]| -10 | +05 | +20 | 窗右最大值:2 + 10,最大值:12
窗口:2 ----- ++++^ 自己 + 循环一
循环三:|[+10 | +02 | -10]| +05 | +20 | 窗右最大值:-10 + 12,最大值:12
窗口:2 xxxxx ~~~~~ ++++^ 自己 + 循环二
循环四:| +10 |[+02 | -10 | +05]| +20 | 窗右最大值:5 + 12,最大值:17
窗口:2 ----- xxxxx ++++^ 自己 + 循环二
循环五:| +10 | +02 |[-10 | +05 | +20]| 窗右最大值:20 + 17,最大值:37
窗口:2 xxxxx ~~~~~ ++++^ 自己 + 循环四
输出:37
窗口大小是 3,理由很简单,j - i <= k,窗口大小其实是 k + 1,但是不代表窗口里存的元素必须满满当当。
看上面的图,大致能明白我的意思,每次循环窗口右端右移一格,循环的最大值是选上窗口右端元素和前面循环已经获得的最大值(前面循环已经获得的最大值要大于 0,不然就没必要选了不是嘛,这里的 0 是循环开始的时候初始化的一个所有循环都能用的“上一次”的最大值)。
写的规范一点,分状态定义,状态初始化和状态转移这样,也许更容易理解。
-
状态定义:当前循环里选到窗口末尾元素之后的最大值
-
状态初始化:
0(一开始窗口里什么都没有,同时在状态转移的时候如果前一次循环获得的最大值还没这个值大,就没必要去加上前一次循环获得的最大值了) -
状态转移:当前循环里选到窗口末尾元素之后的最大值 = Max{当前循环里末尾元素的值,当前循环里末尾元素的值加上前一次循环获得的最大值}
(我觉得不要全都是公式,不然真的看到符号就头大。最后状态转移里这个“Max{a, b}”是获取a和b里的最大值)
写到这里似乎还没提到单调队列,其实单调队列的思想已经贯穿其中了,你看我这个滑动窗口是不是头和尾都能进出的,没错,这是个双向队列。然后来考虑一下窗口里面的两个元素,我们在上面那个例子里面改变一下数组,像这样:
输入:nums = [10,2,-10,5,20,23], k = 2
输出:37
解释:子序列为 [10, 2, 5, 20] 。
滑窗用“[]”表示
原数组:| +10 | +02 | -10 | +05 | +20 | +23 |
循环一:|[+10]| +02 | -10 | +05 | +20 | +23 | 窗右最大值:10 + 0,最大值:10
窗口:1 ~~~~^ 自己 + 循环初始化
循环二:|[+10 | +02]| -10 | +05 | +20 | +23 | 窗右最大值:2 + 10,最大值:12
窗口:2 ~~~~~~~~~~^ 自己 + 循环一
循环三:|[+10 | +02 | -10]| +05 | +20 | +23 | 窗右最大值:-10 + 12,最大值:12
窗口:2 ~~~~~~~~~~~~~~~~^ 自己 + 循环二
循环四:| +10 |[+02 | -10 | +05]| +20 | +23 | 窗右最大值:5 + 12,最大值:17
窗口:2 ~~~~~~~~~~~~~~~~^ 自己 + 循环二
循环五:| +10 | +02 |[-10 | +05 | +20]| +23 | 窗右最大值:20 + 17,最大值:37
窗口:2 ~~~~~~~~~~~~~~~~^ 自己 + 循环四
循环六:| +10 | +02 | -10 |[+05 | +20 | +23]| 窗右最大值:23 + 37,最大值:60
窗口:2 ~~~~~~~~~~~~~~~~^ 自己 + 循环五
输出:60
你看循环六这里需要考虑三种情况,第一是谁都不加,第二是加上循环四,第三是加上循环五。但是我们已经知道了循环五的结果是大于循环四的(在计算循环五的时候就知道了),那我们还需要考虑循环四吗?不用了吧。还需要考虑循环三吗?也不用了吧,一是这个循环三已经在窗口外面了,二是确实循环四循环五的最大值都是在循环三的基础上增长的,这是单调的。看到这个单调的双向队列的威力了没?
这样一来,前面窗口里选过的最大值会逐步积累下来(自己在纸上推一下,确实是能积累下来的)。后面就开始写代码吧。
class Solution {
public:
int constrainedSubsetSum(vector<int>& nums, int k) {
vector<int>dp(nums.size(),0);
dp[0] = nums[0];
int ans = dp[0];
deque<int> sw;
sw.push_back(0);
for (int i = 1; i < nums.size(); i++) {
dp[i] = max(dp[sw.front()] + nums[i], nums[i]);
ans = max(ans, dp[i]);
if (i - sw.front() == k) {
sw.pop_front();
}
while ((!sw.empty()) && (dp[sw.back()] < dp[i])) {
sw.pop_back();
}
sw.push_back(i);
}
return ans;
}
};
代码里面判断窗口里的元素是否要往外扔一是判断序号差距是否大于窗口大小,二是判断即将新加进来的这个值是不是比窗口里面最小的值要大,如果确实即将新加进来的值更大,先把原来最小的值丢掉,再加进来新的值;如果不是,就只是把新的值加进来。
判断序号差距的时机可以在状态转移前也可以在其后,这是另一种写法:
class Solution {
public:
int constrainedSubsetSum(vector<int>& nums, int k) {
vector<int>dp(nums.size(),0);
dp[0] = nums[0];
int ans = dp[0];
deque<int> sw;
sw.push_back(0);
for (int i = 1; i < nums.size(); i++) {
if (i - sw.front() > k) {
sw.pop_front();
}
dp[i] = max(dp[sw.front()] + nums[i], nums[i]);
ans = max(ans, dp[i]);
while ((!sw.empty()) && (dp[sw.back()] < dp[i])) {
sw.pop_back();
}
sw.push_back(i);
}
return ans;
}
};
写完代码发现上面示意图里面有一些地方需要小改一下,但是前面对状态转移那里的叙述就没改,所以可能看的时候会觉得有些矛盾。我实现算法的时候认为只需要一个窗口最大值就可以了,但其实上每次循环得到的最大值和窗口最大值还不一样,而且状态转移必须用到窗右和窗左元素,单调队列的思想只是控制窗右元素入队列前是否要把之前的窗右丢弃。前面先理解我的大致思路,后面优化优化再优化,调完各种各样的 bug 以后会发现我的图和你最终理解的是一样的,所以前面有些特别细节的说明就不放出来了,以免干扰整体思路的形成。有心人可以看一看我的图画没画对,是不是和代码里描述的一模一样。
小结
找值相同,补个头节点,迭代和递归,单调队列和滑动窗口
参考链接
什么是「滑动窗口算法」(sliding window algorithm),有哪些应用场景?
今天绝对有干货,读完绝对有收获。