滑动窗口
- 1、长度最小的子数组
- 2、无重复字符的最长子串
- [3、最大连续1的个数 III](#3、最大连续1的个数 III)
- [4、将 x 减到 0 的最小操作数](#4、将 x 减到 0 的最小操作数)
- 5、水果成篮
- 6、找到字符串中所有字母异位词
- 7、串联所有单词的子串
- 8、最小覆盖子串
1、长度最小的子数组

1.1、题目理解
对于序列2, 3, 1, 2, 4, 3,有以下子序列符合和 >= 7:
-
2, 3, 1, 2\]、\[2, 3, 1, 2, 4\]、\[2, 3, 1, 2, 4, 3
-
3, 1, 2, 4\]、\[3, 1, 2, 4, 3
-
1, 2, 4\]、\[1, 2, 4, 3
-
2, 4, 3
-
4, 3
其中,4, 3序列长度最小,返回2。
对于序列1, 4, 4,有以下子序列符合和 >= 4:
-
1, 4\]、\[1, 4, 4
-
4\]、\[4, 4
-
4
其中,4序列长度最小,返回1。
对于序列1, 1, 1, 1, 1, 1, 1, 1,由于没有连续的子序列满足和 >= 11,所以返回0。
1.2、原理分析
第一种方法就是暴力枚举。

我们定义下标left, right,都指向下标0位置。
接着right一步一步遍历序列,直到结尾:

而right每跳一步,统计当前区间left, right中所有元素的和,用来比较大小,进而判断是否更新最小长度。
当right到达结尾,left向右一步,right又返回至left位置,重复上面操作:

这种利用left和right双指针遍历的方式,时间复杂度为O(N2);而求和的操作,又需要遍历序列,时间复杂度为O(N)。那么整个算法的时间复杂度就为O(N3)。
我们可以对求和操作进行优化。
right每跳一步,序列和就跟着变化;left向右,right返回,序列和也跟着变化。这样时间复杂度就降为了O(N2)。
我们可以进一步优化。

当left, right从起始位置,到right向右跳时第一次遇到满足条件的序列。
此时right就没必要继续向右了,因为题目规定给出的总序列元素都是正整数,意味着right继续向右时,left, right序列都满足和 >= target条件(单调递增),但是序列长度也增加了,不符合题目要求的最小长度。
这时,更新和,判断是否 >= target,再判断是否更新序列长度(比小)。接着left向右。

此时,left, right序列和不满足要求,那么right向右。

符合,更新和,判断是否 >= target,再判断再判断是否更新序列长度。接着left向右。

不断重复操作。
由于区间left, right在变化过程中,整体是不断向右的,区间长度是不断变化的。所以我们称这种优化方法为滑动窗口。
本体采用滑动窗口方法,最坏情况下left, right都将总序列遍历了一遍(可以想象一根皮筋,尾部拉长到一端,头部放回到此时的尾部),时间复杂度为O(2*N),即O(N)。
1.3、代码演示
cpp
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int n = nums.size(), sum = 0, len = INT_MAX;
// 由于len的更新是比小,所以我们用最大值赋值
for (int left = 0, right = 0; right < n; ++right)
{// 外层循环的进行
sum += nums[right];// 更新子序列和
while (sum >= target)
{// 找到一种情况
len = min(len, right - left + 1);// 更新len
sum -= nums[left];// 更新子序列和
++left;// 内层循环的进行
}
}
return len == INT_MAX ? 0 : len;
}
};
2、无重复字符的最长子串
既然需要无重复的子串,那么我们就需要一个可以去重的容器------哈希表。
但是我们并不需要一个真的哈希表,只需开辟一块静态数组hash,每个字符的ASCII值作为下标映射到数组中即可。
接着使用滑动窗口:
- 定义left, right指针
- right向右进窗口,遇到一个字符就入映射入hash
- 当right遇到重复字符,即right指向字符在hash中出现了两次,开始出窗口:
- left指向的字符滑出hash,然后left向右
- 当right指向字符在hash中只出现了一次时,出窗口结束
- 更新序列长度。
left一直出窗口的过程中,当某一个left指向数滑出hash后,right指向数在hash内出现次数恢复为1,说明此时left已经滑出了一段无重复的子串,这个子串是
- 边界与重复序列
- 或者重复序列与重复序列
之间的最长无重复子串。也就是说当前题目的序列长度是随着right移动一步就更新一次的。当left再向前到重复序列的下一个字符位置时,开始下一轮滑动窗口。
cpp
class Solution {
public:
int lengthOfLongestSubstring(string s) {
int ret = 0; // 返回结果
int hash[128] = {0}; // 哈希表
for (int left = 0, right = 0; right < s.size(); ++right)
{
hash[s[right]]++; // 进窗口
while (hash[s[right]] == 2) // 判断
hash[s[left++]]--; // 出窗口
ret = max(ret, right - left + 1); // 更新结果
}
return ret;
}
};
3、最大连续1的个数 III
我们直接翻转0为1去解题,似乎很难下手,所以我们将题目转化为:
对于给出序列,找出最长子序列 ,其中0的个数小于等于k。所以我们就需要统计0的个数,做为出窗口的判断依据。
此时就可以使用滑动窗口方法解题:
- right向右进窗口,遇到0就++zero
- 当zero > k,开始出窗口:
- left向右
- 如果left遇到0,先zero自减,再left向右
- 更新序列长度
cpp
class Solution {
public:
int longestOnes(vector<int>& nums, int k) {
int ret = 0; // 序列长度
zero = 0; // 统计0的个数
for (int left = 0, right = 0; right < nums.size(); ++right)
{
if (nums[right] == 0) zero++;
while (zero > k)
if (nums[left++] == 0)
zero--;
ret = max(ret, right - left + 1);
}
return ret;
}
};
4、将 x 减到 0 的最小操作数
乍一看,我们无从下手,因为序列两边都可以出数据。
我们可以对题目进行进一步转化,转化的思想是正难则反:
假设序列左边存在子序列arr1,子序列和为a;序列右边存在子序列arr2,子序列和为b,且有:
a + b = x
此时剩余序列arr3,其序列和target满足:
target = 总序列和 - x 当arr1与arr2拼起来最短,arr3就最长。
所以问题转化为:
对于给出序列,找出最长序列,最长序列的序列和满足:
最长序列的序列和 = 总序列和 - x
我们就可以使用滑动窗口方法:
cpp
class Solution {
public:
int minOperations(vector<int>& nums, int x) {
// 求出target
int total = 0;
for (auto& e : nums)
total += e;
int target = total - x;
// 如果x比total还大,就不可能找到了
if (target < 0) return -1;
int ret = -1, sum = 0;
for (int left = 0, right = 0; right < nums.size(); right++)
{
sum += nums[right];
while (sum > target)
sum -= nums[left++];
if (sum == target) // 只有相等才交换
ret = max(ret, right - left + 1);
}
if (ret == -1) return ret;
else
return nums.size() - ret;
}
};
5、水果成篮
阅读题干,提炼分析:
- 每棵树上只有一种水果
- 两个篮子,意味着最多只能装两种水果
- 摘水果的过程,是向右一棵一棵接着摘的,意味着过程是连续的
- 遇到了第三种水果的树,就停下
所以问题可以转换成:
对于给出序列,找出最长子序列,这个子序列只能包含两种数。
滑动窗口的思路并不是怕凭空想出来的,而是从暴力解法出发,一步步分析出来的。
5.1、暴力解法

