【算法】专题二:滑动窗口之水果成蓝,找到字符串中所有字⺟异位词等算法题

一.水果成蓝

题目链接:438. 找到字符串中所有字母异位词 - 力扣(LeetCode)

题目描述:

i 是树的位置(第几棵树),fruits[i] 是这棵树上的水果品种编号。fruits[0] = 1 表示第 0 棵树上是 1 号水果,fruits[2] = 1 表示第 2 棵树上也是 1 号水果------同一种水果可以种在多棵树上

这里面的内容看起来很拗口,简单来说就是一句话:你有一排果树,从左往右走,手里最多只能拿两种水果,问最多能摘多少棵。

第一步:只走一遍,用一个窗口

想象你有一个可以伸缩的"窗口",从左往右滑过整个数组。窗口里的内容就是你已经摘到的水果。

规则: 窗口里最多只能有两种数字。一旦出现第三种,窗口左边就得缩,直到只剩两种为止。

窗口在数组上滑动:

cpp 复制代码
[1, 2, 1, 3, 2]
 ↑
left/right

一开始窗口只有一个数字 1,合法,长度1。

第二步:右边界一直往右伸

右边界往右走,把新数字拉进来。

cpp 复制代码
​[1, 2, 1, 3, 2]
 ↑     ↑
 left  right​

窗口 [1,2,1] 里有1和2两种,合法,长度3。

第三步:当出现第三种时,左边界往右缩

右边界继续走:

cpp 复制代码
[1, 2, 1, 3, 2]
 ↑        ↑
 left     right

窗口 [1,2,1,3] 里有1、2、3三种,不合法了。

怎么办?咱就左边开始缩:

text

cpp 复制代码
[1, 2, 1, 3, 2]
    ↑     ↑
    left  right

窗口 [2,1,3] 还是有1、2、3三种,继续缩:

cpp 复制代码
[1, 2, 1, 3, 2]
       ↑  ↑
       left right

窗口 [1,3] 只有1和3两种,合法了。当前长度2。

第四步:继续往前走

右边界继续走:

cpp 复制代码
[1, 2, 1, 3, 2]
       ↑     ↑
       left  right

窗口 [1,3,2] 有1、2、3三种,又不合法了,缩左边:

cpp 复制代码
[1, 2, 1, 3, 2]
          ↑  ↑
          left right

窗口 [3,2] 只有3和2两种,合法,长度2。

整个过程走完了,最长的长度是3。

算法原理:

滑动窗⼝

算法思路:

研究的对象是⼀段连续的区间,可以使⽤滑动窗⼝思想来解决问题。让滑动窗⼝满⾜:窗⼝内⽔果的种类只有两种。

做法:右端⽔果进⼊窗⼝的时候,⽤哈希表统计这个⽔果的频次。这个⽔果进来后,判断哈希表的⼤⼩:

  • 如果⼤⼩超过 2:说明窗⼝内⽔果种类超过了两种。那么就从左侧开始依次将⽔果划出窗⼝,直到哈希表的⼤⼩⼩于等于 2,然后更新结果;
  • 如果没有超过 2,说明当前窗⼝内⽔果的种类不超过两种,直接更新结果 ret。
    算法流程:

a. 初始化哈希表 hash 来统计窗⼝内⽔果的种类和数量;

b. 初始化变量:左右指针 left = 0,right = 0,记录结果的变量 ret = 0;

c. 当 right ⼩于数组⼤⼩的时候,⼀直执⾏下列循环:

i. 将当前⽔果放⼊哈希表中;

ii. 判断当前⽔果进来后,哈希表的⼤⼩:

如果超过 2:

将左侧元素滑出窗⼝,并且在哈希表中将该元素的频次减⼀;

如果这个元素的频次减⼀之后变成了 0,就把该元素从哈希表中删除;
重复上述两个过程,直到哈希表中的⼤⼩不超过 2;

iii. 更新结果 ret;

