【LeetCode滑动窗口专题】水果成篮 + 最小覆盖子串(hard)+ 字符串的排列

二刷刷到滑动窗口,发现有一些细节和遗漏,在此补充
实际上关于滑动窗口的题还有一题:最小长度的子数组
进入正题

水果成篮

LeetCode904水果成篮

你正在探访一家农场,农场从左到右种植了一排果树。这些树用一个整数数组 fruits 表示,其中 fruits[i] 是第 i 棵树上的水果 种类 。

你想要尽可能多地收集水果。然而,农场的主人设定了一些严格的规矩,你必须按照要求采摘水果:

你只有 两个 篮子,并且每个篮子只能装 单一类型 的水果。每个篮子能够装的水果总量没有限制。
你可以选择任意一棵树开始采摘,你必须从 每棵 树(包括开始采摘的树)上 恰好摘一个水果 。采摘的水果应当符合篮子中的水果类型。每采摘一次,你将会向右移动到下一棵树,并继续采摘。
一旦你走到某棵树前,但水果不符合篮子的水果类型,那么就必须停止采摘。
给你一个整数数组 fruits ,返回你可以收集的水果的 最大 数目。

示例 1:

输入:fruits = [1,2,1]
输出:3
解释:可以采摘全部 3 棵树。

示例 2:

输入:fruits = [0,1,2,2]
输出:3
解释:可以采摘 [1,2,2] 这三棵树。
如果从第一棵树开始采摘,则只能采摘 [0,1] 这两棵树。

示例 3:

输入:fruits = [1,2,3,2,2]
输出:4
解释:可以采摘 [2,3,2,2] 这四棵树。
如果从第一棵树开始采摘,则只能采摘 [1,2] 这两棵树。

思路

题目的要求,换个说法就是:找到一段数组中的元素,该段元素中类型最多为2种,如果段中元素达到3种就直接停止。我们的目标是使这个段中元素的个数尽可能多

例如:(示例3)

[[1,2],3,2,2]
[[1,2,3],2,2]

如果是从1开始取的话,到3就会停止,最终只能取到[1, 2],显然这不是题设规则下的最优解

如果我们从2开始取呢?

[1,[2,3],2,2]
[1,[2,3,2],2]
[1,[2,3,2,2]]

最后结果是[2,3,2,2],这是最优解

这里的“段”,实际上很类似窗口,由此可以联想到滑动窗口算法

我们需要维护的窗口内只能存在两种类型的数,一旦出现第三种,缩小窗口的左边界,直到再次满足条件后,继续使右边界扩大

思路很明确了,那怎么实现呢?关键点是如何记录元素出现次数

根据之前的经验,要记录某种东西出现的次数,可以考虑用哈希表

这里创建一个哈希表unordered_map,键是当前的遍历值,值是出现次数

判断哈希表的大小,一旦大于2,就触发循环,在map中找到以左指针指向的数组值为哈希表键的元素,将其移除即可(注意,要先删除干净其键对应的值,这里在下面细说),同时,left的指针向右移动。

最后,左右指针作差,更新结果保存变量(取最大的保存)

代码

坑(关于map的使用)

思路定下来了,写代码吧

这是根据思路写的第一版

class Solution {
public:
    int totalFruit(vector<int>& fruits) {
        //定义左指针
        int left = 0;
        int res = 0;
        unordered_map<int, int> typeCount;//定义一个哈希表,键是遍历值
        for(int right = 0; right < fruits.size(); ++right){
            typeCount[fruits[right]]++;
            while(typeCount.size() > 2){//如果类型大于2,开始移动左指针缩小窗口
                auto it = typeCount.find(fruits[left]);
                if(it != typeCount.end()){//找到左指针对应的键值,删除
                    typeCount.erase(it); 
                }
                left++;
            }
            res = max(res, right - left + 1);
        }
        return res;
    }
};

看起来没有什么问题,其实问题挺大的,并且很隐蔽

问题主要出现在以下部分:

				auto it = typeCount.find(fruits[left]);
                if(it != typeCount.end()){//找到左指针对应的键值,删除
                    typeCount.erase(it); 
                }

这里的本意是:在map中找到以fruits[left]为键的元素,然后将其移除。

这是符合我们之前的逻辑推导的,但是在代码落实的时候出问题了