right探路,遇到两种以内的值都向前,此时right与left之间都是合法区间。可以更新需要返回的区间长度ret:
ret = right - left + 1

当right遇到第三种数,right停止探路,left向右一步,right返回来继续向前探路:

然后left每次向后一格,重复之前操作,直到left遍历完整个序列。
某一种数可能有多个,所以我们定义一个hash,right向右每遇到一种数就存入hash,从而达到去重的目的。
cpp
class Solution {
public:
int totalFruit(vector<int>& fruits) {
int ret = 0;
unordered_set<int> um;
for (int left = 0; left < fruits.size(); left++)
{
int right = left;
while (right < fruits.size())
{
um.insert(fruits[right]);
if (um.size() == 3)
{
um.clear();
break;
}
ret = max(ret, right - left + 1);
right++;
}
}
return ret;
}
};
5.2、滑动窗口
不难看出,left每向后一位,最差情况下right都要从left遍历到结尾,那么时间复杂度就是O(N2)。当然上面代码也出现了超时的问题,需要思考如何优化。

right一定要往回走吗?
如果left往前一步,right回到left处再向右探路,那么还是会遇到红叉处的第三种数,right还得回去。此时left值下得出的序列肯定比前一个left值下的序列要短。
在一个序列中,第三种数与边界,第三种数与第三种数之间,都存在一个各自独有的最长序列。暴力解法将每一个最长序列的,从左开始缩进一位的子序列也遍历到了。
我们为什么找到了其中一个最长序列,就直接找下一个最长序列呢?那就让left向右吧。
当right指向了第三数,left就可以不断出数、向右,直到hash里只有两种数,这就又是一个合法的区间,right就又可以向右了。
这里有一个问题,如果我们只存数本身,即只存数的种类,当一个数有多个的时候,我们如何保证left能出完所有的数,达到正确的位置呢?
所以我们应该使用一个存数的种类与数量的键值对的容器:unordered_map。
上面优化思路中,left与right指针的移动是同向的,所以我们可以采用滑动窗口的方法。
cpp
class Solution {
public:
int totalFruit(vector<int>& fruits) {
int ret = 0;
unordered_map<int, int> hash;
for (int left = 0, right = 0; right < fruits.size(); ++right)
{
hash[fruits[right]]++;
while (hash.size() == 3)
{
hash[fruits[left]]--;
if (hash[fruits[left]] == 0)
hash.erase(fruits[left]); // 某一个数出现次数为0了,即出窗口出完了,就要滑出hash了
left++;
}
ret = max(ret, right - left + 1);
}
return ret;
}
};
5.3、进一步优化
提交上面代码,结果却不尽人意:执行用时不太优秀。
其实滑动窗口本身的效率是比较优秀的,但是unordered_map的相关操作是比较耗时的。
有没有更好的办法?

