字符串相关(更新至SA)
字符串哈希
将字符集通过一些方式映射到整数集中,可以在 \(O(1)\) 时间内判断字符串是否相同。
线段树维护哈希
题目链接。
根据 \(border\) 理论,可以将第二问转化为 \([l,r-c]\) 和 \([l+c,r]\) 区间的字符串是否相同,考虑维护区间哈希。合并时将左边的哈希值乘上 \(base\) 的右边区间长度幂并加上右边的区间哈希值即可,修改是平凡的。
平衡树维护哈希
P4036 [JSOI2008] 火星人:与线段树维护哈希类似,合并时计算即可。
KMP
一种字符串单模匹配算法。
原理
当模式串 \(s\) 与文本串 \(t\) 进行匹配时,容易想到的一种朴素做法就是将模式串的第一位与文本串的每一位进行试配。但是这样效率过低,容易被数据卡成 \(O(n^2)\)。
KMP 单模匹配算法引入了一个失配数组 border。
定义一个字符串的 border 为一个最长的字符串 \(s'\) 的长度,满足字符串 \(s'\) 既是 \(s\) 的真前缀,又是 \(s\) 的真后缀。
当失配时模式串 \(s\) 可以直接跳到 border 记录的位置,并且融入了动态规划的思想,避免了一次一次低效的匹配。
KMP 算法分 \(2\) 步:
-
将模式串 \(s\) 与其自己匹配,求出数组 border。
具体的,定义两个指针 \(i\) 和 \(j\),表示此时 \(s[i-1-j+1 \sim i-1]\) 与 \(s[1 \sim j]\) 已经匹配成功的极大状态(以 \(i\) 和 \(j\) 结尾时无法实现更多字符的匹配),正在匹配 \(s[i]\) 与 \(s[j+1]\)。(指针 \(j\) 是匹配是横跳的指针,指针 \(i\) 从左到右移动)
若 \(s[i]\) 和 \(s[j+1]\) 失配,首先明确此时应该尽量让 \(s[i]\) 与 \(s[j+1]\) 实现匹配。那么指针 \(i\) 保持不变(因为是用 \(s[1 \sim j]\) 去匹配 \(i\) 之前的一段),去移动指针 \(j\)。
引理:当 \(s[i-1-j+1 \sim i-1]\) 与 \(s[1 \sim j]\) 匹配成功时,若 \(s[i]\) 和 \(s[j+1]\) 失配,则可能满足 \(s[i]\) 和 \(s[j+1]\) 匹配成功的最长字串的指针 \(j\) 的位置,应在 \(border[j]\) 处。
根据 border 的定义,反证法易证。
每次 \(i\) 进行匹配时,都会进行 \(border[i]\) 的标记,那么 \(border[j]\) 应在失配前就已经被标记了。所以,每次 \(s[i]\) 和 \(s[j+1]\) 失配时,就将指针 \(j\) 跳到 \(border[j]\),直到 \(s[i]\) 与 \(s[j+1]\) 匹配成功。
-
将模式串 \(s\) 与文本串 \(t\) 进行匹配,与第一步类似。
性质
\(kmp\) 的精髓在于 \(border\)。
-
若一个长为 \(n\) 的字符串 \(s\) 的最短周期长度为 \(k\),则该字符串的 \(border\) 为 \(n-k\)。
证明:根据条件,有 \(s[i]=s[i+k]\),所以 \(s[1\sim n-k]=s[k\sim n]\),得证。(见例题2)
-
一个字符串的最短 \(border\) 长度不大于该字符串长度的一半。
证明:反证法。记字符串 \(s\) 的最短 \(border\) 长度为 \(L\),且 \(2L>n\)。根据定义有 \(s[1\sim L]=s[n-L+1\sim n]\),则有 \(s[1\sim 2L-n]=s[n-L+1\sim L]=s[2n-2L+1\sim n]\)。可得 \(s[1\sim 2L-n]\) 为该字符串的一个 \(border\)。这与“最短”的定义相矛盾。
例题
P3375 【模板】KMP
for(int i=2,j=0;i<=lenb;i++){//first step
while(j>0&&b[i]!=b[j+1]) j=fail[j];
if(b[i]==b[j+1]) j++;
fail[i]=j;
}
for(int i=1,j=0;i<=lena;i++){//second step
while(j>0&&(j==lenb||a[i]!=b[j+1])) j=fail[j];
if(a[i]==b[j+1]) j++;
f[i]=j;
if(f[i]==lenb){
printf("%d\n",i-lenb+1);
}
}
P4391 [BOI2009] Radio Transmission 无线传输
变换一下性质一,可见最短的周期长度为 \(n-border[n]\)。
AC 自动机
AC 自动机是一种字符串多模匹配算法。
如果对每一个模式串,都对文本串跑一遍 KMP,时间复杂度显然不可接受。
AC 自动机先对于模式串建立字典树,然后再在字典树上构建失配指针,可以看作是两种算法的结合。
构建字典树的过程和普通的相同,构建 \(fail\) 指针时,先将字典树上超根的儿子的 \(fail\) 指针指向超根,然后通过超根的儿子,向下去扩展。扩展时,字典树上若没有这个儿子,就将这个儿子的值赋为父亲的 \(fail\) 指向的相同儿子;否则将这个儿子的 \(fail\) 指针指向父亲的 \(fail\) 指向的相同儿子,并且将儿子入队继续扩展,可以用队列实现。
显然所有的 \(fail\) 共同构建成了一个有向无环图。
以P5357 【模板】AC 自动机为例,利用有向无环图性质拓扑排序,代码如下:
queue<int> q;
struct ACAM{
inline void insert(string s,int id){
int p=0,len=s.size();
for(int i=0;i<len;i++){
int now=s[i]-'a';
if(!trie[p].ch[now]) trie[p].ch[now]=++idx;
p=trie[p].ch[now];
}
if(!trie[p].flag) trie[p].flag=++tot;
vis[id]=trie[p].flag;
}
inline void getfail(){
int p=0;
for(int i=0;i<26;i++) if(trie[0].ch[i]) trie[p].fail=0,q.push(trie[p].ch[i]);
while(!q.empty()){
int now=q.front();
q.pop();
for(int i=0;i<26;i++){
int v=trie[now].ch[i];
if(v) trie[v].fail=trie[trie[now].fail].ch[i],ind[trie[v].fail]++,q.push(v);
else trie[now].ch[i]=trie[trie[now].fail].ch[i];
}
}
}
inline void solve(string t){
int p=0,len=t.size();
for(int i=0;i<len;i++){
int now=t[i]-'a';
p=trie[p].ch[now];
trie[p].cnt++;
}
}
inline void topo(){
for(int i=1;i<=idx;i++) if(!ind[i]) q.push(i);
while(!q.empty()){
int u=q.front();
ans[trie[u].flag]=trie[u].cnt;
q.pop();
int v=trie[u].fail;
if(!v) continue;
ind[v]--;
trie[v].cnt+=trie[u].cnt;
if(!ind[v]) q.push(v);
}
}
}A;
例题
BZOJ 4502 串
做完之后对 AC 自动机有了更深的理解。
显然可以对所有字符串的前缀去重,然后记去重后的前缀数量为 \(n\),答案一定不大于 \(n\times n\),考虑对形如 \(ab+c\) 和 \(a+bc\) 的情况去重。
枚举所有这样的 “\(bc\)“,发现作为 ”\(c\)“ 的一定是 “\(bc\)” 的后缀,并且 “\(ab\)” 一定有 “\(b\)” 这个后缀,根据这道题的性质,有一个 “\(ab\)” 就一定有一个 “\(a\)”,所以 “\(ab\)” 的个数就一定是当前重复的个数。
考虑如何统计出这样的 “\(ab\)” 的个数,发现根据 “\(bc\)” 要求出其前缀,又要根据 “\(b\)” 求出以其作为后缀的字符串个数,这一一对应了 AC 自动机的字典树和 \(fail\) 指针。可以枚举每一个 “\(bc\)”,然后将 “\(bc\)” 的 \(fail\) 指针指向的字符串作为 “\(c\)”,两个点一起在字典树上向上跳,直到 \(fail\) 指针指向的那一个点跳到超根,这样所在 “\(bc\)” 的指针指向的字符串就是 “\(b\)”,统计 “\(b\)” 在 \(fail\) 树上的子树大小即可。
SA
如何快速地对一个字符串的所有后缀排序呢?暴力地 sort 时间复杂度是 \(O(n^2\log n)\) 的,运用后缀数组可以做到 \(O(n\log n)\)。
倍增求 SA 数组
还不会 DC3 和 SA-IS,不过感觉倍增已经很优秀了qwq
定义 \(suffix(i)\) 表示字符串 \(S\) 从 \(i\) 位置到结尾的字串,\(sa_i\) 表示 \(S\) 的所有后缀中排名为 \(i\) 的后缀开头的位置。
先将字符串 \(S\) 的每一个字符按字典序排序,得到初始的排名,记排名数组为 \(x\)。设立一个步长 \(k\),每次对于每个字符串的位置 \(i\),将 \(x_i\) 作为第一关键字,\(x_{i+k}\) 作为第二关键字排序,每次重置排名,更新 \(sa\) 数组,并且将步长扩大两倍,直到排名不同时结束排序。每次双关键字排序相当于是将两个长度相等且首位相连的字符串合并后排序的过程,正确性显然。
排序的过程可以用基数排序优化,做到 \(O(n\log n)\) 的时间复杂度。
倍增求 SA 数组的代码如下:
其中数组 \(c\) 是桶,\(x_i\) 表示 \(suffix(i)\) 的当前排名(也就是第一关键字),\(y_i\) 表示第二关键字排名为 \(i\) 的第一关键字的位置。
实现和基数排序不同的是,由于我们已经知道了当前的 \(sa\) 和 \(x\),可以不用桶对第二关键字排序,详见代码。
char s[maxn];
int n,m=122,sa[maxn],c[maxn],x[maxn],y[maxn];
inline void suffix_sort(){
for(int i=1;i<=m;i++) c[i]=0;
for(int i=1;i<=n;i++) c[x[i]]++;
for(int i=1;i<=m;i++) c[i]+=c[i-1];
for(int i=n;i>=1;i--) sa[c[x[y[i]]]--]=y[i];//双关键字,从后往前。
}
inline void get_SA(){//求解SA数组
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=1;i<=n;i++) sa[c[x[i]]--]=i;
for(int k=1;k<=n;k<<=1){//k为步长
int num=0;
for(int i=n-k+1;i<=n;i++) y[++num]=i;//这一部分第二关键字为空,排名在前
for(int i=1;i<=n;i++) if(sa[i]>k) y[++num]=sa[i]-k;//按照sa直接排第二关键字
suffix_sort();//基数排序(双关键字)
swap(x,y);
x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++) x[sa[i]]=((y[sa[i]]==y[sa[i-1]])&&(y[sa[i]+k]==y[sa[i-1]+k])?num:++num);//重新计算x数组(重排名)
if(num==n) break;//已经不同了
m=num;//更新桶大小
}
for(int i=1;i<=n;i++) printf("%d ",sa[i]);
cout<<endl;
}
LCP 与 height 数组
定义 \(LCP(i,j)\) 表示 \(suffix(sa[i])\) 和 \(suffix(sa[j])\) 的最长公共前缀长度。
根据 \(LCP\) 的定义,显然有这样的性质:
- \(LCP(i,j)=LCP(j,i)\)
- \(LCP(i,i)=\left| S\right|-sa_i+1\)
进一步地,可以推出:
- \(LCP(i,j)=\min(LCP(i,k),LCP(k,j))\)
- \(LCP(i,j)=\min_{k=i+1}^{j} LCP(k,k-1)\)
第一个式子画个图就明白了,考虑第二个式子我们可以将 \(LCP(i,j)\) 拆成 \(\min(LCP(i,i+1),LCP(i+1,k))\),这样一直拆下去,式子显然成立。
根据第二个式子,我们可以将 LCP 问题转化成 RMQ 问题,于是引入 \(height\) 数组。
定义 \(rk_i\) 表示开头在 \(i\) 位置的后缀的排名,\(height_i\) 表示 \(LCP(i,i-1)\),\(h_i\) 表示 \(suffix(i)\) 和 \(suffix(sa_{rk_{i}-1})\) 的最长公共前缀长度。
开始学的时候一直搞混 \(height_i\) 和 \(h_i\) 的含义,其实通俗地讲,前者是排名为 \(i\) 和 \(i-1\) 的最长公共前缀,后者是位置是 \(i\) 和排名在位置为 \(i\) 的排名之前的后缀的最长公共前缀。
引入 \(h\) 的意义是因为 \(h\) 有更好的性质,由于有 \(height_{rk_i}=h_i\),实际上这两个数组是可以相互转化的,类似 \(sa_{rk_i}=i\)。
一个重要的定理:
\[h_i\ge h_{i-1}-1,2 \le i \le \left| S\right|\wedge h_{i-1}\ge 1 \]
证明:记排名在 \(i-1\) 之前的后缀为 \(k\)(这里的 \(i-1\) 和 \(k\) 表示的是位置),根据定义,有 \(h_{i-1}\) 等于 \(suffix(i-1)\) 和 \(suffix(k)\) 的最长公共前缀长度。将这两个后缀的开头的一个字符删去,此时就变成了 \(suffix(i)\) 和 \(suffix(k+1)\) 的最长公共前缀。显然 \(suffix(k+1)\) 的排名应该在 \(suffix(i)\) 之前,不一定 \(suffix(k+1)\) 的排名与 \(suffix(i)\) 的排名相邻,但是排名在这两个后缀之间的后缀与 \(suffix(i)\) 的最长公共前缀必定不小于 \(h_{i-1}-1\),否则与排名在两者之间矛盾,得证。
于是,根据 \(h\) 的性质,可以通过计算 \(h\) 的值,得出 \(height\) 数组,时间复杂度 \(O(\left| S\right|)\):
inline void get_height(){//求height函数,height_i表示后缀排名为i的与排名为i-1的后缀的LCP,求出后可以用RMQ求出任意后缀的LCP
int j,k=0;
for(int i=1;i<=n;i++) rk[sa[i]]=i;
for(int i=1;i<=n;i++){
if(k) k--;
j=sa[rk[i]-1];
while(s[j+k]==s[i+k]) k++;
height[rk[i]]=k;//height[rk[i]]=h[i]
}
for(int i=1;i<=n;i++) cout<<height[i]<<" ";
cout<<endl;
}
后缀数组的应用
字符串的不同字串个数
字串一定是这个字符串其中一个后缀的前缀,将这个字符串的后缀排序,并求出 \(height\) 数组,最后的答案应是 \(\sum_{i=1}^{\left| S\right|}{\left| S\right|-sa_i+1-height_i}\)。相当于是每次去掉了和前一个排名的相同前缀,正确性显然。
例题:P2408 不同子串个数。
字符串的最长重复子串长度
- 求可以重叠且重复 \(k\) 次以上的最长字串长度。先求出 \(sa\) 和 \(height\),贪心地,满足重复恰好 \(k\) 次的字串长度一定比重复 \(k\) 次以上的字串更优。相当于我们用一个长度为 \(k\) 的滑块在 \(height\) 数组中滑动,重复恰好 \(k\) 次的字串长度为这个区间的最小值,对所有的取最大值即可,可以用单调队列求最小值做到求解部分的线性复杂度。例题:P2852 [USACO06DEC] Milk Patterns G。
- 求可以重叠的最长重复字串长度。显然为 \(height\) 的最大值。
- 求不可重叠的最长重复字串长度。答案具有单调性,考虑二分。记当前二分的长度为 \(l\),我们可以将 \(height\) 中连续大于 \(l\) 的分成一组,这一组一定是极大的,记录一组中的 \(sa\) 的最大最小值,当 \(sa\) 的极差大于当前的 \(l\) 时,答案成立。
求若干个字符串的最长公共字串(LCS)
假设有 \(n\) 个字符串,将这些字符串拼在一起,中间用分隔符隔开。对这个字符串求出 \(sa\) 和 \(height\),同时记录排序后每一个后缀开头属于哪一个字符,问题转化为可以重叠且重复 \(n\) 次的最长重复子串问题。考虑枚举 \(height\) 的区间左端点,发现左端点向右移的过程中,右端点单调上升。于是可以用双指针去框区间,单调队列维护区间最小值即可。
可以用来求字符串最长回文子串:将字符串复制一份,并将拷贝的字符串反转,转化为最长公共子串问题。不过时间复杂度瓶颈在于求 sa,Manacher 的 \(O(n)\) 还是更加优秀。
与单调栈结合
显然瓶颈在于求任意两个子串的 \(LCP\) 之和。考虑将 \(height\) 放在一个二维平面上,\(x\) 轴表示排名,\(y\) 轴表示对应的 \(height\) 值。这样的问题转变成了一个经典的柱状图上求可以框出的矩形面积和的题,可以通过维护一个单调递增的栈求出。时间复杂度 \(O(n\log n)\),瓶颈在于求 SA。这里附上一部分代码:
for(int i=2;i<=n;i++){
while(top&&height[sta[top]]>=height[i]) sta_ans-=(top==1?sta_ans:1ll*(sta[top]-sta[top-1])*height[sta[top]]),top--;
sta_ans+=(top?1ll*height[i]*(i-sta[top]):1ll*(i-1)*height[i]);
ans+=sta_ans,sta[++top]=i;
}
printf("%lld\n",1ll*(n-1)*(n+1)*n/2-2*ans);
例题
P3181 [HAOI2016] 找相同字符
和差异那一题相似,先分别对两个字符串求出答案,然后再将两个字符串拼起来求出答案 \(ans\),用 \(ans\) 减去两个串的答案即可。
P5341 [TJOI2019] 甲苯先生和大中锋的字符串
用一个长为 \(k\) 的滑块去滑 \(height\) 数组,若当前滑的右端点为 \(i\),符合条件的长度区间应该为 \([\max(height[i-k],height[i+1])+1,LCP(i-k+1,i)]\),后者可以用单调队列维护,答案用差分维护,最后取最大值即可。

浙公网安备 33010602011771号