Open xiedaxia1hao opened 3 years ago
给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的连续子数组。如果不存在符合条件的连续子数组,返回 0。 示例: 输入: target = 7, nums = [2,3,1,2,4,3] 输出: 2 解释: 子数组 [4,3] 是该条件下的长度最小的连续子数组。
题意要求寻找一个连续的子数组,并且子数组的和要大于等于目标值target,这也是一种数组加和的方式。回想到上面的滑动窗口,窗口逐步扩大的时候,可以累加窗口元素的和,如果大于目标s,那么就等于存在一个解。比如[2,3,1,2]这个子数组,是hi不停想右扩展得到的。
但是有解不代表有最优解。假设上面第4个元素就是7,那么[2,3,1,7]自然是一个解,但是单纯的[7]也是一个解。因此我们需要不断的缩小窗口,来找到最小窗口的解。
// Time Complexity: O(n)
// Space Complexity: O(1)
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int minLen = Integer.MAX_VALUE, lo = 0, sum = 0;
for(int hi = 0; hi < nums.length; hi++) {
sum += nums[hi];
while(sum >= target) {
minLen = Math.min(minLen, hi-lo+1);
sum -= nums[lo++];
}
}
return minLen == Integer.MAX_VALUE ? 0 : minLen;
}
}
和第一题一样,初始化窗口为 0,然后 hi 向右移动。每移动一格窗口后,检测当前的窗口计数器是否有解,有解即记录更新解,并且缩小窗口。
一般求最小子数组,有可能没解,因此我们可以把初始化解设为数组本身长度+1或者Integer.MAX_VALUE,方便最后我们判断是否有解的情况。
从上面的两个题大致可以看出,滑动窗口的套路无怪乎两步:
伪代码如下:
lo = 0, hi = 0;
for(int hi = 0; hi < nums.length; hi++) {
windows.add(nums[hi])
while(is_valid) {
handle(res)
windows.remove(s[lo++])
}
}
Grokking the Coding Interview: Patterns for Coding Questions里有这么一道题: Longest Substring with K distinct Characters,即找出字符串中不同字符数为K的最大子串
输入: String="araaci", K=2 输出: 4 解释: 不同字符数 K=2, 即有连个不同字符的最大子串 "araa". 输入: String="araaci", K=1 输出: 2 解释: 不同字符数 K=1, 即有连个不同字符的最大子串 "aa". 输入: String="cbbebi", K=3 输出: 5 解释: 不同字符数 K=3, 即有连个不同字符的最大串"cbbeb" & "bbebi".
我们由题意可知改题是为了求最长子串。可以使用一个窗口,窗口的不同字符数要等于K。窗口的计数器可以设置当前窗口内不同字符的个数,然后我们借助一个hashmap,用来记录当前窗口内的字符出现的次数。
窗口滑动的时候检查下一个字符是否出现,并更新不同字符的数目,然后跟K进行比较,缩小窗口并求解。
滑动窗口对于求解字符出现次数,重复字符数,经常需要借助hashmap。后面还会针对这样的case进行举例。
import java.util.*;
class LongestSubstringKDistinct {
public static int findLength(String str, int k) {
// TODO: Write your code here
int lo = 0, maxLength = 0;
Map<Character, Integer> map = new HashMap<>();
for(int hi = 0; hi < str.length(); hi++) {
char hiChar = str.charAt(hi);
map.put(hiChar, map.getOrDefault(hiChar, 0)+1);
while(map.size() > k) {
//shrink the window
char loChar = str.charAt(lo++);
map.put(loChar, map.get(loChar)-1);
if(map.get(loChar) == 0) {
map.remove(loChar);
}
}
maxLength = Math.max(maxLength, hi-lo+1);
}
return maxLength;
}
public static void main(String[] args) {
System.out.println("Length of the longest substring: " + LongestSubstringKDistinct.findLength("araaci", 2));
System.out.println("Length of the longest substring: " + LongestSubstringKDistinct.findLength("araaci", 1));
System.out.println("Length of the longest substring: " + LongestSubstringKDistinct.findLength("cbbebi", 3));
}
}
同类型的题,leetcode上也有这么一道:
在一排树中,第 i 棵树产生 tree[i] 型的水果。 你可以从你选择的任何树开始,然后重复执行以下步骤: 把这棵树上的水果放进你的篮子里。如果你做不到,就停下来。 移动到当前树右侧的下一棵树。如果右边没有树,就停下来。 请注意,在选择一颗树后,你没有任何选择:你必须执行步骤 1,然后执行步骤 2,然后返回步骤 1,然后执行步骤 2,依此类推,直至停止。 你有两个篮子,每个篮子可以携带任何数量的水果,但你希望每个篮子只携带一种类型的水果。 用这个程序你能收集的水果总量是多少? 示例 1: 输入:[1,2,1] 输出:3 解释:我们可以收集 [1,2,1]。 示例 2: 输入:[0,1,2,2] 输出:3 解释:我们可以收集 [1,2,2]. 如果我们从第一棵树开始,我们将只能收集到 [0, 1]。 示例 3: 输入:[1,2,3,2,2] 输出:4 解释:我们可以收集 [2,3,2,2]. 如果我们从第一棵树开始,我们将只能收集到 [1, 2]。 示例 4: 输入:[3,3,3,1,2,1,1,2,3,3,4] 输出:5 解释:我们可以收集 [1,2,1,1,2]. 如果我们从第一棵树或第八棵树开始,我们将只能收集到 4 个水果。
虽然这题加了一些生活气息,但是本质还是上面一道题,只是限定了k=2这么一个条件,所以我们可以这么解:
class Solution {
public int totalFruit(int[] tree) {
int lo = 0, max = 0;
Map<Integer, Integer> map = new HashMap<>();
for(int hi = 0; hi < tree.length; hi++) {
int hiFruit = tree[hi];
map.put(hiFruit, map.getOrDefault(hiFruit, 0)+1);
while(map.size() > 2) {
int loFruit = tree[lo++];
map.put(loFruit, map.get(loFruit)-1);
if(map.get(loFruit) == 0) {
map.remove(loFruit);
}
}
max = Math.max(max, hi-lo+1);
}
return max;
}
}
正确的设置滑动计数器,以便在合适的时候缩小窗口,这直接关系到滑动窗口的解。正如水果成篮这一题,需要花点时间将题目抽象成滑动窗口的类型,然后才能针对性的进行求解。下面再看两个这样转换思路的题目。
给定一个由若干 0 和 1 组成的数组 A,我们最多可以将 K 个值从 0 变成 1 。 返回仅包含 1 的最长(连续)子数组的长度。 示例 1: 输入:A = [1,1,1,0,0,0,1,1,1,1,0], K = 2 输出:6 解释: [1,1,1,0,0,1,1,1,1,1,1] 粗体数字从 0 翻转到 1,最长的子数组长度为 6。 示例 2: 输入:A = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], K = 3 输出:10 解释: [0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1] 粗体数字从 0 翻转到 1,最长的子数组长度为 10
题意很明确,将 k 个 0 替换成 1,替换后的字串中连续的 1 最长。在替换后,再去检查串的连续性,这是一种思路。但是对于继续右移的时候,替换的串该如何处理?
一个转换思路就是,记录当前连续的 1 的个数,窗口的大小 减去 连续的个数,就是剩余可以替换的个数,如果这个个数等于 k ,那么正好是一个解。因此代码如下:
class Solution {
public int longestOnes(int[] nums, int k) {
int lo = 0, max = 0, repeatedOne = 0;
for(int hi = 0; hi < nums.length; hi++) {
if(nums[hi] == 1) {
repeatedOne++;
}
// current window size is from windowStart to windowEnd, overall we have a maximum of 1s
// repeating a maximum of 'maxOnesCount' times, this means that we can have a window with
// 'maxOnesCount' 1s and the remaining are 0s which should replace with 1s.
// now, if the remaining 0s are more than 'k', it is the time to shrink the window as we
// are not allowed to replace more than 'k' Os
if(hi-lo + 1 - repeatedOne > k) {
if(nums[lo++] == 1) {
repeatedOne--;
}
}
max = Math.max(hi-lo+1, max);
}
return max;
}
}
这一题的关键是如何找到滑动窗口的解。通常情况下,都会利用连续和匹配这样的转换技巧。解决了这一题,下面的一题也就迎刃而解了
给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。 注意: 字符串长度 和 k 不会超过 104。 示例 1: 输入: s = "ABAB", k = 2 输出: 4 解释: 用两个'A'替换为两个'B',反之亦然。 示例 2: 输入: s = "AABABBA", k = 1 输出:4 解释: 将中间的一个'A'替换为'B',字符串变为 "AABBBBA"。子串 "BBBB" 有最长重复字母, 答案为 4。
class Solution {
public int characterReplacement(String s, int k) {
int lo = 0, maxLength = 0, maxRepeatedLetterCount = 0;
Map<Character, Integer> map = new HashMap<>();
for(int hi = 0; hi < s.length(); hi++) {
char hiChar = s.charAt(hi);
map.put(hiChar, map.getOrDefault(hiChar, 0)+1);
maxRepeatedLetterCount = Math.max(maxRepeatedLetterCount, map.get(hiChar));
// current window size is from windowStart to windowEnd, overall we have a letter which is
// repeating 'maxRepeatLetterCount' times, this means we can have a window which has one letter
// repeating 'maxRepeatLetterCount' times and the remaining letters we should replace.
// if the remaining letters are more than 'k', it is the time to shrink the window as we
// are not allowed to replace more than 'k' letters
if(hi-lo+1-maxRepeatedLetterCount > k) {
char loChar = s.charAt(lo++);
map.put(loChar, map.get(loChar)-1);
}
maxLength = Math.max(maxLength, hi-lo+1);
}
return maxLength;
}
}
给定两个字符串 s1 和 s2,写一个函数来判断 s2 是否包含 s1 的排列。 换句话说,第一个字符串的排列之一是第二个字符串的子串。 示例1: 输入: s1 = "ab" s2 = "eidbaooo" 输出: True 解释: s2 包含 s1 的排列之一 ("ba").
示例2: 输入: s1= "ab" s2 = "eidboaoo" 输出: False
题意要求 s1的字串可以任意重新组合,组合之后,可以在 s2 中匹配。假如说A串完全匹配 B串,那么意味着A串和B串一模一样,字母的顺序和次数都应该一致。s1 匹配 s2 的字串,也就是说需要在 s2 中,找到s1出现的字母,并且次数也要一致。同时,s2 的匹配的字串,其长度也要等于 s1。
了解这两个限制之后,使用上述 hash 字典的技巧,可以先求出一个字符表。即 s1 所有字符出现的次数。然后设置一个滑动窗口,开始右移(保证窗口的大小等于s1.length()
)。窗口右移的时候,如果该字符在s1中出现过,我们就把字典里该字符出现的次数减1。如果减掉后该字符出现的次数为0,说明当前窗口当前字母出现的次数和s1中完全相同,我们让matched++
。
如果在任意的时候,matched的值和 hash 字典存的unique characters数值相同了,说明我们得到了需要的排列,return true
即可。
当我们的窗口大小大于s1的大小的时候,我们开始一个字符一个字符得减小窗口,如果该字符出现在hashmap里,那就要把相应的frequency和matched值恢复。
class Solution {
public boolean checkInclusion(String s1, String s2) {
int lo = 0, matched = 0;
Map<Character, Integer> charFrequencyMap = new HashMap<>();
for(char chr : s1.toCharArray()) {
charFrequencyMap.put(chr, charFrequencyMap.getOrDefault(chr, 0)+1);
}
// goal is to match all the characters from the 'charFrequencyMap' with the current window
for(int hi = 0; hi < s2.length(); hi++) {
char hiChar = s2.charAt(hi);
if(charFrequencyMap.containsKey(hiChar)) {
// decrement the frequency of the matched character
charFrequencyMap.put(hiChar, charFrequencyMap.get(hiChar)-1);
if(charFrequencyMap.get(hiChar) == 0) { // character is completely matched
matched++;
}
}
if(matched == charFrequencyMap.size()) {
return true;
}
if(hi >= s1.length()-1) { // shrink the window by one character
char loChar = s2.charAt(lo++);
if(charFrequencyMap.containsKey(loChar)) {
if(charFrequencyMap.get(loChar) == 0) {
matched--; // before putting the character back, decrement the matched count
}
// put the character back for matching
charFrequencyMap.put(loChar, charFrequencyMap.get(loChar)+1);
}
}
}
return false;
}
}
给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。 字符串只包含小写英文字母,并且字符串 s 和 p 的长度都不超过 20100。 说明: 字母异位词指字母相同,但排列不同的字符串。 不考虑答案输出的顺序。 示例 1: 输入: s: "cbaebabacd" p: "abc" 输出: [0, 6] 解释:起始索引等于 0 的子串是 "cba", 它是 "abc" 的字母异位词。起始索引等于 6 的子串是 "bac", 它是 "abc" 的字母异位词。 示例 2: 输入:s: "abab" p: "ab" 输出:[0, 1, 2] 解释:起始索引等于 0 的子串是 "ab", 它是 "ab" 的字母异位词。 起始索引等于 1 的子串是 "ba", 它是 "ab" 的字母异位词。 起始索引等于 2 的子串是 "ab", 它是 "ab" 的字母异位词。
该题的套路和上面一题的套路完全一样,只是需要返回一个list而不是boolean。
class Solution {
public List<Integer> findAnagrams(String s, String p) {
int lo = 0, matched = 0;
List<Integer> res = new ArrayList<>();
Map<Character, Integer> charFrequencyMap = new HashMap<>();
for(char chr : p.toCharArray()) {
charFrequencyMap.put(chr, charFrequencyMap.getOrDefault(chr, 0)+1);
}
for(int hi = 0; hi < s.length(); hi++) {
char hiChar = s.charAt(hi);
if(charFrequencyMap.containsKey(hiChar)) {
charFrequencyMap.put(hiChar, charFrequencyMap.get(hiChar)-1);
if(charFrequencyMap.get(hiChar) == 0) {
matched++;
}
}
// THE ONLY DIFFERENCE
if(matched == charFrequencyMap.size()) {
res.add(lo);
}
if(hi >= p.length()-1) {
char loChar = s.charAt(lo++);
if(charFrequencyMap.containsKey(loChar)) {
if(charFrequencyMap.get(loChar) == 0) {
matched--;
}
charFrequencyMap.put(loChar, charFrequencyMap.get(loChar)+1);
}
}
}
return res;
}
}
给你一个字符串 S、一个字符串 T,请在字符串 S 里面找出:包含 T 所有字母的最小子串。 示例: 输入: S = "ADOBECODEBANC", T = "ABC" 输出: "BANC"
class Solution {
public String minWindow(String s, String t) {
int lo = 0, matched = 0, subStrStart = 0, minLength = Integer.MAX_VALUE;
Map<Character, Integer> charFrequencyMap = new HashMap<>();
for(char chr : t.toCharArray()) {
charFrequencyMap.put(chr, charFrequencyMap.getOrDefault(chr, 0)+1);
}
for(int hi = 0; hi < s.length(); hi++) {
char hiChar = s.charAt(hi);
if(charFrequencyMap.containsKey(hiChar)) {
charFrequencyMap.put(hiChar, charFrequencyMap.get(hiChar)-1);
// now we need to count ALL useful characters matched
if(charFrequencyMap.get(hiChar) >= 0) {
matched++;
}
}
// shrink the window if we can, finish as soon as we remove a matched character
while(matched == t.length()) {
if(minLength > hi - lo + 1) {
minLength = hi - lo + 1;
subStrStart = lo;
}
char loChar = s.charAt(lo++);
if(charFrequencyMap.containsKey(loChar)) {
// note that we could have redundant matching characters, therefore we'll decrement the
// matched count only when a useful occurrence of a matched character is going out of the window
// if the charFrequencyMap.get(loChar) < 0, it means we still have more than needed loChar in our window, we don't need to decrease matched.
if(charFrequencyMap.get(loChar) >= 0) {
matched--;
}
charFrequencyMap.put(loChar, charFrequencyMap.get(loChar)+1);
}
}
}
return minLength == Integer.MAX_VALUE ? "" : s.substring(subStrStart, subStrStart+minLength);
}
}
30. Substring with Concatenation of All Words
注意子串要与 words 中的单词完全匹配,中间不能有其他字符,但不需要考虑 words 中单词串联的顺序。 示例 1: 输入:s = "barfoothefoobarman", words = ["foo","bar"] 输出:[0,9] 解释: 从索引 0 和 9 开始的子串分别是 "barfoo" 和 "foobar" 。 输出的顺序不重要, [9,0] 也是有效答案。 示例 2: 输入: s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"] 输出:[]
题目给出的模式数组,其中元素是一个三个字符的单词,如果问题是 'bftfbm' 中串联 f 和 b,问题就和滑动窗口很似了。如果是 barfoothefoobarman 和 foo bar ,我们可以把 hi 和 lo 的步长设置为 单词的字符数大小。即一次向右移动三格。
解决的思路也很简单,让原始输入的字串的每个字符都成为一次起始字符,再使用上面滑动窗口作为子函数求解即可。*由题意已知,起始知道每次需要匹配判断字串长度(words_len words_count),最外层的循环是可以提前结束的。**此外,在滑动窗口内部,一旦不匹配,也是可以提前返回的。
class Solution {
public List<Integer> findSubstring(String s, String[] words) {
Map<String, Integer> wordFrequencyMap = new HashMap<>();
for(String word : words) {
wordFrequencyMap.put(word, wordFrequencyMap.getOrDefault(word, 0)+1);
}
List<Integer> res = new ArrayList<>();
int wordCount = words.length, wordLength = words[0].length();
// optimize the loop times
for(int i = 0; i <= s.length()-wordCount*wordLength; i++) {
Map<String, Integer> wordSeen = new HashMap<>();
for(int j = 0; j < wordCount; j++) {
// get the next word from the string
int nextWordIndex = i + j * wordLength;
String word = s.substring(nextWordIndex, nextWordIndex+wordLength);
if(!wordFrequencyMap.containsKey(word)) {
break;
}
wordSeen.put(word, wordSeen.getOrDefault(word, 0)+1);
// no need to process further if the word has higher frequency than required
if(wordSeen.get(word) > wordFrequencyMap.get(word)) {
break;
}
// store index if we have found all the words
if(j + 1 == wordCount) {
res.add(i);
}
}
}
return res;
}
}
虽然使用了滑动窗口的思路,但是不再是像之前那样亦步亦趋了。这个解法最终可以跑过 leetcode。实际上还是耗时比较长,算不上一个好的解法。
解法的时间复杂度是 O(N K Len) ,N 是字串的长度,K 是单词的数目,Len 则是每个单词的长度。由此可见,当 K 和 len 比较小的时候,算法还可以。当 K 和 N 差不多的时候,其复杂度就比较高了。
空间复杂度是O(K),因为我们可能最多需要把所有的单词都存到hashmap里,在最差的情况,我们还需要O(N)的空间存储我们的结果res,所以总的空间复杂度是O(K+N)
综上所述,滑动窗口套路对于一些求解连续的字串子数组的问题非常有用。滑动窗口的思路就是设置一个再字符串或数组上进行滑动。窗口扩大的时候寻找一个解,窗口缩小的时候寻找最优解。滑动窗口解法的模板就比较死板。先将窗口不断右移,每次移动之后,使用窗口计算器判断解,判断窗口是否可以缩小。然后再循环往复。
这里的关键是如何定义窗口计数器和找出解。或者如何将一个问题抽象成滑动窗口问题。需要不断的进行练习和总结。
另外,在解决一些重复,匹配模式的字串问题,可以借助hash字典记录字符的出现次数。这些hash字典通常也可以成为窗口计数器来求解问题。
leetcode的643. 子数组最大平均数 I
借助上面的题目可以一窥滑动窗口套路。滑动窗口的本质是窗口扩大的过程中,寻找一个解,然后窗口缩小的时候寻找一个最优解。