题中给出的序列长度,及每个数,都不超过105 。
所以我们可以不用unordered_map,而是只用一个数组hash进行映射存储,然后另外定义一个种类计数器kinds:
cpp
class Solution {
public:
int totalFruit(vector<int>& fruits) {
int ret = 0;
int kinds = 0; // 数的种类
int hash[100001] = {0};
for (int left = 0, right = 0; right < fruits.size(); ++right)
{
if (hash[fruits[right]] == 0)
kinds++;
hash[fruits[right]]++;
while (kinds == 3)
{
hash[fruits[left]]--;
if (hash[fruits[left]] == 0)
kinds--;
left++;
}
ret = max(ret, right - left + 1);
}
return ret;
}
};
这时我们可以来分析一下时间复杂度和空间复杂度:
- 空间复杂度:使用了常数级额外空间,O(1)。
- 时间复杂度:right向右遍历序列,当遇到第三种数时,left才开始向右遍历序列。最坏情况下,left与right各自都把序列遍历了一遍,时间复杂度是O(N)。
6、找到字符串中所有字母异位词
什么是异或词?
比如,
- abc
- acb
- bac
- bca
- cab
- cba
他们都互为异或词。异或词就是字符的排列顺序不一样。
如何判断是否互为异或词?
排序 + 遍历判断?不太行,排序的时间复杂度是O(NlogN),遍历的时间复杂度是O(N),合起来就是O(NlogN),效率不是很高。
我们可以将两个异或词,分别装入两个哈希表 :hash1, hash2。然后遍历哈希表判断字符出现的次数是否相等,即判断哈希表是否相等即可。
暴力解法?
依旧是right右去探路,探完路后left向右,right返回。

