twoPointer-SlidingWindow滑动窗口-明显版

本文写几个比较明显需要用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

3. Longest Substring Without Repeating Characters

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里清除掉,而是留在里面。在每次判断右边新进来的字母是否在窗内的时候,通过下标来得知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;
    }
}

159. Longest Substring with At Most Two Distinct Characters

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;
    }
}

209. Minimum Size Subarray Sum

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.

  • 前面3和159是求最长窗,是尽量“拓展右边界”,整个过程由right的for-loop控制,在循环体内部检查只有满足收缩左边界的条件的时候,才启动收缩左边界的操作,于是用if-condition。
  • 而这个209是求最短窗,是尽量“收缩左边界”。整个外层循环依然是由right的for-loop控制,于是内部不能用if-condition,而是用while-loop来“尽最大努力收缩”。

这个题的窗户的要求比前面更简单,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;
    }
}

76. Minimum Window Substring

Input: S = “ADOBECODEBANC”, T = “ABC”
Output: “BANC”

对窗口的要求:T中所有字母A,B,C,在S中的出现次数都比T中多(或等于)。–>还是用hashmap来维护。key是字母,value是该字母在窗口中的数量。

  • 右边界每拓展一位,就把hashmap中该字母的出现次数加一。
  • 左边界每收缩一位,就把hashmap中该字母的出现次数减一。

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) : "";
    }
}

438. Find All Anagrams in a String

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里。

这里左右边界的变化,“拓展右边界”,“收缩左边界”的交替,是依次分别走一步的:

  • “拓展右边界”:right的for-loop。
  • “收缩左边界”:进入full-length阶段之后,每向右拓展一位,right - left + 1 == p.length()就满足了,就左边收缩一位。
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;
    }
}

239. Sliding Window Maximum

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做的:

  • 272用deque是为了窗可以左右任意移动poll(), pollLast()。
  • 239用deque,左端poll()是为了收缩左边界,右端pollLast()是为了遇到了大的,把queue里所有小的都扔掉。

这里的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;
    }
}

272. Closest Binary Search Tree Value II

在“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);
    }
}

你可能感兴趣的:(leetcode,数据结构)