字符串专题
hash
普通hash非常容易被卡,建议使用双哈希或者自然溢出哈希。(因为个人比较懒,所以喜欢写后者)
非常好用的是截取子串并比较。常用于dp辅助之类。
h[i]=h[i-1]*base+c[i];
p[i]=p[i-1]*base;
if(h[j-1]-h[j-i-1]*p[i]!=h[i]) break;
还有一种特殊的应用:如 P8819 [CSP-S 2022] 星战。题目要求维护出度,但实际上入度很好维护。考虑把维护的入度转化成出度的判定,如给每个点赋值,对当前情况求和看是不是等于所有点之和。
判定答案时构造一个必要不充分条件,但这个“不充分”实际上有非常大非常大的概率充分,以至于不充分性可以忽略不计,从而达到充要判定的效果。这也是哈希的本质。
KMP
(继昨天模拟赛考完trie之后,我就有一种预感它马上要考KMP强迫我复习了,果然如此!)
通俗而讲,KMP适用于关键字查询的问题。(实际上是单配单)
算法本质在于根据已匹配的信息(nxt)来跳指针减少匹配次数。nxt[i]维护的就是主串的每个子串i的最长相等的前后缀。优化O(nm)->O(n)。
注意一个和写法有关的小问题,nxt指的是上一位和j已经匹配过的,那么在下一次匹配时应从j+1开始。这样写也恰好处理了(开头)0的问题。
另外,nxt数组对于求解一些问题很有帮助。
m=a.size(),n=b.size();
a=" "+a;
b=" "+b; // 计算nxt数组,注意这个是给模式串(匹配串,而非主串)计算的
nxt[1]=0;
for(int i=2,j=0;i<=n;i++){//求nxt的过程相当于自我匹配
while(j&&b[i]!=b[j+1]) j=nxt[j];
//此处判断j是否为0的原因在于,如果回跳到第一个字符就不用再回跳了
if(b[i]==b[j+1]) j++;
nxt[i]=j;
}
// KMP匹配
for(int i=1,j=0;i<=m;i++){
while(j&&a[i]!=b[j+1]) j=nxt[j];
if(a[i]==b[j+1]) j++;
if(j==n) printf("%d\n",i-n+1);
}
trie
可以解决查询字符串出现次数的问题;01trie可以解决最大异或和之类的和位运算相关的问题,很好用
写法上就一个insert,后面可以加查询,或者在trie树上跑dp等。维护公共前后缀也比较灵活。
void insert(ll x,int ct){
int p=1;
for(int i=0;i<60;i++){
int tmp=(x>>i)&1ll;
if(!s[p][tmp]) s[p][tmp]=++num;
p=s[p][tmp];
}
c[p]=1ll*ct*(ct-1)/2ll,cnt[p]=ct;
}
AC自动机
KMP和trie结合版,(根本不会,坑了
//看着和trie有点像,但是trie是在多个文章(单词)中查某个词,而AC自动机则是在一篇文章中查多个词
//AC自动机为了实现多模式串匹配文章,实际是对模式串建了trie,然后结合KMP失配指针
//注意一个巨大的优化就是建立tr(字典图,连虚边),节省了向上跳fail的时间
//时间复杂度非常优秀,大约是模式串和文本串长度总和
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+5;
int t,n,tot,p[maxn][26],fail[maxn],e[maxn];
char s[maxn];
void add(char *s){
int x=0;
for(int i=1;s[i];i++){
if(!p[x][s[i]-'a']) p[x][s[i]-'a']=++tot;
x=p[x][s[i]-'a'];
}
e[x]++;
}
void build(){
queue<int>q;//bfs
for(int i=0;i<26;i++){
if(p[0][i]) q.push(p[0][i]);
}
while(q.size()){
int x=q.front();
q.pop();
for(int i=0;i<26;i++){
if(p[x][i]){
fail[p[x][i]]=p[fail[x]][i];
q.push(p[x][i]);
}
else p[x][i]=p[fail[x]][i];//正因为是bfs故可以保证
}
}
}
int query(char *s){
int res=0,x=0;
for(int i=1;s[i];i++){
x=p[x][s[i]-'a'];
for(int j=x;j&&e[j]!=-1;j=fail[j]){
res+=e[j];
e[j]=-1;
}
}
return res;
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%s",s+1);
add(s);
}
build();
scanf("%s",s+1);
cout<<query(s)<<endl;
return 0;
}
后缀数组
用于解决字符串相关问题。倍增法+基数排序O(nlogn),考虑每次把2^(k-1)的两个字符串合并,遇到结束时用空串补足。
sa[i]后缀数组,记录排名为i的后缀在原串中的位置,sa[排名]=位置,rk[位置]=排名。
height[i]=suffix(sa[i-1])和suffix(sa[i])的最长公共前缀(排名相邻的两个后缀的最长公共前缀),h[i]=height[rk[i]]也就是suffix(i)和它前一名后缀的最长公共前缀。(即LCP,最长公共前缀)
注意性质h[i]>=h[i-1]-1,考虑设suffix(k)是suffix(i-1)前一名的后缀,则最长公共前缀为h[i-1]。那么suffix(k+1)排在suffix(i)前,理解上同减一位即可,又考虑到还可能有前几位相同但比suffix(k+1)长的串夹在二者中间(感性理解一下),故得证。(注:不是严谨证明,只是便于理解)
这个性质可以O(n)求h[i],而对于询问任意两个后缀的最长公共前缀,答案几位两者排名之间的height最小值,RMQ问题用ST表维护即可。height数组的特性非常好用,不重不漏,还一定包含公共子串的最值!!!
(真服了,一个板子看了一个下午。。。)
//后缀排序,后缀数组基础,用于字符串问题
#include<bits/stdc++.h>
using namespace std;
const int maxn=2e6+10;//特别注意,数组开二倍!因为有+k
int n,m,x[maxn],y[maxn],c[maxn],sa[maxn],rk[maxn],height[maxn];
char s[maxn];
void getsa(){
//按第一个字母排序
for(int i=1;i<=n;i++) c[x[i]=s[i]]++;//桶
for(int i=1;i<=m;i++) c[i]+=c[i-1];
for(int i=n;i>0;i--) sa[c[x[i]]--]=i;
for(int k=1;k<=n;k<<=1){
//按第二关键字排序
for(int i=0;i<=m;i++) c[i]=0;
for(int i=1;i<=n;i++) y[i]=sa[i];//记录当前某位对应的原位置
for(int i=1;i<=n;i++) c[x[y[i]+k]]++;
for(int i=1;i<=m;i++) c[i]+=c[i-1];//记录某数有若干个,便于排序(注意0的存在,所以从1开始
for(int i=n;i>0;i--) sa[c[x[y[i]+k]]--]=y[i];//按第二位排,注意对应从大到小的顺序
//按第一关键字排序
for(int i=0;i<=m;i++) c[i]=0;
for(int i=1;i<=n;i++) y[i]=sa[i];
for(int i=1;i<=n;i++) c[x[y[i]]]++;
for(int i=1;i<=m;i++) c[i]+=c[i-1];
for(int i=n;i>0;i--) sa[c[x[y[i]]]--]=y[i];//首位排
//把后缀放入桶
for(int i=1;i<=n;i++) y[i]=x[i];//原字符串
m=0;
for(int i=1;i<=n;i++){//相对大小即可
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k]) x[sa[i]]=m;
else x[sa[i]]=++m;
}
if(m==n) break;//均不相同即为完成
}
}
//height[i]为suffix(sa[i-1]和sa[i])最长公共前缀,h[i]为suffix(i)和它前一名后缀的最长公共前缀
void geth(){//性质:h[i]>=h[i-1]-1
for(int i=1;i<=n;i++) rk[sa[i]]=i;
for(int i=1,k=0;i<=n;i++){
if(rk[i]==1) continue;//第一名height为0
if(k) k--;//上一个height-1
int j=sa[rk[i]-1];//前邻后缀j
while(i+k<=n&&j+k<=n&&s[i+k]==s[j+k]) k++;//性质,有点双指针的意思
height[rk[i]]=k;
}
}
int main(){
scanf("%s",s+1);
n=strlen(s+1);
m=122;//'z'
getsa();
geth();
for(int i=1;i<=n;i++) printf("%d ",sa[i]);
printf("\n");
for(int i=1;i<=n;i++) printf("%d ",height[i]);
return 0;
}
后缀数组相关字符串处理技巧
1,断环为链。对于循环串,直接复制到后面。对于回文串,反向复制到后面,在中间加一个大于所有串内ascii的。
2,考虑每个子串都是每个后缀的前缀,而height数组的特殊性质能够满足sum(height)排除全部重复子串,max(height)为最长重复子串。
3,对于非重复最长公共子串、k次重复子串等许多问题都考虑后缀数组,二分+按height划分层次判断,或者用单调队列维护区间最小值。
4,查询公共串时用ST表维护check,O(1)处理两个部分的公共串。
manacher
快速求解回文串,重点在于维护maxr,然后直接根据对称性给个初值,剩下暴力扩展,是O(n)的
事实上SA也可以O(n)求解回文串,不过显然manacher更简单且常数小
#include<bits/stdc++.h>
using namespace std;
int cnt,a[30000005],p[30000005],ans;
string s;
int main(){
cin>>s;
a[0]=-1;
for(int i=0;i<s.size();i++){
cnt+=2;
a[cnt]=s[i]-'a'+1;//little trick
}
for(int x=1,r=0,mid=0;x<=cnt;x++){
if(x<=r) p[x]=min(p[mid*2-x],r-x+1);
//前者为对称位置的回文串长度,而如果超过我们现在维护的maxr,则必须截断
while(a[x-p[x]]==a[x+p[x]]) ++p[x];//暴力扩展
if(p[x]+x>r) r=p[x]+x-1,mid=x;
//更新,r维护的是已更新的回文串最远到达的距离,mid为其对称中心
if(p[x]>ans) ans=p[x];
//手模可以发现由于加了空位,实际上ans就是p数组
}
printf("%d",ans-1);
return 0;
}
回文自动机(PAM)
又名回文树,是一种可以存储一个串中所有回文子串的高效数据结构
对于奇数长和偶数长的回文串,我们选择建两棵树分别处理,奇根为1,偶根为0
一个节点的fail指针指向该节点所代表的回文串的最长回文后缀所对应的节点。
注意状态为从下往上接着从上往下念(奇树最上面的边只读一次),故转移边表示在原节点代表的回文串前后各加一个相同的字符。
注意偶根的fail指针指向奇根,因为奇根(一个点)必然是回文串
时间复杂度的证明:O(n)。因为一个字符串本质不同的回文子串最多n个,在字符全部相同时取到。每次getfail时,执行一次while循环,now的深度-1,而整体上看,now的深度一共增加了n次。故while也是O(n)的。
//相当于只需要维护以当前位置结尾的回文串数和他们的前一个字母
//回文自动机,需要建奇偶树
#include<bits/stdc++.h>
using namespace std;
const int maxn=2e6+5;
int n,fail[maxn],len[maxn],num[maxn],lst,pos,now,t[maxn][26],cnt=1;
string s;
int getfail(int x,int i){
while(s[i-len[x]-1]!=s[i]) x=fail[x];
return x;
}
int main(){
cin>>s;
n=s.size();
s=" "+s;
fail[0]=1,len[1]=-1;
for(int i=1;i<=n;i++){
if(i) s[i]=(s[i]+lst-97)%26+97;
pos=getfail(now,i);//最长回文后缀
if(!t[pos][s[i]-'a']){//需要建新点,新点的意义是增前增后
fail[++cnt]=t[getfail(fail[pos],i)][s[i]-'a'];
//这里匹配不上会到0,有可能会出现形如aa的串,也可能是单点
t[pos][s[i]-'a']=cnt;
len[cnt]=len[pos]+2;
num[cnt]=num[fail[cnt]]+1;
}
now=t[pos][s[i]-'a'];
lst=num[now];
printf("%d ",lst);
}
return 0;
}
后缀自动机(SAM
可以在线性时间内维护一个字符串的所有子串。
考虑到所有子串就是一个字符串所有后缀的所有前缀。那么这里我们考虑直接维护所有后缀,在遍历时就是去遍历所有前缀的过程,这样也就可以达到所有子串的效果。
以下是parent tree的维护。类似于AC自动机中fail树。
endpos表示,子串x在主串t中所有结束位置的集合。如t=abcbc,endpos(bc)={3,5}(编号从1开始).
SAM本质上是维护endpos的等价类。即,对于一个字符串的所有子串,结束的位置,一共就1-n这几种情况。而对于某个子串x的后缀y,endpos(y)一定包含endpos(x).那么我们把endpos集合相等的子串们视为一个等价类,可以发现只需要维护这些且不会超过n个。
根据endpos的包含关系,建出parent tree(空串为根)。考虑每次连向子节点,就是对当前节点整个集合的分割,不会超过n种分割,那么一共就是最多2n个节点。对于一个类(节点),设其中最长子串的长度为len,最短的为mlen。则对于存在父子关系的两个类,mlen[x]=len[fa[x]]+1.(这里,如果mlen[x]<=len[fa[x]],它们就是同一类的了)因此我们只用维护len即可。
每个节点存连边用map和int数组的区别就在于,用时间换空间还是用空间换时间。
再次提醒:这里的len数组是maxlen!
应用:SAM是一个DAG,每从头走多或少走一条转移边,都代表一个不同的子串,可以用拓扑序dp(如果走到一个终止节点,则走过的路径必是字符串s的一个后缀);另外parent tree也可以树形dp
求解不同子串个数尽量使用parent tree的len之差!尤其是如果在DAG上加题目要求的限制,再判断限制的路能不能转移,非常麻烦,正确性不能保证
另,SAM擅长处理公共后缀问题,所以可以把串进行反转
一个经典手法:对于查询某串和另一串的最长公共前后缀等问题,如 [HEOI2016/TJOI2016] 字符串。选择二分+parent树上线段树合并预处理+倍增查询。是 \(O(nlog^2n)\) 的.
线段树合并预处理的复杂度O(nlogn),注意这里线段树合并要写成“不删除线段树”,就是每次遇到重合的节点,都选择新建节点。
那么合并两棵树的复杂度约等于这两棵树重合的节点数,不会超过较小的那棵的点数。因此,总复杂度不会超过总点数。那么时空复杂度都是O(nlogn),实际操作中可能有两倍空间常数。
//luogu3804 SAM模版
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+5;
string s;
vector<int>v[maxn<<1];
int cnt=1,lst=1,siz[maxn<<1];//空串
long long ans;
struct node{
int len,fa,ch[26];//fa:指parent tree上的父子关系
}t[maxn<<1];
void insert(int c){
int now=++cnt;
siz[now]=1;//注意copy的是无贡献的!
t[now].len=t[lst].len+1;
int p=lst;//从lst开始跳,保证后缀关系(跟回文自动机有点像)
while(p&&!t[p].ch[c]){//跳fa,理解为压缩地遍历一个串的所有后缀
t[p].ch[c]=now;//将所有的是旧串后缀且在其后面加c形成的新字符串不是旧串子串的后缀往新串的最大后缀所属节点连一条c边
p=t[p].fa;
}//p:找到第一个有c边(相同后缀)的节点
if(!p){
t[now].fa=1;//旧串不含c
} else{
int q=t[p].ch[c];//相当于找到一个相同的后缀,使s[q]+c是parent tree上now的fa
if(t[q].len==t[p].len+1){//该点代表的(最长串)恰好是s[q]+c的串,直接连
t[now].fa=q;
} else{//len[q]>len[p]+1,二者有公共部分,但最长串没有后缀关系,但还需要保留信息,故新建节点(类似拆解压缩?)
int cpy=++cnt;
t[cpy]=t[q];
t[cpy].len=t[p].len+1;
t[now].fa=t[q].fa=cpy;
while(p&&t[p].ch[c]==q){
t[p].ch[c]=cpy;//这样的连边保证每种子串都能出现在sam上
p=t[p].fa;
}
}
}
lst=now;
}
void dfs(int x){
for(auto y:v[x]){
dfs(y);
siz[x]+=siz[y];
}
if(siz[x]!=1) ans=max(ans,1ll*siz[x]*t[x].len);
}
int main(){
cin>>s;
for(int i=0;i<s.size();i++) insert(s[i]-'a');
for(int i=2;i<=cnt;i++) v[t[i].fa].push_back(i);
dfs(1);
printf("%lld",ans);
return 0;
}
广义SAM
如果把求一个字符串中本质不同的子串数量改为求一个trie树上,从上到下若干前缀串的本质不同子串数量呢?
我们将这种建立在trie树上的sam称为广义sam。经常是解决多串问题,直接给出trie树的很少。
这里只讲述最严谨的做法。即,用所有模式串建出一棵trie树,对其进行dfs/bfs遍历构建sam,insert时使lst为它在trie树上的父亲,其余和普通sam一样。时间复杂度依然线性。
如果要写离线+dfs写法或者在线写法,需要加特判,不好做。
所以这里写复杂度和正确性比dfs更优的离线+bfs写法。
注:暴力跳广义sam的链的时间复杂度是 \(O(n \sqrt n)\) 的。不会证明。
[ZJOI2015] 诸神眷顾的幻想乡
#include<bits/stdc++.h>
#define ll long long
using namespace std;
const int maxn=1e5+5;
int n,c,cnt=1,a[maxn],d[maxn],pos[maxn*20];
ll ans;
vector<int>g[maxn];
queue<int>q;
struct node{
int len,fa,ch[11];
}t[maxn*40];
int tf[maxn*20],tc[maxn*20],tr[maxn*20][10],num=1;//tc:节点代表的元素
int insert(int x,int lst){
int now=++cnt;
t[now].len=t[lst].len+1;
int p=lst;
while(p&&!t[p].ch[x]){
t[p].ch[x]=now;
p=t[p].fa;
}
if(!p){
t[now].fa=1;
} else{
int q=t[p].ch[x];
if(t[q].len==t[p].len+1){
t[now].fa=q;
} else{
int cpy=++cnt;
t[cpy]=t[q];
t[cpy].len=t[p].len+1;
t[now].fa=t[q].fa=cpy;
while(p&&t[p].ch[x]==q){
t[p].ch[x]=cpy;
p=t[p].fa;
}
}
}
return now;
}
void build(){
for(int i=0;i<c;i++) if(tr[1][i]) q.push(tr[1][i]);//这里是bfs建sam
pos[1]=1;//!初始化
while(q.size()){
int x=q.front(); q.pop();
pos[x]=insert(tc[x],pos[tf[x]]);
for(int i=0;i<c;i++){
if(tr[x][i]) q.push(tr[x][i]);
}
}
}
void dfs(int x,int fa,int tfa){//注意,记录trie树上的father
if(!tr[tfa][a[x]]) tr[tfa][a[x]]=++num,tf[num]=tfa,tc[num]=a[x];//建trie,根据题目要求
int tmp=tr[tfa][a[x]];
for(auto y:g[x]){
if(y!=fa) dfs(y,x,tmp);
}
}
int main(){
scanf("%d%d",&n,&c);
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
}
for(int i=1,u,v;i<n;i++){
scanf("%d%d",&u,&v);
g[u].push_back(v);
g[v].push_back(u);
d[u]++,d[v]++;
}
for(int i=1;i<=n;i++){
if(d[i]==1) dfs(i,0,1);
}
build();
for(int i=2;i<=cnt;i++) ans+=t[i].len-t[t[i].fa].len;
printf("%lld",ans);
return 0;
}