3. 无重复字符的最长子串
中 等 \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个指针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=3
- 那么每次循环中怎么确定窗口呢?
我们需要遍历每一个字符,然后从该字符开始,寻找不含有重复字符的子串。left
就指向了那个起始的字符。
例如第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
之中的最大值。
- 怎么判断下一个字符是否在子串中重复呢?
我们需要一个哈希集合来存储当前子串中存在的值。例如C++中的unordered_set
,Python和JavaScript中的Set
,Java中的HashSet
。如果下一个字符在哈希表中,我们就可以判断为right
遍历结束。 - (可选)每次循环中
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}不含重复字符 对于不含重复字符的字符串P,以及P的子串S⊆P有:P不含重复字符⇒S不含重复字符
对于abc
,它的任意子串,a
、ab
、bc
等都是不含重复字符的子串(注意ac
不是子串)。所以我们每次循环结束后,right
的位置可以保留,而不用从left
开始重新往后找。
过程演示
动画中字符串左右两侧的.
代表边界,不是有效数据。
例 1:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
#include <string>
#include <unordered_set>
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. 字符串的排列
中 等 \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
s1
和s2
仅包含小写字母
方法1:滑动窗口
由题目的示例1我们可以发现,本题不需要考虑子串s1
内字符的排列,只要s1
里有的,s2
的某一段也有就行了。因此我们可以对子串中的每个字符进行计数,存到一个长为26(题目中给出字符串中只有小写字母)的数组cnts1
里面。然后在字符串s2
上设置一个长为s1.length()
的窗口,每次对s2
窗口内的字符计数,存到一个长为26的数组cnts2
中,然后比较这两个数组即可。每次循环,我们要将窗口整体往右滑动一个单位,窗口长度始终不变,为字符串s1
的长度。
#include <string>
#include <vector>
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
数组的操作。
在循环开始前,我们要先针对第一个窗口将cnts1
和cnts2
给初始化完成,因为后面每次循环只对窗口两端的元素进行观察。
#include <string>
#include <vector>
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)
上面的方法中,我们每次都要比对整个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++
- 该字符的计数减1
- 如果减1之后,
cnts
中的计数为0了,那就代表窗口和s1
内该字符的数量相等了,那么cnts
中不为0的个数就少了一个,也就意味着diff--
- 如果
- 右侧字符的计数要增加。同样分为多个过程:
- 如果增加之前的计数为0,则代表…的数量是相等的。马上右移之后,
cnts
中不为0的个数就要加1,意味着diff++
- 该字符的计数加1
- 如果现在计数为0,…。(同上),
diff--
- 如果增加之前的计数为0,则代表…的数量是相等的。马上右移之后,
- 如果
diff==0
,则代表窗口移动完成后,现在计数相等,可以返回真值。
经历了上述漫长的推导,我们终于将空间花费减少了一半,时间花费的话,每次窗口滑动之后不需要遍历整个数组了。(请注意我们减少的不是空间、时间复杂度,我们减少的是各自实际资源的消耗)。我们最终会得到如下可能看起来不是特别舒服的代码结构(反正我个人看着这种结构是挺不舒服的):
#include <string>
#include <vector>
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