题目描述
如果一个字符串的形式为 UVU\texttt{UVU}UVU ,其中 U\texttt{U}U 非空,并且 V\texttt{V}V 恰好包含 LLL 个字符,那么我们称 UVU\texttt{UVU}UVU 是一个 LLL -间隔字符串。例如, abcbabc\texttt{abcbabc}abcbabc 是一个 111 -间隔字符串。 xyxyyxyxy\texttt{xyxyyxyxy}xyxyyxyxy 既是一个 222 -间隔字符串,也是一个 666 -间隔字符串,但不是 101010 -间隔字符串(因为 U\texttt{U}U 非空)。
给定一个字符串 sss 和一个正整数 ggg ,你需要找出 sss 中所有 ggg -间隔子串的数量。 sss 只包含小写字母,长度最多为 50,00050,00050,000 个字符。
输入格式
第一行包含一个整数 ttt ( 1≤t≤101 \leq t \leq 101≤t≤10 ),表示测试用例的数量。接下来的 ttt 行中,每行包含一个整数 ggg ( 1≤g≤101 \leq g \leq 101≤g≤10 )和一个字符串 sss 。
输出格式
对于每个测试用例,输出用例编号和 ggg -间隔子串的数量。
样例输入
2
1 bbaabaaaaa
5 abxxxxxab
样例输出
Case 1: 7
Case 2: 1
题目分析
问题理解
我们需要在字符串 sss 中找出所有满足以下条件的子串:
- 子串形式为 UVU\texttt{UVU}UVU ,即由三个部分组成。
- U\texttt{U}U 是非空字符串。
- V\texttt{V}V 的长度恰好为 ggg 。
- 第一个 U\texttt{U}U 和第二个 U\texttt{U}U 完全相同。
换句话说,我们需要找到所有长度相等、内容相同且相隔 ggg 个字符的两个子串 U\texttt{U}U 。
形式化定义
设字符串 sss 的长度为 nnn ,下标从 000 开始。我们需要统计所有满足以下条件的四元组 (i,j,len)(i, j, len)(i,j,len) 的数量:
- iii 是第一个 U\texttt{U}U 的起始位置。
- jjj 是第二个 U\texttt{U}U 的起始位置。
- lenlenlen 是 U\texttt{U}U 的长度( len>0len > 0len>0 )。
- j=i+len+gj = i + len + gj=i+len+g 。
- 对于所有 0≤k<len0 \leq k < len0≤k<len ,有 s[i+k]=s[j+k]s[i + k] = s[j + k]s[i+k]=s[j+k] 。
- 子串的结束位置不超过 n−1n - 1n−1 ,即 i+2×len+g−1<ni + 2 \times len + g - 1 < ni+2×len+g−1<n 。
数据范围分析
- n≤50000n \leq 50000n≤50000 ,因此 O(n2)O(n^2)O(n2) 的算法可能勉强通过(如果常数较小),但 O(n3)O(n^3)O(n3) 的算法肯定超时。
- g≤10g \leq 10g≤10 ,这是一个重要的限制条件,意味着中间间隔很小。
- t≤10t \leq 10t≤10 ,所以需要每个测试用例都高效处理。
解题思路
暴力枚举(不可行)
最直接的想法是枚举所有可能的 U\texttt{U}U 长度 lenlenlen 和起始位置 iii ,然后比较两个 U\texttt{U}U 是否相同。这样需要三层循环:
- 枚举 lenlenlen :从 111 到 (n−g)/2(n - g) / 2(n−g)/2 。
- 枚举 iii :从 000 到 n−2×len−gn - 2 \times len - gn−2×len−g 。
- 比较两个长度为 lenlenlen 的子串是否相等。
时间复杂度为 O(n3)O(n^3)O(n3) ,在 n=50000n = 50000n=50000 时完全不可行。
哈希优化
我们可以使用字符串哈希(Rabin-Karp\texttt{Rabin-Karp}Rabin-Karp 哈希)来在 O(1)O(1)O(1) 时间内比较两个子串是否相等。这样可以将时间复杂度降低到 O(n2)O(n^2)O(n2) 。
哈希原理:
- 预处理字符串的前缀哈希值: hash[i]=s[0]×basei−1+s[1]×basei−2+⋯+s[i−1]×base0hash[i] = s[0] \times base^{i-1} + s[1] \times base^{i-2} + \dots + s[i-1] \times base^0hash[i]=s[0]×basei−1+s[1]×basei−2+⋯+s[i−1]×base0 。
- 计算子串 s[l..r]s[l..r]s[l..r] 的哈希值: hash(l,r)=hash[r+1]−hash[l]×baser−l+1hash(l, r) = hash[r+1] - hash[l] \times base^{r-l+1}hash(l,r)=hash[r+1]−hash[l]×baser−l+1 。
- 如果两个子串的哈希值相同,我们可以认为它们相等(在合适的模数下碰撞概率极低)。
算法步骤:
- 预处理字符串的哈希数组和 basebasebase 的幂次数组。
- 枚举 lenlenlen 从 111 到 (n−g)/2(n - g) / 2(n−g)/2 。
- 对于每个 lenlenlen ,枚举 iii 从 000 到 n−2×len−gn - 2 \times len - gn−2×len−g 。
- 计算第一个 U\texttt{U}U 的哈希值 hash(i,i+len−1)hash(i, i+len-1)hash(i,i+len−1) 和第二个 U\text{U}U 的哈希值 hash(i+len+g,i+2×len+g−1)hash(i+len+g, i+2 \times len+g-1)hash(i+len+g,i+2×len+g−1) 。
- 如果两个哈希值相等,则答案加 111 。
时间复杂度: O(n2)O(n^2)O(n2) 。
空间复杂度: O(n)O(n)O(n) 。
为什么能通过?
虽然 O(n2)O(n^2)O(n2) 对于 n=50000n = 50000n=50000 来说理论计算量很大(约 2.5×1092.5 \times 10^92.5×109 次操作),但实际上:
- ggg 很小( ≤10\leq 10≤10 ),所以内层循环的 iii 的范围随着 lenlenlen 增大而快速减小。
- 哈希比较是 O(1)O(1)O(1) 的,常数很小。
- 现代 CPU\texttt{CPU}CPU 速度较快,且题目时间限制可能较宽松。
- 实际测试中该算法可以在 222 秒内通过。
更优解法(后缀数组)
如果需要处理更大的 nnn 或更严格的时限,可以使用后缀数组 + RMQ\texttt{RMQ}RMQ 的方法:
- 构建字符串的后缀数组和高度数组(LCP\texttt{LCP}LCP 数组)。
- 使用 RMQ\texttt{RMQ}RMQ(稀疏表)预处理 LCP\texttt{LCP}LCP 数组,以便 O(1)O(1)O(1) 查询任意两个后缀的最长公共前缀。
- 枚举 lenlenlen 和 iii ,通过查询 LCP(i,i+len+g)\texttt{LCP}(i, i+len+g)LCP(i,i+len+g) 判断是否满足条件。
- 还可以进一步优化:对于每个偏移量 d=len+gd = len + gd=len+g ,计算所有 iii 的 LCP(i,i+d)\texttt{LCP}(i, i+d)LCP(i,i+d) ,然后统计贡献。
这种方法的时间复杂度为 O(nlogn)O(n \log n)O(nlogn) ,可以处理更大的数据范围。但由于 ggg 很小,哈希法已经足够。
代码实现(字符串哈希)
// L-Gap Substrings
// UVa ID: 10829
// Verdict: Accepted
// Submission Date: 2025-12-05
// UVa Run Time: 1.740s
//
// 版权所有(C)2025,邱秋。metaphysis # yeah dot net
#include <bits/stdc++.h>
using namespace std;
using ull = unsigned long long;
const int MAXN = 50005;
const ull BASE = 131;
ull powBase[MAXN];
ull preHash[MAXN];
void initHash(const string &s) {
int n = s.length();
powBase[0] = 1;
for (int i = 1; i <= n; ++i) powBase[i] = powBase[i-1] * BASE;
preHash[0] = 0;
for (int i = 1; i <= n; ++i) preHash[i] = preHash[i-1] * BASE + (s[i-1] - 'a' + 1);
}
ull getHash(int l, int r) { // 闭区间 [l, r],0-based
return preHash[r+1] - preHash[l] * powBase[r-l+1];
}
int countGapSubstrings(int g, const string &s) {
int n = s.length();
initHash(s);
int ans = 0;
for (int lenU = 1; lenU <= (n - g) / 2; ++lenU) {
for (int i = 0; i + 2 * lenU + g <= n; ++i) {
int j = i + lenU + g;
if (getHash(i, i + lenU - 1) == getHash(j, j + lenU - 1)) ++ans;
}
}
return ans;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int t;
cin >> t;
for (int caseNo = 1; caseNo <= t; ++caseNo) {
int g;
string s;
cin >> g >> s;
int ans = countGapSubstrings(g, s);
cout << "Case " << caseNo << ": " << ans << "\n";
}
return 0;
}
代码实现(后缀数组)
// L-Gap Substrings
// UVa ID: 10829
// Verdict: Accepted
// Submission Date: 2025-12-05
// UVa Run Time: 0.040s
//
// 版权所有(C)2025,邱秋。metaphysis # yeah dot net
#include <bits/stdc++.h>
using namespace std;
const int N = 500050;
char s[N];
// 后缀数组结构体,用于构建后缀数组、高度数组和RMQ查询
struct SuffixArray {
int sa[N]; // 后缀数组,sa[i]表示排第i名的后缀的起始下标
int t1[N], t2[N], c[N]; // 临时数组和计数数组,用于基数排序
int height[N]; // 高度数组,height[i] = LCP(sa[i-1], sa[i])
int rnk[N]; // 排名数组,rnk[i]表示起始位置为i的后缀的排名
int lg[N], st[30][N]; // RMQ所需:lg数组用于快速计算log,st为稀疏表
// 构建后缀数组,n为字符串长度+1(包含末尾的0),m为字符集大小
void buildSA(int n, int m) {
int i, *x = t1, *y = t2;
// 第一轮基数排序:按第一个字符排序
for (i = 0; i < m; i++) c[i] = 0;
for (i = 0; i < n; i++) c[x[i] = s[i]]++;
for (i = 1; i < m; i++) c[i] += c[i - 1];
for (i = n - 1; i >= 0; i--) sa[--c[x[i]]] = i;
// 倍增算法,k为当前比较的步长
for (int k = 1; k <= n; k <<= 1) {
int p = 0;
// 对第二关键字排序:没有第二关键字的排在最前面
for (i = n - k; i < n; i++) y[p++] = i;
// 根据上一轮的sa数组确定第二关键字顺序
for (i = 0; i < n; i++)
if (sa[i] >= k) y[p++] = sa[i] - k;
// 对第一关键字排序
for (i = 0; i < m; i++) c[i] = 0;
for (i = 0; i < n; i++) c[x[y[i]]]++;
for (i = 1; i < m; i++) c[i] += c[i - 1];
for (i = n - 1; i >= 0; i--) sa[--c[x[y[i]]]] = y[i];
// 生成新的rank数组
swap(x, y);
p = 1;
x[sa[0]] = 0;
for (i = 1; i < n; i++)
// 如果当前后缀和前一个后缀的两个关键字都相同,则排名相同
x[sa[i]] = (y[sa[i - 1]] == y[sa[i]] && y[sa[i - 1] + k] == y[sa[i] + k]) ? p - 1 : p++;
// 如果所有后缀的排名都已不同,则提前结束
if (p >= n) break;
m = p; // 更新字符集大小
}
}
// 计算高度数组(LCP数组)
void getHeight(int n) {
int k = 0;
// 计算rank数组
for (int i = 1; i <= n; i++) rnk[sa[i]] = i;
// 计算每个后缀与它前一名的后缀的LCP
for (int i = 0; i < n; i++) {
if (k) k--; // height[rank[i]] >= height[rank[i-1]] - 1
int j = sa[rnk[i] - 1]; // 排名在i之前一位的后缀起始位置
while (s[i + k] == s[j + k]) k++; // 扩展匹配
height[rnk[i]] = k;
}
}
// 初始化RMQ稀疏表,用于快速查询任意两个后缀的LCP
void initilizeRMQ(int n) {
// 预处理log数组,用于RMQ查询时快速计算分段长度
lg[0] = -1;
for (int i = 1; i <= n; i++)
lg[i] = lg[i >> 1] + 1;
// 初始化ST表第一层
for (int i = 1; i <= n; i++)
st[0][i] = height[i];
// 构建ST表,st[j][i]表示区间[i, i+2^j-1]的最小值
for (int j = 1; (1 << j) <= n; j++)
for (int i = 1; i + (1 << j) - 1 <= n; i++)
st[j][i] = min(st[j - 1][i], st[j - 1][i + (1 << (j - 1))]);
}
// 查询两个后缀的最长公共前缀(LCP)
int RMQ(int L, int R) {
// 将字符串位置转换为排名
L = rnk[L]; R = rnk[R];
if (L > R) swap(L, R); // 确保L <= R
L++; // height数组从1开始,表示排名相邻后缀的LCP
// 使用RMQ查询区间[L, R]的最小值
int k = lg[R - L + 1];
return min(st[k][L], st[k][R - (1 << k) + 1]);
}
};
SuffixArray suf, rev; // 正向和反向后缀数组
int main() {
int T, cases = 0;
scanf("%d", &T);
while (T--) {
int g;
scanf("%d%s", &g, s);
int n = strlen(s);
// 构建正向后缀数组(用于计算两个子串的最长公共前缀LCP)
suf.buildSA(n + 1, 128); // n+1是为了包含字符串结束符
suf.getHeight(n);
suf.initilizeRMQ(n);
// 构建反向后缀数组(用于计算两个子串的最长公共后缀LCS)
// 通过反转字符串,将LCS问题转化为LCP问题
for (int i = 0; i < n / 2; i++) swap(s[i], s[n - 1 - i]);
rev.buildSA(n + 1, 128);
rev.getHeight(n);
rev.initilizeRMQ(n);
long long sum = 0;
// 枚举U的长度len
for (int len = 1; len <= n / 2; len++) {
// 枚举第一个U的起始位置i,这里使用了跳跃枚举优化
// 由于我们只关心相距len+g的位置对,所以可以按len步长跳跃
for (int i = 0; i < n; i += len) {
int L = i, R = i + g + len; // 第二个U的起始位置
if (R >= n) break; // 第二个U超出字符串范围
// 计算LCP:从L和R开始的最长公共前缀
// 限制不超过U的长度len
int lcp = min(len, suf.RMQ(L, R));
// 计算LCS:到L-1和R-1结束的最长公共后缀
// 通过反向字符串的LCP计算
L = n - L - 1; // 转换为反向字符串中的位置
R = n - R - 1;
int lcs = min(len, rev.RMQ(L, R));
// 有效的匹配长度计算:
// 考虑重叠情况:总匹配长度 = lcp + lcs - len
// 这是因为两个U的长度为len,而lcp和lcs可能重叠
int cnt = lcp + lcs - len;
sum += max(cnt, 0); // 只累加非负值
}
}
printf("Case %d: %lld\n", ++cases, sum);
}
return 0;
}
总结
本题的关键在于利用字符串哈希将子串比较从 O(n)O(n)O(n) 降低到 O(1)O(1)O(1) ,从而将总时间复杂度从 O(n3)O(n^3)O(n3) 降低到 O(n2)O(n^2)O(n2) 。虽然 O(n2)O(n^2)O(n2) 对于 n=50000n = 50000n=50000 来说看起来很大,但由于 ggg 很小且实际循环次数少于最坏情况,该算法可以在时限内通过。
如果题目数据范围更大(例如 n≤106n \leq 10^6n≤106 ),则需要使用后缀数组等 O(nlogn)O(n \log n)O(nlogn) 的算法。但对于本题的数据范围,字符串哈希是一种简洁高效的解法。
时间复杂度: O(n2)O(n^2)O(n2) 。
空间复杂度: O(n)O(n)O(n) 。

1690

被折叠的 条评论
为什么被折叠?



