滑动窗口常用技巧总结
概述
在解决字串
问题时,滑动窗口技巧可能经常会使用,其本身思想并不难理解,难在灵活。因而本文从一个最小覆盖字串
问题入手总结一个通用的算法框架以解决常见的滑动窗口问题。
算法与框架
下边我们先看一个最小覆盖子串问题:
题目本身不难理解,主要就是从S(source)中找到包含T(target)中全部字幕的一个子串,顺序无所谓,个数相同且子串中一定是所有可能子串中最短的。
最简单的思路是通过暴力法,通过两层搜索来解决,但时间复杂度很高,甚至大于O(n^2)。
此类问题实际上我们可以通过滑动窗口
的思路来解决。具体思路如下:
- 在字符串S中使用双指针中左右指针的技巧,初始化
left = right = 0
,把索引区间[left,right]
称之为一个[窗口]。 - 不断的增加right指针扩大窗口
[left,right ]
,直到窗口中的字符串符合要求(窗口包含T中所有字符)。 - 停止增加right,转而增加left指针,进而缩小窗口直到窗口不再符合要求。同时每增加一个left都要更新一轮结果。
- 重复2和3,直到right达到字符串S的尽头。
整个过程思路并不难,其中第2步相当于在找一个可行解,第3步在优化这个可行解,每轮都进行结果更新,最后找到最优解。
下边我们结合整下边的图来理解算法的整个过程。needs和windows相当于计数器,分别记录T中字符串出现的次数和窗口中的对应字符出现的次数。
第1步:初始状态,left和righ都为0
第2步:向右移动right寻找可行解
第3步:向右移动left,优化可行解
第4步:重复2和3直到,right到达右边界
上述过程可以简单写出如下的代码框架:
public String slidingWindow(String s, String t) {
//定义两个窗口
Map<Character, Integer> need = new HashMap<>(), window = new HashMap<>();
// 初始化need窗口
for (char c : t.toCharArray()) {
need.put(c, need.getOrDefault(c, 0) + 1);
}
int left = 0, right = 0;
// 已经和need匹配的字符串个数
int valid = 0;
while (right < s.length()) {
char c = s.charAt(right);
// move to right
right++;
// 进行窗口内一系列数据的更新
...
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s.charAt(left);
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
其中两处...
表示更新窗口数据的地方,根据不同的问题,进行填充即可。
针对最小覆盖子串问题,开始套模板,只需要考虑如下四个问题:
- 移动right扩大窗口,即加入字符时需要考虑哪些数据?
- 什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?
- 当移动left缩小窗口,即移除字符时,应该更新哪些数据?
- 我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
如果一个字符进入窗口,应该增加 window 计数器;如果一个字符将移出窗口的时候,应该减少 window 计数器;当 valid 满足 need 时应该收缩窗口;应该在收缩窗口的时候更新最终结果。
针对该问题我们将代码进行填充后得到如何解法:
string minWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
// 记录最小覆盖子串的起始索引及长度
int start = 0, len = INT_MAX;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
if (need.count(c)) {
window[c]++;
if (window[c] == need[c])
valid++;
}
// 判断左侧窗口是否要收缩
//必须使用equlals来判断,不能使用 ==
while (valid.equals(need.size())) {
// 在这里更新最小覆盖子串
if (right - left < len) {
start = left;
len = right - left;
}
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
if (need.count(d)) {
if (window[d].euqals(need[d])
valid--;
window[d]--;
}
}
}
// 返回最小覆盖子串
return len == INT_MAX ?
"" : s.substr(start, len);
}
应用
接下来我们再看一下另一个中等难度的题目字符串的排列
题意很好理解,就是判断s2是否包含s1的某种排列。我们比较容易想到用暴力法。但会发现时间复杂度过高无法通过。然后考虑到是子串问题,尝试使用滑动窗口方法。
结合模板,考虑两个问题:
- 右侧窗口滑动时,做哪些操作
- 左侧窗口滑动的条件,以及所做操作
针对第一个问题,我们考虑到当右侧窗口滑动获取一个字符时要判断当前字符是否在need中,如果存在进行windows计数
针对第二个问题,如果窗口的长度大于字符串t
的长度,则需要进行窗口左移操作,进行窗口“瘦身”
该问题具体代码实现如下:
public boolean checkInclusion(String t, String s) {
if (t.length() > s.length()) {
return false;
}
Map<Character, Integer> need = new HashMap<>();
Map<Character, Integer> window = new HashMap<>();
// init need
for (char c : t.toCharArray()) {
need.put(c, need.getOrDefault(c, 0) + 1);
}
// define variable
int left = 0, right = 0;
int valid = 0;
while (right < s.length()) {
char c = s.charAt(right);
right++;
// update right window
if (need.containsKey(c)) {
window.put(c, window.getOrDefault(c, 0) + 1);
if (need.get(c).equals(window.get(c))) {
valid++;
}
}
// shrink left window
// 每一次窗口的尺寸比need的尺寸大的时候都会进行瘦身操作,一直移动到比need的尺寸小1结束
while (right - left >= t.length()) {
if (valid == need.size()) {
return true;
}
char d = s.charAt(left);
left++;
if (need.containsKey(d)) {
if (window.get(d).equals(need.get(d))) {
valid--;
}
window.put(d, window.get(d) - 1);
}
}
}
return false;
}
总结
简单来说滑动窗口问题其实只要记下这个框架,大部分类似问题都可迎刃而解。