但是,依旧可以做优化:right有必要回来吗?
当right向右,走到超过字符串p的长度的时候,我们不妨让left向右一步,此时长度恢复与p相同,这时我们只需每次right一步、left一步,即left, right维护的窗口的长度是固定的。
滑动窗口:
- left = 0, right = 0
- 将字符串p每个字符存入hash1
- right向右遇到字符就入hash2 ------ 入窗口
- 如果left, right之间的长度超过了字符串p,出窗口:
- 只需left出掉当前指向字符,然后向右一步
- 比较hash1, hash2是否相等,更新结果
cpp
class Solution {
public:
bool Check(int* hash1, int* hash2) // 比较哈希表的函数
{
for (int i = 0; i < 26; ++i)
{
if (hash1[i] != hash2[i])
return false;
}
return true;
}
vector<int> findAnagrams(string s, string p) {
vector<int> ret;
int hash1[26] = {0};
for (auto& ch : p)
hash1[ch - 'a']++; // p入hash1
int hash2[26] = {0};
int sz = p.size();
for (int left = 0, right = 0; right < s.size(); ++right)
{
hash2[s[right] - 'a']++; // 入窗口
if (right - left + 1 > sz) // 判断
hash2[s[left++] - 'a']--; // 出窗口
if (Check(hash1, hash2))
ret.push_back(left);// 更新结果
}
return ret;
}
};
优化?
我们这里对哈希表进行比较的方法是直接遍历比较,有没有更好的方法?
我们可以定义count:记录hash2中有效字符的个数。
我们用下面序列c,与p = "abc",来具体演示一遍count到底是怎么用的:
c c b a e b a b a c d
hash1中a, b, c三个字符分别出现了1次。hash2中无非出现a, b, c, d, e五个字符。

right指向c,先入c进hash2。此时hash2中c的个数是1,(小于)等于hash1中c的个数,说明此时入的c是有效的,count++。

right向右指向c,先入c。此时hash2中c的个数是2,大于hash1中c的个数,说明此时入的c是无效的,count不动。

right向右指向b,先入b。此时hash2中b的个数是1,(小于)等于hash1中b的个数,说明此时入的b是有效的,count++。

right向右指向a,先入a。此时hash2中a的个数是1,(小于)等于hash1中a的个数,说明此时入的a是有效的,count++。

但是,此时left与right之间的序列,超过了p的长度,需要left出字符。
left需要出此时的c,出之前我们先看一看,c在hash2的个数为2,超过hash1中c的个数,说明此时出的c是无效的(多余的),count不动。

此时我们发现,count正好等于p的长度,而left与right之间的序列,正好是abc的异或词。我们就可以记录left所在的位置。
至此,我们可以提炼出四种规则:
- right入字符后,如果right指向的字符在hash2出现的次数,小于等于在hash1出现的次数,count++
- left出字符前,如果left指向的字符在hash2出现的次数,小于等于在hash1出现的次数,count++
- 其余情况count不动
- 当count == p.size(),就找到了异或词,记录left
那么我们就可以对代码做进一步优化:
cpp
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
vector<int> ret;
int count = 0; // 记录有效字符个数
int hash1[26] = {0};
for (auto& ch : p)
hash1[ch - 'a']++; // p入hash1
int hash2[26] = {0};
int sz = p.size();
for (int left = 0, right = 0; right < s.size(); ++right)
{
hash2[s[right] - 'a']++; // 入窗口
if (hash2[s[right] - 'a'] <= hash1[s[right] - 'a']) // 判断是否有效
count++;
if (right - left + 1 > sz) // 判断
{
if (hash2[s[left] - 'a'] <= hash1[s[left] - 'a']) // 判断是否有效
count--;
hash2[s[left++] - 'a']--; // 出窗口
}
if (count == sz)
ret.push_back(left);// 更新结果
}
return ret;
}
};
我们甚至可以把代码改得再精简一点:
cpp
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
vector<int> ret;
int count = 0; // 记录有效字符个数
int hash1[26] = {0};
for (auto& ch : p)
hash1[ch - 'a']++; // p入hash1
int hash2[26] = {0};
int sz = p.size();
for (int left = 0, right = 0; right < s.size(); ++right)
{
if (++hash2[s[right] - 'a'] <= hash1[s[right] - 'a']) count++;
if (right - left + 1 > sz)
{
char out = s[left++];
if (hash2[out - 'a']-- <= hash1[out - 'a']) count--;
}
if (count == sz)
ret.push_back(left);// 更新结果
}
return ret;
}
};
7、串联所有单词的子串
我们在第6题中,对于一段长序列找的是异或词 ,组成异或词的是一个个字符。现在一个个字符变成了字符串。
由于题目要求这一个个字符串的长度相等 ,所以我们不妨将字符串打包在一起。

