我学习了一个模版,竟然在LeetCode上AC了14道题?!(滑动窗口)

本博客是我在学习滑动窗口的心得与体会,借鉴的地方均已标注。

什么时候需要用滑动窗口?

在一个序列中查找另一个序列:76、567、438;

在一个序列中查找 “最”xx 的序列:3、209、904;

滑动窗口的三个难点问题:

  1. 什么时候应该扩大窗口?

  2. 什么时候应该缩小窗口?

  3. 什么时候得到一个合法的答案?

看看 labuladong 提供的模版:

/* 滑动窗口算法框架 */
public void slidingWindow(String s, String t) {
    // need 是需要的
    Map<Character, Integer> need = new HashMap<>(), window = new HashMap<>();
    for (int i = 0; i < t.length(); i++) {
        char ch = t.charAt(i);
        need.put(ch, need.getOrDefault(ch, 0) + 1);
    }
    int left = 0, right = 0, valid = 0;
    // 此处可以自由添加变量,方便得到答案
    while (right < s.size()) {
        // c 是将移入窗口的字符
        char c = s.charAt(right);
        // 右移窗口
        right++;
        // 进行窗口内数据的一系列更新
        ...

            /*** debug 输出的位置 ***/
            System.out.println("window: ["+left+","+ right+")");
        /********************/

        // 判断左侧窗口是否要收缩
        while (window needs shrink) {
            // d 是将移出窗口的字符
            char d = s[left];
            // 左移窗口
            left++;
            // 进行窗口内数据的一系列更新
            ...
        }
    }
}

其中两处 ... 表示的更新窗口数据的地方,到时候你直接往里面填就行了。

而且,这两个 ... 处的操作分别是右移和左移窗口更新操作,等会你会发现它们操作是 完全对称 的。

right++ 放在前面和后面的区别:放在前面,在统计长度的时候就不需要 + 1,因为右窗已经移动过;放在后面,在统计长度的时候就需要 + 1,因为在最后才移动右窗。

我们统一将 right++ 放在前面。

76. 最小覆盖子串

套模版:

class Solution {
    public String minWindow(String s, String t) {
        Map<Character, Integer> need = new HashMap<>(), window = new HashMap<>();
        int start = 0, end = 0, valid = 0;
        // 这两个变量用来记录截串的位置和长度
        int begin = 0, size = Integer.MAX_VALUE;
        for (int i = 0; i < t.length(); i++) {
            char ch = t.charAt(i);
            need.put(ch, need.getOrDefault(ch, 0) + 1);
        }
        while (end < s.length()) {
            char ch = s.charAt(end);
            end++;
            if (need.containsKey(ch)) {
                window.put(ch, window.getOrDefault(ch, 0) + 1);
                if (window.get(ch).equals(need.get(ch))) {
                    valid++;
                }
            }
            // 难点:什么时候需要收缩呢?
            // 对于本题,可以想到,只要所需的字符和窗口中对应字符的大小一致时,即可收缩窗口
            while (valid == need.size()) {
                // 难点:什么时候需要更新答案呢?
                // 对于本题,只要窗口的长度比原来小,就需要更新
                // 注意这里并没有 + 1 操作
                if (end - start < size) {
                    size = end - start;
                    begin = start;
                }
                char d = s.charAt(start);
                start++;
                if (need.containsKey(d)) {
                    if (window.get(d).equals(need.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d) - 1);
                }
            }
        }
        return size == Integer.MAX_VALUE ? "" : s.substring(begin, size + begin);
    }
}

执行用时:10 ms, 在所有 Java 提交中击败了65.02%的用户

内存消耗:42.5 MB, 在所有 Java 提交中击败了8.73%的用户

考虑优化时间:

从上面的执行时间来看,此模版方法时间较慢,由于数组的查询效率高于哈希表(直接通过索引查找),因此我们可以考虑使用数组进行优化。