iv. right++,让下⼀个元素进⼊窗⼝;

d. 循环结束后,ret 存的就是最终结果。

过程展现:

cpp 复制代码
class Solution {
public:
    int totalFruit(vector<int>& f) {
        unordered_map<int, int> hash; // 统计窗口内水果种类和数量
        int ret = 0;                  // 记录最大长度
        
        // left和right维护滑动窗口
        for (int left = 0, right = 0; right < f.size(); right++) {
            hash[f[right]]++;         // 1. 进窗口:把当前水果加入
            
            while (hash.size() > 2) { // 2. 判断:种类超过2种
                // 3. 出窗口:缩左边
                hash[f[left]]--;      // 左边水果数量减1
                if (hash[f[left]] == 0) 
                    hash.erase(f[left]); // 数量为0就删掉这个键
                left++;
            }
            
            ret = max(ret, right - left + 1); // 4. 更新答案
        }
        return ret;
    }
};

拆开讲

1.准备工作

cpp 复制代码
unordered_map<int, int> hash;  // key是水果种类,value是这个种类在窗口里有多少个
int ret = 0;                   // 记录最长长度

2.进窗口

cpp 复制代码
for (int left = 0, right = 0; right < f.size(); right++) {
    hash[f[right]]++;

right 一步一步往右走,把当前水果加到哈希表里。如果原来窗口里没有这个水果,hash.size() 就会加1。如果已经有了,就只是计数加1。

3.判断 + 出窗口

cpp 复制代码
while (hash.size() > 2) {
    hash[f[left]]--;
    if (hash[f[left]] == 0) 
        hash.erase(f[left]);
    left++;
}

如果窗口里有超过2种水果,就要把左边的水果移出去。先把 left 指向的水果计数减1,如果减到0了,就从哈希表里删掉这个键。然后 left++ 往右移,直到 hash.size() 又变成2为止。

4.更新答案

cpp 复制代码
ret = max(ret, right - left + 1);

当前窗口是合法的(最多2种水果),窗口长度是 right - left + 1,跟之前记录的最大值比一下,取大的。


二.找到字符串中所有字⺟异位词

题目链接:438. 找到字符串中所有字母异位词 - 力扣(LeetCode)

题目描述:

算法原理:

解法(滑动窗⼝ + 哈希表):

算法思路:

  • 因为字符串 p 的异位词的⻓度⼀定与字符串 p 的⻓度相同,所以我们可以在字符串 s 中构造⼀个⻓度为与字符串 p 的⻓度相同的滑动窗⼝,并在滑动中维护窗⼝中每种字⺟的数量;
  • 当窗⼝中每种字⺟的数量与字符串 p 中每种字⺟的数量相同时,则说明当前窗⼝为字符串 p的异位词
  • 因此可以⽤两个⼤⼩为 26 的数组来模拟哈希表,⼀个来保存 s 中的⼦串每个字符出现的个数,另⼀个来保存 p 中每⼀个字符出现的个数。这样就能判断两个串是否是异位词。

过程展现:

cpp 复制代码
class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        vector<int> ret;
        int hash1[26] = {0}; // 统计字符串 p 中每个字符出现的个数
        for (auto ch : p)
            hash1[ch - 'a']++;
        int hash2[26] = {0}; // 统计窗⼝⾥⾯的每⼀个字符出现的个数
        int m = p.size();
        for (int left = 0, right = 0, count = 0; right < s.size(); right++) {
            char in = s[right];
            // 进窗⼝ + 维护 count
            if (++hash2[in - 'a'] <= hash1[in - 'a'])
                count++;
            if (right - left + 1 > m) // 判断
            {
                char out = s[left++];
                // 出窗⼝ + 维护 count
                if (hash2[out - 'a']-- <= hash1[out - 'a'])
                    count--;
            }
            // 更新结果
            if (count == m)
                ret.push_back(left);
        }
        return ret;
    }
};

1.准备工作

