【LeetCode学习计划】《算法-入门-C++》第6天 滑动窗口

文章目录

  • 3. 无重复字符的最长子串
    • 方法1:滑动窗口
      • 过程演示
  • 567. 字符串的排列
    • 方法1:滑动窗口
    • 方法1优化
    • 方法1优化(2)



3. 无重复字符的最长子串

LeetCode

中 等 \color{#FFB800}{中等}

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:

输入: s = "abcabcbb"
输出: 3 
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3

示例 2:

输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1

示例 3:

输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

示例 4:

输入: s = ""
输出: 0

提示:

  • 0 <= s.length <= 5 * 104
  • s 由英文字母、数字、符号和空格组成

方法1:滑动窗口

滑动窗口是基于双指针的方法。

双指针每次确定两个指针指向的值;二分查找可以看做每次确定中间值的双指针;而滑动窗口可以看做每次确定一段值的双指针。

对于本题,设定2个指针leftright,它们指向一个不含有重复字符的子串的两端,这样leftright就组成了一个窗口。我们每次循环都确定一个子串,如果该子串的长度比已记录的答案更长,则将答案更新。

我们先看一下大体的流程:
动画中字符串左右两侧的.代表边界,不是有效数据。

用括号表示两端的指针。每次循环,括号内(窗口内)都是一个当前最长的不含有重复字符的子串,则对于例abcabcbb的过程可表示为:

  • )(abcabcbb, ans=0
  • (abc)abcbb, ans=3
  • a(bca)bcbb, ans=3
  • ab(cab)cbb, ans=3
  • abc(abc)bb, ans=3
  • abca(bc)bb, ans=3
  • abcab(cb)b, ans=3
  • abcabc(b)b, ans=3
  • abcabcb(b), ans=3
  • abcabcbb)(, ans=3
  1. 那么每次循环中怎么确定窗口呢?
    我们需要遍历每一个字符,然后从该字符开始,寻找不含有重复字符的子串。left就指向了那个起始的字符。
    例如第0次循环,我们从abcabcbb的第0项a开始,left目前就指向了a,其值为0。
    由于我们要维护窗口内是有效的子串,所以我们每次要判断第right+1项的字符是否重复,然后再选择是否把right+1项加入窗口。因此right-1开始,往右开始找符合条件的子串。
    • right+1=0,指向了a,目前窗口为空,a没有重复,往后走。窗口更新为a
    • right+1=1,指向了b,目前窗口为ab没有重复,往后走。窗口更新为ab
    • right+1=2,指向了c,目前窗口为abc没有重复,往后走。窗口更新为abc
    • right+1=3,指向了a,目前窗口为abca重复了,于是这一次循环中right跑完了。目前left=0, right=2,可得目前有效子串的长度为right-left+1=3,即我们每次循环的最后,ans要更新为ansright-left+1之中的最大值。
  2. 怎么判断下一个字符是否在子串中重复呢?
    我们需要一个哈希集合来存储当前子串中存在的值。例如C++中的unordered_set,Python和JavaScript中的Set,Java中的HashSet。如果下一个字符在哈希表中,我们就可以判断为right遍历结束。
  3. 可选)每次循环中right从什么地方开始?
    在每次循环中,left已自增完成。right可以选择从left开始,即right=left,然后right不断自增。
    我们也可以减少查找的次数。我们看一下abca这个例子。第一次循环后,窗口为abc。第二次循环开始,left自增,窗口目前为bc,然后right一直往后走,将窗口更新为了bca。我们可以发现,两次循环的过渡过程中,有一段bc是这两次循环共享的子串。这是基于以下的事实:
    对 于 不 含 重 复 字 符 的 字 符 串 P , 以 及 P 的 子 串 S ⊆ P 有 : P 不 含 重 复 字 符 ⇒ S 不 含 重 复 字 符 对于不含重复字符的字符串\mathbb{P},以及\mathbb{P}的子串\mathbb{S} \subseteq \mathbb{P}有:\\ \mathbb{P}不含重复字符 \Rightarrow \mathbb{S}不含重复字符 PPSPPS
    对于abc,它的任意子串,aabbc等都是不含重复字符的子串(注意ac不是子串)。所以我们每次循环结束后,right的位置可以保留,而不用从left开始重新往后找。

过程演示

动画中字符串左右两侧的.代表边界,不是有效数据。

例 1:

输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

#include 
#include 
using namespace std;
class Solution
{
public:
    int lengthOfLongestSubstring(string s)
    {
        int n = s.length();
        unordered_set<char> hashtable;

        int ans = 0;
        for (int left = 0, right = -1; left < n; left++)
        {
            if (left != 0)
            {
                hashtable.erase(s[left - 1]);
            }
            while (right + 1 < n && hashtable.find(s[right + 1]) == hashtable.end())
            {
                right++;
                hashtable.insert(s[right]);
            }
            ans = max(ans, right - left + 1);
        }
        return ans;
    }
};

复杂度分析

时间复杂度:O(n)

