以下为力扣官方题解
给你一个字符串 s s s 和一个整数 k k k,请你找出 s s s 中的最长子串, 要求该子串中的每一字符出现次数都不少于 k k k 。返回这一子串的长度。
输入: s = " a a a b b " , k = 3 s = "aaabb", k = 3 s="aaabb",k=3
输出: 3 3 3
解释:最长子串为 “ a a a aaa aaa” ,其中 ‘ a a a’ 重复了 3 3 3 次。
输入: s = " a b a b b c " , k = 2 s = "ababbc", k = 2 s="ababbc",k=2
输出: 5 5 5
解释:最长子串为 “ a b a b b ababb ababb” ,其中 ‘ a a a’ 重复了 2 2 2 次, ‘ b b b’ 重复了 3 3 3 次。
- 1 < = s . l e n g t h < = 1 0 4 1 <= s.length <= 10^4 1<=s.length<=104
- s s s 仅由小写英文字母组成
- 1 < = k < = 1 0 5 1 <= k <= 10^5 1<=k<=105
对于字符串 s s s,如果存在某个字符 c h ch ch,它的出现次数大于 0 0 0 且小于 k k k,则任何包含 c h ch ch 的子串都不可能满足要求。也就是说,我们将字符串按照 c h ch ch 切分成若干段,则满足要求的最长子串一定出现在某个被切分的段内,而不能跨越一个或多个段。因此,可以考虑分治的方式求解本题。
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;
}
}
我们枚举最长子串中的字符种类数目,它最小为 1 1 1,最大为 ∣ Σ ∣ |\Sigma| ∣Σ∣(字符集的大小,本题中为 26 26 26)。
对于给定的字符种类数量 t t t,我们维护滑动窗口的左右边界 l , r l,r l,r、滑动窗口内部每个字符出现的次数 c n t cnt cnt,以及滑动窗口内的字符种类数目 t o t a l total total。当 t o t a l > t total>t total>t 时,我们不断地右移左边界 l l l,并对应地更新 c n t cnt cnt 以及 t o t a l total total,直到 total ≤ t \textit{total} \le t total≤t 为止。这样,对于任何一个右边界 r r r,我们都能找到最小的 l l l(记为 l m i n l_{min} lmin),使得 s [ l m i n . . . r ] s[l_{min}...r] s[lmin...r] 之间的字符种类数目不多于 t t t。
对于任何一组 l m i n , r l_{min}, r lmin,r 而言,如果 s [ l m i n . . . r ] s[l_{min}...r] s[lmin...r] 之间存在某个出现次数小于 k k k (且不为 0 0 0,下文不再特殊说明)的字符,我们可以断定:对于任何 l ′ ∈ ( l m i n , r ) l' \in (l_{min}, r) l′∈(lmin,r) 而言, s [ l ′ . . . r ] s[l'...r] s[l′...r] 依然不可能是满足题意的子串,因为:
根据上面的结论,我们发现:当限定字符种类数目为 t t t 时,满足题意的最长子串,就一定出自某个 s [ l m i n . . . r ] s[l_{min}...r] s[lmin...r]。因此,在滑动窗口的维护过程中,就可以直接得到最长子串的大小。
此外还剩下一个细节:如何判断某个子串内的字符是否都出现了至少 k k k 次?我们当然可以每次遍历 c n t cnt cnt 数组,但是这会带来 O ( ∣ Σ ∣ ) O(|\Sigma|) O(∣Σ∣) 的额外开销。
我们可以维护一个计数器 l e s s less less,代表当前出现次数小于 k k k 的字符的数量。注意到:每次移动滑动窗口的边界时,只会让某个字符的出现次数加一或者减一。对于移动右边界 l l l 的情况而言:
对于移动左边界的情形,讨论是类似的。
通过维护额外的计数器 l e s s less less,我们无需遍历 c n t cnt cnt 数组,就能知道每个字符是否都出现了至少 k k k 次,同时可以在每次循环时,在常数时间内更新计数器的取值。读者可以自行思考 k = 1 k=1 k=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;
}
}