【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),hs和ht
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/
给你两个字符串 s1
和 s2
,写一个函数来判断 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
s1
和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();//获取窗口大小
}
};
注意!!!还要将最小的字符串(也就是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;
}
};