在map中查找某个键对应的值,返回的是一个迭代体,我们可以通过it != typeCount.end()判断是否查找到对应的键值

这里在找到键值对后,我直接把返回的迭代体删了

只删迭代体对象没用啊,map中对应的键值对是不受影响的,也就是说,删了个寂寞

正确删除map中键值对的操作是:

  • 使用find找到键值对
  • 将键对应的值(it->second)全部删除
  • 判断当前键对应的值为0后,删除指向键值对的迭代体
完整代码

根据上述讨论修改后的代码如下:

class Solution {
public:
    int totalFruit(vector<int>& fruits) {
        int left = 0;//定义左指针
        int res = 0;
        unordered_map<int, int> typeCount;//定义一个哈希表,键是遍历值
        for(int right = 0; right < fruits.size(); ++right){
            typeCount[fruits[right]]++;//记录出现次数
            while(typeCount.size() > 2){//如果类型大于2,开始移动左指针缩小窗口
                auto it = typeCount.find(fruits[left]);//使用find找到键值对,并返回迭代体对象
                it->second--;//将键对应的值全部删除
                if(it->second == 0){
                    typeCount.erase(it); //删除指向键值对的迭代体
                }
                left++;//左指针右移
            }
            res = max(res, right - left + 1);//将最大值更新到结果变量
        }
        return res;
    }
};

本题思路其实不难想,主要问题出现在代码实现,对于map的使用不熟练

最小覆盖子串

LeetCode76最小覆盖子串

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。

注意:

对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例 1:

输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
示例 2:

输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。
示例 3:

输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。

思路

大体思路

从题目给的模板(minWindow)可以得到提示,这题可以用滑动窗口算法解(其实不看也应该能想到)

我们需要维护一个窗口

窗口的右边界不断遍历输入字符串s,往窗口内添加字符,直到当前窗口内出现目标字符串t中的所有字符,此时移动左边界来缩小窗口,取出冗余字符

好了,此题关于滑动窗口部分的思路讨论完毕

很清晰是吧?然后写代码的时候会发现根本写不出

因为很多思路中理所当然的地方用代码很难实现

代码实现思路

那么用代码写的时候应该怎么考虑呢?

我们要维护两个哈希表(unordered_map),hsht

ht用于统计目标字符串t中各字符出现的次数;

hs用于记录当前遍历字符出现的次数;(至于为什么是“次数”,后面会说)

定义结果字符串res、左指针变量left、计数变量count

右指针遍历输入字符串s,将遍历字符加入hs

如果当前遍历字符是目标字符串t中的目标字符,且出现次数小于等于ht中的出现次数,计数变量加1

代码实现中,记录当前遍历字符出现的次数是为了判断是否包含字符串t中所有的字符。

具体来说,算法维护两个哈希表:ht和hs,分别表示字符串t和当前窗口内的字符串s中每个字符的出现次数。

当扫描到一个新的字符时,程序会检查该字符是否在ht中出现。如果出现,就将hs中对应的计数器加1,并判断是否小于等于ht中的计数器。如果小于等于,说明该字符是目标字符之一,并且当前窗口内仍然存在未满足条件的目标字符,因此 count 计数器加1。

通过统计s中目标字符出现的次数,可以判断当前窗口内是否包含t中的所有字符。当count计数与t长度相等时表示当前窗口中已经包含有全部t中的目标字符,需要更新结果字符串res。

因此,记录遍历字符出现的次数在这个算法中起到至关重要的作用,它帮助我们确定了窗口内目标字符的数量,从而实现了正确的滑动窗口匹配。

首先,需要定义一堆变量