问题就转化成了寻找异或词的问题。
但是串联子串问题的解决,与之前寻找异或词的问题解决方式有三点不同:
哈希表的选取与使用
由于异或词是一个个字符组成的,我们可以将字符直接按照ASCII值映射到一个数组中。
而串联子串面对的不再是字符,而是字符串。所以我们使用unordered_map,记录string与int的键值对,即字符串的出现次数。
left与right的读取与移动
left与right读取的不再只是当前指向的一个字符,而是从当前位置开始,数(shu三声)字符串长度个字符,再拼成字符串入哈希表。
left与right的移动,也应该跳过一个字符串的长度。
滑动窗口执行的次数

对于这个序列,left与right从下标0开始就能找到串联子串。
可如果是这个序列呢?

left与right就必须向后一位开始进行滑动窗口,才能找到串联子串。
所以,当left, right从下标0开始滑动窗口,结束后还必须返回;接着left, right从下标1开始滑动窗口...
什么时候结束呢,即滑动窗口总共执行多少次呢?

答案是字符串有多长,就执行多少次。
同时我们也可以观察到,每一次滑动窗口中,right走到尽头时,只要再跨过一个字符串长度,right就一定会达到或者大于总字符串长度(s.size())。所以我们可以依据这个现象,设计right的遍历范围。
我们就可以开始编写代码了:
cpp
class Solution {
public:
vector<int> findSubstring(string s, vector<string>& words) {
vector<int> ret;
int len = words[0].size(), num = words.size(); // 字符串长度 & 字符串数量
unordered_map<string, int> hash1;
for (auto& w : words)
hash1[w]++; // 将words的字符串入哈希表hash1
for (int i = 0; i < len; ++i)
{
unordered_map<string, int> hash2; // hash2一定要建在里面,每次滑动窗口结束后,要刷新
// count也要放在里面,每次滑动窗口结束后要刷新
for (int left = i, right = i, count = 0; right + len <= s.size(); right += len)
{
string in = s.substr(right, len);
hash2[in]++; // 入窗口
if (hash2[in] <= hash1[in]) count++; // 判断,维护count
if (right - left + len > len*num)
{
string out = s.substr(left, len);
left += len;
if (hash2[out] <= hash1[out]) count--; // 判断,维护count判断
hash2[out]--; //出窗口
}
if (count == num) ret.push_back(left);
}
}
return ret;
}
};
但是结果不是很好。因为unordered_map的重载 ,对于不存在的字符串也会先插入,有一定的损耗。所以我们可以做一步限制:当字符串在hash1中不存在时,就不进行判断。
cpp
public:
vector<int> findSubstring(string s, vector<string>& words) {
vector<int> ret;
int len = words[0].size(), num = words.size();
unordered_map<string, int> hash1;
for (auto& w : words)
hash1[w]++;
for (int i = 0; i < len; ++i)
{
unordered_map<string, int> hash2;
for (int left = i, right = i, count = 0; right + len <= s.size(); right += len)
{
string in = s.substr(right, len);
hash2[in]++;
if (hash1.count(in) && hash2[in] <= hash1[in]) count++; // 不存在的就不插入
if (right - left + len > len*num)
{
string out = s.substr(left, len);
left += len;
if (hash2.count(out) && hash2[out] <= hash1[out]) count--; // 不存在的就不插入
hash2[out]--;
}
if (count == num) ret.push_back(left);
}
}
return ret;
}
};
8、最小覆盖子串
首先我们要知道,对于s与t,s的最小覆盖子串满足的条件是:
最小覆盖子串包含t中所有种类 的字符,并且最小覆盖子串每种字符的数量均需大于等于t中对应字符的数量。
8.1、暴力解法

