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);
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值