【题目描述】
原题来自:POI 2012
给出一个由小写英文字母组成的字符串 S,再给出 q 个询问,要求回答 S 某个子串的最短循环节。
如果字符串 B 是字符串 A 的循环节,那么 A 可以由 B 重复若干次得到。
【输入】
第一行一个正整数 n,表示 S 的长度。
第二行 n 个小写英文字母,表示字符串 S 。
第三行一个正整数 q ,表示询问个数。
下面 q 行每行两个正整数 a,b,表示询问字符串 S[a..b] 的最短循环节长度。
【输出】
依次输出 q 行正整数,第 i 行的正整数对应第 i 个询问的答案。
【输入样例】
8
aaabcabc
3
1 3
3 8
4 8
【输出样例】
1
3
5
【提示】
1≤a≤b≤n≤5×10^5 , q≤2×10^6。
一、 题目分析
核心诉求 :给定长度为N的字符串,进行Q次查询,每次查询要求找出一个给定子串S[a..b]的最短循环节长度。
数据范围 :,
。
这是一个极其庞大的查询量。如果单次查询的时间复杂度达到O(N)甚至,总时间都会飙升到
级别,必然超时。这要求我们在预处理后,单次查询的时间复杂度必须被压制在O(1)或
级别。
二、 思考过程与解题思路
面对这种题,我们需要拆解两个核心问题:如何快速验证循环节? 以及如何快速找到最短的那个?
1.
验证循环节(错位重叠法)
判断一个长度为的前缀是否是总长为
的字符串的循环节,有一个非常经典的结论:
如果是循环节,必然满足:原串掐掉结尾
个字符的子串,完全等于掐掉开头
个字符的子串。
映射到区间 上就是:
S[a...b-len]==S[a+len...b]
要实现O(1)的字符串相等比较,字符串前缀哈希是唯一解。
2. 避免暴力枚举(引入质因数分解)
已知最短循环节长度一定能整除总长度L。
最朴素的想法是枚举L的所有约数,但时间吃不消。
逆向思考:假设初始循环节就是L本身,如果它能被压缩,必然是除以了L的某个质因数p。
因此,我们只需要找出L的所有质因数。对于每一个质因数p,只要当前循环节长度能被p整除,且尝试除以p后的长度能通过"错位重叠哈希校验",我们就把循环节长度缩小为原先的。一直榨干这个质因数,再换下一个质因数继续测试。
3. O(1)获取质因数(欧拉筛改造)
每次查询如果现场做的质因数分解,依然会超时。
我们可以利用欧拉筛(线性筛)的特性:欧拉筛保证了每个合数只会被它的最小质因数筛掉。我们在筛的过程中,顺手记录下每一个数字的"最小质因数"。
这样分解质因数时,只需不断查表p=minprim[L],然后 L/=p,就能在极其严格的 时间内剥离出所有质因数。
三、 算法设计
-
预处理哈希 :
时间预处理字符串的前缀哈希数组
ans和进制的次幂数组p。 -
预处理最小质因数 :
时间跑一遍改造后的欧拉筛,利用
minprim数组记录每一个数的最小质因数。
-
处理查询:
-
计算区间长度L=b-a+1。
-
设定初始答案
shortlen=L,复制一份用于提供质因数的len2=L。 -
当
len2>1时,取p=minprim[len2]。 -
不断尝试
testlen=shortlen/p。若哈希校验通过,则shortlen=testlen,继续死磕这个p;若不通过则break。 -
将
len2中关于p的因子全部除尽,准备获取下一个全新的质因数。 -
最终留下的
shortlen即为绝对最短循环节。
-
四、 时空复杂度分析
-
时间复杂度 :预处理哈希O(N),欧拉筛O(N)。单次查询中,分解质因数最多执行
次除法和哈希验证(哈希验证为
)。总时间复杂度为
。完美通过。
-
空间复杂度:需要维护长度为N的次幂数组、哈希数组、素数表、埃氏表、最小质因数表。全部使用连续内存的静态数组,空间复杂度O(N)。
五、 易错点总结
-
哈希区间提取边界 :提取闭区间[l, r]的哈希值,公式必须是
ans[r]-ans[l-1]*p[r-l+1],千万不能写成减去ans[l],否则会把第l个字符给删掉。 -
素数的最小质因数 :在欧拉筛中,不要只给合数标记
minprim。当扫描到一个素数时,必须明确记录它的最小质因数就是它自己 (minprim[i]=i),否则后续查表会遇到0导致死循环或运行错误。 -
海量I/O卡常 :
,输出量极大,必须关闭流同步
ios::sync_with_stdio(false); cin.tie(0);,否则算法再优也可能会超时。 -
自然溢出被出题人针对 :正常情况下
unsigned long long的自然溢出足够安全,但如果有出题人恶意构造冲突数据,可将ull改为带模数的大质数单哈希或双哈希机制。
六、 完整代码
cpp
#include <iostream>
using namespace std;
typedef unsigned long long ull;
int n,q;
string s;
ull p[500010];//存储131的i次方
//如果ull过不了 就是出题人专门构造数据来卡我们
//我们改用大质数取模单哈希或者双哈希即可
ull ans[500010];//存储字符串s的前缀哈希
int es[500010];//存埃氏表(es[i]==0代表i为素数否则为合数)
int prim[500010];//从小到大存n以内的素数
int minprim[500010];//记录每个数的最小质因数
int k;//用来记录n以内素数的个数
void oula(){
//这里的i代表两层意思:1.作为倍数因子
// 2.作为被检查者 看是否为素数
for(int i=2;i<=n;i++){
//如果i没有被标记 代表为素数
if(es[i]==0){
prim[++k]=i;
//素数的最小质因数是自身
minprim[i]=i;
}
//用当前的i去乘已知的素数,筛掉合数
//j遍历的是素数表,prime[j]是我们选定的"最小质因子"
//遍历素数表
for(int j=1;j<=k&&prim[j]*i<=n;j++){
es[prim[j]*i]=1;
//记录该合数单最小质因数
minprim[prim[j]*i]=prim[j];
//为了保证"每个合数只被它的最小质因子筛一次",必须立刻停止。
if(i%prim[j]==0) break;
}
}
}
//返回闭区间[l,r]的哈希值
ull gethash(int l,int r){
return ans[r]-ans[l-1]*p[r-l+1];
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n;
cin>>s;
//存储131的i次方
p[0]=1;
for(int i=1;i<=n;i++) p[i]=131*p[i-1];
//存储s的前缀哈希
for(int i=1;i<=n;i++) ans[i]=131*ans[i-1]+s[i-1];
//欧拉筛预处理找出1-n内每个数的素因数
oula();
cin>>q;
while(q--){
int a,b;
cin>>a>>b;
//区间长度len
int len=b-a+1;
//最短循环节长度 初始化为自身长度
int shortlen=len;
//len2作为工具变量
//用len2来掉落素因数 每判断完一个素因数 就从中取出
int len2=len;
while(len2>1){
//获取一个质因数p
int p=minprim[len2];
//只要当前的最短循环节还能被p整除 就尝试压缩
while(shortlen%p==0){
//用testlen来测试当前长度是否可以满足为循环节
int testlen=shortlen/p;
//错位重叠哈希校验
//比对左半部[a,b-testlen]和右半部[a+testlen,b]
if(gethash(a,b-testlen)==gethash(a+testlen,b)){
shortlen=testlen;
}
//校验失败,说明该质因数不能再使用了,立刻停止
else break;
}
//将len2中包含的质因数p全部除尽,以便下一次拿全新的质因数
while(len2%p==0){
len2/=p;
}
}
cout<<shortlen<<"\n";
}
return 0;
}