【题目描述】
原题来自:POJ 2406
给定若干个长度 ≤10^6 的字符串,询问每个字符串最多是由多少个相同的子字符串重复连接而成的。如:ababab 则最多有 3 个 ab 连接而成。
【输入】
输入若干行,每行有一个字符串,字符串仅含英语字母。特别的,字符串可能为 . 即一个半角句号,此时输入结束。
【输出】
【输入样例】
abcd
aaaa
ababab
.
【输出样例】
1
4
3
今天来盘一道极其经典的字符串老题------POJ 2406 (Power Strings)。这道题是字符串周期、循环节问题的"试金石"。
题目要求很简单:给一个长度最大为106的字符串,问它最多能由多少个相同的子串重复拼接而成。 比如ababab,可以看作3个ab拼成,答案就是3。
这道题的标准解法其实是KMP算法的next数组定理,但如果对KMP原理理解不够透彻,很容易写挂。相比之下,字符串哈希的思维负担极小,且通过合理的预处理,依然能轻松跑进1s。
下面记录一下用字符串哈希解决这道题的思考过程,以及代码是如何一步步进化的。
一、 题目分析与解题思路
数据范围预警:字符串长度 L≤10^6。 这意味着任何 O(N^2) 的算法都会被无情地判定为超时。
核心思路:
-
枚举长度 :既然是由同一子串重复构成的,那么子串的长度
len一定能被总长度N整除。我们只需要从小到大枚举可能存在的子串长度len。 -
校验合法性 :假设当前枚举的子串长度是
len,我们就把原串按照len切成若干块。如果每一块的字符串都和第一块一模一样,那拼凑就成立。 -
哈希降维 :怎么快速判断每一块是否和第一块一样?用字符串哈希 把字符串压缩成
unsigned long long数字。比较数字是否相等,就是O(1)的操作。
二、 代码演进:
版本一:暴力哈希(能过纯属评测机数据太水)
最开始的思路非常直白:外层循环枚举长度len,内层循环挨个切块。每次切出一块,就当场从头算一遍这块的哈希值,看看跟第一块一不一样。
这种写法虽然把"字符串比较"优化成了"算数字",但内层循环反复去乘 131,最坏情况下(比如给了个极其长的aaaa...aaa,而在枚举较大的len时才break),时间复杂度会退化到近似O(N^2)。
V1代码展示:
cpp
//这个代码能过 但纯粹是评测机不严格 有很大的优化空间
//思想:求出不同长度子串的哈希值 去看给出的父字符串是否每一段和子串长度相同的
//窗口的哈希值都和子串相同
#include <iostream>
using namespace std;
typedef unsigned long long ull;
string s;
int main(){
while(cin>>s){
if(s[0]=='.') return 0;
//遍历可能的子字符串长度
//从1到s.size()
for(int len=1;len<=s.size();len++){
//当前子字符串长度为len
//flag标记是否可以拼凑成功
bool flag=1;
//子字符串长度一定能被给出的字符串整除
if(s.size()%len!=0) continue;
ull ans=0;//存储子字符串哈希值
//131进制,进制要大于ascii最大值
for(int i=0;i<len;i++){
ans=ans*131+s[i];
}
//检查给定字符串是否由子字符串拼凑而成
for(int i=len;i<s.size();i=i+len){
//存储给定字符串每一段和子字符串长度相等
//的字符串的哈希值
ull ans2=0;
for(int j=i;j<i+len;j++){
ans2=ans2*131+s[j];
}
//如果给定字符串存在一段和子字符串哈希值不同
//代表不可以拼凑成功
if(ans2!=ans){
flag=0;
break;
}
}
//如果标记为1 代表给定字符串每一段哈希值都和
//子字符串相同,说明可以拼凑成功 同时第一次找到的
//一定是长度最小即切分最多的 输出段数
if(flag==1){
cout<<s.size()/len<<"\n";
break;
}
}
}
return 0;
}
版本二:前缀哈希+砍半优化
为了干掉V1中最耗时的内层O(len)计算,我们引入字符串哈希的终极奥义:前缀哈希预处理 。 如果我们提前花O(N)的时间把前缀哈希数组ans2[]和131的次幂数组p[]算出来,那么任意区间 [L,R]的哈希值就可以用O(1)的时间求出:
Hash=ans2[R]−ans2[L−1]×p[len]
此外,外层枚举也没必要循环到s.size()。一个字符串如果要被分割重复,至少得重复2次。所以len最多只可能到s.size()/2。
V2 代码展示(优化版):
cpp
//在上个版本基础上就行优化
//第一个优化:不用判断到s.size() 判断到s.size()/2即可
//第二个优化:预处理s数组的哈希前缀和 最后求窗口值直接O(1)查询
//不用每次都反复计算
//等学习了kmp 同学们再用kmp来补一下这道题
#include <iostream>
using namespace std;
//利用unsigned long long的自然溢出机制,代替取模运算
typedef unsigned long long ull;
string s;
ull ans2[1000010];//预处理父字符串的前缀哈希
ull p[1000010];//存131的i次方
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
//预处理131的i次方
p[0]=1;
for(int i=1;i<=1000005;i++)
p[i]=p[i-1]*131;
while(cin>>s){
//如果是.代表输入结束
if(s[0]=='.') return 0;
//标记是否找到非本身的子串
bool flag2=0;
//第二个优化:预处理父字符串的前缀哈希
ans2[0]=s[0];
for(int i=1;i<s.size();i++){
ans2[i]=ans2[i-1]*131+s[i];
}
//第一个优化:遍历可能的子字符串长度
//不需要遍历到s.size() 最多遍历到s.size()/2即可
//如果s.size()/2都不行 那么只能是自己本身作为子串
for(int len=1;len<=s.size()/2;len++){
//当前子字符串长度为len
//flag标记是否可以拼凑成功
bool flag=1;
//子字符串长度一定能被给出的字符串整除
if(s.size()%len!=0) continue;
ull ans=0;//存储子字符串哈希值
//取第一块子串的哈希值
ans=ans2[len-1];
//检查给定字符串是否由子字符串拼凑而成
for(int i=len;i<s.size();i=i+len){
//ans3存储给定字符串每一段和子字符串长度相等
//的字符串的哈希值
ull ans3=0;
ans3=ans2[i+len-1]-ans2[i-1]*p[len];
//如果给定字符串存在一段和子字符串哈希值不同
//代表不可以拼凑成功
if(ans3!=ans){
flag=0;
break;
}
}
//如果标记为1 代表给定字符串每一段哈希值都和
//子字符串相同,说明可以拼凑成功 同时第一次找到的
//一定是长度最短的即切分最多的 输出段数 并标记找到非本身子串
if(flag==1){
cout<<s.size()/len<<"\n";
flag2=1;
break;
}
}
if(flag2==0) cout<<1<<"\n";
}
return 0;
}
三、 时空复杂度分析 (以V2为准)
-
时间复杂度:
-
预处理前缀哈希:O(N)。
-
枚举长度:外层循环执行次数看似是N/2,但因为有
N%len==0的剪枝,实际上只有N的约数才会进入内层循环。内层按len步进,单次查询是O(1)。 -
整体时间复杂度远低于O(NlogN),逼近O(N),对于10^6的数据量轻松秒杀。
-
-
空间复杂度 : 使用了两个大小为10^6的
unsigned long long数组,占用大约16MB内存,完全符合题目要求。空间复杂度O(N)。
四、 易错点总结
-
IO 读写瓶颈 : 对于10^6级别的字符串读入,建议不要裸奔使用
cin/cout,可以加上ios::sync_with_stdio(false); cin.tie(0);否则会因为读写太慢容易卡常数。 -
0-based索引的区间减法 : 这道题采用了以
0为起点的下标(ans2[0]=s[0])。在做区间减法ans2[i+len-1]-ans2[i-1]*p[len]时,必须保证i-1>=0。因为我们的内层循环是从i=len开始的,所以完美避开了越界。但在写别的题时,一定要高度警惕下标为负的越界问题。
写在最后 :从暴力的两层for循环,到最后用前缀哈希将查询压榨到O(1),对时间复杂度的敏感度就是这样一步步练出来的,建议同学们每道题ac之后一定不要就over了,还是要去想想能不能优化。这道题用KMP算法的Next数组循环节定理甚至可以一行代码出结果,下节课学习了kmp,同学们回头用kmp再来补一下。