class Solution {//输入:s = "ADOBECODEBANC", t = "ABC"
public:
    string minWindow(string s, string t) {
        unordered_map<char, int>hash4s, hash4t;//用于统计s和t的hash表,键为对应字符,值为出现次数
        int left = 0;
        string res;//定义结果字符串
        int count = 0;//用于统计s中目标字符(t中字符)出现的次数
        ...
    }

然后我们遍历字符串t,将其中的字符出现次数统计到hash4t中

class Solution {
public:
    string minWindow(string s, string t) {
        ...
        for(int i = 0; i < t.size(); ++i){//统计t的字符出现次数
            hash4t[t[i]]++;
        }
    }

接下来开始遍历字符串s,将遍历到的元素统计到hash4s中,然后我们去hash4t中找当前hash4s中被记录的元素是否出现过

目的是为了看当前元素是不是目标元素,如果是目标元素,并且该元素在s中出现的次数少于t中出现的次数时,那么我们认为找到了一个有效的目标字符

class Solution {
public:
    string minWindow(string s, string t) {
        ...
        for(int i = 0; i < t.size(); ++i){//统计t的字符出现次数
            hash4t[t[i]]++;
        }
        for(int right = 0; right < s.size(); ++right){//遍历字符串s,对应字符出现后在hash4s记录
            hash4s[s[right]]++;
            if(hash4s[s[right]] <= hash4t[s[right]]){
                count++;//计数+1
            }
        }   
    }

这里需要特别解释一下(关于代码实现)

1、实际上hash4t在一开始的时候大小为3,为什么还能一直与大于3位置的元素进行比较

举个例子,我们一开始统计完t的元素后,hash4t={'A': 1, 'B': 1, 'C': 1}。

然后我们开始遍历s,第一次得到A,A在s中出现一次,在t中也出现一次,这是一个有效的目标字符,因此计数加1

第二次遍历s得到D,而D在t中是没有的,在hash4t中也没有,D不是目标字符,但因为进行了比较,所以hash4t中也要生成一个关于D的记录,此时hash4t={'A': 1, 'B': 1, 'C': 1, 'D': 0}

结论就是:哈希表会给不存在的键值一个默认值

继续

在遍历s的过程中,if条件不断被触发,当我们的窗口内收集到了3个目标字符(ADOBEC),此时count = 3

因为已经收集到3个有效的目标字符,所以我们要对当前子串进行保存

class Solution {
public:
    string minWindow(string s, string t) {
        ...
        for(int i = 0; i < t.size(); ++i){//统计t的字符出现次数
            hash4t[t[i]]++;
        }
        for(int right = 0; right < s.size(); ++right){//遍历字符串s,对应字符出现后在hash4s记录
            hash4s[s[right]]++;
            if(hash4s[s[right]] <= hash4t[s[right]]){
                count++;//计数+1
            }
            while(hash4s[s[left]] > hash4t[s[left]]){//此时,要移动左边界去除当前窗口中的冗余字符
            //注意,是先减值,再移动left!!!!
                hash4s[s[left]]--;//对应减少hs中的值,直到hs中的目标字符出现次数均与ht中相等
                left++;//移动左边界
            }
            if(count == t.size()){//找到3个有效目标字符
                if(res.empty() || right - left + 1 < res.size()){
                    res = s.substr(left, right - left + 1);//截取当前窗口内字符串作为结果字符串
                }
            }
        } 
    }

关键点!!!!!!!!

按理来说应该开始缩小窗口的左边界,但是如果现在马上缩的话,当前子串中目标字符的数量就会又不满足3个了,我们可能会因此漏掉某些情况或者需要进行多余的处理

比如如果变量到ADOBEC立刻缩小左边界,那么此时窗口内为DOBEC,没凑齐目标字符,因此右边界继续移动

当移动到DOBECODEB时,B出现了2次,此时仍然没有凑齐目标字符

再往后移动到了DOBECODEBA,终于又凑齐3个目标字符,但是该子串长于第一次找到的子串因此不更新,此时缩小左边界

缩到CODEBA,继续缩ODEBA,不满足了,右边界继续移动直到ODEBANC又再次满足

然后是缩小左边界,缩到ANC不满足条件,但是因为s已经遍历完成,因此我们需要回退到上一个满足条件的位置即BANC,期间我们要需要比较其是否为目前找到的最小子串,最后返回结果

根据上面的分析,如果我们立刻进行缩窗操作,最后也是可以得到结果的,但是需要进行更多的逻辑控制

因此我们选择先不缩小窗口,继续遍历

那么还是跟前面一样,遇到目标字符,并且目标字符在s中出现的次数小于t中的次数(有效目标字符),count就加1

否则就在hash4t中创建一个默认值,这样做你会发现当遍历到ADOBECODEB时,B已经出现了两次而我们还没有对其进行处理

先别急,继续遍历到ADOBECODEBA,OK此时我们要进行缩窗操作

class Solution {
public:
    string minWindow(string s, string t) {
        ...
        for(int i = 0; i < t.size(); ++i){//统计t的字符出现次数
            hash4t[t[i]]++;
        }
        for(int right = 0; right < s.size(); ++right){//遍历字符串s,对应字符出现后在hash4s记录
            hash4s[s[right]]++;
            if(hash4s[s[right]] <= hash4t[s[right]]){
                count++;//计数+1
            }
            while(hash4s[s[left]] > hash4t[s[left]]){//此时,要移动左边界去除当前窗口中的冗余字符
            //注意,是先减值,再移动left!!!!
                hash4s[s[left]]--;//对应减少hs中的值,直到hs中的目标字符出现次数均与ht中相等
                left++;//移动左边界
            }
        } 
    }

注意我们缩小左边界时的逻辑,下面以实例来说明

遍历到ADOBECODEBA时,此时

hash4t={'A': 1, 'B': 1, 'C': 1,'D': 0, 'O': 0, 'E': 0}

hash4s={'A': 2, 'B': 2, 'C': 1,'D': 1, 'O': 2, 'E': 2}

那么此时hash4s[s[left]] > hash4t[s[left]]这个条件会一直满足,因此s[left]在hash4s中的值会对应的被减少

同时left指针也不断往右移动,缩小窗口,最终left来到第二个B的位置,此时while循环的条件无法满足,跳出循环

ADOBECODEBANC
         ↑
        left

此时hash4s={'A': 1, 'B': 1, 'C': 0,'D': 0, 'O': 0, 'E': 0}

当前子串由于缺少C还不满足条件,且s还没有遍历完,因此right指针继续向右遍历s

当s遍历完成,C正好也获取到了,并且此时的子串也是长度最小的一个,返回结果即可

还要注意的一点是关于count的

其实我们可能会有一个惯性,认为只要count=3就必须更新子串,其实不是,虽然每次我们都触发count=3的if条件,但是如果当前子串的长度没有之前的小,也是不会更新最小子串的

也就是说,肯会有很多个满足条件的子串,但我们只保存最小的

完整代码

class Solution {
public:
    string minWindow(string s, string t) {
        unordered_map<char, int> hash4t, hash4s;
        int left = 0;
        int count = 0;
        string res;
        for(int i = 0; i < t.size(); ++i){//统计t的字符出现次数
            hash4t[t[i]]++;
        }
        //遍历字符串s
        for(int right = 0; right < s.size(); ++right){
            hash4s[s[right]]++;//标记出现过的元素
            if(hash4s[s[right]] <= hash4t[s[right]]){//如果当前字符为有效目标字符,计数加1
                count++;
            }
            //处理窗口左边界
            while(hash4s[s[left]] > hash4t[s[left]]){
                hash4s[s[left]]--;//对应计数值减减
                left++;//窗口左边界右移
            }
            //处理收集到3个有效目标字符时的情况
            if(count == t.size()){
                if(res.empty() || right - left + 1 < res.size()){//保存最小的子串
                    res = s.substr(left, right - left + 1);
                }
            }
        }
        return res;
    }
};

再提供一个Python版的方便忘了的时候自己debug想想

from collections import defaultdict

def minWindow(s, t):
    hash4s = defaultdict(int)
    hash4t = defaultdict(int)
    left = 0
    res = ""
    count = 0

