LeetCode 1358. 包含所有三种字符的子字符串数目
题目描述
题意分析
给一个只由
a、b、c三种字符组成的字符串,数一数其中有多少个子串同时包含这三种字符。子串是连续的一段,按起止位置区分,内容相同但位置不同也要分别计数。字符集只有三种,这是最强的信号:判断一个窗口是否合格,只需要看三个计数是否都为正,一次比较就是常数时间,完全不必用哈希表或者集合。
「同时包含」是一个单调条件——一段区间合格的话,把它向左或向右延长仍然合格,因为原有字符一个都没少。这条单调性是后面所有优化的地基。
字符串长度可达 $5 \times 10^4$,子串总数是 $O(n^2)$ 级别(约 12 亿),逐个枚举并检查显然过不了,必须找到批量计数的办法。答案本身不超过 $n(n+1)/2 \approx 1.25 \times 10^9$,超过了 32 位有符号整数的一半但仍在范围内,Java 用
int恰好安全。
解法:滑动窗口计数贡献
核心思路
暴力做法是双重循环枚举左右端点,再花 $O(1)$ 到 $O(n)$ 判断是否合格,总代价至少 $O(n^2)$,$n = 5 \times 10^4$ 时跑不完。
瓶颈在于「逐个数」这件事本身。既然合格性对区间延长是单调的,那么固定左端点 $l$ 之后,一定存在一个临界位置 $R(l)$:右端点取到 $R(l)$ 时窗口刚好凑齐三种字符,取更小则不够,取更大则依然合格。于是以 $l$ 开头的合格子串恰好有 $n - R(l)$ 个,一次就能算出一整批,不必逐个枚举右端。
更进一步,$R(l)$ 随 $l$ 增大而单调不减:左端右移只会让窗口内字符变少,凑齐所需的右边界只会更远。这正是双指针能成立的条件——左右指针都只向右走,总步数 $O(n)$。
代码用的是一个等价但更紧凑的组织方式:外层枚举右端 $right$ 逐个加入字符,内层
while在窗口合格时不断做两件事——把当前左端 $left$ 的贡献 $n - right$ 计入答案,然后把 $left$ 移出窗口。这里的不变量是:每个左端点 $left$ 恰好在窗口首次对它合格的那一刻被结算一次,结算用的 $right$ 正是 $R(left)$。因为内层循环一旦发现合格就立刻结算并弹出左端,左端不会被结算第二次;而弹出后若仍合格,说明下一个左端的临界值也是当前 $right$,继续结算即可。换句话说,答案被拆成了 $\sum_{l} (n - R(l))$,每一项在左指针经过时一次性加满。窗口状态只用三个计数维护,加入和移出都是 $O(1)$。
解题步骤
- 准备长度为 3 的计数数组
count、左指针left = 0、答案res = 0。用数组下标c - 'a'定位,比哈希表省掉哈希开销。- 外层循环让
right从 0 走到 $n-1$,每轮先把s[right]的计数加一,把它纳入窗口。先扩右再判定,保证判定时窗口是 $[left, right]$ 的真实状态。- 内层
while判断三个计数是否都大于 0。用while而非if,是因为弹出一个左端字符后窗口可能仍然合格(比如该字符在窗口里有多份),此时下一个左端的临界右界同样是当前right,必须继续结算。- 每次满足条件就
res += n - right。这一项的含义是:以当前left为起点、以right到 $n-1$ 中任意位置为终点的子串全部合格,共 $n - right$ 个。加的是 $n - right$ 而不是 $right - left + 1$,因为批量方向是向右延伸而非向左。- 结算完立刻把
s[left]的计数减一并让left++。先结算后弹出的顺序不能反,否则这个左端的贡献就丢了。- 外层跑完返回
res。左右指针各自最多前进 $n$ 步,整体线性。以
s = "abcabc"走一遍($n = 6$):right = 0加入a,计数是 $(1,0,0)$,c缺席,不结算。right = 1加入b,计数 $(1,1,0)$,仍缺c。right = 2加入c,计数 $(1,1,1)$ 全部为正:结算left = 0,res += 6 - 2 = 4(对应子串abc、abca、abcab、abcabc),弹出s[0] = a,计数变 $(0,1,1)$,left = 1,不再合格,退出内层。right = 3加入a,计数 $(1,1,1)$:结算left = 1,res += 6 - 3 = 3,累计 7(新增以下标 1 开头的bca、bcab、bcabc),弹出s[1] = b,计数 $(1,0,1)$,left = 2。right = 4加入b,计数 $(1,1,1)$:结算left = 2,res += 6 - 4 = 2,累计 9,弹出s[2] = c,计数 $(1,1,0)$,left = 3。right = 5加入c,计数 $(1,1,1)$:结算left = 3,res += 6 - 5 = 1,累计 10,弹出s[3] = a,计数 $(0,1,1)$,left = 4。外层结束,返回 10。四个起点 0、1、2、3 各被结算一次,起点 4 和 5 后面的字符不足三种,一次都没被结算,正是应有的结果。
代码实现
class Solution {
// 固定左端时不好直接数所有右端。
public int numberOfSubstrings(String s) {
int[] count = new int[3];
int left = 0;
int res = 0;
for (int right = 0; right < s.length(); right++) {
count[s.charAt(right) - 'a']++;
while (count[0] > 0 && count[1] > 0 && count[2] > 0) {
res += s.length() - right;
count[s.charAt(left) - 'a']--;
left++;
}
}
return res;
}
}
func numberOfSubstrings(s string) int {
// 固定左端时不好直接数所有右端。
count := make([]int, 3)
left := 0
res := 0
for right := 0; right < len(s); right++ {
count[s[right]-'a']++
for count[0] > 0 && count[1] > 0 && count[2] > 0 {
res += len(s) - right
count[s[left]-'a']--
left++
}
}
return res
}
复杂度分析
- 时间复杂度:$O(n)$,
right单调走完整个串,left在所有内层循环中累计前进也不超过 $n$ 次,两个指针互不回退;窗口的加入、移出和合格判定都是常数时间。- 空间复杂度:$O(1)$,只有长度为 3 的计数数组和几个标量,与串长无关。
关键点总结
- 「统计满足条件的子串数目」和「求满足条件的最长/最短子串」是两类题。前者要按贡献批量累加,后者只需在极值处更新,套错模板会从根上算错。
- 单调性是双指针的前提:本题的条件对区间延长封闭,所以临界右界 $R(l)$ 随 $l$ 单调不减,左右指针才能都只进不退。
- 贡献取 $n - right$ 而不是 $right - left + 1$,方向由「固定左端向右延伸」这个拆分方式决定。写之前先确认自己拆的是哪一维,能避免大多数计数偏差。
- 内层必须用
while:弹出一个字符后窗口可能仍合格,这时是下一个左端在同一个right处结算,用if会漏掉整批答案。- 字符集固定为三种时,用长度为 3 的数组代替哈希表,常数小且不会有哈希碰撞开销;这个技巧在只含小写字母的题里同样适用。
- 面试视角:面试官通常会先追问「为什么两个指针都不回头」,再追问「换成必须包含 $k$ 种字符怎么改」。前者答单调性,后者答把三个计数换成哈希表加一个「已凑齐种类数」的计数器——能自然给出这条推广,说明理解的是模型而不是模板。
易错点总结
- 错误写法:把内层的
while写成if:s = "aaabc"→right = 4时窗口 $[0,4]$ 合格,只结算left = 0得 1 就退出,而left = 1和left = 2同样以right = 4为临界值,各自还该贡献 1,正确答案是 3,if版本只会输出 1。- 错误写法:贡献写成
right - left + 1:s = "abc"→ 输出 3,而以下标 0 开头的合格子串只有abc一个,正确答案是 1。- 错误写法:先弹出左端字符再累加贡献 → 结算用的窗口已经不是那个刚刚合格的窗口,当前左端的贡献整段丢失,答案系统性偏小。
- 错误写法:只在窗口刚好等于「恰好三种字符各一个」时才计数 → 题目要的是「至少包含」,
s = "aabc"里的aabc也合格,加上等号限制会大量漏算。- 错误写法:外层枚举左端、内层从左端往右扫找临界点,每次都从头开始 → 逻辑正确但退化成 $O(n^2)$,$5 \times 10^4$ 的输入直接超时;双指针的价值就在于左指针不回退。
- 错误写法:用
HashSet记录窗口内出现过的字符、弹出左端时直接remove→ 同一字符在窗口里有多份时,弹掉一份就把整个字符从集合里删了,窗口被误判为不合格,答案偏小。必须用计数而非存在性。- 错误写法:Go 里把
res声明成int32,或在其他语言里用 16 位类型 → 最坏答案接近 $1.25 \times 10^9$,32 位有符号刚好装得下,更窄的类型会溢出成负数。- 错误写法:判定条件写成
count[0] + count[1] + count[2] >= 3→ 窗口是"aaa"时三者之和也是 3,却一种字符都没凑齐,会把大量非法窗口算进答案。- 错误写法:忘记
left可能追上并超过right的情形而额外加left <= right的守卫,却把它写进合格判定里 → 本题中窗口合格必然意味着长度至少为 3,left不会越过right,多余的守卫反而可能提前中断内层循环、漏掉结算。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 76. 最小覆盖子串 | 困难 | 同为「至少包含」,但求最短窗口,需在收缩时更新极值 |
| 3. 无重复字符的最长子串 | 中等 | 条件对延长不封闭而是对收缩封闭,收缩逻辑正好相反 |
| 713. 乘积小于 K 的子数组 | 中等 | 同样按贡献计数,但贡献取窗口长度,方向是固定右端向左延伸 |
| 930. 和相同的二元子数组 | 中等 | 「恰好等于」不单调,需用两次「至多」相减来转化 |
| 992. K 个不同整数的子数组 | 困难 | 同样是恰好型计数,标准解法是维护两个左指针同时滑动 |
| 340. 至多包含 K 个不同字符的最长子串 | 中等 | 上界型约束,窗口越界时收缩,字符集不定所以要用哈希表计数 |
| 424. 替换后的最长重复字符 | 中等 | 合格判定依赖窗口内的众数频次,收缩条件不再是简单的计数比较 |