滑动窗口的目的在于节省时间复杂度。经典场景为,两层循环遍历从 [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为即将移入窗口的下标,均初始化为0int left = 0, right = 0;int valid = 0;// 记录最小覆盖子串的起始索引及⻓度int start = 0, len = Integer.MAX_VALUE;// 先右移right直到满足条件,循环条件为 right<s.lengthwhile (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) {// 变窗口长度更新结果的位置,改为了 只要发现满足条件的就直接返回trueif (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;}}