    for char in t:
        hash4t[char] += 1

    for right in range(len(s)):
        hash4s[s[right]] += 1

        if hash4s[s[right]] <= hash4t[s[right]]:
            count += 1

        while hash4s[s[left]] > hash4t[s[left]]:
            hash4s[s[left]] -= 1
            left += 1

        if count == len(t):
            if res == "" or right - left + 1 < len(res):
                res = s[left:right + 1]

    return res

字符串的排列

https://leetcode.cn/problems/permutation-in-string/

给你两个字符串 s1s2 ,写一个函数来判断 s2 是否包含 s1 的排列。如果是,返回 true ;否则,返回 false

换句话说,s1 的排列之一是 s2子串

示例 1:

输入:s1 = "ab" s2 = "eidbaooo"
输出:true
解释:s2 包含 s1 的排列之一 ("ba").

示例 2:

输入:s1= "ab" s2 = "eidboaoo"
输出:false

提示:

  • 1 <= s1.length, s2.length <= 104
  • s1s2 仅包含小写字母

思路

本题为最小覆盖子串的青春版,主要思想是利用了滑动窗口+哈希表统计出现次数

首先,创建两个数组充当哈希表

class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        if(s1.size() > s2.size()) return false;
        vector<int> hash_s1(26, 0);//创建两个数组用于记录字符串出现的次数
        vector<int> hash_s2(26, 0);
        int winLen = s1.size();//获取窗口大小
    }
};

注意!!!还要将最小的字符串(也就是s1)的长度作为窗口大小保存

然后我们遍历两个字符串的前winLen个元素,即初始时窗口内的元素

class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        ...
       for(int i = 0; i < winLen; ++i){
           hash_s1[s1[i] - 'a']++;
           hash_s2[s2[i] - 'a']++;
       }
       if(hash_s1 == hash_s2) return true;
    }
};

以s1 = "ab" s2 = "eidbaooo"为例,那么此时有

hash_s1 = {0:1, 1:1}
hash_s2 = {5:1, 9:1}

这俩玩意显然不相等,要不然就触发返回条件了

接下来去遍历s2,注意遍历的起始点是winLen而不是0,并且遍历过程中我们需要不断移动窗口

class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        ...
       for(int right = winLen; right < s2.size(); ++right){
           hash_s2[s2[right - winLen] - 'a']--;
           hash_s2[s2[right] - 'a']++;
           if(hash_s1 == hash_s2) return true;
       }
       return hash_s1 == hash_s2;
    }
};

