文章目录
核心思想
滑动窗口是双指针思想的一种应用方式,使用左右指针维护窗口在给定区间上滑动,常用来解决结果和某段区间相关的问题。
关键在于找出左右边界移动的条件:
- 右边界扩张:一般无条件,作为外部循环,直到扩张到数组末尾
- 左边界收缩:在右边界扩张的过程中,要根据具体题目分析出区间的极限情况在哪里,然后开始收缩左边界直到区间不满足条件,此时继续扩张右边界,以此不断循环到结束。
力扣题目总结
一、双指针原地修改数组元素
1.原地删除指定元素
力扣链接
题目描述:
给你一个数组 nums 和一个值 val,你需要 原地
移除所有数值等于 val 的元素。元素的顺序可能发生改变。然后返回 nums 中与 val 不同的元素的数量。
假设 nums 中不等于 val 的元素数量为 k,要通过此题,您需要执行以下操作:
更改 nums 数组,使 nums 的前 k 个元素包含不等于 val 的元素。nums 的其余元素和 nums 的大小并不重要。
返回 k。
题目分析:
原地移除指定元素,不能开辟新的数组,所以只能在原数组空间上修改,考虑到修改的过程为:遇到非删除元素,顺序往左平移,遇到待删除元素,用后续元素覆盖掉即可,于是可以使用两个指针,一个指代旧数组中的元素,一个指代修改后的数组末尾位置。
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
//left 代表 新数组的将要拷贝的下个元素位置
//right 代表 旧数组的位置
//right遇到非修改数时,将旧数组元素拷贝到新数组末尾
//right遇到待修改数时,新数组不为所动,忽略掉此数,下次该位置被后面元素覆盖
int left = 0,right = 0;
while(right < nums.size()){
if(nums[right] != val){
nums[left++] = nums[right];
}
++right;
}
return left;
}
};
2.原地移动指定元素
力扣链接
题目描述:
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
请注意 ,必须在不复制数组的情况下原地对数组进行操作。
题目分析:
这题和原地删除元素类似,都是原地处理某个数组元素,一个是删除,即是用后面元素直接覆盖该元素位置即可,本题是将其移动到数组末尾,也可采用同样思路,用后续元素覆盖o的位置,同时将后续元素置0,这样遍历一遍自然将所有0移动到末尾了。
需要注意的是,这种操作有个特殊情况,只有遇到0的时候才能用后面元素覆盖前面元素,并将后面元素置0,在没有遇到0的时候,什么操作也不需要做,只需将两个指针都挪到下一个位置。
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int left = 0, right = 0;
while(right < nums.size()){
//在没有遇到0之前,left = right
//在遇到0之后,left停留在0的位置,left<right
if(nums[right] != 0){
//
if(left < right){
//遇到0后,每次将后面不为0的数与前面的0交换
nums[left] = nums[right];
nums[right] = 0;
}
++left;
}
++right;
}
}
};
3.原地删除重复元素
力扣链接
题目描述:
给你一个 非严格递增排列 的数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。然后返回 nums 中唯一元素的个数。
考虑 nums 的唯一元素的数量为 k ,你需要做以下事情确保你的题解可以被通过:
更改数组 nums ,使 nums 的前 k 个元素包含唯一元素,并按照它们最初在 nums 中出现的顺序排列。nums 的其余元素与 nums 的大小不重要。
返回 k
题目分析:
与上两题类似,使用快慢指针分别代表旧数组和新数组的元素位置,当满足要求是才将旧数组的数拷贝到新数组元素末尾,否则就忽略掉。
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
// left 指向 新数组 已经拷贝的位置, left + 1 为下个元素拷贝的位置
// right 指向 旧数组 的元素位置
// 遇到相同元素,新数组忽略掉
// 遇到不同元素,拷贝到新数组下个位置
int left = 0, right = 0;
while(right < nums.size()){
if(nums[right] != nums[left]){
nums[++left] = nums[right];
}
++right;
}
return left+1;
}
};
4.原地删除重复元素 II
力扣链接
题目描述:
给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使得出现次数超过两次的元素只出现两次
,返回删除后数组的新长度。
不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
返回 k
题目分析:
可以利用count记录重复次数,当重复次数<=2时,将右指针的数拷贝到左指针下个位置,当重复次数>2时,左指针不动,右指针顺移一个位置。
当遇到不同元素时,就可以直接拷贝到新数组,并将count置1
在初始时需要左右指针一前一后,count = 1,左指针表示新数组中已存在的元素,新元素需要加入左指针+1的位置。
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
if(nums.size() <= 2)return nums.size();
int left = 0, right = 1, count = 1;
while(right < nums.size()){
if(nums[right] != nums[left]){
nums[++left] = nums[right];
count = 1;
}
else if(nums[right] == nums[left]){
++count;
if(count <= 2){
nums[++left] = nums[right];
}
}
++right;
}
return left+1;
}
};
5.原地排序(荷兰国旗问题)
力扣链接
题目描述:
给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。
必须在不使用库内置的 sort 函数的情况下解决这个问题。
题目分析:
使用左右指针分别表示下一个0和2的插入位置,从左到右遍历:
- 遇到1无需操作,只需继续遍历下一个元素
- 遇到0就交换到左指针位置,并将指针向右移动一位,此时直接遍历下个元素,不需再判断交换过来的元素,因为我们是从左向右遍历,所以交换过来的只能是1或0;
- 遇到2就交换到右指针位置,并将指针向左移动一位,但此时不能接着遍历下个元素,因为交换过来的元素仍需判断是否是0,我们只需进入下个循环判断即可
class Solution {
public:
void sortColors(vector<int>& nums) {
int left = 0, right = nums.size()-1, cur = 0;
while(cur <= right){
if(nums[cur] == 0){
swap(nums[cur++],nums[left++]);
}
else if(nums[cur] == 2){
swap(nums[cur],nums[right--]);
}
else{
++cur;
}
}
}
};
二、给定长度的典型滑动窗口
1.子数组最大平均数
力扣链接
题目描述:
给你一个由 n 个元素组成的整数数组 nums 和一个整数 k 。
请你找出平均数最大且 长度为 k 的连续子数组,并输出该最大平均数。
任何误差小于 10-5 的答案都将被视为正确答案。
题目分析:
按照长度为k的窗口来一次一个元素的更新窗口大小;
窗口大小从0开始扩张,达到指定大小后每次从右边界扩张一个元素,从左边界缩减一个元素。
class Solution {
public:
double findMaxAverage(vector<int>& nums, int k) {
int left = 0, right = 0;
double result = INT_MIN, sum = 0;
while(right < nums.size()){
sum += nums[right];
if(right - left + 1 == k){
result = std::max(result,sum/k);
sum -= nums[left++];
}
++right;
}
return result;
}
};
2.爱生气的书店老板
力扣链接
题目描述:
有一个书店老板,他的书店开了 n 分钟。每分钟都有一些顾客进入这家商店。给定一个长度为 n 的整数数组 customers ,其中 customers[i] 是在第 i 分钟开始时进入商店的顾客数量,所有这些顾客在第 i 分钟结束后离开。
在某些时候,书店老板会生气。 如果书店老板在第 i 分钟生气,那么 grumpy[i] = 1,否则 grumpy[i] = 0。
当书店老板生气时,那一分钟的顾客就会不满意,若老板不生气则顾客是满意的。
书店老板知道一个秘密技巧,能抑制自己的情绪,可以让自己连续 minutes 分钟不生气,但却只能使用一次。
请你返回 这一天营业下来,最多有多少客户能够感到满意 。
题目分析:
情景题:先考虑不使用秘密技巧的情况,然后以秘密技巧所指定窗口来滑动,计算在不同窗口位置使用秘密技巧所带来的收益,最后求出最高收益即可。
class Solution {
public:
int maxSatisfied(vector<int>& customers, vector<int>& grumpy, int minutes) {
// source 代表 没有使用秘密技巧 情况下 原始的顾客数量
// tempAdd 代表 在当前窗口下使用秘密技巧 情况下增加的顾客数量
// maxAdd 代表增加的最多顾客数
int left = 0, right = 0, source = 0, maxAdd = 0, tempAdd = 0;
int n = customers.size();
for(int i = 0; i < n; ++i){
source += grumpy[i] == 0 ? customers[i] : 0;
}
while(right < n){
// 右边界扩张区间,每次将新元素考虑在内
tempAdd += grumpy[right] == 1 ? customers[right] : 0;
// 当达到区间长度时,开始缩减左边界
// 此时先计算当前窗口结果,再从左边界移除元素带来的效果
if(right - left + 1 == minutes){
maxAdd = std::max(maxAdd,tempAdd);
tempAdd -= grumpy[left] == 1 ? customers[left] : 0;
++left;
}
++right;
}
return source + maxAdd;
}
};
三、寻找最长连续区间
1.水果成蓝
力扣链接
题目描述:
你正在探访一家农场,农场从左到右种植了一排果树。这些树用一个整数数组 fruits 表示,其中 fruits[i] 是第 i 棵树上的水果 种类 。
你想要尽可能多地收集水果。然而,农场的主人设定了一些严格的规矩,你必须按照要求采摘水果:
你只有 两个 篮子,并且每个篮子只能装 单一类型 的水果。每个篮子能够装的水果总量没有限制。
你可以选择任意一棵树开始采摘,你必须从 每棵 树(包括开始采摘的树)上 恰好摘一个水果 。采摘的水果应当符合篮子中的水果类型。每采摘一次,你将会向右移动到下一棵树,并继续采摘。
一旦你走到某棵树前,但水果不符合篮子的水果类型,那么就必须停止采摘。
给你一个整数数组 fruits ,返回你可以收集的水果的 最大 数目。
题目分析:
题目表述挺吓人,简化后其实就是求一个数组包含两个不同元素的最长子数组长度的问题,这类题目一般有以下描述:至多
包含几个不同元素的最长区间
,对于本题,该区间最多只能包含两个不同元素,可以用哈希表存放区间中元素的个数,当超过给定个数时,开始从左边界向右缩减区间,并相应减少哈希表中元素个数,直到找到第一个个数减少为0的位置,并将其从哈希表中删除,在从右边界向右扩张区间,查找满足条件的元素。
class Solution {
public:
int totalFruit(vector<int>& fruits) {
unordered_map<int,int> selectFruits;
int left = 0, right = 0, result = 0;
//右边界扩张,直接将新元素加入哈希表中
//再判断是否需要缩减左边界
while(right < fruits.size()){
++selectFruits[fruits[right]];
//缩减左边界
while(selectFruits.size() > 2){
if(--selectFruits[fruits[left]] == 0){
selectFruits.erase(fruits[left]);
}
++left;
}
//每次更新区间长度,注意此时的right已经经过判断,成为新区间的元素
result = std::max(result, right - left + 1);
++right;
}
return result;
}
};
2.恰好包含K个不同元素的子数组个数
力扣链接
题目描述:
给定一个正整数数组 nums和一个整数 k,返回 nums 中 「好子数组」 的数目。
如果 nums 的某个子数组中不同整数的个数恰好
为 k,则称 nums 的这个连续、不一定不同的子数组为 「好子数组 」。
例如,[1,2,3,1,2] 中有 3 个不同的整数:1,2,以及 3。
子数组 是数组的 连续 部分。
题目分析:
注意此题区间条件为恰好有k个不同整数,不同于水果成蓝中寻找至多k个元素,就可以在区间包含k+1个元素的边界情况下固定一个右边界,然后缩减左边界,恰好包含k个元素这种情况的右边界无法固定,因为存在重复元素时,满足条件的右边界可以有多个,这样就不能唯一固定右边界的情况下,缩减左边界来枚举所有情况。
因此考虑恰好和至多两种情况的差异,发现恰好包含k种元素,可以转换为至多包含k种元素—至多包含k-1种元素,而至多包含k种元素的子区间个数是可以利用滑动窗口求出的。
至多包含k种元素的子区间个数的求法:
固定区间左边界,右边界向右滑动扩张区间,当超出k种元素时,区间长度即为满足条件的以左边界元素开头的子区间个数,这样挨个遍历数组,将结果相加即为至多包含k种元素的子区间个数。
或者每次固定右边界,移动左边界累加符合条件的区间长度
class Solution {
public:
int subarraysWithKDistinct(vector<int>& nums, int k) {
return countOfMaxElement(nums,k) - countOfMaxElement(nums,k-1);
}
int countOfMaxElement(vector<int>& nums, int k){
unordered_map<int,int> selcetNums;
int left = 0,right = 0,count = 0;
while(right < nums.size()){
++selcetNums[nums[right]];
while(selcetNums.size() > k){
if(--selcetNums[nums[left]] == 0){
selcetNums.erase(nums[left]);
}
++left;
}
count += (right - left + 1);
++right;
}
return count;
}
};
3.替换后的最长重复子串
力扣链接
题目描述:
给你一个字符串 s 和一个整数 k 。你可以选择字符串中的任一字符,并将其更改为任何其他大写英文字符。该操作最多可执行 k 次。
在执行上述操作后,返回 包含相同字母的最长子字符串的长度。
题目分析:
使用一个滑动窗口,表示使用k次替换后区间全部为重复元素的区间,扩张右边界考虑新元素是否可加入区间从而更新最长字串,当新元素加入后不满足条件时,需要缩减左边界,去除一个字符,因为这个字符对于区间已经没有效果了,后续无论进来什么新字符,都不能利用左边界的字符来增加重复子串的长度了。
class Solution {
public:
int characterReplacement(string s, int k) {
// 使用滑动窗口 维护一个 使用k次替换就能使区间都为重复字符 的字符区间
// 当最多字符个数c + k > 区间长度时,可以继续扩张右边界
// 当最多字符个数c + k < 区间长度时,需要缩减左边界
// 使用vector存储26个大写字符出现的次数
vector<int>charCount(26,0);
int left = 0, right = 0,result = 0,n=s.length();
int maxCount = 0;
while(right < n){
++charCount[s[right]-'A'];
// 出现次数最多的一定是从右边界新增的字符次数
// 与 上次计算的最多次数 中最大的那个
maxCount = std::max(maxCount, charCount[s[right]-'A']);
result = std::max(result, maxCount+k);
if(maxCount + k < right-left+1){
--charCount[s[left]-'A'];
++left;
}
++right;
}
return std::min(result,n);
}
};
4.最大连续1的个数
力扣链接
题目描述:
给定一个二进制数组 nums 和一个整数 k,如果可以翻转最多 k 个 0 ,则返回 数组中连续 1 的最大个数 。
题目分析:
可以使用滑动窗口,
- 右边界扩充时,遇到0多于k则要缩减左边界
- 左边界缩减时,遇到1直接缩减,直到遇到0
class Solution {
public:
int longestOnes(vector<int>& nums, int k) {
// 滑动窗口
int countOfO = 0;
int maxCountOf1 = 0;
int left = 0, right = 0;
while(right < nums.size()){
if(nums[right] == 0)++countOfO;
while(countOfO > k){
if(nums[left] == 0){
--countOfO;
}
++left;
}
maxCountOf1 = std::max(maxCountOf1, right - left + 1);
++right;
}
return maxCountOf1;
}
};
四、寻找最短连续区间
1.区间和满足条件的最短子数组
力扣链接
题目描述:
给定一个含有 n 个正整数的数组和一个正整数 target 。
找出该数组中满足其总和大于等于 target 的长度最小的
子数组
[numsl, numsl+1, …, numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
滑动窗口法:
因为元素都为正整数,每次向区间添加元素,和会增大,减少元素,和会减小,因此可以利用滑动窗口,先找到区间右边界,再从左边界缩减区间,以此枚举出所有区间得情况。
```class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int result = INT_MAX,left = 0,right = 0,sum = 0;
while(right < nums.size()){
sum += nums[right];
while(sum >= target){
result = std::min(result, right - left + 1);
sum -= nums[left];
++left;
}
++right;
}
return result == INT_MAX ? 0 : result;
}
};
前缀和+二分法: 在数组总结中分析了此方法。
2.最短覆盖子串
力扣链接
题目描述:
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 “” 。
注意:
对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
如果 s 中存在这样的子串,我们保证它是唯一的答案。
滑动窗口法:
利用哈希表,将t中字符放入哈希表中,值代表出现的次数,用负数表示;
遍历s时,使用滑动窗口计算区间长度。
class Solution {
public:
// 初始化hashMap 中 字符均为负值
// 当区间加入元素为 t中元素,将哈希表此元素值+1
// 当区间删除元素为 t中元素,将哈希表此元素值-1
// 判断区间是否包含了所有字符,如果字符值小于0则未被全部包含
bool bHaveAllChar(unordered_map<char,int>& hashMap){
for(auto it : hashMap){
if(it.second < 0){
return false;
}
}
return true;
}
string minWindow(string s, string t) {
unordered_map<char,int> hashMap;
// 初始化hashMap 中 字符均为负值
for(char c : t){
--hashMap[c];
}
// 使用子串长度length 和 第一个字符下标pos 来表示子串
int left = 0, right = 0,length = INT_MAX,pos = 0;
// 扩张右边界
while(right < s.length()){
// 只有遇到t中元素才将哈希表中值+1
if(!hashMap.count(s[right])){++right;continue;}
++hashMap[s[right]];
// 当区间包含t中所有元素,缩减左边界
while(bHaveAllChar(hashMap)){
// 记录此时满足条件区间的子串长度和位置
if(length > right-left+1){
pos = left;
length = right-left+1;
}
// 只有是t中元素时缩减左边界,将哈希表中值-1
if(!hashMap.count(s[left])){++left;continue;}
--hashMap[s[left++]];
}
++right;
}
return length == INT_MAX ? "" : s.substr(pos,length);
}
};
五、使用数据结构维护窗口性质
1.滑动窗口最大值
力扣链接
题目描述:
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 。
优先队列(堆):
维护一个区间的最大值可以用大顶堆,堆是一种抽象的数据结构,可以用数组来实现,在c++中用priority_queue指代,可以返回一组数据中的最大值/最小值。
单调的双端队列:
维护一个单调递增的双端队列,存放对当前窗口最大值有贡献的元素(最大值、次最大值们、最新的有可能成为下个窗口最大值的较小值),队列的队头永远存放当前窗口的最大值,队尾存放未来窗口最大值候选人的最小值。
当窗口加入元素时:
- 如果是较大的值(> 队尾元素),让他从队尾一路淘汰比自己小的元素,他们已经不可能成为未来窗口最大值的候选人了,有也只能是自己。
- 如果是较小的值(<= 队尾元素),还是一个新人,未来还有发展空间,直接插入队尾,可能在未来窗口中成为最大值
当窗口移除元素时:
- 如果移除的是当前窗口最大值(队头元素),则直接移除队头元素。
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
// 双端队列,队头存放最大值
// 新加入的元素从队尾开始,剔除掉所有小于新元素的,再插入队尾
// 删除窗口元素时,如果是最大值,则队列队头出队
deque<int> maxQueue;
vector<int> result;
int left = 0, right = 0;
while(right < nums.size()){
// 除去队列为空时,加入新元素:
// 比当前队列中最小值小时,直接放在队尾,还是新人有培养空间
// 比当前队列中最小值大时,需要从队尾一路淘汰过来,把不如自己的老人t掉
while(maxQueue.size() && nums[right] > maxQueue.back()){
maxQueue.pop_back();
}
maxQueue.push_back(nums[right]);
// 形成滑动窗口时,每次剔除最左边元素
if(right -left + 1 == k){
result.push_back(maxQueue.front());
if(maxQueue.front() == nums[left]){
maxQueue.pop_front();
}
++left;
}
++right;
}
return result;
}
};
2.滑动窗口中位数(待解决)
力扣链接
题目描述:
中位数是有序序列最中间的那个数。如果序列的长度是偶数,则没有最中间的数;此时中位数是最中间的两个数的平均数。
例如:
[2,3,4],中位数是 3
[2,3],中位数是 (2 + 3) / 2 = 2.5
给你一个数组 nums,有一个长度为 k 的窗口从最左端滑动到最右端。窗口中有 k 个数,每次窗口向右移动 1 位。你的任务是找出每次窗口移动后得到的新窗口中元素的中位数,并输出由它们组成的数组。
优先队列(堆):