1. 滑动窗口简介
滑动窗口技术最早应用于网络网络通信中,是一种流量控制技术。后来,滑动窗口逐渐应用于图像处理等算法设计中。在图像处理领域,滑动窗口方法是以一个固定或可变的窗口在图像上滑动,并对窗口内的图像区域进行处理,直到遍历完整幅图像,如下图:
在算法与数据结构中,滑动窗口技术同图像处理中的类似,不同的是我们常常处理的是一维数组。如下图所示:
一般来说滑动窗口适用于求数组中某段连续的元素是否满足某个条件的情景。如求一个整型数组中和为 的连续子序列数、最小含重复元素的子序列长度等。在使用滑动窗口的过程是:首先,定义初始滑动窗口大小,这往往根据题意确定;然后,判断当前窗口内的内容是否满足条件;接着,如果满足条件,根据题意以合适的方式将当前滑动窗口缩小或扩大,直到滑动到尾部。在维护滑动窗口时,我们通常使用双指针或双端队列的方法。我们首先来介绍双端队列的使用。我们直到,队列是一种先进先出的数据结构,且只能在一端操作。双端队列在队列的基础上,可以在队列的两端进行先进先出的操作,如下图:
在
种,双端队列的声明和使用定义包含在头文件deque
种,这里首先介绍几种常用的函数。假设de
是一个整型的双端队列。
de.push_front(int val);
在队首插入值val
,de.push_back(int val);
在队尾插入值val
;de.front();
返回队首元素值,de.back();
返回队尾元素值,这里假设双端队列不为空;de.pop_front();
删除队首元素,de.pop_back();
删除队尾元素,这里假设双端队列不为空;de.clear();
清空双端队列里的元素;de.empty();
判断双端队列是否为空,如果是则返回true
,否则返回false
;de.size();
返回双端队列中的元素个数。
同时,我们也可以使用数组手动实现一个双端队列。通常,在使用滑动窗口时,为了节省程序运行的空间,尽量使双指针的方式来维护滑动窗口。下面就 中的几道经典的滑动窗口相关的题型来进一步了解和掌握滑动窗口的使用。
2. 滑动窗口经典例题
本文主要使用滑动窗口的方法解决问题,具体其他方法可参考题末的 官方给出的题解。
2.1 无重复字符的最长子串
题目来源 3.无重复字符的最长子串
题目描述 给定一个字符串,找出其中不含重复字符的最长子串的长度。
如给定字符串为abcabcbb
,则返回的结果为
。因为在给定的字符串中,不含重复字符的最长子串为abc
或bca
等,其长度为
。
本题的性质满足滑动窗口的特点,因为最终答案来源于一个连续子串。接下的问题是我们如何扩张或缩小窗口,如下图由指针
和指针
所形成的窗口是一个备选答案,因为指针
再往后移窗口内就会包含重复字符:
下图双指针所形成的窗口也是一个备选窗口:
我们发现,在
往右移动一步后窗口内会出现重复的字符,即s[i]==s[j+1]
时,那么s[i,j-1]
为一个备选的窗口,其长度为
。所以,我们首先想到的是使用一个双重循环去寻找这个窗口。具体地,首先外层循环,即前面提到的指针
用于遍历整个字符串,内层循环在指针
的左端遍历来寻找备选窗口。如前面提到的,当s[i]==s[j]
时,我们判断是否需要更新最长子串的长度。这里还有一个细节,如下图:
该情形会产生备选窗口bca
,然后指针
向右移动。但是下次我们可以避免指针
从最开始位置开始移动,转而从上次重复字符处的下一个位置处开始移动,即i+1
。这样会节省大量不必要的判断。最后
代码如下:
int lengthOfLongestSubstring(string s) {
if (s.length() == 0) {
return 0;
}
int k = 0, count = 0;
int sSize = s.length();
// 遍历字符串
for (int j = 0; j < sSize; ++j) {
// 遍历左边字符串,起点为0或上次重复字符的下一个位置
for (int i = k; i < j; ++i) {
if (s[i] == s[j]) {
k = i + 1;
break;
}
}
// 更新结果
count = max(count, j - k + 1);
}
// 返回结果
return count;
}
其他题解 官方题解
2.2 滑动窗口最大值
题目来源 239.滑动窗口最大值
题目描述 给定一个数组和一个固定大小的滑动窗口,该窗口从数组的最左端移动到数组的左右端。窗口每次仅向后移动一位,要求返回每个滑动窗口内的最大值。下图用于举例说明:
显然,这道题不涉及滑动窗口的扩张和收缩,而是直接要求计算固定长度滑动窗口内的最大值。由上一题的思路,我们可以很容易想到直接使用双重循环解决该问题。外层循环遍历整个数组,内层循环用于求取滑动窗口内的最大值。对于这一题,我们换一种解决思路。前面提到,滑动窗口的表示方法通常有双指针和双端队列两种。这里我们考虑使用双端队列这种数据结构。在双端队列中,我们要取到当前窗口内的最大值,且再下一个元素加入窗口后能够有效地去掉窗口首的值,我们需要将双端队列内的元素按照一定的顺序进行排列。由于我们每次需要取最大值,我们在双端队列中维护一个序列。在这个序列中,对于每个当前遍历的元素,将该元素加入后移除比当前元素小的元素,因为他们都不可能成为答案。同时取队列首的元素作为当前位置的答案。我们以图来说明:
最后 代码如下:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
// 特判
int size = nums.size();
if (size < k) {
return {};
}
// 将首个窗口的值以非递增方式入队
deque<int> de;
for (int i = 0; i < k; ++i) {
// 将比当前元素小的元素出队
while (!de.empty() && nums[i] >= nums[de.back()])
{
de.pop_back();
}
de.push_back(i);
}
// 窗口向右滑动
vector<int> res;
res.push_back(nums[de.front()]);
for (int i = k; i < size; ++i) {
// 判断向右移动时队首的元素是否被移动出去
if (!de.empty() && de.front() <= i - k) {
de.pop_front();
}
// 同上,将比当前元素小的元素出队
while (!de.empty() && nums[i] >= nums[de.back()])
{
de.pop_back();
}
de.push_back(i);
// 加入结果
res.push_back(nums[de.front()]);
}
return res;
}
其他题解 官方题解
2.3 最小覆盖子串
题目来源 76.最小覆盖子串
题目描述 给定字符串S
和字符串T
,请在S
中找出包含T
所有字符的最小子串。
如S="ADOBECODEBANC"
,T="ABC"
,则S
中满足题目的子字符串为BANC
。如果不存在这样的子字符串,则返回一个空串。
如下图,我们先来观察一个满足条件的子串的情况:
如上图,此时的子串为ADOBEC
,包含ABC
,此时子串的长度为
。现在我们需要考虑的如何寻找下一个满足条件的子串。显然,如果我们移动右指针,那么形成的子字符串肯定满足条件,且其长度还在不断增加,这明显不能得到下一个满足条件的子串。这里我们考虑移动左指针,我们只要移动一步左指针,当前子串就不满足条件,而现在我们移动右指针去寻找当前子串中所缺少的那个字符,即A
。如下图:
观察上图,为了保证所形成的子串最短,我们可以直接将左指针移动到B
处,即得到又一个满足条件的子串。如下图:
此时满足条件的子串为BECODEBA
,其长度为
。重复该过程,指针右指针到达字符串的边界。由上述过程,我们可以总结出本题的解题流程:
- 移动双指针,找到首个满足条件的子串。此时的双指针应指向
T
中某两个不重复的字符; - 移动左指针,直到遇到
T
中第二个(左指针本来指向T
中的某个字符)字符,同时与左指针最初指向的值不同; - 移动右指针,寻找
T
中的某个字符,使得当前双指针所形成的子串满足题条件; - 右指针到达字符串边界,则程序结束。
现在我们通过某种数据结构实现上述过程。这里有几个关键的点:首先,如何快速判断当前双指针所形成的子串满足条件;其次,在移动左指针时如何快速移动到下一个位置;最后,在移动右指针时,如何快速移动到下一个位置。
首先来看第一个问题,这里我们可以使用一个哈希表存储T
中的元素,键为具体的字符,值为相应字符出现的次数。然后遍历字符串S
,将相应哈希表中对应的值减去一(如果该元素原来不存在于哈希表中,即不是T
中的元素,那么当前元素的哈希表变为
;如果该元素原来存在于哈希表中,当前哈希值减一后仍大于等于零)。如果当前元素的哈希值大于等于零,我们将计数器的值加一,直到计数器的值等于T
的长度,则说明我们找到了第一个可能的满足条件的子串。但此时我们需要注意所求结果为最短子串。其次我们来看第二个问题,现在我们需要移动左指针,我们将对应元素的哈希值加一。和前面对应,如果哈希值大于零,则表明当前元素是T
中的元素的元素;否则一直移动左指针,直到达到T
中的下一个元素。最后我们可以通过第一个过程寻找右指针的下一位位置,即计数器的值等于T
的长度。最后
代码如下,其中给出了较为详细的注释:
string minWindow(string s, string t) {
map<char, int> t_map; // 定义哈希表存储t中的元素
for(char ch : t){ // 将t中元素存入哈希表中
++t_map[ch];
}
// 相关初始化,left为滑动窗口左端,minLen和start用于暂存结果
int left = 0, cnt = 0, minLen = INT_MAX, start = left;
for (int i = 0; i < s.size(); ++i) {
// 对应哈希表值减1,如果s[i]原不存在于哈希表,会自动建立映射并将其值设为0
--t_map[s[i]];
// 该元素为t中的元素,即上一次处理后的被滑动窗口踢出的左端点的值
if (t_map[s[i]] >= 0) {
++cnt; // 计数器加1
}
while (cnt == t.size()) // while训练用于寻找滑动窗口的左端
{
++t_map[s[left]]; // 滑动窗口左端点值对应哈希值加1以寻找下一个存在于t中的值
if (t_map[s[left]] > 0) { // 该值存在于t中,即找到下一个窗口的左端点
if (minLen > i - left + 1) { // 判断是否可以更新结果
minLen = i - left + 1;
start = left;
}
--cnt; // 破坏循环
}
++left; // 移动左指针
}
}
return minLen == INT_MAX ? "" : s.substr(start, minLen);
}
其他题解 官方题解
3. 总结
上面例题给出了使用双指针和双端队列解决基于滑动窗口的问题,二者各有所长。第三个例子是滑动窗口算法中较为复杂的一个例子,它综合了滑动窗口使用过程的几个点:寻找初始窗口、窗口扩张、窗口收缩,并借助哈希表指导指针的移动。最后,给出 中关于滑动窗口的题目,题目大多为中等难度和困难难度。
参考
- https://leetcode-cn.com/tag/sliding-window/.