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
提示:
s
由英文字母、数字、符号和空格组成滑动窗口是基于双指针的方法。
双指针每次确定两个指针指向的值;二分查找可以看做每次确定中间值的双指针;而滑动窗口可以看做每次确定一段值的双指针。
对于本题,设定2个指针left
和right
,它们指向一个不含有重复字符的子串的两端,这样left
和right
就组成了一个窗口
。我们每次循环都确定一个子串,如果该子串的长度比已记录的答案更长,则将答案更新。
我们先看一下大体的流程:
动画中字符串左右两侧的.
代表边界,不是有效数据。
用括号表示两端的指针。每次循环,括号内(窗口内)都是一个当前最长的不含有重复字符的子串,则对于例abcabcbb
的过程可表示为:
)(abcabcbb
, ans=0(abc)abcbb
, ans=3a(bca)bcbb
, ans=3ab(cab)cbb
, ans=3abc(abc)bb
, ans=3abca(bc)bb
, ans=3abcab(cb)b
, ans=3abcabc(b)b
, ans=3abcabcb(b)
, ans=3abcabcbb)(
, ans=3left
就指向了那个起始的字符。0
次循环,我们从abcabcbb
的第0项a
开始,left
目前就指向了a
,其值为0。right+1
项的字符是否重复,然后再选择是否把right+1
项加入窗口。因此right
从-1
开始,往右开始找符合条件的子串。
right+1=0
,指向了a
,目前窗口为空,a
没有重复,往后走。窗口更新为a
;right+1=1
,指向了b
,目前窗口为a
,b
没有重复,往后走。窗口更新为ab
;right+1=2
,指向了c
,目前窗口为ab
,c
没有重复,往后走。窗口更新为abc
;right+1=3
,指向了a
,目前窗口为abc
,a
重复了,于是这一次循环中right
跑完了。目前left=0, right=2
,可得目前有效子串的长度为right-left+1=3
,即我们每次循环的最后,ans
要更新为ans
和right-left+1
之中的最大值。unordered_set
,Python和JavaScript中的Set
,Java中的HashSet
。如果下一个字符在哈希表中,我们就可以判断为right
遍历结束。right
从什么地方开始?left
已自增完成。right
可以选择从left
开始,即right=left
,然后right
不断自增。abca
这个例子。第一次循环后,窗口为abc
。第二次循环开始,left
自增,窗口目前为bc
,然后right
一直往后走,将窗口更新为了bca
。我们可以发现,两次循环的过渡过程中,有一段bc
是这两次循环共享的子串。这是基于以下的事实:abc
,它的任意子串,a
、ab
、bc
等都是不含重复字符的子串(注意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)
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
提示:
s1
和 s2
仅包含小写字母由题目的示例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中,也存在着和上一题中一样的一点。也就是两次相邻的循环中,存在着共享的部分。
我们设s1="bcd", s2="abcd"
。第一次循环时s2
的窗口内是abc
,第二次循环则是bcd
。其中有着bc
这些中间字符是两次循环共享的。而根据方法1的思路:每次循环时窗口整体右移,我们可知:左侧字符被抛弃相当于该字符在cnts2
数组中计数减1,而右侧框进来一个字符相当与该字符在cnts2
数组中计数加1,而中间的字符都是不用动的。这样一来我们就省去了多次重构cnts2
数组的操作。
在循环开始前,我们要先针对第一个窗口将cnts1
和cnts2
给初始化完成,因为后面每次循环只对窗口两端的元素进行观察。
#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)
上面的方法中,我们每次都要比对整个cnts1
和cnts2
。如果说字符集越大,那么我们在数组比较上花的时间也就越多,这一次的优化主要针对的就是这个问题。我们希望找到一个方法,使得我们只用到一个数组。我们若是仍旧用之前的方法记录窗口内字符的个数,那必然要用到2个数组然后进行比对,所以现在我们的这个数组必须存别的信息。
其实数组如何变化并不是很难想到。我们可以思考一下,如果2个数组一模一样,那这两个数组的每个对应项相减必定是0;如果2个数组不一样,那么对应项相减之后必定有些项为正数或负数。而正好我们可以发现,相减的结果怎么存?用一个数组存。
既然如此,我们设置一个数组cnts
,其中每一项存放的都是cnts2
和cnts1
的相减结果。cnts[i]=cnts2[i]-cnts1[i]
也就代表着:某一字符在s2
中出现的次数减去该字符在s1
中出现的次数。换一种说法,将cnts1
中所有值转化为负数,找到一个能将所有值加回0的窗口,如果有这样的窗口,那么就说明条件满足。
然后我们就能把数组cnts1
和cnts2
抽象出来。原本我们将s2
中某字符的计数在cnts2
中加1,现在则现在我们加到cnts
中;原本我们将s1
中某字符的计数在cnts1
中加1,现在则变成了在cnts
中减1。这样一来,每次对窗口处理完成后,如果cnts
中的所有项均为0,那么就说明答案为真。这就是为什么我们可以只用到一个数组的原因。
至此,空间上的花费就成功地减少了。然而,咱做算法得贪心一点。我们即使只用到了一个数组,但我们还是要对cnts
进行遍历去和0比较。相对于优化前我们对cnts1
和cnts2
同时遍历进行比较,时间上的花费还是省的不够多,只是省去了从内存中读cnts2
的时间。
(要注意的是,算法的时间复杂度和硬件没有关系。一个时间复杂度为O(n2)的算法,就算里面有很多的内存读取操作,算法的时间复杂度还是O(n2),因为时间复杂度只跟我们的代码结构有关。即使实际上花费的时间确实是会跟着内存读取操作的减少而减少。)
我们可以用一个变量diff
来记录每个窗口下,数组cnts
中不为0的个数。如果某次循环结束后,diff==0
则条件满足。每次窗口滑动时,变量diff
的变化情况如下(要注意我们所有的方法中窗口都是针对s2
的):
continue
进行下一次循环。cnts
中该字符当前的计数为0,代表之前窗口和s1
内该字符的数量是相等的,马上右移之后,cnts
中不为0的个数就要加1,也就意味着diff++
cnts
中的计数为0了,那就代表窗口和s1
内该字符的数量相等了,那么cnts
中不为0的个数就少了一个,也就意味着diff--
cnts
中不为0的个数就要加1,意味着diff++
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