cpp 复制代码
vector<int> ret;                // 存结果的数组
int hash1[26] = {0};            // 存 p 里每个字母出现了几次
for (auto ch : p)
    hash1[ch - 'a']++;          // 比如 p="abc",hash1[0]=1,hash1[1]=1,hash1[2]=1
int hash2[26] = {0};            // 存当前窗口里每个字母出现了几次
int m = p.size();               // 窗口大小固定为 m

hash1 是"标准答案",一直不变。hash2 是"当前窗口",实时更新。

2.主循环

cpp 复制代码
for (int left = 0, right = 0, count = 0; right < s.size(); right++)

left 是窗口左边界,right 是右边界,count 是有效字符的数量。三个变量一起初始化。

3.进窗口

cpp 复制代码
char in = s[right];
if (++hash2[in - 'a'] <= hash1[in - 'a'])
    count++;

把当前字符加到窗口里,hash2 里对应的计数加 1。如果加完后,这个字符的数量还没超过 p 里的数量,说明这个字符是"有效"的,count 加 1。如果超过了,说明多出来的这个字符在 p 里没有对应的"空位"了,count 不变。

4.判断窗口长度是否超了

cpp 复制代码
if (right - left + 1 > m)
{
    char out = s[left++];
    if (hash2[out - 'a']-- <= hash1[out - 'a'])
        count--;
}

窗口长度只能等于 m,一旦超过,左边就得缩。先记住左边的字符,然后 left 加 1。接着判断这个字符在移除之前是不是"有效"的------如果移除前它的数量没超过 p 里的数量,说明它之前贡献了一个 count,移除后 count 要减 1。如果移除前已经超过 p 里的数量了,说明它之前就是多余的,count 本来就没加过,移除后也不用减。

5.更新答案

cpp 复制代码
if (count == m)
    ret.push_back(left);

如果有效字符数量等于窗口长度,说明窗口里每个字符都刚好对上,没有多余的也没有缺的,完全匹配。此时 left 就是答案,存起来。

问题:为啥用 count 不用直接比较数组?

如果直接比较 hash1 和 hash2 两个数组,每次要遍历 26 个元素,窗口移动一次就比较一次,虽然 26 不大,但 s 很长的时候还是有开销。用 count 记录有效字符数,判断是否匹配只需要 O(1) 时间,更快。

整个流程走一遍

s = "cbaebabacd",p = "abc",m = 3

  • 窗口滑到 "cba",hash2 里 c、b、a 各 1,都 ≤ hash1,count=3,count==m,记录 left=0

  • 窗口滑到 "bae",hash2 里 a:1、b:1、c:0、e:1,e 的 hash1 是 0,所以 e 不算,count=2,不匹配

  • 窗口滑到 "eba",e 不算,b 和 a 算,count=2,不匹配

  • 窗口滑到 "bab",a:1、b:2,b 有两个但 hash1 里 b 只有 1,第二个 b 不算,count=2,不匹配

  • 窗口滑到 "bac",b:1、a:1、c:1,都 ≤ hash1,count=3,匹配,记录 left=6

最终返回 0, 6


三.串联所有单词的⼦串

题目链接: 30. 串联所有单词的子串 - 力扣(LeetCode)

题目描述:

算法原理:

算法思路:

如果我们把每⼀个单词看成⼀个⼀个字⺟,问题就变成了找到字符串中所有的字⺟异位词。⽆⾮就是之前处理的对象是⼀个⼀个的字符,我们这⾥处理的对象是⼀个⼀个的单词。


过程展现:

cpp 复制代码
class Solution {
public:
    vector<int> findSubstring(string s, vector<string>& words) {
        vector<int> ret;
        unordered_map<string, int> hash1; // 保存 words ⾥⾯所有单词的频次
        for (auto& s : words)
            hash1[s]++;
        int len = words[0].size(), m = words.size();
        for (int i = 0; i < len; i++) // 执⾏ len 次
        {
            unordered_map<string, int> hash2; // 维护窗⼝内单词的频次
            for (int left = i, right = i, count = 0; right + len <= s.size();
                 right += len) {
                // 进窗⼝ + 维护 count
                string in = s.substr(right, len);
                hash2[in]++;
                if (hash1.count(in) && hash2[in] <= hash1[in])
                    count++;
                // 判断
                if (right - left + 1 > len * m) {
                    // 出窗⼝ + 维护 count
                    string out = s.substr(left, len);
                    if (hash1.count(out) && hash2[out] <= hash1[out])
                        count--;
                    hash2[out]--;
                    left += len;
                }
                // 更新结果
                if (count == m)
                    ret.push_back(left);
            }
        }
        return ret;
    }
};

1.准备工作

cpp 复制代码
vector<int> ret;
unordered_map<string, int> hash1;
for (auto& s : words)
    hash1[s]++;
int len = words[0].size(), m = words.size();

ret 存结果。hash1 存 words 里每个单词出现了几次,比如 words = "foo", "bar", "foo",那 hash1"foo" = 2,hash1"bar" = 1。len 是每个单词的长度,m 是单词个数。总共要匹配的长度是 len * m。

2.为什么要循环 len 次

cpp 复制代码
for (int i = 0; i < len; i++)

这是这题比较绕的地方。因为窗口每次移动一个单词(len 个字符),但 s 的开头不一定正好是某个单词的边界。比如 s = "barfoothefoobarman",len = 3,单词边界在位置 0、3、6、9... 但如果 s 是 "xbarfoo...",那从位置 0 开始就切不对了。

所以要从 0、1、2(len-1)分别开始滑一次,覆盖所有可能的起始位置。这样就不会漏掉答案了。

3.每个起始位置单独滑

cpp 复制代码
unordered_map<string, int> hash2;
for (int left = i, right = i, count = 0; right + len <= s.size(); right += len)

hash2 存当前窗口里单词的频次。left 和 right 从 i 开始,每次走 len 步(一个单词的长度)。count 记录窗口里已经匹配上的单词数量。
为什么 right + len <= s.size()?

保证取子串的时候不越界。

4.进窗口

cpp 复制代码
string in = s.substr(right, len);
hash2[in]++;
if (hash1.count(in) && hash2[in] <= hash1[in])
    count++;

取当前单词 in,加到 hash2 里。如果这个单词在 hash1 里存在,并且窗口中这个单词的数量没超过它在 words 里的数量,说明这个单词是"有效"的,count++。如果超了,说明这个单词是多出来的,不算。

5.判断窗口是否超长

cpp 复制代码
if (right - left + 1 > len * m) {
    string out = s.substr(left, len);
    if (hash1.count(out) && hash2[out] <= hash1[out])
        count--;
    hash2[out]--;
    left += len;
}

窗口长度只能等于 len * m,一旦超过,左边就要缩。先取左边要出去的单词 out,如果它是有效单词(在 hash1 里存在且数量没超过),count--。然后把 hash2 里 out 的计数减 1,left 右移一个单词长度。

6.更新结果

cpp 复制代码
if (count == m)
    ret.push_back(left);

如果有效单词数等于 m,说明窗口里所有单词都匹配上了,left 就是答案。


四.最⼩覆盖⼦串

题⽬链接: 76. 最小覆盖子串 - 力扣(LeetCode)

题目描述:

算法原理:

解法(滑动窗⼝ + 哈希表):

算法思路:

  • 研究对象是连续的区间,因此可以尝试使⽤滑动窗⼝的思想来解决。
  • 如何判断当前窗⼝内的所有字符是符合要求的呢?

我们可以使⽤两个哈希表,其中⼀个将⽬标串的信息统计起来,另⼀个哈希表动态的维护窗⼝内字符串的信息。当动态哈希表中包含⽬标串中所有的字符,并且对应的个数都不⼩于⽬标串的哈希表中各个字符的个数,那么当前的窗⼝就是⼀种可⾏的⽅案。
算法流程:

a. 定义两个全局的哈希表: 1 号哈希表 hash1 ⽤来记录⼦串的信息, 2 号哈希表 hash2

⽤来记录⽬标串 t 的信息;

b. 实现⼀个接⼝函数,判断当前窗⼝是否满⾜要求:

i. 遍历两个哈希表中对应位置的元素:

• 如果 t 中某个字符的数量⼤于窗⼝中字符的数量,也就是 2 号哈希表某个位置⼤于

1 号哈希表。说明不匹配,返回 false ;

• 如果全都匹配,返回 true 。
主函数中:

a. 先将 t 的信息放⼊ 2 号哈希表中;

b. 初始化⼀些变量:左右指针: left = 0,right = 0 ;⽬标⼦串的⻓度: len =

INT_MAX ;⽬标⼦串的起始位置: retleft ;(通过⽬标⼦串的起始位置和⻓度,我们就

能找到结果)

c. 当 right ⼩于字符串 s 的⻓度时,⼀直下列循环:

i. 将当前遍历到的元素扔进 1 号哈希表中;

ii. 检测当前窗⼝是否满⾜条件:

• 如果满⾜条件:

  1. 判断当前窗⼝是否变⼩。如果变⼩:更新⻓度 len ,以及字符串的起始位置

retleft ;

  1. 判断完毕后,将左侧元素滑出窗⼝,顺便更新 1 号哈希表;

  2. 重复上⾯两个过程,直到窗⼝不满⾜条件;

iii. right++ ,遍历下⼀个元素;

d. 判断 len 的⻓度是否等于 INT_MAX :

i. 如果相等,说明没有匹配,返回空串;

ii. 如果不想等,说明匹配,返回 s 中从 retleft 位置往后 len ⻓度的字符串。

过程展现:

cpp 复制代码
class Solution {
public:
    string minWindow(string s, string t) {
        int hash1[128] = {0}; // 统计字符串 t 中每⼀个字符的频次
        int kinds = 0;        // 统计有效字符有多少种
        for (auto ch : t)
            if (hash1[ch]++ == 0)
                kinds++;
        int hash2[128] = {0}; // 统计窗⼝内每个字符的频次
        int minlen = INT_MAX, begin = -1;
        for (int left = 0, right = 0, count = 0; right < s.size(); right++) {
            char in = s[right];
            if (++hash2[in] == hash1[in])
                count++;           // 进窗⼝ + 维护 count
            while (count == kinds) // 判断条件
            {
                if (right - left + 1 < minlen) // 更新结果
                {
                    minlen = right - left + 1;
                    begin = left;
                }
                char out = s[left++];
                if (hash2[out]-- == hash1[out])
                    count--; // 出窗⼝ + 维护 count
            }
        }
        if (begin == -1)
            return "";
        else
            return s.substr(begin, minlen);
    }
};

一板块板块讲

1.统计 t 里有哪些字符

cpp 复制代码
int hash1[128] = {0};
int kinds = 0;
for (auto ch : t)
    if (hash1[ch]++ == 0)
        kinds++;

hash1 记录 t 里每个字符出现了几次。kinds 记录 t 里有几种不同的字符,比如 t = "ABC",kinds = 3。后面判断窗口是否覆盖全部字符,就是看 count 是否等于 kinds。

这里用了一个小技巧hash1[ch]++ == 0,如果某个字符第一次出现,hash1ch 从0变成1,等于0成立,kinds++。如果第二次出现,hash1ch 从1变成2,等于0不成立,kinds 不变。这样就把 t 里有多少种不同字符算出来了。

2.准备工作

cpp 复制代码
int hash2[128] = {0};          // 窗口内字符统计
int minlen = INT_MAX, begin = -1;  // 最短长度和起始位置

hash2 跟 hash1 结构一样,存窗口内每个字符出现了几次。

3.进窗口

