LeetCode 1456. 定长子串中元音的最大数目
题目描述
题意分析
给一个小写字母串
s和整数k,在所有长度恰好为k的连续子串中,找出元音字母(a、e、i、o、u)最多的那一个,返回它的元音个数。
两个词决定了整道题的形态。「连续」意味着候选区间只有
n - k + 1个,而不是组合数级别;「长度恰好为k」意味着区间长度是固定的,不需要根据某个条件去伸缩左边界——这和「最长/最短满足某条件的子串」那一类题有本质区别,本题的两个端点是绑定同步移动的。
约束里
1 <= k <= s.length <= 10^5。k不超过串长这一点很重要:它保证了至少存在一个合法窗口,初始化时可以放心地先填满前k个字符,不必担心越界或「无解」。串长 $10^5$ 也把 $O(nk)$ 的暴力挡在了门外(最坏 $10^{10}$ 次比较)。
元音只有 5 个固定字符,判定是纯粹的 $O(1)$ 常数操作,不需要哈希集合,一个五路
||或一个长度 26 的布尔表就够。
边界有三类:
k == s.length()时只有一个窗口,答案就是全串元音数;k == 1时答案是 0 或 1,取决于串里有没有元音;全是辅音时答案为 0,此时不能因为「没找到」而返回 -1 之类的哨兵。另外一个隐蔽的上界是:答案最大就是k,若中途已经达到k其实可以提前返回,但没必要,不影响复杂度量级。
解法:滑动窗口维护有效区间
核心思路
暴力是枚举所有起点,对每个长度为
k的窗口重新数一遍元音,$O(nk)$。瓶颈很清楚:相邻两个窗口有k - 1个字符完全重合,却被重复统计了k - 1次。
观察这个重合关系:窗口从
[i-k, i-1]移到[i-k+1, i]时,只有两个字符发生变化——右端进来一个s[i],左端出去一个s[i-k]。中间部分的元音数没有任何变化。因此不需要重新数,只需要在计数器上做两次 $O(1)$ 的增减。
于是维护一个整数
count,写死它的含义并作为不变量:在处理完下标i之后,count恰好等于子串s[i-k+1 .. i]中的元音个数。每轮循环通过「加入s[i]、移除s[i-k]」这一对操作把不变量从i-1维护到i,代价 $O(1)$。
答案则是所有窗口的
count的最大值。首窗口单独统计,随后每次右移恰好生成下一个起点对应的窗口,因此n-k+1个合法窗口不重不漏;对这些精确计数取最大值就是答案。窗口长度固定,不存在「窗口非法需要收缩」的情形:左右端点的距离恒为k,左指针不需要独立维护,被移出的下标就是i-k。
初始窗口需要单独构造:先把
s[0..k-1]的元音数一次数完作为第一个count,并用它初始化答案,然后主循环从i = k开始。这样写让主循环体保持整齐的「进一个、出一个、更新答案」三步,没有任何特判。
解题步骤
- 构造首个窗口:循环
i从 0 到k-1,把元音计入count。这一步必须独立于主循环,因为下标小于k时不存在要移出的左端字符(i - k会是负数)。用「先建首窗口、再滑动」代替「在主循环里判断i >= k才移出」,能省掉每轮一次分支判断,逻辑也更清晰。- 用首个窗口初始化答案:
answer = count。不能初始化为 0 然后只在主循环里更新——k == s.length()时主循环一次都不进,答案就丢了。- 主循环从
i = k开始:此时窗口是s[i-k+1 .. i],右端点是i。- 先加入右端
s[i],再移出左端s[i-k]:两句都是无条件执行的独立判断,谁先谁后其实不影响结果(它们操作的是不同字符,加减可交换)。但移出的下标必须是i - k而不是i - k + 1:新窗口的左边界是i - k + 1,被挤出去的是它前面那一个,即i - k。这是本题唯一的 off-by-one 陷阱。- 更新答案:
answer = max(answer, count)。放在两次增减之后,保证count描述的是完整的当前窗口。- 返回
answer,不是count(count只是最后一个窗口的值),也不是窗口的起始下标。
以
s = "abciiidef"、k = 3走一遍。元音集合是{a, e, i, o, u}。
构造首窗口
[0, 2]即"abc":a是元音,b、c不是,count = 1,answer = 1。
i = 3(字符i):右端s[3] = 'i'是元音,count = 2;左端移出s[3-3] = s[0] = 'a',是元音,count = 1。当前窗口[1, 3]即"bci",确实 1 个元音。answer仍是 1。
i = 4(字符i):右端'i'元音,count = 2;移出s[1] = 'b',非元音,count保持 2。窗口[2, 4]即"cii",2 个元音。answer = 2。
i = 5(字符i):右端'i'元音,count = 3;移出s[2] = 'c',非元音。窗口[3, 5]即"iii",3 个元音。answer = 3。
i = 6(字符d):右端非元音,count保持 3;移出s[3] = 'i'元音,count = 2。窗口[4, 6]即"iid",2 个。answer仍是 3。
i = 7(字符e):右端元音,count = 3;移出s[4] = 'i'元音,count = 2。窗口[5, 7]即"ide",2 个。answer仍是 3。
i = 8(字符f):右端非元音,count保持 2;移出s[5] = 'i'元音,count = 1。窗口[6, 8]即"def",1 个。
返回 3,对应窗口
"iii"。移出下标若写成i - k + 1,维护的就不再是当前窗口;可用更小的反例s = "ba"、k = 1复现:滑到"a"时错误代码把刚加入的a又移出,返回 0 而不是 1。
代码实现
class Solution {
public int maxVowels(String s, int k) {
// 先把首个窗口 [0, k-1] 数满,主循环才能保持「进一个、出一个」的整齐结构。
int count = 0;
for (int i = 0; i < k; i++) {
if (isVowel(s.charAt(i))) {
count++;
}
}
// 必须用首窗口初始化答案,否则 k == n 时主循环不执行会漏掉唯一的窗口。
int answer = count;
for (int i = k; i < s.length(); i++) {
if (isVowel(s.charAt(i))) {
count++;
}
// 新窗口左边界是 i-k+1,被挤出去的是它前面的 i-k。
if (isVowel(s.charAt(i - k))) {
count--;
}
answer = Math.max(answer, count);
}
return answer;
}
private boolean isVowel(char c) {
return c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u';
}
}
func maxVowels(s string, k int) int {
// 先把首个窗口 [0, k-1] 数满,主循环才能保持「进一个、出一个」的整齐结构。
count := 0
for i := 0; i < k; i++ {
if isVowel(s[i]) {
count++
}
}
// 必须用首窗口初始化答案,否则 k == n 时主循环不执行会漏掉唯一的窗口。
answer := count
for i := k; i < len(s); i++ {
if isVowel(s[i]) {
count++
}
// 新窗口左边界是 i-k+1,被挤出去的是它前面的 i-k。
if isVowel(s[i-k]) {
count--
}
if count > answer {
answer = count
}
}
return answer
}
func isVowel(c byte) bool {
return c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u'
}
复杂度分析
- 时间复杂度:$O(n)$。构造首窗口扫描
k个字符,主循环扫描剩下的n - k个字符,每个字符最多被「加入」一次、被「移出」一次,元音判定是 5 次比较的常数操作。总共 $O(n)$,与k无关——这正是相对 $O(nk)$ 暴力的全部收益。- 空间复杂度:$O(1)$。只用了
count和answer两个整数,没有开数组、没有做子串切分。若把元音判定改成哈希集合会额外占常数空间且常数更大,本题用 5 路比较即可。
关键点总结
- 定长窗口不需要独立的左指针:左边界恒等于
i - k + 1,被移出的字符恒是s[i-k]。识别出「长度固定」这一条,就能把变长滑窗那套「while 收缩」的模板整个砍掉,代码短一半、也少一类死循环风险。- 增量维护的前提是「贡献可加可减」:元音计数满足这个性质,所以窗口移动时只需两次 $O(1)$ 修正。遇到「窗口内最大值」这类不可减的统计量,就必须换成单调队列(239 题),这是面试常见的追问方向。
- 先建首窗口、再滑动优于「在主循环里判断
i >= k」:省一次分支,也让「用首窗口初始化答案」变得自然,顺手覆盖了k == n的边界。- 移出下标是
i - k,不是i - k + 1。自检方法是:答案必须落在[0, k]区间内,一旦超过k就说明移出逻辑写错了。- 元音判定用固定的 5 路比较而不是集合查找。面试时若面试官把字符集扩大到需要动态配置,再升级成布尔表或哈希集合,说明「按需求选择数据结构」的判断力。
易错点总结
- 移出下标写成
i - k + 1:s = "ba"、k = 1滑到第二个窗口时会把刚加入的a移出,返回 0 而不是 1;正确移出位置是i - k。answer初始化为 0 而不是首窗口的count:s = "aeiou"、k = 5时主循环一次都不进,直接返回 0,正确答案是 5。- 主循环从
i = 0开始且没有独立构造首窗口:i < k时s.charAt(i - k)的下标是负数,Java 抛越界、Go 直接 panic。- 只加入右端忘记移出左端:
count变成整个前缀的元音数而不是窗口内的,s = "aeiou"、k = 2会返回 5 而不是 2。- 先更新答案再做加减:
answer会始终落后一个窗口。s = "bbbbae"、k = 2的最后一个窗口"ae"才首次达到 2,错误顺序只会记录到前一个窗口的 1,返回 1 而不是 2。- 返回
count而不是answer:s = "abciiidef"、k = 3的最后一个窗口是"def",返回 1,而正确答案是 3。- 在循环里用
s.substring(i-k+1, i+1)重新统计:每轮都构造长度k的新串并重数一遍,时间退化回 $O(nk)$,$10^5$ 规模直接超时,同时还产生大量垃圾对象。- 用
Set<Character>且在循环内新建集合:每轮都分配一次集合,常数急剧放大;即使提到循环外,装箱比较也远慢于 5 次char比较。- 误以为要找「至少
k长度」或「最多k长度」的子串:改成可伸缩窗口后s = "aeiou"、k = 2会返回 5(整串),题目要的是恰好长度为 2 的窗口的最大值 2。- Go 里用
for i, c := range s取字符:range遍历字符串产出的是 rune 及其字节偏移,与后面s[i-k]的字节索引混用会在多字节字符下错位;本题限定小写字母虽不触发,但形成的习惯会在别的题上炸掉。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 643. 子数组最大平均数 I | 简单 | 同为定长窗口,统计量换成和并要求返回浮点均值,注意精度与整型除法 |
| 1423. 可获得的最大点数 | 中等 | 取首尾两端的牌,需先把问题转化为「中间长度 n-k 的最小窗口和」 |
| 1052. 爱生气的书店老板 | 中等 | 定长窗口只负责统计「额外收益」,基础收益要单独累加后再相加 |
| 1151. 最少交换次数来组合所有的 1 | 中等 | 窗口长度由 1 的总数决定,答案是窗口内 0 的最小值而非最大值 |
| 209. 长度最小的子数组 | 中等 | 窗口长度不固定,必须引入独立左指针并在满足条件时主动收缩 |
| 3. 无重复字符的最长子串 | 中等 | 变长窗口 + 字符计数表,收缩条件由窗口内是否出现重复字符驱动 |