目录
????????滑动窗口算法(Sliding Window Algorithm)是一种常见的算法技巧,用于解决一些数组或字符串相关的问题。滑动窗口算法的本质是同向双指针,双指针算法已经在前面博客讲过了,所谓滑动窗口,顾名思义可以理解成是一个会滑动的窗口,每次记录下窗口的状态,再找出符合条件的适合的窗口。它可以将双层嵌套的循环问题,转换为单层遍历的循环问题。使用两个指针一左一右构成一个窗口,就可以将二维循环的问题转化成一维循环一次遍历,相当于通过旧有的计算结果对搜索空间进行剪枝,使时间复杂度从O(N^2)降低至O(N),比如经典字符串查找算法Rabin-Karp 指纹字符串查找算法,它本质上也使用了滑动窗口的思想,通过公式推导降低窗口滑动时计算子串哈希值的复杂度。
算法原理:
应用场景:
算法通常步骤:
难度 中等
给定一个含有?n
?个正整数的数组和一个正整数?target
?。
找出该数组中满足其总和大于等于?target
?的长度最小的?连续子数组?[numsl, numsl+1, ..., numsr-1, numsr]
?,并返回其长度。如果不存在符合条件的子数组,返回?0
?。
示例 1:
输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组?[4,3]
?是该条件下的长度最小的子数组。
示例 2:
输入:target = 4, nums = [1,4,4] 输出:1
示例 3:
输入:target = 11, nums = [1,1,1,1,1,1,1,1] 输出:0
提示:
1 <= target <= 10^9
1 <= nums.length <= 10^5
1 <= nums[i] <= 10^5
进阶:
O(n)
?时间复杂度的解法, 请尝试设计一个?O(n log(n))
?时间复杂度的解法。class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
}
};
首先想到的是暴力枚举,定义两个指针和一个变量,可以做到时间为O(N^2)。
由于此问题分析的对象是一段连续的区间,因此可以考虑滑动窗口的思想来解决这道题。
本题为变长的滑动窗口,其窗口大小由target决定(窗口总和大于等于target),所以定义一个变量(窗口内总和),小于target右指针右移,大于target左指针右移,等于记录更新窗口大小即可。
i 位置开始,窗口内所有元素的和小于 target让滑动窗口满足:(那么当窗口内元素之和第一次大于等于目标值的时候,就是 i 位置开始,满足条件的最小长度)
做法:将右端元素划入窗口中,统计出此时窗口内元素的和:如果窗口内元素之和大于等于 target : 更新结果,并且将左端元素划出去的同时继续判断是否满足条件并更新结果(因为左端元素可能很小,划出去之后依旧满足条件,如果窗口内元素之和不满足条件:right++ ,另下一个元素进入窗口。
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
// 时间:O(N)
int n = nums.size(), ret = n + 1; // 返回值不可能超过数组长度
int sum = 0, left = 0, right = 0;
while(right < n)
{
sum += nums[right]; // 进窗口
while(sum >= target) // 判断
{
ret = min(ret, right - left + 1); // 更新结果
sum -= nums[left++]; // 出窗口 然后left++
}
++right; // 判断不符合了
}
return ret == n + 1 ? 0 : ret;
}
};
为什么滑动窗口可以解决问题,并且时间复杂度更低?
本质是利用单调性规避了很多不必要的枚举。
这个窗口寻找的是:以当前窗口最左侧元素 (记为 left)为基准,符合条件的情况。也就是在这道题中,从 left?开始,满足区间和 sum >= target 时的最右侧 (记为right))能到哪里。
我们既然已经找到从 left?开始的最优的区间,那么就可以大胆舍去? left?。但是如果继续像暴力枚举,重新开始统计第二个元素 后的和,势必会有大量重复的计算 (因为我们在求第一段区间的时候,已经算出很多元素的和了,这些和是可以在计算下次区间和的时候用上的)。
此时,rigth 的作用就体现出来了,我们只需将 left?这个值从 sum 中剔除。从right?这个元素开始,往后找满足的区间(此时 right?也有可能是满足的,因为 left?可能很小。 sum 剔除掉 left?之后,依旧满足大于等于target )。这样就能省掉大量重复的计算。这样不仅能解决问题,而且效率也会大大提升时间复杂度:虽然代码是两层循环,但是 left 指针和 right 指针都是不回退的,两者最多都往后移动 N 次。因此时间复杂度是 0(N)。
难度 中等
给定一个字符串?s
?,请你找出其中不含有重复字符的?最长子串?的长度。
示例?1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc"
,所以其长度为 3。
示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: s = "pwwkew" 输出: 3 解释: 因为无重复字符的最长子串是?"wke",所以其长度为 3。
? 请注意,你的答案必须是 子串 的长度,"pwke"?是一个子序列,不是子串。
提示:
0 <= s.length <= 5 * 10^4
s
?由英文字母、数字、符号和空格组成class Solution {
public:
int lengthOfLongestSubstring(string s) {
}
};
- 研究的对象是一段连续的区间,因此继续使用「滑动窗口」思想来写。让滑动窗口满足:窗口内所有元素都是不重复的。
- 假设选择字符串中的第 left 个字符作为起始位置,并且得到了不包含重复字符的最长子串的结束位置为 right。那么当我们选择第 left+1个字符作为起始位置时,首先从 left+1到 right的字符显然是不重复的,并且由于少了原本的第 left个字符,可以尝试继续增大 right,直到右侧出现了重复字符为止,出现重复字符时,left指针可以直接移动到重复字符的右边。
- 那么使用两个指针表示字符串中的某个子串的左右边界。其中左指针代表着上文中「枚举子串的起始位置」,而右指针即为上文中的 right;
在每一步的操作中,会将左指针向右移动一格,表示开始枚举下一个字符作为起始位置,然后可以不断地向右移动右指针,但需要保证这两个指针对应的子串中没有重复的字符。在移动结束后,这个子串就对应着以左指针开始的,不包含重复字符的最长子串。记录下这个子串的长度。- 在枚举结束后,找到的最长的子串的长度即为答案。
这里可以借助哈希的思想:
右端X元素进入窗口的时候,哈希表统计这个字符的频次:
如果这个字符出现的频次超过1,说明窗口内有重复元素,那么就从左侧开始划出窗口,
直到X这个元素的频次变为1,然后再更新结果。
如果没有超过1, 说明当前窗口没有重复元素,可以直接更新结果。
class Solution {
public:
int lengthOfLongestSubstring(string s) {
// 时间O(N), 空间O(1)
int hash[128] = { 0 }; // 数组模拟哈希表->s由英文字母、数字、符号和空格组成
int n = s.size(), ret = 0, left = 0, right = 0;
while(right < n)
{
hash[s[right]]++; // 进窗口->进入哈希表
while(hash[s[right]] > 1)
{
hash[s[left++]]--; // 出窗口->哈希表的值减减然后left加加
}
ret = max(ret, right - left + 1); // 更新结果
++right; // 下一个元素进入窗口
}
return ret;
}
};
1004. 最大连续1的个数 III - 力扣(LeetCode)
难度 中等
给定一个二进制数组?nums
?和一个整数?k
,如果可以翻转最多?k
?个?0
?,则返回?数组中连续?1
?的最大个数?。
示例 1:
输入:nums = [1,1,1,0,0,0,1,1,1,1,0], K = 2 输出:6 解释:[1,1,1,0,0,1,1,1,1,1,1] 粗体数字从 0 翻转到 1,最长的子数组长度为 6。
示例 2:
输入:nums = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], K = 3 输出:10 解释:[0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1] 粗体数字从 0 翻转到 1,最长的子数组长度为 10。
提示:
1 <= nums.length <= 10^5
nums[i]
?不是?0
?就是?1
0 <= k <= nums.length
class Solution {
public:
int longestOnes(vector<int>& nums, int k) {
}
};
此题可转化为找出最长子数组,子数组0的个数不超过K个,就和前两篇滑动窗口的题目一样了。
设置计数器,计算0的个数
首先数组不能越界,所以最外层循环进入条件为right<n(数组长度)
滑动窗口四步走:
- 进窗口,遇到0计数器++,否则忽略( ps:滑动窗口的进窗口操作是载入数据,而不是单纯的移动right指针,如果只移动了right而没有对数据进行处理,则不算进窗口。)
- 判断,0个数是否大于 题目中的k (不是等于而是大于,这是因为如果在计数器的0个数等于k时就停止进窗口,并更新状态,那么就会漏情况。比如k为2 那么数组 1 ?1 ?0 ?0 ?1 ?1 ?1 1 这个数组中最长子串的长度就是8,如果是在0等于2个时就停下,那么更新的结果就会是4。)
- 出窗口,left移动,遇到0时计数器--,直到计数器的0个数等于k个,符合题目要求为止。
- 更新结果,无论是否出窗口都更新状态。只要用个max函数来判断此时的子串是否是最大长度即可。
class Solution {
public:
int longestOnes(vector<int>& nums, int k) {
// 时间O(N), 空间O(1)
int n = nums.size(), ret = 0, left = 0, right = 0, zero = 0;
while(right < n)
{
if(nums[right] == 0)
{
zero++; // 进窗?
}
while(zero > k) // 判断
{
if(nums[left++] == 0)
{
zero--; // 出窗?
}
}
ret = max(ret, right - left + 1); // 更新结果
++right;
}
return ret;
}
};
1658. 将 x 减到 0 的最小操作数 - 力扣(LeetCode)
难度 中等
给你一个整数数组?nums
?和一个整数?x
?。每一次操作时,你应当移除数组?nums
?最左边或最右边的元素,然后从?x
?中减去该元素的值。请注意,需要?修改?数组以供接下来的操作使用。
如果可以将?x
?恰好?减到?0
?,返回?最小操作数?;否则,返回?-1
?。
示例 1:
输入:nums = [1,1,4,2,3], x = 5 输出:2 解释:最佳解决方案是移除后两个元素,将 x 减到 0 。
示例 2:
输入:nums = [5,6,7,8,9], x = 4 输出:-1
示例 3:
输入:nums = [3,2,20,1,1,3], x = 10 输出:5 解释:最佳解决方案是移除后三个元素和前两个元素(总共 5 次操作),将 x 减到 0 。
提示:
1 <= nums.length <= 10^5
1 <= nums[i] <= 10^4
1 <= x <= 10^9
class Solution {
public:
int minOperations(vector<int>& nums, int x) {
}
};
转化成了最长子数组的长度,所有元素的和正好等于数组和 - x。
题意是从两边找几个数,使其的和等于x,从两边找的话很难,因为有时候删左边,有时候删右边,所以“正难则反”,可以找中间有没有等于数组和 - x的区间,这样两边的和就是x了。
class Solution {
public:
int minOperations(vector<int>& nums, int x) {
// 时间O(N), 空间O(1)
int tmp = 0;
for(auto& e : nums)
{
tmp += e;
}
int target = tmp - x;
if(target < 0)
{
return -1;
}
int n = nums.size(), len = -1, sum = 0, left = 0, right = 0;
while(right < n) // 找最大子数组的和,长度为len
{
sum += nums[right];
while(sum > target) // 判断
{
sum -= nums[left++]; // 出窗口 然后left++
}
if(sum == target)
{
len = max(len, right - left + 1); // 更新结果
}
++right; // 判断不符合了
}
return len == -1 ? len : (n - len);
}
};
难度 中等
你正在探访一家农场,农场从左到右种植了一排果树。这些树用一个整数数组?fruits
?表示,其中?fruits[i]
?是第?i
?棵树上的水果?种类?。
你想要尽可能多地收集水果。然而,农场的主人设定了一些严格的规矩,你必须按照要求采摘水果:
给你一个整数数组?fruits
?,返回你可以收集的水果的?最大?数目。
示例 1:
输入:fruits = [1,2,1] 输出:3 解释:可以采摘全部 3 棵树。
示例 2:
输入:fruits = [0,1,2,2] 输出:3 解释:可以采摘 [1,2,2] 这三棵树。 如果从第一棵树开始采摘,则只能采摘 [0,1] 这两棵树。
示例 3:
输入:fruits = [1,2,3,2,2] 输出:4 解释:可以采摘 [2,3,2,2] 这四棵树。 如果从第一棵树开始采摘,则只能采摘 [1,2] 这两棵树。
示例 4:
输入:fruits = [3,3,3,1,2,1,1,2,3,3,4] 输出:5 解释:可以采摘 [1,2,1,1,2] 这五棵树。
提示:
1 <= fruits.length <= 10^5
0 <= fruits[i] < fruits.length
class Solution {
public:
int totalFruit(vector<int>& fruits) {
}
};
研究的对象是?段连续的区间,可以使用「滑动窗口」思想来解决问题。
让滑动窗口满足:窗口内水果的种类只有两种。
做法:右端水果进入窗口的时候,用哈希表统计这个水果的频次。这个水果进来后,判断哈希表的大小:
如果大小超过2:说明窗口内水果种类超过了两种。那么就从左侧开始依次将水果划出窗口,直到哈希表的大小小于等于2,然后更新结果
如果没有超过2:说明当前窗口内水果的种类不超过两种,直接更新结果 ret
class Solution {
public:
int totalFruit(vector<int>& fruits) {
unordered_map<int, int> hash;
int ret = 0, left = 0, right = 0;
while(right < fruits.size())
{
hash[fruits[right]]++; // 进窗口
while(hash.size() > 2) // 判断
{
hash[fruits[left]]--; // 出窗口
if(hash[fruits[left]] == 0)
{
hash.erase(fruits[left]);
}
++left;
}
ret = max(ret, right - left + 1);
++right;
}
return ret;
}
};
可以看到上面的代码的通过效率还是很差的,因为容器的删除耗了时间,注意到这题的范围,此时就可以开一个数组来代替容器:
class Solution {
public:
int totalFruit(vector<int>& fruits) {
// unordered_map<int, int> hash;
int hash[100001] = { 0 }, kinds = 0; // 有数据范围->数组代替容器->提高效率
int ret = 0, left = 0, right = 0;
while(right < fruits.size())
{
if(hash[fruits[right]] == 0) // 维护数组水果种类
{
++kinds;
}
hash[fruits[right]]++; // 进窗口
// while(hash.size() > 2) // 判断
while(kinds > 2) // 判断
{
hash[fruits[left]]--; // 出窗口
if(hash[fruits[left]] == 0)
{
// hash.erase(fruits[left]);
--kinds;
}
++left;
}
ret = max(ret, right - left + 1);
++right;
}
return ret;
}
};
此时效率就会提高一些。
438. 找到字符串中所有字母异位词 - 力扣(LeetCode)
难度 中等
给定两个字符串?s
?和?p
,找到?s
?中所有?p
?的?异位词?的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
异位词?指由相同字母重排列形成的字符串(包括相同的字符串)。
示例?1:
输入: s = "cbaebabacd", p = "abc" 输出: [0,6] 解释: 起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。 起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
?示例 2:
输入: s = "abab", p = "ab" 输出: [0,1,2] 解释: 起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。 起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。 起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。
提示:
1 <= s.length, p.length <= 3 * 10^4
s
?和?p
?仅包含小写字母class Solution {
public:
vector<int> findAnagrams(string s, string p) {
}
};
首先用两个数组做哈希表敲出来的代码:
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
int hash1[26] = { 0 }, hash2[26] = { 0 };
for (auto& e : p)
{
hash2[e - 'a']++;
}
int left = 0, right = 0, n1 = s.size(), n2 = p.size();
vector<int> ret;
if(n2 > n1)
return ret;
while (right < n2)
{
hash1[s[right++] - 'a']++;
}
while (right < n1)
{
int j = 0;
for (; j < 26; ++j)
{
if (hash1[j] != hash2[j])
{
break;
}
}
if (j == 26)
{
ret.push_back(left);
}
hash1[s[right++] - 'a']++;
hash1[s[left++] - 'a']--;
}
int j = 0;
for (; j < 26; ++j) // 再判断一遍
{
if (hash1[j] != hash2[j])
{
break;
}
}
if (j == 26)
{
ret.push_back(left);
}
return ret;
}
};
改进:用一个变量count维护判断逻辑
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
int hash1[26] = { 0 }, hash2[26] = { 0 };
for (auto& e : p)
{
hash2[e - 'a']++;
}
int left = 0, right = 0, n1 = s.size(), n2 = p.size(), count = 0;
vector<int> ret;
if(n2 > n1)
return ret;
while (right < n1)
{
char in = s[right];
if(++hash1[in - 'a'] <= hash2[in - 'a'])
{
++count; // 进窗口和维护count
}
if(right - left + 1 > n2) // 判断
{
char out = s[left++];
if(hash1[out - 'a']-- <= hash2[out - 'a'])
{
--count; // 出窗口和维护count
}
}
if (count == n2) // 更新结果
{
ret.push_back(left);
}
++right;
}
return ret;
}
};
难度 困难
给定一个字符串?s
?和一个字符串数组?words
。?words
?中所有字符串?长度相同。
?s
?中的?串联子串?是指一个包含??words
?中所有字符串以任意顺序排列连接起来的子串。
words = ["ab","cd","ef"]
, 那么?"abcdef"
,?"abefcd"
,"cdabef"
,?"cdefab"
,"efabcd"
, 和?"efcdab"
?都是串联子串。?"acdbef"
?不是串联子串,因为他不是任何?words
?排列的连接。返回所有串联子串在?s
?中的开始索引。你可以以?任意顺序?返回答案。
示例 1:
输入:s = "barfoothefoobarman", words = ["foo","bar"]
输出:[0,9]
解释:因为 words.length == 2 同时 words[i].length == 3,连接的子字符串的长度必须为 6。
子串 "barfoo" 开始位置是 0。它是 words 中以 ["bar","foo"] 顺序排列的连接。
子串 "foobar" 开始位置是 9。它是 words 中以 ["foo","bar"] 顺序排列的连接。
输出顺序无关紧要。返回 [9,0] 也是可以的。
示例 2:
输入:s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]
输出:[]
解释:因为 words.length == 4 并且 words[i].length == 4,所以串联子串的长度必须为 16。
s 中没有子串长度为 16 并且等于 words 的任何顺序排列的连接。
所以我们返回一个空数组。
示例 3:
输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"] 输出:[6,9,12] 解释:因为 words.length == 3 并且 words[i].length == 3,所以串联子串的长度必须为 9。 子串 "foobarthe" 开始位置是 6。它是 words 中以 ["foo","bar","the"] 顺序排列的连接。 子串 "barthefoo" 开始位置是 9。它是 words 中以 ["bar","the","foo"] 顺序排列的连接。 子串 "thefoobar" 开始位置是 12。它是 words 中以 ["the","foo","bar"] 顺序排列的连接。
提示:
1 <= s.length <= 10^4
1 <= words.length <= 5000
1 <= words[i].length <= 30
words[i]
?和?s
?由小写英文字母组成class Solution {
public:
vector<int> findSubstring(string s, vector<string>& words) {
}
};
此题和上一题力扣438题思路类似,也是滑动窗口+哈希表,比较考验容器的使用,用一个变量count维护判断逻辑。
class Solution {
public:
vector<int> findSubstring(string s, vector<string>& words) {
vector<int> ret;
unordered_map<string, int> hash2; // 保存 words ??所有单词的频次
for (auto& e : words)
{
hash2[e]++;
}
int len = words[0].size(), m = words.size();
for (int i = 0; i < len; i++) // 执? len 次
{
int left = i, right = i, count = 0;
unordered_map<string, int> hash1; // 维护窗?内单词的频次
while (right + len <= s.size())
{
string in = s.substr(right, len); // 进窗? + 维护 count
hash1[in]++;
if (hash2.count(in) && hash1[in] <= hash2[in])
{
count++;
}
if (right - left + 1 > len * m) // 判断
{
// 出窗? + 维护 count
string out = s.substr(left, len);
if (hash2.count(out) && hash1[out] <= hash2[out])
{
count--;
}
hash1[out]--;
left += len;
}
if (count == m) // 更新结果
{
ret.push_back(left);
}
right += len;
}
}
return ret;
}
};
难度 困难
给你一个字符串?s
?、一个字符串?t
?。返回?s
?中涵盖?t
?所有字符的最小子串。如果?s
?中不存在涵盖?t
?所有字符的子串,则返回空字符串?""
?。
注意:
t
?中重复字符,我们寻找的子字符串中该字符数量必须不少于?t
?中该字符数量。s
?中存在这样的子串,我们保证它是唯一的答案。示例 1:
输入:s = "ADOBECODEBANC", t = "ABC" 输出:"BANC" 解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
示例 2:
输入:s = "a", t = "a" 输出:"a" 解释:整个字符串 s 是最小覆盖子串。
示例 3:
输入: s = "a", t = "aa" 输出: "" 解释: t 中两个字符 'a' 均应包含在 s 的子串中, 因此没有符合条件的子字符串,返回空字符串。
提示:
m == s.length
n == t.length
1 <= m, n <= 105
s
?和?t
?由英文字母组成o(m+n)
?时间内解决此问题的算法吗?class Solution {
public:
string minWindow(string s, string t){
}
};
此题和上篇力扣30题思路类似,也是滑动窗口+哈希表,用一个变量count维护判断逻辑。
class Solution {
public:
string minWindow(string s, string t){
int hash1[128] = { 0 }; // 统计字符串 t 中每?个字符的频次
int kinds = 0; // 统计有效字符有多少种
for (auto& e : t)
{
if (hash1[e]++ == 0)
{
kinds++;
}
}
int hash2[128] = { 0 }; // 统计窗?内每个字符的频次
int minlen = INT_MAX, begin = -1;
for (int left = 0, right = 0, count = 0; right < s.size(); right++)
{
char in = s[right];
if (++hash2[in] == hash1[in]) // 进窗? + 维护 count
{
count++;
}
while (count == kinds) // 判断条件
{
if (right - left + 1 < minlen) // 更新结果
{
minlen = right - left + 1;
begin = left;
}
char out = s[left++];
if (hash2[out]-- == hash1[out]) // 出窗? + 维护 count
{
count--;
}
}
}
return begin == -1 ? "" : s.substr(begin, minlen);
}
};
下一部分是二分查找算法的讲解和刷题。