cpp 复制代码
for (int left = 0, right = 0, count = 0; right < s.size(); right++) {
    char in = s[right];
    if (++hash2[in] == hash1[in])
        count++;

right 往右走,把当前字符加入窗口,hash2 计数加1。如果加完后这个字符在窗口里的数量刚好等于它在 t 里的数量,说明这个字符"达标"了,count++。注意这里用的是 ==,不是 <=因为 count 记录的是"有多少种字符已经达标了",不是"有多少个字符达标了"。每种字符只要达标一次就够了,多出来的不算

4.判断是否可以缩窗口

cpp 复制代码
while (count == kinds) {
    // 更新结果
    if (right - left + 1 < minlen) {
        minlen = right - left + 1;
        begin = left;
    }
    // 出窗口
    char out = s[left++];
    if (hash2[out]-- == hash1[out])
        count--;
}

如果 count == kinds,说明窗口里所有种类的字符都达标了,当前窗口有效。这时候尝试缩左边,看能不能让窗口更短。每次缩之前先把当前窗口长度记录下来,如果比之前的最短还短就更新。

缩的时候把 left 指向的字符从窗口里移出去。如果移出去之前这个字符的数量刚好等于它在 t 里的数量,说明移出去之后这个字符就不达标了,count--。如果移出去之前数量已经大于 t 里的数量,说明移出去一个还有余量,count 不变。

5.返回结果

cpp 复制代码
return begin == -1 ? "" : s.substr(begin, minlen);

如果 begin 还是 -1,说明从来没见过有效窗口,返回空串。否则从 begin 开始截取 minlen 个字符返回。

咱举个例子走一遍:

s = "ADOBECODEBANC",t = "ABC"

t 里 A、B、C 各一个,kinds = 3

right 一直走到 "ADOBEC",窗口里 A、B、C 都齐了,count = 3。此时窗口长度6,记录 "ADOBEC"。

开始缩左边。left 从 A 开始,A 移出去后 A 没了,count 变成2。窗口变成 "DOBEC",不再覆盖全部字符。

right 继续走,到 "CODEB" 的时候又齐了,但长度5,比6短,更新为 "CODEB"。

缩左边,缩到 "ODEB" 的时候又不齐了。

right 继续走,到 "BANC" 的时候齐了,长度4,更新为 "BANC"。

继续缩左边,"ANC" 不齐了,停。

最终答案 "BANC",长度4。

问题补充:啥用 count 不用比较数组

跟之前一样,用 count 就不用每次都遍历128个字符比较 hash1 和 hash2 了。count 记录有多少种字符达标了,达标的意思是窗口里这个字符的数量 >= t 里这个字符的数量。当 count == kinds 时,说明 t 里所有字符都被窗口覆盖了。用count 判断是 O(1),比较数组是 O(128),虽然128不大,但 s 很长的时候还是有差距。

相关推荐
盐焗鹌鹑蛋1 小时前
【C++】set和map
c++
木木子221 小时前
# 星座配对深度解析:Select 长列表选择器、矩阵评分算法与数据驱动建议
java·算法·华为·矩阵·harmonyos
一拳一个呆瓜1 小时前
【STL】iostream 编程:文件输出流的成员函数
c++·stl
大鱼>1 小时前
AI+货物追踪:跨境电商包裹追踪系统
人工智能·深度学习·算法·机器学习
Huangjin007_2 小时前
【C++11篇(四)】新的类功能、lambda 与包装器详解
开发语言·c++
大鱼>2 小时前
AI+货物追踪:全链路货物可视化追踪系统
人工智能·深度学习·算法·机器学习
大江东去浪淘尽千古风流人物2 小时前
【SLAM】slam高动态位姿估计全链路拆解
算法·面试·职场和发展·视觉里程计·slam·vio·大疆
想你依然心痛2 小时前
量子计算对嵌入式密码学的挑战与后量子算法准备
算法·密码学·量子计算
Jerry5 小时前
LeetCode 28. 找出字符串中第一个匹配项的下标
算法