xiedaxia1hao / leetcode_pattern

1 stars 0 forks source link

Sliding Window #7

Open xiedaxia1hao opened 3 years ago

xiedaxia1hao commented 3 years ago

leetcode的643. 子数组最大平均数 I

给定 n 个整数,找出平均数最大且长度为 k 的连续子数组,并输出该最大平均数。 示例 1: 输入: [1,12,-5,-6,50,3], k = 4 输出: 12.75 解释: 最大平均数 (12-5-6+50)/4 = 51/4 = 12.75

// Time Complexity: O(n)
// Space Complexity: O(1)
class Solution {
    public double findMaxAverage(int[] nums, int k) {
        if(nums == null || nums.length < 1) {
            return 0;
        }

        /**
         * In java, Double.MIN_VALUE is actually a positive number,
         * so we have to use -Double.MAX_VALUE or Double.NEGATIVE_INFINITY at the beginning
         * if we want to keep a double value max as the answer.
         */
        double res = Double.NEGATIVE_INFINITY;
        int sum = 0, lo = 0;
        for(int hi = 0; hi < nums.length; hi++) {
            sum += nums[hi];
            if(hi >= k-1) {
                res = Math.max(res, sum * 1.0 / k);
                sum -= nums[lo++];
            }
        }
        return res;
    }
}

借助上面的题目可以一窥滑动窗口套路。滑动窗口的本质是窗口扩大的过程中,寻找一个解,然后窗口缩小的时候寻找一个最优解。

xiedaxia1hao commented 3 years ago

滑动窗口的模版

209. 长度最小的子数组

给定一个含有 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++])
        }
}
xiedaxia1hao commented 3 years ago

窗口计数器

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进行举例。

image

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上也有这么一道:

904. 水果成篮

在一排树中,第 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;
    }
}
xiedaxia1hao commented 3 years ago

滑动窗口的解

正确的设置滑动计数器,以便在合适的时候缩小窗口,这直接关系到滑动窗口的解。正如水果成篮这一题,需要花点时间将题目抽象成滑动窗口的类型,然后才能针对性的进行求解。下面再看两个这样转换思路的题目。

1004. 最大连续1的个数 III

给定一个由若干 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;
    }
}

这一题的关键是如何找到滑动窗口的解。通常情况下,都会利用连续和匹配这样的转换技巧。解决了这一题,下面的一题也就迎刃而解了

424. 替换后的最长重复字符

给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 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;
    }
}
xiedaxia1hao commented 3 years ago

滑动窗口和匹配

567. 字符串的排列

给定两个字符串 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;
    }
}

438. 找到字符串中所有字母异位词

给定一个字符串 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;
    }
}

76. 最小覆盖子串

给你一个字符串 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);
    }
}
xiedaxia1hao commented 3 years ago

字符与单词的滑动

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 的步长设置为 单词的字符数大小。即一次向右移动三格。

image

解决的思路也很简单,让原始输入的字串的每个字符都成为一次起始字符,再使用上面滑动窗口作为子函数求解即可。*由题意已知,起始知道每次需要匹配判断字串长度(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)

xiedaxia1hao commented 3 years ago

总结

综上所述,滑动窗口套路对于一些求解连续的字串子数组的问题非常有用。滑动窗口的思路就是设置一个再字符串或数组上进行滑动。窗口扩大的时候寻找一个解,窗口缩小的时候寻找最优解。滑动窗口解法的模板就比较死板。先将窗口不断右移,每次移动之后,使用窗口计算器判断解,判断窗口是否可以缩小。然后再循环往复。

这里的关键是如何定义窗口计数器和找出解。或者如何将一个问题抽象成滑动窗口问题。需要不断的进行练习和总结。

另外,在解决一些重复,匹配模式的字串问题,可以借助hash字典记录字符的出现次数。这些hash字典通常也可以成为窗口计数器来求解问题。