class Solution {
    public String minWindow(String s, String t) {
        int[] need = new int[128], window = new int[128];
        // 用来统计 need 中非零元素的个数
        int count = 0;
        for (int i = 0; i < t.length(); i++) {
            if (need[t.charAt(i)] == 0) {
                count++;
            }
            need[t.charAt(i)]++;
        }
        int start = 0, end = 0, valid = 0;
        int size = Integer.MAX_VALUE, begin = 0;
        while (end < s.length()) {
            char c = s.charAt(end);
            end++;
            if (need[c] > 0) {
                window[c]++;
                if (need[c] == window[c]) {
                    valid++;
                }
            }
            while (valid == count) {
                if (end - start < size) {
                    begin = start;
                    size = end - start;
                }
                char d = s.charAt(start);
                start++;
                if (need[d] > 0) {
                    if (need[d] == window[d]) {
                        valid--;
                    }
                    window[d]--;
                }
            }
        }
        return size == Integer.MAX_VALUE ? "" : s.substring(begin, begin + size);
    }
}

执行用时:2 ms, 在所有 Java 提交中击败了96.02%的用户

内存消耗:41.2 MB, 在所有 Java 提交中击败了94.68%的用户

运行时间有大幅度的减少,数组确实是比哈希表在查找上更快一些。

567. 字符串的排列

套模版:

class Solution {
    public boolean checkInclusion(String s1, String s2) {
        Map<Character, Integer> need = new HashMap<>(), window = new HashMap<>();
        for (int i = 0; i < s1.length(); i++) {
            char ch = s1.charAt(i);
            need.put(ch, need.getOrDefault(ch, 0) + 1);
        }
        int start = 0, end = 0, valid = 0;
        while (end < s2.length()) {
            char c = s2.charAt(end);
            end++;
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (need.get(c).equals(window.get(c))) {
                    valid++;
                }
            }
            // 难点:什么时候需要收缩呢?
            // 这里有两种方式
            // 第一种,就是本代码的判断方式
            // 第二种,是判断 end - start >= s1.length(),再将下面的 if 条件改为 valid == need.size()
            // 这两种有什么区别?
            // 个人觉得两种都能通过,区别不大:第一种是先保证窗口包含了所有的字符串,再收缩窗口;第二种是先保证窗口长度先满足条件,再收缩窗口。
            while (valid == need.size()) {
                if (end - start == s1.length()) return true;
                char d = s2.charAt(start);
                start++;
                if (need.containsKey(d)) {
                    if (need.get(d).equals(window.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d) - 1);
                }
            }
        }
        return false;
    }
}

执行用时:24 ms, 在所有 Java 提交中击败了23.70%的用户

内存消耗:41.1 MB, 在所有 Java 提交中击败了76.46%的用户

考虑优化时间:

同样,可以使用数组进行优化。

class Solution {
    public boolean checkInclusion(String s1, String s2) {
        int[] need = new int[128], window = new int[128];
        int count = 0;
        for (int i = 0; i < s1.length(); i++) {
            if (need[s1.charAt(i)] == 0) {
                count++;
            }
            need[s1.charAt(i)]++;
        }
        int start = 0, end = 0, valid = 0;
        while (end < s2.length()) {
            char c = s2.charAt(end);
            end++;
            if (need[c] > 0) {
                window[c]++;
                if (need[c] == window[c]) {
                    valid++;
                }
            }
            while (valid == count) {
                if (end - start == s1.length()) return true;
                char d = s2.charAt(start);
                start++;
                if (need[d] > 0) {
                    if (need[d] == window[d]) {
                        valid--;
                    }
                    window[d]--;
                }
            }
        }
        return false;
    }
}

执行用时:5 ms, 在所有 Java 提交中击败了77.27%的用户

内存消耗:41.1 MB, 在所有 Java 提交中击败了74.33%的用户

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

这题其实和上题一样,只不过这题要返回所有排列的起始下标罢了。

