本文写几个比较明显需要用SlidingWindow的。
题目一般是:对某些字符出现次数有要求,还要求最长/最短的子串来满足这个条件。
思路一般是:用一个hashmap来辅助判断是否达到标准
题目 | 简介 |
---|---|
3. Longest Substring Without Repeating Characters | 求最长窗 |
159. Longest Substring with At Most Two Distinct Characters | 求最长窗 |
209. Minimum Size Subarray Sum | 求最短窗 |
76. Minimum Window Substring | 求最短窗 |
438. Find All Anagrams in a String | 固定长度窗 |
239. Sliding Window Maximum | 固定长度窗Deque |
272. Closest Binary Search Tree Value II | 固定长度窗Deque |
Input: “abcabcbb” 求最长的不含重复字母的子串。
Output: 3
Explanation: The answer is “abc”, with the length of 3.
对窗内的要求:不存在重复字母。–>用hashmap来检查。
具体hashmap存什么呢?想可以key是字母,value是在窗口内是否出现 。然而不对!hashmap不是只具备“检查是否符合要求”这一个功能,还要在“收缩左边界”的时候,能提供“如何收缩”的技术指导。
这个题不是求“最长子串”,而是求“最短子串”,收缩左边界,不是为了优化,而是迫不得已,为了满足窗口要求。
为什么会不满足要求呢?因为我们在右边界加入了一个新的字母,假如说是"a",那么窗口需要怎么修改呢?窗口中哪些元素就变得invalid了呢?那当然是窗口里的a(唯一一个)需要扔掉,于是左边界挪到a的紧挨的右邻居处。
于是hashmap的key是字母,value是窗口中该字母的下标,或者说是,从左往右遍历,最后一次的出现该字母的下标。
另外一点,左边界收缩之后,并不把扔出去的元素从hashmap里清除掉,而是留在里面。在每次判断右边新进来的字母是否在窗内的时候,通过下标来得知hashmap里的某个元素是否还有效(还在窗内)lastOccur.get(ch) >= left。
class Solution {
public int lengthOfLongestSubstring(String s) {
if (s.length() < 1) {
return 0;}
Map<Character, Integer> lastOccur = new HashMap<>();
int left = 0, right = 0, maxLen = Integer.MIN_VALUE;
for (; right < s.length(); right++) {
//拓展右边界
char ch = s.charAt(right);
if (lastOccur.containsKey(ch) && lastOccur.get(ch) >= left) {
left = lastOccur.get(ch) + 1;//收缩左边界
}
lastOccur.put(ch, right);//<字母,此字母下标>
maxLen = Math.max(maxLen, right - left + 1);//维护最大的窗口长度
}
return maxLen;
}
}
Input: “eceba” 求最长子串,其中最多包含两个不同的字母(不能出现第三个)。
Output: 3
Explanation: t is “ece” which its length is 3.
3是要求“窗内字母不能重复”,159要求“窗内字母尽量相同,顶多有两个不同的”。–>这次hashmap的key是字母,value依然是该字母最后一次出现的下标,然而区别是,更新规则变化了。3是每添加进一个新字母,如果窗里有该字母,则收缩左边界;159是每添加进一个新字母,更新hashmap之后,如果这个新来的字母是第三个不同的,则收缩左边界。
class Solution {
public int lengthOfLongestSubstringTwoDistinct(String s) {
Map<Character, Integer> lastOccur = new HashMap<>();
int left = 0, right = 0; //right:one after window
int maxLen = 0;
for (; right < s.length(); right++) {
if (lastOccur.size() <= 2) {
//拓展右边界
lastOccur.put(s.charAt(right), right);
}
if (lastOccur.size() > 2) {
//确定如何收缩左边界(三者踢出去谁)
int leftmostIdx = s.length();
for (int index : lastOccur.values()) {
//踢lastOccur下标最靠左的那个字母
leftmostIdx = Math.min(leftmostIdx, index);
}
char ch = s.charAt(leftmostIdx);
lastOccur.remove(ch);
left = leftmostIdx + 1;//收缩左边界
}
maxLen = Math.max(maxLen, right - left + 1);//维护最大的窗口长度
}
return maxLen;
}
}
Input: s = 7, nums = [2,3,1,2,4,3] 求和为7的最短子串
Output: 2
Explanation: the subarray [4,3] has the minimal length under the problem constraint.
这个题的窗户的要求比前面更简单,sum得s即可。于是不需要hashmap了。更新sum的操作也很简单。
一点要注意,最后返回的时候,注意判断winLen是否真的进入了主循环,若还是初始化的默认值,则说明根本没进入,要特殊处理。
class Solution {
public int minSubArrayLen(int s, int[] nums) {
if (nums.length == 0) {
return 0;}
int left = 0, right = 0;
int sum = 0;
int winLen = Integer.MAX_VALUE;
for (; right < nums.length; right++) {
//拓展右边界
sum += nums[right];
while (sum >= s) {
//收缩左边界
sum -= nums[left];
winLen = Math.min(winLen, right - left + 1);
left++;
}
}
return (winLen == Integer.MAX_VALUE) ? 0 : winLen;
}
}
Input: S = “ADOBECODEBANC”, T = “ABC”
Output: “BANC”
对窗口的要求:T中所有字母A,B,C,在S中的出现次数都比T中多(或等于)。–>还是用hashmap来维护。key是字母,value是该字母在窗口中的数量。
3,159是求最长窗,209,76是求最短窗。所以要尽量收缩左边界,用while-loop,不断收缩,直到再次“不满足条件”,接着拓展右边界。
这个题用长度256的int数组来代替前面用的hashmap(这里我们叫它dict)。其实前面几个题也可以这样优化。这里S和T分别各一个dict,hashText和hashPattern。hashPattern={A:1,B:1,C:1},S中来A,D,O,B,E……逐渐更新hashText。 不用非得用两个,可以共用一个dict。(下面有解释)
本来判断窗是否满足要求,是用“dict中所有元素的值都为0,或者小于0(如果不存在的字母我们仍然做减法的话)”,但每次检查都要撸一遍256长度,太麻烦了……于是用一个matched变量来记录“已经有几个字母成功地修改了dict”,我们规定只有该字母在dict中值大于0,才视为“成功修改”。这样等Match等于T的长度的时候,我们就知道,此时窗已经满足条件啦。
最终要求的是“最短子串的整个串”,而不是只求长度。于是不仅仅维护一个minLen,还要维护一个起始点leftFinal。
和上面的题一样,最后要检查我们是否真的进入了主循环,如果leftFinal != -1说明根本没进入,返回空(否则minLen还是Integer.MAX_VALUE,leftFinal + minLen值就不对了)。
为什么要用两个dict呢?S和T能否共用一个dict呢?可以的!
下面我们假设可以,则value的定义不是“该字母出现次数”,而是“该字母在T中出现的次数减去在S中的窗户中出现的次数的差”。先初始化{A:1,B:1,C:1},S中来A,D,O,B,E……若dict中该字母的value不为零,则把value减一{A:0,B:0,C:1}。
然而,如果只有“value不为零”的才减一,根本没在T中出现过的字母不管它,则等到收缩左边界的时候,若某个字母dict值为0,则无法分辨该字母究竟是原来在T中出现过,后来被减为零的,还是根本就没在T中出现过。不分辨清楚这个,就没法在恢复dict的值(将要扔掉的那个字母,要在dict中加一),没法确定左边界收缩到哪里。
所以,正确的做法是:如果不管value是否大于零,都减一。即case 1:有些T中没出现过的字母会被减为负数,case 2:有些S中含量超过T中含量的字母会被减为负数……这样,在收缩左边界的时候,恢复dict没什么trick,很简单,就是踢出去谁,就在dict里更新它的值就行。
关键的是“恢复matched”。当初修改matched的时候,就规定,只有该字母在dict中值大于0,才视为“成功修改”。因为只有dict值大于零才说明这个字母在T中存在。而我们知道,在经历过右边界扫过之后,那些在T中不存在的字母,都dict值被减为负数了,在左边界往外踢的时候,也不可能>=0,所以此时dict值>=0的,都是原来在T中就存在的字母,我们才matched减一。
class Solution {
public String minWindow(String s, String t) {
int[] dict = new int[256];
//统计T中的字母存入hashPattern
for (char ch : t.toCharArray()) {
dict[ch]++;
}
//窗从左到右扫S
int left = 0, right = 0, leftFinal = -1;
int winLen = -1, minLen = Integer.MAX_VALUE;
int matched = 0;
for (; right < s.length(); right++) {
if (dict[s.charAt(right)] > 0) {
matched++;
}
dict[s.charAt(right)]--;
while (matched == t.length()) {
//收缩左边界
if (dict[s.charAt(left)] >= 0) {
matched--;
}
dict[s.charAt(left)]++;
winLen = right - left + 1;//维护最小窗口
if (winLen < minLen) {
minLen = winLen;
leftFinal = left ;
}
left++;//注意在更新完最小窗之后再left++
}
}
return (leftFinal != -1) ? s.substring(leftFinal, leftFinal + minLen) : "";
}
}
Input: s: “cbaebabacd” p: “abc”
Output: [0, 6] 求所有
Explanation:
The substring with start index = 0 is “cba”, which is an anagram of “abc”.
The substring with start index = 6 is “bac”, which is an anagram of “abc”.
前面四个题都是窗长度不固定的,这个438是窗长度固定的。
依然是用一个dict。dict的定义不是“该字母出现次数”,而是“该字母在T中出现的次数减去在S中的窗户中出现的次数的差”。先初始化{a:1,b:1,c:1},S中来c,b,a,e……不管dict中该字母值是多少,都把value减一{a:0,b:0,c:0,d:0,e:-1}。
同样的,这里还是用一个matched变量来记录“已经有几个字母成功地修改了dict”,只有在右拓展中dict值>0, 左收缩中>=0的,才认为是P中存在的字母,才更改matched值。这里窗长度固定,不需要用matched来决定左右边界的变化,只是用来当matched == p.length()的时候,把当前窗的内容放进最终的result里。
这里左右边界的变化,“拓展右边界”,“收缩左边界”的交替,是依次分别走一步的:
class Solution {
public List<Integer> findAnagrams(String s, String p) {
List<Integer> list = new ArrayList<>();
//如果s不够长,提前返回
if (s == null || s.length() == 0 || p == null || p.length() == 0|| s.length() < p.length()) {
return list;}
//统计T中的字母存入dict
int[] dict = new int[256];
for (char c : p.toCharArray()) {
dict[c]++;
}
//窗从左到右扫s
int left = 0, right = 0;
int matched = 0;
for (; right < s.length(); right++) {
//拓展右边界
if (dict[s.charAt(right)] > 0) {
//只有dict值大于零的才是在P中出现过的
matched++;
}
dict[s.charAt(right)]--;
if (matched == p.length()) {
//an anagram found
list.add(left);
}
if (right - left + 1 == p.length()) {
//收缩左边界
if (dict[s.charAt(left)] >= 0) {
//若窗中存在P中没出现过的字母,则dict值已经被减为负数。正数或零说明是P中存在的字母
matched--;
}
dict[s.charAt(left)]++;
left++;
}
}
return list;
}
}
Input: nums = [1,3,-1,-3,5,3,6,7], and k = 3
Output: [3,3,5,5,6,7] 求滑动窗口每个位置的k个元素的最大值。时间O(1)
Explanation:
Window position ---- Max
[1 3 -1] -3 5 3 6 7 ---- 3
1 [3 -1 -3] 5 3 6 7 ---- 3
1 3 [-1 -3 5] 3 6 7 ---- 5
1 3 -1 [-3 5 3] 6 7 ---- 5
1 3 -1 -3 [5 3 6] 7 ---- 6
1 3 -1 -3 5 [3 6 7] ---- 7
438和239是窗长度固定的。注意题目要求:不是求窗内元素和的max,而是窗停留在每个位置,求窗内部元素的max。
这个题239和下面的272都是用Deque做的:
这里的Deque定义是:从队头开始数的第i个元素是“第i个窗位的那个窗中K个数的最大值,的下标”,比如,[1 3 -1] -3 5 3 6 7,这个[1 3 -1]就是第一个窗位(0 index basis),则这个Deque中最终剩下的值,就是[1 3 -1]这个窗中的最大值的下标,即1。这个Deque长度为n-k+1,因为一共有n-k+1个窗位置。
i-k+1是根据某窗的右边界下标i来求左边界下标。q.peek() < i-k+1就是说当前的i下标能cover的K长度窗的左边界,超过界限的元素,需要踢出去。(相当于前面几个题的left++)。
拓展右边界,每来一个新的值,都和deque里的值比较,把比新值小的元素全扔了(因为不具备竞争力了)。deque只保留有可能成为当前位置窗内
对于左边界在i-k+1下标的窗来说,当第i个元素揭示出来之后,它的所有K个元素全都揭晓完毕,有点像揭晓体育彩票,此时已经没有变更大的机会了,于是死心了,可以写入最终的result里了。
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || k <= 0) {
return new int[]{
0};}
int n = nums.length;
int[] result = new int[n-k+1];
int resultIdx = 0;
Deque<Integer> q = new ArrayDeque<>(); //q:存放index
for (int i = 0; i < nums.length; i++) {
while (!q.isEmpty() && q.peek() < i-k+1) {
//收缩左边界
q.poll();
}
while (!q.isEmpty() && nums[q.peekLast()] < nums[i]) {
//拓展右边界
q.pollLast();//q中比当前新来的元素小的,都不要了
}
q.offer(i);
if (i-k+1 >= 0) {
//确保左边界>=0,即已形成完整的K长窗
result[resultIdx++] = nums[q.peek()];//揭晓完毕
}
}
return result;
}
}
在“BST-找最接近的值”那篇里写过,其中的方法二,就是把BST拍扁,然后用slidingWindow做的。是固定窗长度。用长度为K的deque是为了窗可以左右任意移动,找到最接近的K个。
class Solution {
public List<Integer> closestKValues(TreeNode root, double target, int k) {
Deque<Integer> result = new LinkedList<>();
inorder(result, root, target, k);
return new ArrayList<Integer>(result);
}
private void inorder(Deque<Integer> result, TreeNode root, double target, int k) {
if (root == null) {
return;}
inorder(result, root.left, target, k);
if (result.size() < k) {
result.add(root.val);
} else if (Math.abs(root.val-target) < Math.abs(result.peekFirst()-target)) {
//右边界(新访问到的元素)closer
result.pollFirst();
result.add(root.val);
} else {
//左边界closer,新访问到的元素远,直接扔掉
return;
}
inorder(result, root.right, target, k);
}
}