滑动窗口的目的在于节省时间复杂度。经典场景为,两层循环遍历从 [i, j]序列,可以通过滑动窗口将时间复杂度降为O(N)。
滑动窗口根据窗口长度是否固定,分为两种情况。变窗口用于求一些最大、最小子串问题;定窗口用于求一些是否有定长的子串满足条件等问题。
变窗口长度模板(以最小窗口为例)
用left标识即将移出窗口的下标,right标识即将移入窗口的下标,用两个HashMap,need和window来记录需要的条件和窗口内的情况。
public static String minWindow(String s, String t) {
HashMap<Character, Integer> need = new HashMap<>();
int t_len = t.length();
for (int i = 0; i < t_len; i++) {
need.put(t.charAt(i), need.getOrDefault(t.charAt(i), 0) + 1);
}
// left为即将移出窗口的下标,right为即将移入窗口的下标,均初始化为0
int left = 0, right = 0;
int valid = 0;
// 记录最小覆盖子串的起始索引及⻓度
int start = 0, len = Integer.MAX_VALUE;
// 先右移right直到满足条件,循环条件为 right<s.length
while (right < s.length()) {
// c 是将移入窗口的字符
char c = s.charAt(right);
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
// 判断左侧窗口是否要收缩,一般条件是看window是否满足了need的要求
while (...) {
// d 是将移出窗口的字符
char d = s.charAt(left);
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
// 更新最优结果
}
}
return ...;
}
定窗口长度模板
基本组成与结构与变窗口的模板一致,区别在于左窗口移动的条件变化
public static boolean checkInclusion(String s, String t) {
HashMap<Character, Integer> need = new HashMap<>();
HashMap<Character, Integer> window = new HashMap<>();
int t_len = t.length();
for (int i = 0; i < t_len; i++) {
need.put(t.charAt(i), need.getOrDefault(t.charAt(i), 0) + 1);
}
int left = 0, right = 0;
int valid = 0;
// 记录最小覆盖子串的起始索引及⻓度
int start = 0, len = Integer.MAX_VALUE;
while (right < s.length()) {
// c 是将移入窗口的字符
char c = s.charAt(right);
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
if (need.containsKey(c)) {
int tmp = window.getOrDefault(c, 0);
window.put(c, ++tmp);
if (window.get(c) == need.get(c))
valid++;
}
// 判断左侧窗口是否要收缩,条件为right-left>=要求的长度
while (right-left>=t_len) {
// 变窗口长度更新结果的位置,改为了 只要发现满足条件的就直接返回true
if (valid==need.size()) {
return true;
}
// d 是将移出窗口的字符
char d = s.charAt(left);
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
if (need.containsKey(d)) {
if (window.get(d) == need.get(d))
valid--;
int tmp = window.get(d);
window.put(d, --tmp);
}
}
}
// 返回最小覆盖子串
return false;
}
例子1:定长的滑动窗口。187. 重复的DNA序列
一般的方法是使用一层循环O(N)遍历开始元素的位置,在循环用substring()得到子串O(L),记录在map中O(1),并且检查map对应key的值是否为2,是则说明重复,添加到结果集中。因为每次循环内都用了substring(),耗费O(L)的时间并创建一个占用O(L)空间的新字符串,因此整体的时间复杂度为O(NL),空间复杂度为O(NL)。
改进可以从空间与时间两个维度。空间方面,想办法找个方案替代substring(),节省占用的空间,需要一种新的表示子串的方法。由于DNA序列中的字符只有4种,可以使用一个2位的二进制数表示,则一个长L的子串可以用一个2*L位的二进制数表示,即可以用一个int/ long表示,空间占用O(1)。
时间方面,如果用int的二进制位表示子串,则每次循环内,计算子串的int值仍然需要循环L次。由于前一个子串与后一个子串只有头尾不一样,此时可以使用滑动窗口节省时间复杂度,只需要在窗口移动时更新头尾改变导致的子串int值变化,时间占用O(1)。
class Solution {
static final int L = 10;
// 使用 2位二进制数表示4种可能字符
Map<Character, Integer> bin = new HashMap<Character, Integer>() {{
put('A', 0);
put('C', 1);
put('G', 2);
put('T', 3);
}};
public List<String> findRepeatedDnaSequences(String s) {
List<String> ans = new ArrayList<String>();
int n = s.length();
if (n <= L) {
return ans;
}
// 使用20位二进制数 x 表示一个子串
int x = 0;
// 初始化窗口一开始对应的 x 值
for (int i = 0; i < L - 1; ++i) {
x = (x << 2) | bin.get(s.charAt(i));
}
Map<Integer, Integer> cnt = new HashMap<Integer, Integer>();
for (int i = 0; i <= n - L; ++i) {
// 窗口滑动时,更新子串对应的 x 值
x = ((x << 2) | bin.get(s.charAt(i + L - 1))) & ((1 << (L * 2)) - 1);
cnt.put(x, cnt.getOrDefault(x, 0) + 1);
if (cnt.get(x) == 2) {
ans.add(s.substring(i, i + L));
}
}
return ans;
}
}