好的,我们来详细分析一下这道题。
题目描述
给定一个由小写字母组成的字符串 S 和一个整数 k。我们需要计算 S 中有多少个子串,这些子串至少包含 k 个不同的字母。
子串是指字符串中连续的一段字符。例如,“abc” 是 “abcabc” 的子串,但 “acb” 不是。
样例
样例 1:
输入:
abcabcabca
4
输出:
0
解释: 字符串 “abcabcabca” 中没有子串包含至少 4 个不同的字母(实际上最多只有 ‘a’, ‘b’, ‘c’ 三种)。
样例 2:
输入:
abcabcabcabc
3
输出:
55
解释: 字符串 “abcabcabcabc” 长度为 12。有很多子串包含 ‘a’, ‘b’, ‘c’ 这 3 个不同的字母。例如 “abc”, “abca”, “abcab”, …, “bcabcabcabc”, …, “abcabcabcabc”。我们需要计算所有这样的子串的数量。
算法分析
暴力枚举 (会超时)
最直观的想法是枚举所有的子串。一个字符串 S 的子串由其起始位置 i
和结束位置 j
(其中 i <= j
) 唯一确定。
- 我们可以用两层循环枚举所有可能的
i
和j
。i
从 0 到n-1
,j
从i
到n-1
,其中n
是字符串 S 的长度。 - 对于每个子串
S[i...j]
,我们计算它包含多少个不同的字母。这可以通过使用一个哈希集合(std::set
或std::unordered_set
)或一个频率数组(大小为 26,记录 ‘a’ 到 ‘z’ 是否出现)来实现。 - 如果不同字母的数量大于或等于
k
,我们就将计数器加一。
时间复杂度分析:
- 外层循环
i
运行 n 次。 - 内层循环
j
最多运行 n 次。 - 在内层循环中,提取子串
S[i...j]
并计算不同字母的数量。如果每次都重新计算,最坏情况下(例如使用std::set
)需要 O(LlogA) 或 O(L) 的时间,其中 L=j−i+1 是子串长度, A 是字母表大小 (26)。如果使用频率数组,则是 O(L) 或优化后 O(A)。 - 总时间复杂度约为 O(n3) 或 O(n2×A)。
考虑到 n 最大可达 106,n2 级别的复杂度(大约 1012 次操作)是无法接受的。我们需要更快的算法。
滑动窗口 / 双指针优化 O(n)
暴力枚举的主要问题在于重复计算。当我们从子串 S[i...j]
移动到 S[i...j+1]
时,我们没有利用之前计算的结果。滑动窗口(或称为双指针)技术非常适合处理这类涉及连续子数组/子串计数的问题。
核心思想:
我们尝试固定子串的右端点 end
,然后找到最小的左端点 start
,使得子串 S[start...end]
满足条件(至少包含 k
个不同字母)。
如果 S[start...end]
满足条件,那么任何包含 S[start...end]
并且也以 end
结尾的子串(即 S[s...end]
其中 s <= start
)也一定满足条件,因为它们包含的字母集合更大或相同。
这还不够直接。我们换个思路:我们维护一个窗口 [start, end]
。我们不断尝试扩大窗口的右边界 end
。同时,我们维护窗口内不同字符的数量 unique
。当 unique
达到 k
时,我们就找到了一个满足条件的窗口 S[start...end]
。
关键点: 如果 S[start...end]
满足条件(unique >= k
),那么所有以 start
为左端点,且右端点 j >= end
的子串 S[start...j]
(即 S[start...end]
, S[start...end+1]
, …, S[start...n-1]
)都必然满足条件。为什么?因为它们都包含了 S[start...end]
这个子串,所以它们的不同字母数至少和 S[start...end]
一样多,也就是至少为 k
。
有多少个这样的子串呢?右端点 j
可以是 end
, end+1
, …, n-1
。总共有 (n-1) - end + 1 = n - end
个。
算法流程:
- 初始化两个指针
start = 0
和end = 0
。 - 初始化一个频率映射
f
(可以用std::map<char, int>
或大小为 26 的数组) 来存储当前窗口S[start...end]
中每个字符出现的次数。 - 初始化一个变量
unique = 0
来记录当前窗口中不同字符的数量。 - 初始化答案
ans = 0
(使用long long
类型,因为结果可能很大)。 - 让
end
指针从 0 遍历到n-1
:
a. 将字符S[end]
加入窗口:
i. 检查f[S[end]]
是否为 0。如果是,说明这是一个新出现的不同字符,所以unique++
。
ii. 递增f[S[end]]++
。
b. 现在窗口是S[start...end]
。检查条件是否满足:只要unique >= k
,就说明当前的窗口S[start...end]
是一个有效的”基础”窗口。
c. 当unique >= k
时:
i. 我们找到了一个有效的起始点start
和当前的结束点end
。所有以start
开头,以end
或更后的位置结尾的子串都满足条件。这些子串的数量是n - end
。我们将这个数量加到ans
上:ans += n - end;
。
ii. 现在,我们需要尝试缩小窗口,看看是否还能保持unique >= k
。我们尝试将窗口的左边界右移,即移除S[start]
:
* 递减f[S[start]]--
。
* 检查f[S[start]]
是否变为 0。如果是,说明我们失去了一个不同的字符,所以unique--
。
* 将start
指针右移:start++
。
iii. 持续这个收缩过程 (while循环) 直到unique < k
。这意味着对于当前的end
,我们已经考虑完了所有可能的、能让unique >= k
成立的start
位置。 - 当
end
遍历完整个字符串后,ans
就包含了所有满足条件的子串的数量。
代码逻辑解释 (对照提供的代码):
// ... (includes and setup) ...
void solve() {
string s;
cin >> s; // 读入字符串 S
int k;
cin >> k; // 读入整数 k
int n = s.size(); // 字符串长度
i64 ans = 0; // 结果,用 long long (i64)
map<char, int> f; // 频率映射,记录窗口内字符计数
int unique = 0; // 窗口内不同字符的数量
int start = 0; // 窗口左边界
// end 是窗口右边界,从 0 遍历到 n-1
for (int end = 0; end < n; end ++ ) {
// 1. 扩展窗口:加入 s[end]
if (f[s[end]] == 0) { // 如果 s[end] 是新字符
unique ++ ; // 不同字符数增加
}
f[s[end]] ++ ; // s[end] 的计数增加
// 2. 检查并收缩窗口:只要满足条件 (unique >= k)
while (unique >= k and start <= end) {
// 找到了一个有效的窗口 S[start...end]
// 所有以 start 开头,end 或之后结尾的子串都有效
ans += n - end; // 累加这些子串的数量
// 尝试收缩窗口:移除 s[start]
f[s[start]] -- ; // s[start] 计数减少
if (f[s[start]] == 0) { // 如果 s[start] 的计数变为 0
unique -- ; // 不同字符数减少
}
start ++ ; // 左边界右移
}
// while 循环结束后,unique < k 或者 start > end (虽然这里 start <= end 条件保证不会越界)
// 此时对于当前的 end,不能再通过增加 start 来找到有效子串了
// 外层循环会继续增加 end,扩展窗口
}
cout << ans << "\n"; // 输出最终结果
}
// ... (main function) ...
例子 walkthrough (S=”abcabc”, k=3):
- n = 6, k = 3, ans = 0, start = 0, f = {}, unique = 0
end = 0
: s[0]=’a’. f={‘a’:1}, unique=1.unique < k
.end = 1
: s[1]=’b’. f={‘a’:1, ‘b’:1}, unique=2.unique < k
.end = 2
: s[2]=’c’. f={‘a’:1, ‘b’:1, ‘c’:1}, unique=3.unique >= k
.while (unique >= k)
:ans += n - end = 6 - 2 = 4
.ans = 4
. (子串 “abc”, “abca”, “abcab”, “abcabc” 起点为 0)- 移除 s[start=0]=’a’. f={‘a’:0, ‘b’:1, ‘c’:1}, unique=2.
start = 1
.
while
结束,因为unique < k
.
end = 3
: s[3]=’a’. f={‘a’:1, ‘b’:1, ‘c’:1}, unique=3.unique >= k
.while (unique >= k)
:ans += n - end = 6 - 3 = 3
.ans = 4 + 3 = 7
. (子串 “bca”, “bcab”, “bcabc” 起点为 1)- 移除 s[start=1]=’b’. f={‘a’:1, ‘b’:0, ‘c’:1}, unique=2.
start = 2
.
while
结束.
end = 4
: s[4]=’b’. f={‘a’:1, ‘b’:1, ‘c’:1}, unique=3.unique >= k
.while (unique >= k)
:ans += n - end = 6 - 4 = 2
.ans = 7 + 2 = 9
. (子串 “cab”, “cabc” 起点为 2)- 移除 s[start=2]=’c’. f={‘a’:1, ‘b’:1, ‘c’:0}, unique=2.
start = 3
.
while
结束.
end = 5
: s[5]=’c’. f={‘a’:1, ‘b’:1, ‘c’:1}, unique=3.unique >= k
.while (unique >= k)
:ans += n - end = 6 - 5 = 1
.ans = 9 + 1 = 10
. (子串 “abc” 起点为 3)- 移除 s[start=3]=’a’. f={‘a’:0, ‘b’:1, ‘c’:1}, unique=2.
start = 4
.
while
结束.
end
循环结束。最终ans = 10
.
时间复杂度
end
指针从 0 遍历到n-1
,共n
步。start
指针也从 0 开始,最多移动到n
。- 在
for
循环的每次迭代中,对f
的操作(插入、查找、更新)在使用std::map
时是 O(logA)(其中 A=26 是字母表大小),在使用哈希表或数组时是 O(1)。 while
循环内部的操作也是类似的复杂度。关键在于start
指针的总移动次数。虽然while
循环可能执行多次,但每次执行都会使start
增加 1。由于start
最多增加到n
,所以while
循环体内的所有操作(在整个for
循环过程中)总共执行不超过 O(n) 次。- 因此,每个字符最多被加入窗口一次 (
end
指针),最多被移出窗口一次 (start
指针)。 - 总时间复杂度是 O(nlogA) 或 O(n),取决于频率映射的实现。对于小的字母表大小 A=26,可以认为是 O(n)。
空间复杂度
- 我们使用了一个频率映射
f
来存储字符计数。最多存储 A=26 个不同的字符。 - 所以空间复杂度是 O(A) 或 O(1) (因为 A 是常数)。
C++ 代码 (重述提供的代码)
#include <bits/stdc++.h> // 包含了常用的库,包括 iostream, string, map 等
using namespace std;
using i64 = long long; // 定义 i64 为 long long 的别名,方便使用
// 处理单组测试数据的函数
void solve() {
string s; // 存储输入的字符串
cin >> s; // 从标准输入读取字符串
int k; // 存储输入的整数 k
cin >> k; // 从标准输入读取 k
int n = s.size(); // 获取字符串 s 的长度
i64 ans = 0; // 初始化答案计数器为 0,使用 long long 类型防止溢出
map<char, int> f; // 字符频率映射,key 是字符,value 是该字符在当前窗口中的数量
int unique = 0; // 当前窗口中不同字符的数量
int start = 0; // 滑动窗口的左边界(起始索引)
// end 是滑动窗口的右边界,从 0 遍历到 n-1
for (int end = 0; end < n; end ++ ) {
// --- 窗口扩张阶段 ---
// 检查即将加入的字符 s[end] 是否是窗口中的新字符
if (f[s[end]] == 0) {
// 如果 s[end] 的计数为 0,说明它是新的不同字符
unique ++ ; // 不同字符数加 1
}
// 将字符 s[end] 的频率计数加 1
f[s[end]] ++ ;
// --- 检查与收缩阶段 ---
// 只要当前窗口 [start, end] 包含至少 k 个不同字符
// 并且 start 没有超过 end (保证窗口有效)
while (unique >= k and start <= end) {
// 此时,以 start 为起点,以 end, end+1, ..., n-1 为终点的所有子串都满足条件
// 这些子串的数量是 n - end
ans += n - end; // 将这些子串的数量累加到答案中
// 尝试收缩窗口,将左边界 start 右移
// 先更新移除字符 s[start] 带来的影响
f[s[start]] -- ; // 将 s[start] 的频率计数减 1
// 检查移除 s[start] 后,它是否在窗口中完全消失了
if (f[s[start]] == 0) {
// 如果 s[start] 的计数变为 0,说明失去了一个不同字符
unique -- ; // 不同字符数减 1
}
// 正式将左边界右移
start ++ ;
}
// 当 while 循环结束时,表示对于当前的 end,从当前 start 开始的窗口不再满足 unique >= k
// 外层 for 循环将继续移动 end,探索新的可能性
}
// 所有可能的 end 都遍历完毕后,ans 即为最终答案
cout << ans << "\n"; // 输出结果,并换行
}
int main() {
// 优化 C++ 输入输出速度
ios::sync_with_stdio(false); // 关闭 C++ 标准流与 C 标准流的同步
cin.tie(nullptr); // 解除 cin 和 cout 的绑定
int t; // 测试数据组数
cin >> t; // 读取测试数据组数
// 循环处理每组测试数据
while (t -- ) {
solve(); // 调用 solve 函数处理单组数据
}
return 0; // 程序正常结束
}