移动窗口的过程中,不断判断两个哈希表是否满足条件,满足就返回

遍历结束再次判断,返回判断结果

代码

class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        if(s1.size() > s2.size()) return false;
        vector<int> hash_s1(26, 0);//创建两个数组用于记录字符串出现的次数
        vector<int> hash_s2(26, 0);
        int winLen = s1.size();//获取窗口大小

        for(int i = 0; i < winLen; ++i){//遍历两字符串的前winLen个值,也就是第一个窗口中的值
            hash_s1[s1[i] - 'a']++;
            hash_s2[s2[i] - 'a']++;
        }//如果此时两个哈希表直接相等了,那么说明在第一个窗口就已经确定s2包含s1的排列之一,返回结果即可
        if(hash_s1 == hash_s2) return true;

        //开始从第二个窗口遍历s2
        for(int right = winLen; right < s2.size(); ++right){
            hash_s2[s2[right - winLen] - 'a']--;//窗口的左边界向右移动
            hash_s2[s2[right] - 'a']++;//窗口的右边界向左移动
            //窗口滑动完成,比较此时两哈希表是否相等
            if(hash_s1 == hash_s2) return true;
            //不相等就继续滑动
        }//遍历完s2最后再判断一次两表是否相等,返回结果
        return hash_s1 == hash_s2;
    }
};

从本题可以直观体会到,双指针与滑动窗口的一个区别

滑动窗口的精髓在于"缩小窗口"这一操作,不同的场景会有不同的缩小时机

本题中,窗口是固定的,因此右边界移动时左边界也必须跟着移动来保证窗口大小固定

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

https://leetcode.cn/problems/find-all-anagrams-in-a-string/description/

给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。

示例 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" 的异位词。

提示:

1 <= s.length, p.length <= 3 * 104
s 和 p 仅包含小写字母

代码

class Solution {
public:
    //滑动窗口
    vector<int> findAnagrams(string s, string p) {
        if(s.size() < p.size()) return vector<int>();
        vector<int> hash_s(26, 0);
        vector<int> hash_p(26, 0);
        int winLen = p.size();
        vector<int> res;

        //遍历两个字符串的初始窗口内的元素
        for(int i = 0 ; i < winLen; ++i){
            hash_s[s[i] - 'a']++;
            hash_p[p[i] - 'a']++;
        }
        //此时如果俩hash相等,那么就获得一个结果,保存其位置(位置就是0处)
        if(hash_s == hash_p) res.push_back(0);

        //然后从第二个窗口开始遍历
        for(int right = winLen; right  < s.size(); ++right){
            hash_s[s[right - winLen] - 'a']--;//左边界缩窗
            hash_s[s[right] - 'a']++;//右边界移动
            if(hash_s == hash_p) res.push_back(right - winLen + 1);
        }
        return res;
    }
};
posted @ 2023-03-27 21:35  dayceng  阅读(84)  评论(0编辑  收藏  举报