套模版:

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        Map<Character, Integer> need = new HashMap<>(), window = new HashMap<>();
        for (int i = 0; i < p.length(); i++) {
            char ch = p.charAt(i);
            need.put(ch, need.getOrDefault(ch, 0) + 1);
        }
        int start = 0, end = 0, valid = 0;
        List<Integer> res = new ArrayList<>();
        while (end < s.length()) {
            char c = s.charAt(end);
            end++;
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (need.get(c).equals(window.get(c))) {
                    valid++;
                }
            }
            while (valid == need.size()) {
                if (end - start == p.length()) {
                    res.add(start);
                }
                char d = s.charAt(start);
                start++;
                if (need.containsKey(d)) {
                    if (need.get(d).equals(window.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d) - 1);
                }
            }
        }
        return res;
    }
}

执行用时:25 ms, 在所有 Java 提交中击败了34.36%的用户

内存消耗:42.3 MB, 在所有 Java 提交中击败了71.27%的用户

考虑优化时间:

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        int[] need = new int[128], window = new int[128];
        int count = 0;
        for (int i = 0; i < p.length(); i++) {
            if (need[p.charAt(i)] == 0) {
                count++;
            }
            need[p.charAt(i)]++;
        }
        int start = 0, end = 0, valid = 0;
        List<Integer> res = new ArrayList<>();
        while (end < s.length()) {
            char c = s.charAt(end);
            end++;
            if (need[c] > 0) {
                window[c]++;
                if (need[c] == window[c]) {
                    valid++;
                }
            }
            while (valid == count) {
                if (end - start == p.length()) {
                    res.add(start);
                }
                char d = s.charAt(start);
                start++;
                if (need[d] > 0) {
                    if (need[d] == window[d]) {
                        valid--;
                    }
                    window[d]--;
                }
            }
        }
        return res;
    }
}

执行用时:6 ms, 在所有 Java 提交中击败了90.23%的用户

内存消耗:42.2 MB, 在所有 Java 提交中击败了82.44%的用户

3. 无重复字符的最长子串

这题就和上面的有所不同了,不过看到题干还是很容易想到用滑动窗口来解决的。

套模版:

class Solution {
    public int lengthOfLongestSubstring(String s) {
        Map<Character, Integer> window = new HashMap<>();
        int start = 0, end = 0, res = 0;
        while (end < s.length()) {
            char c = s.charAt(end);
            end++;
            window.put(c, window.getOrDefault(c, 0) + 1);
            // 难点:什么时候需要收缩呢?事实上,本题收缩的条件很简单,因为要求无重复,所以当重复的时候就需要收缩了。
            while (window.get(c) > 1) {
                char d = s.charAt(start);
                start++;
                window.put(d, window.get(d) - 1);
            }
            // 难点:在哪里更新答案?前面都是在内层 while 循环中更新答案,但此题在内层更新并不满足无重复的条件,因此,需要在收缩完毕后,更新答案。
            // 这个地方是本题与上面其它模版题的不同之处。
            res = Math.max(res, end - start);
        }
        return res;
    }
}

执行用时:9 ms, 在所有 Java 提交中击败了20.01%的用户

内存消耗:41.8 MB, 在所有 Java 提交中击败了25.20%的用户

考虑优化时间:

class Solution {
    public int lengthOfLongestSubstring(String s) {
        int[] window = new int[128];
        int start = 0, end = 0, res = 0;
        while (end < s.length()) {
            char c = s.charAt(end);
            end++;
            window[c]++;
            while (window[c] > 1) {
                char d = s.charAt(start);
                start++;
                window[d]--;
            }
            res = Math.max(res, end - start);
        }
        return res;
    }
}

执行用时:2 ms, 在所有 Java 提交中击败了92.01%的用户

内存消耗:41.7 MB, 在所有 Java 提交中击败了34.56%的用户

209. 长度最小的子数组

这题又和上面那题类似了,只不过用 window 作为窗口数组的和。

套模版:

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        int window = 0;
        int start = 0, end = 0, res = Integer.MAX_VALUE;
        while (end < nums.length) {
            int num = nums[end];
            end++;
            window += num;
            while (window >= target) {
                res = Math.min(res, end - start);
                int num2 = nums[start];
                start++;
                window -= num2;
            }
        }
        return res == Integer.MAX_VALUE ? 0 : res;
    }
}