我们定义left, right,找到符合要求的子串就停下;如果子串比之前的要短,记录子串的初始位置与最短长度;然后left向前一位,right返回,继续找符合要求的子串...

能不能对暴力解法作一步优化------right真的要返回吗?
8.2、滑动窗口

- 进窗口:hash2in++,right向右
- 判断:Check(hash1, hash2)
- 更新:起始位置,最短长度
- 出窗口:hash2out++,left向右
对于判断的Check(hash1, hash2),如果我们遍历判断,那么时间复杂度比较大。能不能作进一步优化?
8.3、进一步优化

创建hash1放入t,此时t有:
- A : 1
- B : 1
- C : 1
创建hash2针对s进行进窗口与出窗口。
当right找到一个符合的序列时,做好记录:
- 如果比之前短,更新初始位置与最短长度
- 否则不记录
此时,s中left, right之间有:
- A : 1
- B : 1
- C : 1
left要出窗口,此时A在hash1的数量等于在hash2的数量;如果left出A,那么A在hash2的数量变为0,小于A在hash1的数量。
此时left出窗口停止,right向右进窗口。

找到了下一个序列,此时left, right之间有:
- A : 1
- B : 2
- C : 1
left出窗口,直到指向B:

left出之前,B在hash2的数量是2,大于B在hash1的数量,所以left还可以继续向右出窗口。直到left指向C:

此时,left, right之间有:
- A : 1
- B : 1
- C : 1
left出之前,C在hash2的数量是1,等于B在hash1的数量;当left出了C,C在hash2的数量就小于hash1中的数量,那么right就要向右找符合数量的C。
根据以上过程,我们就可以提炼出优化的方法:
- 定义count,记录有效种类
- right进窗口向右。当进完一次后 ,right指向的字符在hash2的数量等于在hash1的数量,此时这个字符就是有效种类,count++。
- left出窗口向右。当出完一次前 ,left指向的字符在hash2的数量等于在hash1的数量,此时这个字符刚好就是有效种类,出了一个就不是有效种类了,count自减。
- 当count等于t中字符种类数时,就可以进行初始位置与最小长度的更新。
代码:
cpp
class Solution {
public:
string minWindow(string s, string t) {
int begin = -1, minlen = INT_MAX;
// 起始可能在开头 长度要求比小
int kinds = 0;
int hash1[128] = {0};
for (auto& ch : t)
{
if (hash1[ch] == 0) kinds++; // 统计种类数
hash1[ch]++; // 入hash1
}
int hash2[128] = {0};
for (int left = 0, right = 0, count = 0; right < s.size(); ++right)
{
char in = s[right];
hash2[in]++; // 进窗口
if (hash2[in] == hash1[in]) // 维护count
count++;
while (count == kinds) // 判断
{
if (right - left + 1 < minlen) // 更新
{
begin = left;
minlen = right - left + 1;
}
char out = s[left++];
if (hash2[out] == hash1[out]) // 维护count
count--;
hash2[out]--; // 出窗口
}
}
if (begin == -1) return ""; // begin没有变动,说明不存在最小覆盖子串,返回空串
else return s.substr(begin, minlen);
}
};