洛谷题目页面传送门 & AtCoder题目页面传送门
若一个字符串\(a\)有\(<|a|\)的循环节,我们称它是循环的。给定一个字符串\(a,|a|=n\),求它最少能被分成多少个子串,使得每个分成的子串都不循环,并求分成最少数量个子串的方案数。第\(2\)问答案对\(10^9+7\)取模。
\(n\in\left[1,5\times10^5\right]\)。
首先,如果\(a\)不循环,那么\(2\)问的答案显然分别是\(1,1\)。
先介绍一下字符串判循环的基本方法:字符串\(x\)有循环节\(len\)当且仅当\(len\mid |x|,x_{1\sim |x|-len}=x_{len+1\sim|x|}\)。证明:\(x_{1\sim |x|-len}=x_{len+1\sim|x|}\),即\(\forall i\in[1,|x|-len],x_i=x_{i+len}\)。显然\(len\)个字符是一个周期。又因为\(len\mid |x|\),所以\(len\)是\(x\)的循环节。得证。于是可以用这个结论判\(a\)是否循环。
接下来讨论\(a\)循环的情况。分\(2\)种情况:
- \(a\)的最小循环节为\(1\)。此时显然只能分成\(n\)份,方案数为\(1\);
- \(a\)的最小循环节\(>1\)。下面专门讨论这种情况。
可以发现,若将\(a\)分成\(a_{1\sim n-1},a_{n\sim n}\)这\(2\)段,是满足题意的。\(a_{n\sim n}\)显然不循环。\(a_{1\sim n-1}\)可以看作一个循环字符串删除了后\(x\)(\(x<\)此字符串的最小循环节)个字符后得到的字符串。我们称这类字符串是不完整循环的。容易(个p)证明,不完整循环的字符串一定是不循环的。
证明:反证法。设字符串\(x\)是一个不完整循环的字符串。设\(len\)为最小不完整循环节,即\(len\)是最小的满足\(len\nmid|x|,x_{1\sim |x|-len}=x_{len+1\sim|x|}\)的数。假设\(x\)也是循环的,那么设\(len'\)为最小循环节,即\(len'\)是最小的满足\(len'\mid|x|,x_{1\sim |x|-len'}=x_{len'+1\sim|x|}\)的数。显然\(len\neq len'\)。
引理:若存在\(2\)个数\(len,len'(len<len')\)满足\(x_{1\sim |x|-len}=x_{len+1\sim|x|},x_{1\sim |x|-len'}=x_{len'+1\sim|x|}\),那么必存在第\(3\)个数\(len''=len'-len\)满足\(x_{1\sim |x|-len''}=x_{len''+1\sim|x|}\)。
证明:
- \(x_{1\sim |x|-len}=x_{len+1\sim|x|},x_{1\sim |x|-len'}=x_{len'+1\sim|x|}\),即\(\forall i\in[1,|x|-len],x_i=x_{i+len},\forall i\in[1,|x|-len'],x_i=x_{i+len'}\)。显然,\(\forall i\in[1,|x|-len'],x_i=x_{i+len}=x_{i+len'}\)。所以\(\forall i\in[len+1,|x|-len'+len],x_i=x_{i+len'-len}\)。即\(\forall i\in[len+1,|x|-len''],x_i=x_{i+len''}\);
- \(x_{1\sim |x|-len}=x_{len+1\sim|x|},x_{1\sim |x|-len'}=x_{len'+1\sim|x|}\),即\(\forall i\in[len+1,|x|],x_i=x_{i-len},\forall i\in[len'+1,|x|],x_i=x_{i-len'}\)。显然,\(\forall i\in[len'+1,|x|],x_i=x_{i-len}=x_{i-len'}\)。所以\(\forall i\in[1,|x|-len'],x_{i-len+len'=x_i}\)。即\(\forall i\in[1,|x|-len'],x_i=x_{i+len''}\)。
(仿照上例显然)
综上,\(\forall i\in[len+1,|x|-len'']\cup[1,|x|-len']=[\min(len+1,1),\max(|x|-len'',|x|-len')]=[1,|x|-len''],x_i=x_{i+len''}\)。即\(x_{1\sim |x|-len''}=x_{len''+1\sim|x|}\)。得证。
根据引理和辗转相除法可知,对于\(len''=\gcd(len,len')\),\(x_{1\sim |x|-len''}=x_{len''+1\sim|x|}\)。分\(2\)种情况:
- \(len'\mid len\)。显然\(x_{1\sim len}\)有循环节\(len'\),所以任何以\(x_{1\sim len}\)循环的字符串一定有循环节\(len'\)。所以任何以\(x_{1\sim len}\)循环的字符串的最小循环节一定\(\leq len'\)。设\(t\)为最小的满足\(t\cdot len\geq|x|\)的整数。因为\(len'\mid|x|,len'\mid len\),所以\(len'\mid t\cdot len-|x|\)。又因为\(len\nmid|x|\),所以\(t\cdot len\neq|x|\),所以\(t\cdot len-|x|>0\)。所以\(t\cdot len-|x|\geq len'\)。即\(x\)是一个循环字符串删除了至少后\(len'\)个字符后得到的。又任何以\(x_{1\sim len}\)循环的字符串的最小循环节一定\(\leq len'\),与不完整循环的定义中“\(x<\)此字符串的最小循环节”矛盾;
- \(len'\nmid len\)。显然\(\gcd(len,len')<len'\)。又显然\(\gcd(len,len')\mid len'\),所以\(\gcd(len,len')\)是\(x\)更小的循环节,与\(len'\)是最小循环节矛盾。
综上,\(2\)种情况都推出矛盾。得证。(怎么证明了这么长啊)
所以一定存在将\(a\)分成\(2\)段的方法。接下来要做的只是计数。不难发现,分成\(2\)段的方法数不会超过\(n-1\)(所以说“对\(10^9+7\)取模”是个幌子),我们可以枚举断点,一个一个判断。
考虑怎么判断某个前/后缀是否循环。显然可以用Z算法预处理出每个后缀与\(a\)的LCP\(lcP_i\)和每个前缀与\(a\)的LCS\(Lcs_i\),然后判断时枚举要判断的前/后缀长度的所有真因数作为循环节,每个真因数\(\mathrm O(1)\)判断是否循环节。这看起来枚举每个数的真因数都要\(\mathrm O(\sqrt n)\),总时间\(\mathrm O(n\sqrt n)\),然鹅其实根本不用这么慢。如果每次枚举的都正好是真因数的话,那么时间复杂度就是\(1\sim n\)之间(真因数,真倍数)对的个数,考虑计算每个数当作真因数对总对数的贡献,那么时间复杂度就是\(\mathrm O\!\left(\sum\limits_{i=1}^n\dfrac ni\right)\)。这是\(n\)与第\(n\)个调和数的乘积。由于第\(n\)个调和数与\(\ln n\)的差收敛于欧拉常数,所以第\(n\)个调和数可以认为等于\(\mathrm O(\ln n)\)。所以时间复杂度就是\(\mathrm O(n\ln n)\)。至于如何做到“每次枚举的都正好是真因数”,枚举每个数当作真因数,然后将它压进每个真倍数的真因数序列里,这样预处理即可。
下面是AC代码:
#include<bits/stdc++.h>
using namespace std;
#define pb push_back
const int N=500000;
int n;//|a|
char a[N+5];//字符串
int lcP[N+1],Lcs[N+1];//每个后缀与a的LCP、每个前缀与a的LCS
int z[N+1];//z数组
void z_init(){//对a跑Z算法
z[1]=n;
int zl=0,zr=0;
for(int i=2;i<=n;i++)
if(zr<i){
z[i]=0;
while(i+z[i]<=n&&a[1+z[i]]==a[i+z[i]])z[i]++;
if(z[i])zl=i,zr=i+z[i]-1;
}
else if(i+z[i-zl+1]<=zr)z[i]=z[i-zl+1];
else{
z[i]=zr-i+1;
while(i+z[i]<=n&&a[1+z[i]]==a[i+z[i]])z[i]++;
zl=i;zr=i+z[i]-1;
}
}
vector<int> dsr[N+1];//真因数序列
int main(){
cin>>a+1;
n=strlen(a+1);
bool ok=true;
for(int i=1;i<n;i++)ok&=a[i]==a[i+1];//a的最小循环节是否为1
if(ok)return cout<<n<<"\n1",0;//特判最小循环节为1
z_init();
for(int i=1;i<=n;i++)lcP[i]=z[i];//算lcP
reverse(a+1,a+n+1);//令a=a^r以算Lcs
z_init();
for(int i=1;i<=n;i++)Lcs[i]=z[n-i+1];//算Lcs
ok=true;
for(int i=1;i<n;i++)if(n%i==0&&lcP[i+1]==n-i)ok=false;//a是否循环
if(ok)return puts("1\n1"),0;//特判不循环
puts("2");//一定有分成2段的方式
for(int i=1;i<n;i++)for(int j=2*i;j<=n;j+=i)dsr[j].pb(i);//预处理每个数的真因数序列
int ans=0;
for(int i=1;i<n;i++){
ok=true;
for(int j=0;j<dsr[i].size();j++)if(lcP[dsr[i][j]+1]>=i-dsr[i][j])ok=false;//判断分成的前缀是否循环
for(int j=0;j<dsr[n-i].size();j++)if(Lcs[n-dsr[n-i][j]]>=n-i-dsr[n-i][j])ok=false;//判断分成的后缀是否循环
ans+=ok;//如果都不循环,答案+1
}
cout<<ans;
return 0;
}
然鹅我们是追求完美的OIer(咦咋又是这句话/yiw),怎么能止步于这种复杂度泥?(话说\(\mathrm O(n\ln n)\)惹我了么)直觉告诉我们有\(\mathrm O(n)\)算法。不难发现一个性质:若对于某个长度\(len\),\(a_{1\sim len}\)是循环的,那么\(len\)这个长度对判断前缀循环是完全没有用的,因为任何有循环节\(len\)的字符串都有\(a_{1\sim len}\)的每个循环节当循环节。于是我们可以从小到大直接枚举循环节\(len\),如果\(a_{1\sim len}\)是循环的(因为是从小到大枚举的,此时循环性已经确定)就continue
,否则从\(2len\)开始往后枚举倍数标记循环前缀。不难发现,\(len\)所有倍数为长度的前缀的循环性满足单调性,所以可以一不循环就break
。
我们来分析这样做的时间复杂度。所有尝试标记分为标记成功和标记失败。因为每次一标记失败就break
,容易得出标记失败的总复杂度为\(\mathrm O(n)\)。容易发现,若一个字符串是循环的,那么它的所有循环节都是最小循环节的倍数。证明:反证法。若不是,那么可以用前面那一大堆证明中的引理和辗转相除法得出比最小循环节更小的循环节,矛盾。得证。于是显然,每个循环的前缀都仅被它的最小循环节成功标记,因为它的其他循环节都是最小循环节的倍数,以其他循环节为长度的前缀一定循环,根据性质continue
掉了。所以标记成功的总复杂度为\(\mathrm O(n)\)。所以总复杂度就是\(\mathrm O(n)\)了。
后缀判循环类似。
下面是AC代码:
#include<bits/stdc++.h>
using namespace std;
#define pb push_back
const int N=500000;
int n;//|a|
char a[N+5];//字符串
int lcP[N+1],Lcs[N+1];//每个后缀与a的LCP、每个前缀与a的LCS
int z[N+1];//z数组
void z_init(){//对a跑Z算法
z[1]=n;
int zl=0,zr=0;
for(int i=2;i<=n;i++)
if(zr<i){
z[i]=0;
while(i+z[i]<=n&&a[1+z[i]]==a[i+z[i]])z[i]++;
if(z[i])zl=i,zr=i+z[i]-1;
}
else if(i+z[i-zl+1]<=zr)z[i]=z[i-zl+1];
else{
z[i]=zr-i+1;
while(i+z[i]<=n&&a[1+z[i]]==a[i+z[i]])z[i]++;
zl=i;zr=i+z[i]-1;
}
}
bool Cyc[N+1],cyC[N+1];//前、后缀循环性
int main(){
cin>>a+1;
n=strlen(a+1);
bool ok=true;
for(int i=1;i<n;i++)ok&=a[i]==a[i+1];//a的最小循环节是否为1
if(ok)return cout<<n<<"\n1",0;//特判最小循环节为1
z_init();
for(int i=1;i<=n;i++)lcP[i]=z[i];//算lcP
reverse(a+1,a+n+1);//令a=a^r以算Lcs
z_init();
for(int i=1;i<=n;i++)Lcs[i]=z[n-i+1];//算Lcs
ok=true;
for(int i=1;i<n;i++)if(n%i==0&&lcP[i+1]==n-i)ok=false;//a是否循环
if(ok)return puts("1\n1"),0;//特判不循环
puts("2");//一定有分成2段的方式
for(int i=1;i<=n;i++){//枚举循环节
if(Cyc[i])continue;//如果a[1~i]循环则continue
for(int j=2*i;j<=n;j+=i)
if(lcP[i+1]>=j-i)Cyc[j]=true;//标记循环前缀
else break;//一不循环则break
}
for(int i=1;i<=n;i++){//后缀类似
if(cyC[n-i+1])continue;
for(int j=n-2*i+1;j>=1;j-=i)
if(Lcs[n-i]>=n-j+1-i)cyC[j]=true;
else break;
}
int ans=0;
for(int i=1;i<n;i++)ans+=!Cyc[i]&&!cyC[i+1];//如果都不循环,答案+1
cout<<ans;
return 0;
}
然鹅其实有更简单的方法做到\(\mathrm O(n)\),那就是KMP。不难发现,\(kmp\)数组的定义与字符串判循环的方法高度相似。很容易得出结论:
- 若\(kmp_{x,|x|}=0\),显然\(x\)不循环;
- 若\(kmp_{x,|x|}\neq0,|x|-kmp_{x,|x|}\mid|x|\),显然\(x\)循环,且最小循环节为\(|x|-kmp_{x,|x|}\);
- 若\(kmp_{x,|x|}\neq0,|x|-kmp_{x,|x|}\nmid|x|\),则\(x\)不循环。可以把\(x\)看作一个不完整循环字符串加以证明。
于是跑出\(kmp_a,kmp_{a^\mathrm r}\),即可\(\mathrm O(1)\)判断每个前/后缀是否循环,\(lcP,Lcs\)什么的都不需要算了。
下面是AC代码:
#include<bits/stdc++.h>
using namespace std;
#define pb push_back
const int N=500000;
int n;//|a|
char a[N+5];//字符串
int kmp[N+1],kmp_r[N+1];//kmp数组
void kmp_init(int kmp0[]){//对a跑KMP算法
for(int i=2;i<=n;i++){
int now=kmp0[i-1];
while(now&&a[now+1]!=a[i])now=kmp0[now];
kmp0[i]=a[now+1]==a[i]?now+1:0;
}
}
int main(){
cin>>a+1;
n=strlen(a+1);
bool ok=true;
for(int i=1;i<n;i++)ok&=a[i]==a[i+1];//a的最小循环节是否为1
if(ok)return cout<<n<<"\n1",0;//特判最小循环节为1
kmp_init(kmp);
reverse(a+1,a+n+1);//令a=a^r以算kmp_r
kmp_init(kmp_r);
if(!kmp[n]||n%(n-kmp[n]))return puts("1\n1"),0;//特判不循环
puts("2");//一定有分成2段的方式
int ans=0;
for(int i=1;i<n;i++)ans+=(!kmp[i]||i%(i-kmp[i]))&&(!kmp_r[n-i]||(n-i)%(n-i-kmp_r[n-i]));//如果都不循环,答案+1
cout<<ans;
return 0;
}
其实想要AC这题并不难,那些难证的结论也可以感性理解。但是仔细研究起来,这题还是很有价值的,包括了关于字符串循环的几乎所有性质。
最后再吐槽一句:为蛤AtCoder就不能像凉心出题人Alex_Wei和ET2006一样毒瘤,有\(\mathrm O(n)\)算法就不给\(\mathrm O(n\ln n)\)过泥¿¿¿/yiw