空间复杂度:O(|Σ|)。其中 Σ 表示字符集(即字符串中可以出现的字符),|Σ| 表示字符集的大小。本题没有明确说明字符集,因此可以默认为 ASCII 码字符,范围为 [0, 128) ,即 |Σ|=128。我们需要用到哈希集合来存储出现过的字符,而字符最多有 |Σ| 个,因此空间复杂度为 O(|Σ|)。

参考结果

Accepted
987/987 cases passed (28 ms)
Your runtime beats 44.13 % of cpp submissions
Your memory usage beats 48.05 % of cpp submissions (10.5 MB)


567. 字符串的排列

LeetCode

中 等 \color{#FFB800}{中等}

给你两个字符串 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
  • s1s2 仅包含小写字母

方法1:滑动窗口

由题目的示例1我们可以发现,本题不需要考虑子串s1内字符的排列,只要s1里有的,s2的某一段也有就行了。因此我们可以对子串中的每个字符进行计数,存到一个长为26(题目中给出字符串中只有小写字母)的数组cnts1里面。然后在字符串s2上设置一个长为s1.length()的窗口,每次对s2窗口内的字符计数,存到一个长为26的数组cnts2中,然后比较这两个数组即可。每次循环,我们要将窗口整体往右滑动一个单位,窗口长度始终不变,为字符串s1的长度。

#include 
#include 
using namespace std;
class Solution
{
public:
    bool checkInclusion(string s1, string s2)
    {
        const int len1 = s1.length(), len2 = s2.length();
        if (len1 > len2)
            return false;

        vector<int> cnts1(26);
        for (int i = 0; i < len1; i++)
        {
            cnts1[s1[i] - 'a']++;
        }

        for (int left = 0; left <= len2 - len1; left++)
        {
            vector<int> cnts2(26);
            const int right = left + len1 - 1;
            for (int i = left; i <= right; i++)
            {
                cnts2[s2[i] - 'a']++;
            }
            if (cnts1 == cnts2)
            {
                return true;
            }
        }
        return false;
    }
};

复杂度分析

时间复杂度:O(n+m+|Σ|)。其中,n是字符串s1的长度,m是字符串s2的长度,|Σ|是字符集的长度。这道题中的字符集是小写字母,则|Σ|=26。

空间复杂度:O(|Σ|)。其中 Σ 表示字符集(即字符串中可以出现的字符),|Σ| 表示字符集的大小。本题没有明确说明字符集,因此可以默认为 ASCII 码字符,范围为 [0, 128) ,即 |Σ|=128。我们需要用到哈希集合来存储出现过的字符,而字符最多有 |Σ| 个,因此空间复杂度为 O(|Σ|)。

参考结果

Accepted
106/106 cases passed (228 ms)
Your runtime beats 8.66 % of cpp submissions
Your memory usage beats 8.22 % of cpp submissions (18.1 MB)

方法1优化

方法1中,也存在着和上一题中一样的一点。也就是两次相邻的循环中,存在着共享的部分。

我们设s1="bcd", s2="abcd"。第一次循环时s2的窗口内是abc,第二次循环则是bcd。其中有着bc这些中间字符是两次循环共享的。而根据方法1的思路:每次循环时窗口整体右移,我们可知:左侧字符被抛弃相当于该字符在cnts2数组中计数减1,而右侧框进来一个字符相当与该字符在cnts2数组中计数加1,而中间的字符都是不用动的。这样一来我们就省去了多次重构cnts2数组的操作。

在循环开始前,我们要先针对第一个窗口将cnts1cnts2给初始化完成,因为后面每次循环只对窗口两端的元素进行观察。

#include 
#include 
using namespace std;
class Solution
{
public:
    bool checkInclusion(string s1, string s2)
    {
        int len1 = s1.length(), len2 = s2.length();
        if (len1 > len2)
            return false;

        vector<int> cnts1(26), cnts2(26);
        for (int i = 0; i < len1; i++)
        {
            cnts1[s1[i] - 'a']++;
            cnts2[s2[i] - 'a']++;
        }
        if (cnts1 == cnts2)
        {
            return true;
        }

        for (int right = len1; right < len2; right++)
        {
            cnts2[s2[right] - 'a']++;
            const int left = right - len1;
            cnts2[s2[left] - 'a']--;
            if (cnts1 == cnts2)
            {
                return true;
            }
        }
        return false;
    }
};

复杂度分析

时间复杂度:O(n+m+|Σ|)

空间复杂度:O(|Σ|)

参考结果

Accepted
106/106 cases passed (16 ms)
Your runtime beats 29.68 % of cpp submissions
Your memory usage beats 87.86 % of cpp submissions (7 MB)

方法1优化(2)

上面的方法中,我们每次都要比对整个cnts1cnts2。如果说字符集越大,那么我们在数组比较上花的时间也就越多,这一次的优化主要针对的就是这个问题。我们希望找到一个方法,使得我们只用到一个数组。我们若是仍旧用之前的方法记录窗口内字符的个数,那必然要用到2个数组然后进行比对,所以现在我们的这个数组必须存别的信息。

其实数组如何变化并不是很难想到。我们可以思考一下,如果2个数组一模一样,那这两个数组的每个对应项相减必定是0;如果2个数组不一样,那么对应项相减之后必定有些项为正数或负数。而正好我们可以发现,相减的结果怎么存?用一个数组存。

既然如此,我们设置一个数组cnts,其中每一项存放的都是cnts2cnts1的相减结果。cnts[i]=cnts2[i]-cnts1[i]也就代表着:某一字符在s2中出现的次数减去该字符在s1中出现的次数。换一种说法,cnts1中所有值转化为负数,找到一个能将所有值加回0的窗口,如果有这样的窗口,那么就说明条件满足。

然后我们就能把数组cnts1cnts2抽象出来。原本我们将s2中某字符的计数在cnts2中加1,现在则现在我们加到cnts中;原本我们将s1中某字符的计数在cnts1中加1,现在则变成了在cnts中减1。这样一来,每次对窗口处理完成后,如果cnts中的所有项均为0,那么就说明答案为真。这就是为什么我们可以只用到一个数组的原因。

至此,空间上的花费就成功地减少了。然而,咱做算法得贪心一点。我们即使只用到了一个数组,但我们还是要对cnts进行遍历去和0比较。相对于优化前我们对cnts1cnts2同时遍历进行比较,时间上的花费还是省的不够多,只是省去了从内存中读cnts2的时间。

(要注意的是,算法的时间复杂度和硬件没有关系。一个时间复杂度为O(n2)的算法,就算里面有很多的内存读取操作,算法的时间复杂度还是O(n2),因为时间复杂度只跟我们的代码结构有关。即使实际上花费的时间确实是会跟着内存读取操作的减少而减少。)

我们可以用一个变量diff来记录每个窗口下,数组cnts中不为0的个数。如果某次循环结束后,diff==0则条件满足。每次窗口滑动时,变量diff的变化情况如下(要注意我们所有的方法中窗口都是针对s2的):

  • 窗口右移了,如果我们加进来的右侧字符和扔出去的左侧字符是一个字符,那么我们就不用做任何操作,如果是子串那么这样做只会还是子串,不是的话仍旧不是子串。所以我们之间continue进行下一次循环。
  • 左侧字符的计数要减少。减少的操作还分多个过程:
    1. 如果cnts中该字符当前的计数为0,代表之前窗口和s1内该字符的数量是相等的,马上右移之后,cnts中不为0的个数就要加1,也就意味着diff++
    2. 该字符的计数减1
    3. 如果减1之后,cnts中的计数为0了,那就代表窗口和s1内该字符的数量相等了,那么cnts中不为0的个数就少了一个,也就意味着diff--
  • 右侧字符的计数要增加。同样分为多个过程:
    1. 如果增加之前的计数为0,则代表…的数量是相等的。马上右移之后,cnts中不为0的个数就要加1,意味着diff++
    2. 该字符的计数加1
    3. 如果现在计数为0,…。(同上),diff--
  • 如果diff==0,则代表窗口移动完成后,现在计数相等,可以返回真值。

经历了上述漫长的推导,我们终于将空间花费减少了一半,时间花费的话,每次窗口滑动之后不需要遍历整个数组了。(请注意我们减少的不是空间、时间复杂度,我们减少的是各自实际资源的消耗)。我们最终会得到如下可能看起来不是特别舒服的代码结构(反正我个人看着这种结构是挺不舒服的):

#include 
#include 
using namespace std;
class Solution
{
public:
    bool checkInclusion(string s1, string s2)
    {
        const int len1 = s1.length(), len2 = s2.length();
        if (len1 > len2)
            return false;

        vector<int> cnts(26);
        for (int i = 0; i < len1; i++)
        {
            cnts[s1[i] - 'a']--;
            cnts[s2[i] - 'a']++;
        }

        int diff = 0;
        for (int cnt : cnts)
        {
            cnt != 0 && diff++;
        }
        if (diff == 0)
            return true;

        for (int right = len1; right < len2; right++)
        {
            const int left = right - len1;
            char in = s2[right] - 'a', out = s2[left] - 'a';
            if (in == out)
                continue;

            if (cnts[out] == 0)
            {
                diff++;
            }
            cnts[out]--;
            if (cnts[out] == 0)
            {
                diff--;
            }

            if (cnts[in] == 0)
            {
                diff++;
            }
            cnts[in]++;
            if (cnts[in] == 0)
            {
                diff--;
            }

            if (diff == 0)
                return true;
        }
        return false;
    }
};

复杂度分析

时间复杂度:O(n+m+|Σ|)

空间复杂度:O(|Σ|)

参考结果

Accepted
106/106 cases passed (4 ms)
Your runtime beats 96.24 % of cpp submissions
Your memory usage beats 73.29 % of cpp submissions (7.1 MB)

Animation powered by ManimCommunity/manim

你可能感兴趣的:(LeetCode刷题,算法,数据结构,c++,leetcode,数组)