执行用时:1 ms, 在所有 Java 提交中击败了99.98%的用户

内存消耗:48.5 MB, 在所有 Java 提交中击败了13.88%的用户

904. 水果成篮

这题和 3. 无重复字符的最长子串 类似。

套模版:

class Solution {
    public int totalFruit(int[] fruits) {
        Map<Integer, Integer> window = new HashMap<>();
        int start = 0, end = 0, res = 0;
        while (end < fruits.length) {
            int num = fruits[end];
            end++;
            window.put(num, window.getOrDefault(num, 0) + 1);
            while (window.size() > 2) {
                int num2 = fruits[start];
                start++;
                window.put(num2, window.get(num2) - 1);
                if (window.get(num2) == 0) {
                    window.remove(num2);
                }
            }
            res = Math.max(res, end - start);
        }
        return res;
    }
}

执行用时:57 ms, 在所有 Java 提交中击败了19.08%的用户

内存消耗:50.7 MB, 在所有 Java 提交中击败了28.29%的用户

三指针的速度可能较快,且空间复杂度为 O ( 1 ) O(1) O(1),可以考虑使用三指针。

30. 串联所有单词的子串

438. 找到字符串中所有字母异位词 的进阶版,此题套用模版有相当多的细节,请仔细阅读。

首先,需要将此题转化为 438. 找到字符串中所有字母异位词,记 w o r d s words words 的长度为 m m m w o r d s words words 中每个单词的长度为 n n n s s s 的长度为 l s ls ls。将 s s s 划分为单词组,每个单词的大小均为 n n n (首尾除外)。这样的划分方法有 n n n 种,即先删去前 i i i i = 0 ∼ n − 1 i=0∼n−1 i=0n1)个字母后,将剩下的字母进行划分,如果末尾有不到 n n n 个字母也删去。对这 n n n 种划分得到的单词数组分别使用滑动窗口对 w o r d s words words 进行类似于「字母异位词」的搜寻。(这里参考了 官方题解

套模版(细节见注释):

class Solution {
    public List<Integer> findSubstring(String s, String[] words) {
        Map<String, Integer> need = new HashMap<>(), window = new HashMap<>();
        for (String word : words) {
            need.put(word, need.getOrDefault(word, 0) + 1);
        }
        int len = words[0].length();
        List<Integer> res = new ArrayList<>();
        for (int i = 0; i < len; i++) {
            // 模版套在 for 循环里面
            int start = i, end = i, valid = 0;
            // 注意,每次循环结束,需要清空 window 中的数
            window.clear();
            // 这里为什么要这样写?当需要判断是否能取到时,可以直接用一个例子来验证。
            while (end < s.length() - len + 1) {
                String s1 = s.substring(end, end + len);
                end += len;
                if (need.containsKey(s1)) {
                    window.put(s1, window.getOrDefault(s1, 0) + 1);
                    if (need.get(s1).equals(window.get(s1))) {
                        valid++;
                    }
                }
                while (valid == need.size()) {
                    if (end - start == len * words.length) {
                        res.add(start);
                    }
                    String s2 = s.substring(start, start + len);
                    start += len;
                    if (need.containsKey(s2)) {
                        if (need.get(s2).equals(window.get(s2))) {
                            valid--;
                        }
                        window.put(s2, window.get(s2) - 1);
                    }
                }
            }
        }
        return res;
    }
}

执行用时:6 ms, 在所有 Java 提交中击败了97.90%的用户

内存消耗:42.6 MB, 在所有 Java 提交中击败了7.58%的用户

632. 最小区间

本题的核心在于通过构建映射,转换为 76. 最小覆盖子串

参考 官方题解

我们先思考这样一个问题:有一个序列 A = { a 1 , a 2 , ⋯ , a n } A=\{a_1,a_2,⋯,a_n\} A={a1,a2,,an} 和一个序列 B = { b 1 , b 2 , ⋯ , b m } B=\{b_1,b_2,⋯,b_m\} B={b1,b2,,bm},请找出一个 B B B 中的一个最小的区间,使得在这个区间中 A A A 序列的每个数字至少出现一次,请注意 A A A 中的元素可能重复,也就是说如果 A A A 中有 p p p u u u,那么你选择的这个区间中 u u u 的个数一定不少于 p p p。没错,这就是:「76. 最小覆盖子串」。可以使用了一种滑动窗口的方法,遍历整个 B B B 序列并用一个哈希表表示当前窗口中的元素:

  • 右边界在每次遍历到新元素的时候右移,同时将拓展到的新元素加入哈希表;
  • 左边界右移当且仅当当前区间为一个合法的答案区间,即当前窗口内的元素包含 A A A 中所有的元素,同时将原来左边界指向的元素从哈希表中移除;
  • 答案更新当且仅当当前窗口内的元素包含 A 中所有的元素。

回到这道题,我们发现这两道题的相似之处在于都要求我们找到某个符合条件的最小区间,我们可以借鉴「76. 最小覆盖子串」的做法:这里序列 { 0 , 1 , ⋯ , k − 1 } \{0,1,⋯,k−1\} {0,1,,k1} 就是上面描述的 A A A 序列,即 k k k 个列表,我们需要在一个 B B B 序列当中找到一个区间,可以覆盖 A A A 序列。这里的 B B B 序列是什么?我们可以用一个哈希映射来表示 B B B 序列—— B [ i ] B[i] B[i] 表示 i i i 在哪些列表当中出现过,这里哈希映射的键是一个整数,表示列表中的某个数值,哈希映射的值是一个数组,这个数组里的元素代表当前的键出现在哪些列表里。也许文字表述比较抽象,大家可以结合下面这个例子来理解。

  • 如果列表集合为:

    0: [-1, 2, 3]
    1: [1]
    2: [1, 2]
    3: [1, 1, 3]
    
  • 那么可以得到这样一个哈希映射

    -1: [0]
     1: [1, 2, 3, 3]
     2: [0, 2]
     3: [0, 3]
    

我们得到的这个哈希映射就是这里的 B B B 序列。我们要做的就是在 B B B 序列上使用两个指针维护一个滑动窗口,并用一个哈希表维护当前窗口中已经包含了哪些列表中的元素,记录它们的索引。遍历 B B B 序列的每一个元素:

  • 指向窗口右边界的指针右移当且仅当每次遍历到新的元素,并将这个新的元素对应的值数组中的每一个数加入到哈希表中;
  • 指向窗口左边界的指针右移当且仅当当前区间内的元素包含 A A A 中所有的元素,同时将原来左边界对应的值数组的元素们从哈希表中移除;
  • 答案更新当且仅当当前窗口内的元素包含 A A A 中所有的元素。

细节参考代码:

class Solution {
    public int[] smallestRange(List<List<Integer>> nums) {
        int n = nums.size();
        Map<Integer, List<Integer>> indices = new HashMap<>();
        int xMin = Integer.MAX_VALUE, xMax = Integer.MIN_VALUE;
        for (int i = 0; i < n; i++) {
            for (int x : nums.get(i)) {
                List<Integer> list = indices.getOrDefault(x, new ArrayList<>());
                list.add(i);
                indices.put(x, list);
                xMin = Math.min(xMin, x);
                xMax = Math.max(xMax, x);
            }
        }
        Map<Integer, Integer> need = new HashMap<>(), window = new HashMap<>();
        int start = xMin, end = xMin, valid = 0;
        int bestStart = xMin, size = Integer.MAX_VALUE;
        for (int i = 0; i < n; i++) {
            need.put(i, 1);
        }
        // 小于等于,因为 end 会取到 xMax
        while (end <= xMax) {
            // 这里要先判断哈希表是否存在键
            if (indices.containsKey(end)) {
                List<Integer> list = indices.get(end);
                end++;
                // 遍历值
                for (int x : list) {
                    if (need.containsKey(x)) {
                        window.put(x, window.getOrDefault(x, 0) + 1);
                        if (need.get(x).equals(window.get(x))) {
                            valid++;
                        }
                    }
                }
                while (valid == need.size()) {
                    if (end - start < size) {
                        size = end - start;
                        bestStart = start;
                    }
                    if (indices.containsKey(start)) {
                        List<Integer> list2 = indices.get(start);
                        start++;
                        for (int x : list2) {
                            if (need.containsKey(x)) {
                                if (need.get(x).equals(window.get(x))) {
                                    valid--;
                                }
                                window.put(x, window.get(x) - 1);
                            }
                        }
                    } else {
                        start++;
                    }
                }
            } else {
                end++;
            }
        }
        return new int[]{bestStart, bestStart + size - 1};
    }
}

执行用时:83 ms, 在所有 Java 提交中击败了8.92%的用户

内存消耗:49.2 MB, 在所有 Java 提交中击败了13.05%的用户

虽然速度较慢,但毕竟是套模版的,知足吧。

1438. 绝对差不超过限制的最长连续子数组

这题和 3. 无重复字符的最长子串 类似,只不过使用 TreeMap 方便对窗口的判断。

套模版:

class Solution {
    public int longestSubarray(int[] nums, int limit) {
        TreeMap<Integer, Integer> window = new TreeMap<>();
        int start = 0, end = 0;
        int res = 0;
        while (end < nums.length) {
            int num = nums[end];
            end++;
            window.put(num, window.getOrDefault(num, 0) + 1);
            while (window.lastKey() - window.firstKey() > limit) {
                int num2 = nums[start];
                start++;
                window.put(num2, window.get(num2) - 1);
                if (window.get(num2) == 0) {
                    window.remove(num2);
                }
            }
            res = Math.max(res, end - start);
        }
        return res;
    }
}

执行用时:65 ms, 在所有 Java 提交中击败了39.90%的用户

内存消耗:49.5 MB, 在所有 Java 提交中击败了65.60%的用户

也可以维护两个单调队列作为 window

class Solution {
    public int longestSubarray(int[] nums, int limit) {
        Deque<Integer> queMax = new LinkedList<Integer>();
        Deque<Integer> queMin = new LinkedList<Integer>();
        int n = nums.length;
        int start = 0, end = 0;
        int res = 0;
        while (end < n) {
            while (!queMax.isEmpty() && queMax.peekLast() < nums[end]) {
                queMax.pollLast();
            }
            while (!queMin.isEmpty() && queMin.peekLast() > nums[end]) {
                queMin.pollLast();
            }
            queMax.offerLast(nums[end]);
            queMin.offerLast(nums[end]);
            end++;
            while (!queMax.isEmpty() && !queMin.isEmpty() && queMax.peekFirst() - queMin.peekFirst() > limit) {
                if (nums[start] == queMin.peekFirst()) {
                    queMin.pollFirst();
                }
                if (nums[start] == queMax.peekFirst()) {
                    queMax.pollFirst();
                }
                start++;
            }
            res = Math.max(res, end -start);
        }
        return res;
    }
}

执行用时:30 ms, 在所有 Java 提交中击败了72.23%的用户

内存消耗:53.4 MB, 在所有 Java 提交中击败了6.31%的用户

剑指 Offer 48. 最长不含重复字符的子字符串

此题和 3. 无重复字符的最长子串 是一样的。

剑指 Offer II 014. 字符串中的变位词

此题和 567. 字符串的排列 是一样的。

剑指 Offer II 015. 字符串中的所有变位词

此题和 438. 找到字符串中所有字母异位词 是一样的。

剑指 Offer II 016. 不含重复字符的最长子字符串

此题和 3. 无重复字符的最长子串 是一样的。

剑指 Offer II 017. 含有所有字符的最短字符串

此题和 76. 最小覆盖子串 是一样的。

总结:万变不离其宗,掌握了滑动窗口的精髓,下一步就是将其它问题转换为滑动窗口问题(这点是模版无法提供的),还是需要多刷多总结。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值