以下为力扣官方题解
题目
给你一个字符串 s ss 和一个整数 k kk,请你找出 s ss 中的最长子串, 要求该子串中的每一字符出现次数都不少于 k kk 。返回这一子串的长度。
示例1
输入:s = " a a a b b " , k = 3 s = "aaabb", k = 3s="aaabb",k=3
输出:3 33
解释:最长子串为 “a a a aaaaaa” ,其中 ‘a aa’ 重复了 3 33 次。
示例2
输入:s = " a b a b b c " , k = 2 s = "ababbc", k = 2s="ababbc",k=2
输出:5 55
解释:最长子串为 “a b a b b ababbababb” ,其中 ‘a aa’ 重复了 2 22 次, ‘b bb’ 重复了 3 33 次。
提示
- 1 < = s . l e n g t h < = 1 0 4 1 <= s.length <= 10^41<=s.length<=104
- s ss 仅由小写英文字母组成
- 1 < = k < = 1 0 5 1 <= k <= 10^51<=k<=105
官方题解
方法一:分治
对于字符串 s ss,如果存在某个字符 c h chch,它的出现次数大于 0 00 且小于 k kk,则任何包含 c h chch 的子串都不可能满足要求。也就是说,我们将字符串按照 c h chch 切分成若干段,则满足要求的最长子串一定出现在某个被切分的段内,而不能跨越一个或多个段。因此,可以考虑分治的方式求解本题。
代码
class Solution {
public int longestSubstring(String s, int k) {
int n = s.length();
return dfs(s, 0, n-1, k);
}
public int dfs(String s, int l, int r, int k) {
int[] cnt = new int[26];
for (int i=l; i<=r; i++)
{
cnt[s.charAt(i)-'a'] ++;
}
char split = 0;
for (int i=0; i<26; i++)
{
if (cnt[i]>0 && cnt[i]<k)
{
split = (char) (i+'a');
break;
}
}
if (split == 0)
{
return r-l+1;
}
int i = l;
int ret = 0;
while (i <= r)
{
while (i<=r && s.charAt(i)==split)
{
i ++;
}
if (i > r)
{
break;
}
int start = i;
while (i<=r && s.charAt(i)!=split)
{
i ++;
}
int length = dfs(s, start, i-1, k);
ret = Math.max(ret, length);
}
return ret;
}
}
复杂度分析
- 时间复杂度:O ( N ⋅ ∣ Σ ∣ ) O(N\cdot |\Sigma|)O(N⋅∣Σ∣),其中 N NN 为字符串的长度,Σ \SigmaΣ 为字符集,本题中字符串仅包含小写字母,因此 ∣ Σ ∣ = 26 |\Sigma| = 26∣Σ∣=26。由于每次递归调用都会完全去除某个字符,因此递归深度最多为 ∣ Σ ∣ |\Sigma|∣Σ∣。
- 空间复杂度:O ( ∣ Σ ∣ 2 ) O(|\Sigma|^2)O(∣Σ∣2)。递归的深度为 O ( ∣ Σ ∣ ) O(|\Sigma|)O(∣Σ∣),每层递归需要开辟 O ( ∣ Σ ∣ ) O(|\Sigma|)O(∣Σ∣) 的额外空间。
方法二:滑动窗口
我们枚举最长子串中的字符种类数目,它最小为 1 11,最大为 ∣ Σ ∣ |\Sigma|∣Σ∣(字符集的大小,本题中为 26 2626)。
对于给定的字符种类数量 t tt,我们维护滑动窗口的左右边界 l , r l,rl,r、滑动窗口内部每个字符出现的次数 c n t cntcnt,以及滑动窗口内的字符种类数目 t o t a l totaltotal。当 t o t a l > t total>ttotal>t 时,我们不断地右移左边界 l ll,并对应地更新 c n t cntcnt 以及 t o t a l totaltotal,直到 total ≤ t \textit{total} \le ttotal≤t 为止。这样,对于任何一个右边界 r rr,我们都能找到最小的 l ll(记为 l m i n l_{min}lmin),使得 s [ l m i n . . . r ] s[l_{min}...r]s[lmin...r] 之间的字符种类数目不多于 t tt。
对于任何一组 l m i n , r l_{min}, rlmin,r 而言,如果 s [ l m i n . . . r ] s[l_{min}...r]s[lmin...r] 之间存在某个出现次数小于 k kk (且不为 0 00,下文不再特殊说明)的字符,我们可以断定:对于任何 l ′ ∈ ( l m i n , r ) l' \in (l_{min}, r)l′∈(lmin,r) 而言,s [ l ′ . . . r ] s[l'...r]s[l′...r] 依然不可能是满足题意的子串,因为:
- 要么该字符的出现次数降为 0 00,此时子串内虽然少了一个出现次数小于 k kk 的字符,但字符种类数目也随之小于 t tt 了;
- 要么该字符的出现次数降为非 0 00 整数,此时该字符的出现次数依然小于 k kk。
根据上面的结论,我们发现:当限定字符种类数目为 t tt 时,满足题意的最长子串,就一定出自某个 s [ l m i n . . . r ] s[l_{min}...r]s[lmin...r]。因此,在滑动窗口的维护过程中,就可以直接得到最长子串的大小。
此外还剩下一个细节:如何判断某个子串内的字符是否都出现了至少 k kk 次?我们当然可以每次遍历 c n t cntcnt 数组,但是这会带来 O ( ∣ Σ ∣ ) O(|\Sigma|)O(∣Σ∣) 的额外开销。
我们可以维护一个计数器 l e s s lessless,代表当前出现次数小于 k kk 的字符的数量。注意到:每次移动滑动窗口的边界时,只会让某个字符的出现次数加一或者减一。对于移动右边界 l ll 的情况而言:
- 当某个字符的出现次数从 0 00 增加到 1 11 时,将 l e s s lessless 加一;
- 当某个字符的出现次数从 k − 1 k-1k−1 增加到 k kk 时,将 l e s s lessless 减一。
对于移动左边界的情形,讨论是类似的。
通过维护额外的计数器 l e s s lessless,我们无需遍历 c n t cntcnt 数组,就能知道每个字符是否都出现了至少 k kk 次,同时可以在每次循环时,在常数时间内更新计数器的取值。读者可以自行思考 k = 1 k=1k=1 时的处理逻辑。
代码
class Solution {
public int longestSubstring(String s, int k) {
int ret = 0;
int n = s.length();
for (int t=1; t<=26; t++)
{
int l = 0, r = 0;
int[] cnt = new int[26];
int tot = 0;
int less = 0;
while (r < n)
{
cnt[s.charAt(r)-'a'] ++;
if (cnt[s.charAt(r)-'a'] == 1)
{
tot ++;
less ++;
}
if (cnt[s.charAt(r)-'a'] == k)
{
less --;
}
while (tot > t)
{
cnt[s.charAt(l)-'a'] --;
if (cnt[s.charAt(l)-'a'] == k-1)
{
less ++;
}
if (cnt[s.charAt(l)-'a'] == 0)
{
tot --;
less --;
}
l ++;
}
if (less == 0)
{
ret = Math.max(ret, r-l+1);
}
r ++;
}
}
return ret;
}
}
复杂度分析
- 时间复杂度:O ( N ⋅ ∣ Σ ∣ + ∣ Σ ∣ 2 ) O(N \cdot |\Sigma| + |\Sigma|^2)O(N⋅∣Σ∣+∣Σ∣2),其中 N NN 为字符串的长度,Σ \SigmaΣ 为字符集,本题中字符串仅包含小写字母,因此 ∣ Σ ∣ = 26 |\Sigma| = 26∣Σ∣=26。我们需要遍历所有可能的 t tt,共 ∣ Σ ∣ |\Sigma|∣Σ∣ 种可能性;内层循环中滑动窗口的复杂度为 O ( N ) O(N)O(N),且初始时需要 O ( ∣ Σ ∣ ) O(|\Sigma|)O(∣Σ∣) 的时间初始化 c n t cntcnt 数组。
- 空间复杂度:O ( ∣ Σ ∣ ) O(|\Sigma|)O(∣Σ∣)。