找出出现至少三次的最长特殊子字符串 I 图解题解
这道题到底在问什么
- 输入
- s = "aaaa"
- 输出
- 2
- 输入
- s = "abcdef"
- 输出
- 负一
- 输入
- s = "abcaba"
- 输出
- 1
最优解:为什么这么做
一句话答案:LeetCode 2981 找出至少出现三次的最长特殊子串用二分答案:长度越大越难凑够三次这条单调性,让判定把串切成一段段同字符再累计验证、往长收缩,时间 O(n log n)、空间 O(1)。
特殊子串、出现三次、可重叠——这题到底在数什么
给一个只含小写字母的串 s。特殊子串指整段只由同一个字符组成,像 aa、ddd 都算;子串是连续非空的一截,可重叠着数。要求出现次数达到三次及以上的最长特殊子串有多长,凑不够就返回负一。题面 s = aaaa 时,长度 2 的 aa 靠重叠出现了三次,答案是 2;s = abcdef 每个字母只出现一次,返回负一。
逐个长度、逐个位置地数,规模压不住
枚举所有子串就有 O(n²) 个:长度从 1 到 n、起点也有 n 个,每个还要判断是不是整段同字符、再数它出现几次,规模一大直接拖垮。二分答案把要试的长度从 n 个压到 O(log n) 个,每轮只扫一遍串统计,量级差出一大截。
长度轴一刀切成两半,才轮到二分登场
把候选长度排成一条轴看:长度 x 能凑够三次,那要求缩到 x−1 更容易——短窗口在同一段能停的位置更多;反过来 x 凑不够,更长的更没戏。于是长度轴被切成两半:短的都可行、长的都不行,答案就是可行那截的右端。与其一个个长度试,不如二分猜长度、拿判定卡一刀再收,这就是二分答案。
判定一个长度行不行,怎么按极长段来数
判定函数 check(x) 很直白:给它一个长度,回答能不能凑够三次。先把 s 切成极长段——连续同字符、左右再扩不出去的片段。一段长度 L 的段里能装几个长度 x 的特殊子串?宽 x 的窗口从左滑到右,能停 max(0, L − x + 1) 个位置,段比 x 短就归零。同一字符可能散在好几段,把位置数全加起来,某个字符累计到 3 长度 x 就可行——这三次不必挤在同一段。取中点 mid = (l + r + 1) // 2 偏上:check(mid) 为真把 l 抬到 mid,为假把 r 收到 mid − 1。
题面 aaaa 走一遍二分,l 和 r 最后停在 2
s = aaaa,n = 4,整串一段 a、长 4。l = 0、r = 4。第一轮 mid = (0 + 4 + 1) // 2 = 2,check(2) 这段贡献 max(0, 4 − 2 + 1) = 3 个 aa,a 累计到 3,长度 2 可行,l 抬到 2。第二轮 l = 2、r = 4,mid = 3,check(3) 只贡献 max(0, 4 − 3 + 1) = 2 个,不到 3,r 收到 2。此时 l = r = 2,循环停,l 不为 0,返回 2。换成 s = abcdef 六段各长 1,长度 1 凑不满三次,l 收到 0,返回负一。
中点取偏下,剩两格时就在原地打转
二分 O(log n) 轮,每轮 check 扫一遍串是 O(n),合起来 O(n log n);空间是长度 26 的计数,O(1)。三处只要错一处,整段二分就白跑:中点配 l = mid 必须取偏上 (l + r + 1) // 2,写成偏下时剩相邻两格 mid 恒等于 l、原地不动出不来;算贡献一定要套 max(0, L − x + 1),段比 x 短时括号里是负数,不归零会扣掉别处段的正确计数、答案偏小甚至变负;最后 l 停在 0 得返回负一,那是连单字符都没出现三次,返回 0 错了。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记牢这条主线:先切极长段,再二分答案长度 x。判可行只看一件事,某个字符把它所有段能提供的长度 x 特殊子串数目加起来,够不够 3 个。下面先把 s 切成段,再动手二分。
- 4准备:切极长段这是输入 s,一共八个字符,下标从 0 到 7。第一步不急着数,先把它切成极长段。所谓极长段,就是一段连续、字符都相同、而且左右再也扩不出去的片段。你顺着看过去:开头单独一个 a,接着单独一个 b,然后连着三个 a,再一个 b,最后两个 a。下面逐段点出来。
- 5切段:第 1 段绿色框住的是第 1 段,字符是 a,从下标 0 到 0,长度 1。把它记进段列表,继续往后切。
- 6切段:第 2 段绿色框住的是第 2 段,字符是 b,从下标 1 到 1,长度 1。把它记进段列表,继续往后切。
- 7切段:第 3 段绿色框住的是第 3 段,字符是 a,从下标 2 到 4,长度 3。这是最长的一段,三个 a 连在一起,后面数长度 2、长度 3 的特殊子串,主要靠它出力。
- 8切段:第 4 段绿色框住的是第 4 段,字符是 b,从下标 5 到 5,长度 1。把它记进段列表,继续往后切。
- 9切段:第 5 段绿色框住的是第 5 段,字符是 a,从下标 6 到 7,长度 2。切到这里,五段全部出来了:a 长 1、b 长 1、a 长 3、b 长 1、a 长 2。注意字符 a 分散在三段里,后面要把它们的贡献合起来算。
- 10公式:max(0, L − x + 1)先把最关键的一步公式讲透。绿色这段是 aaa,长度 L 等于 3。想在里面找长度 x 等于 2 的 aa,就像拿一个宽度 2 的窗口从左往右滑:能停的位置有 3 减 2 加 1 等于 2 个,分别盖住前两个 a 和后两个 a。一般地,长度 L 的段能切出 max(0, L − x + 1) 个长度 x 的特殊子串;当 x 比 L 还大时这个值会变成负数,用 max 跟 0 一比就归零,表示放不下。这个数目,就是这一段给这个字符贡献的特殊子串次数。
- 11二分区间 [0, 8]换一张图。这一排数字是候选答案长度 x 的所有取值,从 0 一直到 8。既然 x 越大越难凑够三次、可行性单调,我们就在这条轴上二分。左指针 l 是目前还没否掉的下界,右指针 r 是上界,灰掉的格子是已经排除的长度。开始时范围是从 0 到 8。
- 12测 x = 4范围 [0, 8] 取中点偏上,mid 等于 4。为什么偏上,因为下界会写成 l 等于 mid,取偏下会卡在原地死循环,后面坑里细说。现在先问:长度 4 的特殊子串,能不能有某个字符凑够三次。
- 13x = 4 判定回到字符串。要放长度 4 的特殊子串,得有一段极长段长度至少是 4。可我们最长的一段就是绿色这个 aaa,才 3 格,连一个长度 4 的都摆不下,贡献是 max(0, 3 − 4 + 1) 等于 0。其余的段更短,贡献全是 0。侧栏 cnt 里 a 和 b 都是 0,没有任何字符到 3。所以 x 等于 4 不可行。
- 14区间 [0, 3]既然 4 不行,那么 4 以及比 4 更长的都不可能了,右边一片灰掉。上界收到 mid 减 1 等于 3,搜索范围变成 [0, 3],接着在里面找。
- 15测 x = 2在 [0, 3] 里再取中点偏上,mid 等于 2。这一回问:长度 2 的特殊子串,也就是某个字母连着两个,能不能凑够三次。这是关键一测,盯住侧栏 cnt 的变化。
- 16x = 2 累计中开始给字符 a 数长度 2 的 aa。前面单独的 a 段只有 1 格,放不下 aa,贡献 0,先跳过。走到绿色这段 aaa,长度 3,能放 3 − 2 + 1 等于 2 个 aa。cnt 里 a 记成 2,已经很接近 3 了,就差 1 个。继续往后看还有没有 a 段。
- 17x = 2 达标继续走到最后这段 aa,长度 2,能放 2 − 2 + 1 等于 1 个 aa。把它加到前面的 2 上,cnt 里 a 变成 3,正好达到三次。注意这三个 aa 来自两段不同的极长段,蓝色那段 aaa 出了 2 个,绿色这段 aa 出了 1 个,合起来才够。一旦有字符到 3,判定立刻返回可行,x 等于 2 成立。
- 18区间 [2, 3]2 可行,说明答案至少是 2,先把它记成当前最优,绿色标在长度 2 上。但也许还能更长,所以不是收工,而是把下界 l 抬到 mid 等于 2,继续在 [2, 3] 里往更长的方向试。
- 19测 x = 3在 [2, 3] 里取中点偏上,mid 等于 3。问长度 3 的特殊子串,也就是某字母连着三个 aaa,能不能凑够三次。绿色的 2 是我们已经拿到手的最优,这次是看能不能突破到 3。
- 20x = 3 累计中给字符 a 数长度 3 的 aaa。绿色这段 aaa 长度 3,恰好只能放 3 − 3 + 1 等于 1 个,就是它自己。cnt 里 a 记成 1,离 3 还差得远。指望后面的 a 段接力,可它们更短。
- 21x = 3 不达标走到最后这段 aa,长度只有 2,连一个长度 3 的 aaa 都放不进去,贡献 max(0, 2 − 3 + 1) 等于 0。字符 a 的累计停在 1,b 更是从头到尾都是 0。没有任何字符能把长度 3 的特殊子串凑够三次,所以 x 等于 3 不可行。
- 22区间 [2, 2]3 不行,那 3 以上就更不用看了,上界收到 mid 减 1 等于 2。这时候左指针和右指针都落在 2 上,区间只剩一个点,二分到此结束。
- 23答案 = 2二分停在下界 l 等于 2。它不是 0,说明确实存在出现三次的特殊子串,最长那个的长度就是 2。如果 l 最后停在 0,那才表示连长度 1 的单字符都凑不够三次,那种情况返回负一。这里答案是 2。
- 24答案 2 成立回过头验一遍。长度 2 的 aa,在 s 里出现在三个位置:下标 [2,3]、[3,4],这两个都在中间那段 aaa 里,靠重叠数出来;还有一个在结尾的 [6,7]。三个凑齐,达到至少三次,答案 2 稳稳成立。而长度 3 的 aaa 全串只出现 1 次,达不到三次,这就是为什么答案卡在 2。
⚠️ 容易写错的地方
✗ 错:二分取中点偏下 mid = (l + r) 除 2
✓ 对:最大可行写法必须取中点偏上 (l + r + 1) 除 2
可行分支是 l = mid,区间剩相邻两格时偏下的 mid 恒等于 l,更新后原地不动,死循环
✗ 错:算一段贡献时漏了和 0 取 max
✓ 对:一律写 max(0, L − x + 1)
当段长 L 比 x 小时 L − x + 1 是负数,不归零会错误地扣掉别处的计数,答案偏小甚至出负
✗ 错:把某一段够不够长当成判据
✓ 对:要把同一字符所有段的贡献累加再比 3
三次可以来自不同段,本例的三个 aa 就分别来自 aaa 段和 aa 段,只看单段会漏判
✗ 错:忘了返回负一的条件
✓ 对:l 最终为 0 时返回负一
l 停在 0 表示连长度 1 的单字符都凑不够三次,此时没有合法答案,要返回负一而不是 0
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def maximumLength(self, s: str) -> int:
def check(x: int) -> bool:
cnt = defaultdict(int)
i = 0
while i < n:
j = i + 1
while j < n and s[j] == s[i]:
j += 1
cnt[s[i]] += max(0, j - i - x + 1)
i = j
return max(cnt.values()) >= 3
n = len(s)
l, r = 0, n
while l < r:
mid = (l + r + 1) >> 1
if check(mid):
l = mid
else:
r = mid - 1
return -1 if l == 0 else lC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int maximumLength(string s) {
int n = s.size();
int l = 0, r = n;
auto check = [&](int x) {
int cnt[26]{};
for (int i = 0; i < n;) {
int j = i + 1;
while (j < n && s[j] == s[i]) {
++j;
}
int k = s[i] - 'a';
cnt[k] += max(0, j - i - x + 1);
if (cnt[k] >= 3) {
return true;
}
i = j;
}
return false;
};
while (l < r) {
int mid = (l + r + 1) >> 1;
if (check(mid)) {
l = mid;
} else {
r = mid - 1;
}
}
return l == 0 ? -1 : l;
}
};Java
import java.util.*;
class Solution {
private String s;
private int n;
public int maximumLength(String s) {
this.s = s;
n = s.length();
int l = 0, r = n;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (check(mid)) {
l = mid;
} else {
r = mid - 1;
}
}
return l == 0 ? -1 : l;
}
private boolean check(int x) {
int[] cnt = new int[26];
for (int i = 0; i < n;) {
int j = i + 1;
while (j < n && s.charAt(j) == s.charAt(i)) {
j++;
}
int k = s.charAt(i) - 'a';
cnt[k] += Math.max(0, j - i - x + 1);
if (cnt[k] >= 3) {
return true;
}
i = j;
}
return false;
}
}复杂度
时间
O(n log n)
n 是字符串长度。二分的候选长度范围是 0 到 n,循环 O(log n) 轮;每一轮的 check 都要从头扫一遍字符串框极长段,是 O(n)。两者相乘,总时间 O(n log n)
空间
O(1)
按峰值算。计数用一个固定长度 26 的数组,或 Python 里至多 26 个键的字典,和输入规模无关,是常数;二分只用 l、r、mid 几个变量。所以额外空间是 O(1)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 找出出现至少三次的最长特殊子字符串 I 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
判定一个长度可不可行,到底在算什么?+
先把字符串压成一段段极长同字符段。对固定长度 x,一段长度 L 的段能提供 max(0, L − x + 1) 个长度 x 的特殊子串;把同一字符所有段的数目累加起来,只要某个字符累计到 3,长度 x 就算可行。因为 x 越大、每段能塞进的窗口越少,可行性随 x 增大只会变差,是单调的,所以能二分出最大可行的 x。
除了二分,有没有一遍扫完的 O(n) 解法?+
有,也是进阶版 II 的标准解。对每个字符只保留它最长的三段,记从大到小为 a、b、c。这个字符能出现三次的最长特殊子串长度,是三种情况取最大:最长段自己减 2(在一段里挤三个)、次长段与最长段减 1 取较小(两段各出一部分)、第三长段本身(三段各出一个)。26 个字符各算一遍再取最大,总时间 O(n)。面试里先说二分保底、再点出这个 O(n) 优化会很加分。
为什么这三次可以来自不同的段?+
题目只要求特殊子串在整个 s 里出现三次,没规定它们挨在一起或落在同一段。比方 s 里字符 a 分散在长度 3 的 aaa 段和长度 2 的 aa 段,长度 2 的 aa 能从 aaa 段数出 2 个、从 aa 段数出 1 个,合起来正好三次。所以判定时要把同一字符所有段的贡献相加,只盯某一段够不够长会漏掉这种跨段凑齐的情况。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 找出出现至少三次的最长特殊子字符串 I 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。