【8】后缀数组学习笔记
前言
后缀数组是一个很好用的字符串算法,可以解决的问题远远不止后缀。是后期字符串的重要基础,值得学习。
学习后缀数组的关键是弄清楚每个数组的意思,不然就很难理解。
学习串串好快乐啊。
基数排序
基数排序是一种 \(O(n)\) 的稳定排序算法,我们将会在后缀数组中用到它。基数排序用于给多关键字的元素排序。
例如,每个元素有两个关键字,优先按照第一关键字排序。我们先按照第一关键字排序,第一关键字相同时按照第二关键字排序。这个过程可以通过控制枚举顺序确定。
我们直接来看代码,\(x[i]\) 表示第 \(i\) 个元素的第一关键字,\(y[i]\) 表示第二关键字排名为 \(i\) 的元素,\(s[i]\) 表示排完序后第 \(i\) 项的排名。\(m\) 表示排名的值域。
for(int i=1;i<=n;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)s[t[x[y[i]]]--]=y[i];
第一、二行是在第二关键字有序的情况下,对于第一关键字进行桶排序,处理结束之后 \(t[i]\) 第一关键字小于等于 \(t[i]\) 的元素有多少个。这样,不考虑第一关键字的重复,如果一个元素的第一关键字是 \(x\),那么它这一轮的排名就是 \(t[x]\),因为有 \(t[x]\) 个数小于等于它。
第三行是将排序完成后的顺序记录下来。我们从大到小枚举第二关键字,如果这个第二关键字对应的元素的第一关键字与其他元素不同,那么 t[x[y[i]]]-- 相当于 t[x[y[i]]],也就是按照第一关键字的排名来。否则,在第一次访问到这个第一关键字的排名时,我们赋予这个元素这个第一关键字的最大排名,因为顺序是从大到小,此时这个元素第二关键字较大,排在后面。第二次访问时,由于 t[x[y[i]]]--,相当于排在了上一个元素的前面。不难发现是正确的。
多关键字的基数排序和这个类似,但是现在用不到。
后缀数组
一些记号:后缀 \(x\) 表示起始位置为 \(x\) 的后缀,\(sa[x]\) 表示排名为 \(x\) 的后缀的起始位置,\(rk[x]\) 表示以第 \(x\) 个位置开头的后缀的排名,\(len(s)\) 表示字符串 \(s\) 的长度。
后缀数组用于对于字符串的每一个后缀进行字典序升序排序。通常有 \(O(n\log n)\) 的倍增算法和 \(O(n)\) 的 DC3 算法。这里讲常用的 \(O(n\log n)\) 的倍增算法。
比较后缀字典序的第一步,就是按照第一个字符进行排序。我们使用基数排序。\(m\) 表示排名的值域。
for(int i=1;i<=n;i++)x[i]=s[i],t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[i]]--]=i;
这一段中 \(x[i]\) 表示后缀 \(i\) 的第一关键字,此时是每个后缀的第一个字符。现在,\(sa[i]\) 中存储的是第一个字符排名为 \(i\) 的后缀的起始位置。代码中 t[x[i]]-- 是为了避免出现相同排名。倒着遍历是因为第一关键字相同的位置靠前的排名小。
接下来,我们要对于第一个字符相同的后缀比较第二个字符。但是这样比较显然是没有前途的,时间复杂度至少为 \(O(n^2)\)。
字符串类题目,讲究的就是利用字符串中的重复信息。想一想,现在有什么重复信息可以利用呢?我们发现,后缀 \(i\) 的第二个字符,其实就是后缀 \(i+1\) 的第一个字符。因此,它们已经排有顺序了。
这样,我们把第一个字符相同的后缀比较完之后(先不管是怎么比较的),相当于我们对于每一个后缀已经比较了两位。接下来,我们可以对于前两个字符相同的后缀比较第三、四个字符。我们又发现,后缀 \(i\) 的第三、四个字符,其实就是后缀 \(i+2\) 的前两个字符。因此,它们又已经排有顺序了。
我们发现这是一个倍增的过程,每一次排好序的序列长度翻倍,总共的排序次数就是 \(\log n\) 级别的。如果我们能使每一次排序都是 \(O(n)\) 的,我们就有了一个 \(O(n\log n)\) 求出后缀数组 \(sa\) 的方法。
我们刚才就学习了一种 \(O(n)\) 的排序算法,现在我们来看看代码实现。
for(int k=1;k<=n;k<<=1)
要倍增,我们肯定要枚举当前排序的字符串长度。\(k\) 表示按照前 \(k\) 位拍好了序。接下来的代码都在这个循环中。
我们把后缀 \(i\) 的前 \(k\) 位作为它的第一关键字,之后的 \(k\) 位作为它的第二关键字。\(x[i]\) 表示起始位置为 \(i\) 的后缀按照第一关键字的排名,\(y[i]\) 表示第二关键字排名为 \(i\) 的后缀的起始位置。
long long 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;
我们用 \(num\) 来记录当前第二关键字的排名。
我们先对第二关键字进行排序,因为使用基数排序要求第二关键字有序。对于起始位置大于 \(n-k+1\) 的后缀,它们没有前 \(k\) 位之后的 \(k\) 位作为它的第二关键字,我们就把它们放到前面。因为没有字符的字典序比有字符更小,所以第二关键字它们字典序最小,放在前面,它们之间顺序不重要。
此时 \(sa[i]\) 表示按照前 \(k\) 个字符排序后排名为 \(i\) 的后缀的起始位置。之后,我们从小到大枚举 \(sa\) 数组,我们把遍历到的按照前 \(k\) 个字符排序后排名为 \(i\) 的后缀的前 \(k\) 个字符作为另一个后缀的第二关键字。目前遍历到的后缀起始位置为 \(sa[i]\),那么它作为起始位置为 \(k\) 个字符前的后缀 \(sa[i]-k\) 的第二关键字,因为前面放的 \(k\) 个字符是后缀 \(sa[i]-k\) 的第一关键字。注意可能存在不是任何一个后缀的第二关键字的情况,这就是代码中 if 做的事情。由于遍历顺序是从小到大,所以第二关键字已经排好序了。
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[y[i]]]--]=y[i],y[i]=0;
这是一个基数排序的过程。\(m\) 表示排名的值域。我们发现除了第一行的清空剩下的部分和基数排序部分几乎一模一样。由于我们桶中存的是第一关键字的对应排名,所以我们是优先按照第一关键字进行排序。之后,因为是从大到小遍历第二关键字,所以对于第一关键字相同的情况,我们会赋予第二关键字较大的元素较大的排名。
swap(x,y);
x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++)
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;
else x[sa[i]]=++num;
m=num;
最后,我们要做的就是更新 \(x\) 数组。回到定义,\(sa[i]\) 表示已经排好的范围内排名为 \(i\) 的后缀的起始位置,\(x[i]\) 表示起始位置为 \(i\) 的后缀按照第一关键字的排名。这一次排序的结果直接作为下一次排序的第一关键字,所以我们要把 \(x\) 更新为这一次排序之后的排名。
我们按照已经排好顺序从小到大枚举每一个后缀,这个过程可以通过在 \(sa\) 数组中遍历来实现。我们把每一个后缀和字典序在它前面一个的后缀比较,如果不一样,就给这个后缀赋予一个新的排名。
swap(x,y) 只是为了清空数组 \(x\),因为在基数排序的过程中我们顺便清空了 \(y\) 数组(y[i]=0)。\(m\) 记录的是当前排名的值域,也就是最大的排名。
if(num==n)break;
最后还有一个小优化,如果最外层循环还没排完,但是已经出现了 \(n\) 个排名,表示所有后缀都有属于自己的排名,实际上已经排完了,可以直接退出。
把这些拼到一起,就得到了完整的求后缀数组的代码。
void getsa()
{
for(int i=1;i<=n;i++)x[i]=s[i],t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
long long 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;
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y);
x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++)
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;
else x[sa[i]]=++num;
m=num;
if(num==n)break;
}
}
初始值域为 \(122\),这是可能出现字符的 ASCII 码范围。
LCP
LCP:两个字符串的最长公共前缀,称之为 LCP。
一些记号:\(LCP(x,y)\) 表示起始位置为 \(sa[x]\) 和起始位置为 \(sa[y]\) 的后缀(下记作字符串 \(x,y\))的最长公共前缀。
一些性质
这两条性质比较显然。
这一条性质可以把 LCP 划分成两个子问题进行求解,作用很大。接下来给出证明。
设 \(p=\min(LCP(i,k),LCP(k,j))\),则\(LCP(i,k)\ge p,LCP(k,j)\ge p\)。
所以后缀 \(i\) 与后缀 \(k\) 从前往后至少有 \(p\) 个字符相等,后缀 \(k\) 与后缀 \(j\) 从前往后至少有 \(p\) 个字符相等。因此,后缀 \(i\) 与后缀 \(j\) 从前往后至少有 \(p\) 个字符相等,\(LCP(i,j)\ge p\)。
根据字典序的性质,对于字典序 \(i\le k\le j\),\(k\) 中与 \(LCP(i,j)\) 相同的长必需大于或等于 \(LCP(i,j)\) 的长度,否则就不会被排到这个位置。因此,后缀 \(i\) 与后缀 \(k\) 从前往后相等的长度大于等于 \(LCP(i,j)\)。而 \(p\) 被定义为后缀 \(i\) 与后缀 \(k\) 从前往后相等的长度,因此,\(LCP(i,j)\le p\)。
综上所述,\(LCP(i,j)=p=\min(LCP(i,k),LCP(k,j))\)。
显然,使用上面性质一直划分直到 LCP 区间只有两个元素即可。
height 数组
注意到 LCP 的第四条性质,这已经是一个感觉很好维护的东西了。我们考虑定义 \(height[i]=LCP(sa[i-1],sa[i])),h[i]=height[rk[i]]\)。这个数组有一个非常重要的性质:
证明:(引用自 OI wiki)
当 \(height[rk[i-1]]\le1\) 时,上式显然成立(右边小于等于 \(0\))。
当 \(height[rk[i-1]]>1\) 时:
根据 \(height\) 定义,有 \(LCP(sa[rk[i-1]], sa[rk[i-1]-1])=height[rk[i-1]]>1\)。
既然后缀 \(i-1\) 和后缀 \(sa[rk[i-1]-1]\) 有长度为 \(height[rk[i-1]]\) 的最长公共前缀,
那么不妨用 \(aA\) 来表示这个最长公共前缀(其中 \(a\) 是一个字符,A 是长度为 \(height[rk[i-1]]-1\) 的字符串,非空)。
那么后缀 \(i-1\) 可以表示为 \(aAD\),后缀 \(sa[rk[i-1]-1]\) 可以表示为 \(aAB\)(\(B<D\),\(B\) 可能为空串,\(D\) 非空)。
进一步地,后缀 \(i\) 可以表示为 \(AD\),存在后缀\((sa[rk[i-1]-1]+1)AB\)。
因为后缀 \(sa[rk[i]-1]\) 在大小关系的排名上仅比后缀 \(sa[rk[i]]\) 也就是后缀 \(i\),小一位,而 \(AB<AD\)。
所以 \(AB \le\) 后缀 \(sa[rk[i]-1]<AD\),显然后缀 \(i\) 和后缀 \(sa[rk[i]-1]\) 有公共前缀 \(A\)。
于是就可以得出 \(lcp(i,sa[rk[i]-1])\) 至少是 \(height[rk[i-1]]-1\),也即 \(height[rk[i]]\ge height[rk[i-1]]-1\),所以 \(h[i]\ge h[i-1]-1\)。
运用上面的结论,可以在 \(O(n)\) 的时间内求出 \(height\) 数组。我们只需要维护上一个 \(h[i-1]\) 是多少,每次向后枚举时。由于每一个元素至多被遍历两次,所以复杂度是 \(O(n)\)。
代码中 \(h\) 数组定义与上文不一样,\(h[i]\) 表示 \(LCP(sa[i-1],sa[i])\),即 \(height\) 数组。这是为了方便按照字典序遍历字符串时的查询。注意到这样不影响原本 \(O(n)\) 的做法,因为求值时不需要用到 \(h[i-1]\)。
void getheight()
{
long long k=0;
for(int i=1;i<=n;i++)
{
if(k)k--;
long long j=sa[rk[i]-1];
while(s[i+k]==s[j+k])k++;
h[rk[i]]=k;
}
}
经典运用
后缀排序(例题 \(1,2\))
求两个后缀的LCP(例题 \(5,6,7\))
没找到洛谷上的模板,直接写在这里。
对于后缀 \(i\) 和后缀 \(j\),我们钦定 \(sa[i]\lt sa[j]\)。则它们的 LCP 可以表示为:(性质 \(4\))
我们使用 ST 预处理之后查询区间最小值即可,见 【6】ST表学习笔记。
本质相同/不同的子串的数量(例题 \(3,4,6\))
重复至少两次子串统计(例题 \(7\))
例题
例题 \(1\) :
后缀数组模板题,不多赘述。
#include <bits/stdc++.h>
using namespace std;
long long n,m=122,t[2000000],x[2000000],y[2000000],sa[2000000];
char s[2000000];
void getsa()
{
for(int i=1;i<=n;i++)x[i]=s[i],t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
long long 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;
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y);
x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++)
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;
else x[sa[i]]=++num;
m=num;
if(num==n)break;
}
}
int main()
{
scanf("%s",s+1);
n=strlen(s+1);
getsa();
for(int i=1;i<=n;i++)printf("%lld ",sa[i]);
return 0;
}
例题 \(2\) :
与子串字典序有关,考虑后缀数组。我们发现加密的过程实际上是一个环,利用断环为链的思想,我们只需要把字符串复制一遍到末尾,然后直接用后缀数组求出每个后缀的排名,取起始位置 \(\le n\) 的后缀按照字典序排序输出 \(n-1\) 个元素后的末尾即可。由于字典序优先考虑高位,所以一个长度 \(\ge n\) 的后缀超过 \(n\) 的那些位置不会有影响。
#include <bits/stdc++.h>
using namespace std;
long long n,m,sa[300000],x[300000],y[300000],t[300000];
char s[300000];
void getsa()
{
for(int i=1;i<=n*2;i++)x[i]=s[i],t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n*2;i>=1;i--)sa[t[x[i]]--]=i;
for(int k=1;k<=n*2;k<<=1)
{
long long num=0;
for(int i=n*2-k+1;i<=n*2;i++)y[++num]=i;
for(int i=1;i<=n*2;i++)
if(sa[i]>k)y[++num]=sa[i]-k;
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n*2;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n*2;i>=1;i--)sa[t[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y);
x[sa[1]]=1,num=1;
for(int i=2;i<=n*2;i++)
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;
else x[sa[i]]=++num;
m=num;
if(num==n*2)break;
}
}
int main()
{
scanf("%s",s+1);
n=strlen(s+1),m=256;
for(int i=1;i<=n;i++)s[n+i]=s[i];
getsa();
for(int i=1;i<=n*2;i++)
if(sa[i]<=n)printf("%c",s[sa[i]+n-1]);
return 0;
}
例题 \(3\) :
字符串中的本质不同子串数可以有以下计算式得到:
这个式子相当于按照字典序从小到大枚举每一个后缀,\(n-sa[i]+1\) 是以这个后缀的起始位置开头的子串数量。
但是可能会有重复子串,为了避免算重,我们钦定在字典序较大的后缀处计算与字典序较小的后缀的重复子串的贡献。我们考虑一个动态的过程,每次加入一个后缀,我们都把这个后缀中已经出现过的子串删掉,而这恰好是 \(h[i]\),即以排名为 \(i\) 的后缀起始位置开头的子串的重复子串一定可以在以排名为 \(i-1\) 的后缀起始位置开头的子串中找到。下面给出证明。
情况 \(1\) :
比排名为 \(i-1\) 的后缀排名更小的后缀可能与排名为 \(i\) 的后缀有相同子串。
如果这种情况成立,那么排名为 \(i-1\) 的后缀一定包含这些重复的子串,因为排名为 \(i-1\) 的后缀按照字典序排在这两个后缀中间。也就是说,这种情况已经被排名为 \(i-1\) 的后缀中的重复子串计算过了,不需要再次考虑。
情况 \(2\) :
比排名为 \(i-1\) 的后缀排名更大的后缀可能与排名为 \(i\) 的后缀有相同子串。
注意我们钦定了计算重复子串的顺序。如果这是这个子串第一次出现,那么后面有与之相同的子串不影响这个子串被统计贡献,不需要考虑。否则,这个子串之前已经出现过,根据对情况 \(1\) 的讨论,已经被计算过了,不需要再次考虑。
综上所述,以排名为 \(i\) 的后缀起始位置开头的子串的重复子串一定可以在以排名为 \(i-1\) 的后缀起始位置开头的子串中找到。
最后,根据 \(h\) 数组的定义,对每个后缀 \(i\) 减去 \(h[i]\) 即可。
#include <bits/stdc++.h>
using namespace std;
long long n,m=122,t[200000],x[200000],y[200000],sa[200000],rk[200000],h[200000],ans=0;
char s[200000];
void getsa()
{
for(int i=1;i<=n;i++)x[i]=s[i],t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
long long 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;
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y);
x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++)
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;
else x[sa[i]]=++num;
m=num;
if(num==n)break;
}
}
void getheight()
{
long long k=0;
for(int i=1;i<=n;i++)
{
if(k)k--;
long long j=sa[rk[i]-1];
while(s[i+k]==s[j+k])k++;
h[rk[i]]=k;
}
}
int main()
{
scanf("%lld%s",&n,s+1);
getsa();
for(int i=1;i<=n;i++)rk[sa[i]]=i;
getheight();
for(int i=1;i<=n;i++)ans+=(n-sa[i]+1-h[i]);
printf("%lld\n",ans);
return 0;
}
例题 \(4\) :
考虑和例题 \(3\) 差不多的思想,把相同子串问题转化为求 LCP。
由于我们需要求 LCP,就比较两个字符串的后缀大小,我们必须要把两个字符串合到一起求后缀数组。注意这时多余的字符会影响后缀排序的结果,我们需要在两个字符串中插入一个小于字符 a 的字符作为分隔符。注意不能为 \0,因为字符串末尾也是这个字符,求 \(height\) 数组时会出问题。
为了方便计算,我们不妨按照字典序从小到大枚举每个后缀。如果这个后缀在 \(s_1\) 中,那就统计它与 \(s_2\) 中字典序比它小的后缀的 LCP。不难发现这些 LCP 的长度之和就是相同子串数量。
我们考虑如何快速维护这个东西。由于 \(s_2\) 统计 \(s_1\) 中的贡献与 \(s_1\) 统计 \(s_2\) 中的贡献做法一样,这里只讲 \(s_1\) 统计 \(s_2\) 中的贡献。
假设现在遍历到后缀 \(sa[i]\),我们把 \(s_2\) 中字典序比 \(sa[i]\) 小的后缀丢进一个栈里面。栈里面维护每个 \(s_2\) 中字典序比 \(sa[i]\) 小的后缀 \(sa[j]\) 与 \(sa[i]\) 的 LCP,即 \(\min\{height[k]\}(j\lt k\le i)\)。在哪个字串中可以由 \(sa\) 数组对应的起始位置得到。
当我们处理 \(sa[i]\to sa[i+1]\),如果 \(sa[i]\) 属于 \(s_1\),栈中不需要增加元素,只需要把所有元素对 \(height[i]\) 取 \(\min\) 即可。否则,我们不仅需要把所有元素对 \(height[i]\) 取 \(\min\),还需要添加一个 LCP 值为 \(height[i]\) 的元素。这个过程如果朴素维护,总复杂度是 \(O(n^2)\) 的。
注意到会存在许多相同的元素,我们把相同的元素合并为一段,额外记录这一段的元素数。注意到小于 \(height[i]\) 的段不会被更新,我们维护一个从栈顶到栈底单调递减的栈,每次加入时踢掉栈顶大于 LCP 值 \(height[i]\) 的段,累加数量,最后加入一段 LCP 为 \(height[i]\),数量为累加值的段。同时更新段内元素的总和。这样每个元素之多进出栈 \(1\) 次,总时间复杂度 \(O(n)\)。
注意对 \(len(s_1),len(s_2)\) 取 \(\min\),以及特判以分隔字符开头的串,注意到这个串排名为 \(1\),直接跳过即可。
#include <bits/stdc++.h>
using namespace std;
long long n,n1,n2,m=122,t[500000],x[500000],y[500000],sa[500000],rk[500000],h[500000],ans=0;
long long st1[500000],st2[500000],sn1[500000],sn2[500000],top1=0,top2=0,sum1=0,sum2=0;
char s[500000],s1[500000],s2[500000];
void getsa()
{
for(int i=1;i<=n;i++)x[i]=s[i],t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
long long 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;
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y);
x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++)
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;
else x[sa[i]]=++num;
m=num;
if(num==n)break;
}
}
void getheight()
{
long long k=0;
for(int i=1;i<=n;i++)
{
if(k)k--;
long long j=sa[rk[i]-1];
while(s[i+k]==s[j+k])k++;
h[rk[i]]=k;
}
}
void add1(long long x,long long k)
{
x=min(x,min(n1,n2));
long long num=k;
while(st1[top1]>=x&&top1!=0)num+=sn1[top1],sum1-=st1[top1]*sn1[top1],top1--;
st1[++top1]=x,sn1[top1]=num,sum1+=st1[top1]*sn1[top1];
}
void add2(long long x,long long k)
{
x=min(x,min(n1,n2));
long long num=k;
while(st2[top2]>=x&&top2!=0)num+=sn2[top2],sum2-=st2[top2]*sn2[top2],top2--;
st2[++top2]=x,sn2[top2]=num,sum2+=st2[top2]*sn2[top2];
}
int main()
{
scanf("%s%s",s1+1,s2+1);
n1=strlen(s1+1),n2=strlen(s2+1),n=n1+n2+1;
for(int i=1;i<=n1;i++)s[i]=s1[i];
s[n1+1]='a'-1;
for(int i=1;i<=n2;i++)s[n1+i+1]=s2[i];
getsa();
for(int i=1;i<=n;i++)rk[sa[i]]=i;
getheight();
for(int i=2;i<=n;i++)
{
if(sa[i]<=n1)ans+=sum2,add1(h[i+1],1),add2(h[i+1],0);
else if(sa[i]>=n1+2)ans+=sum1,add2(h[i+1],1),add1(h[i+1],0);
}
printf("%lld\n",ans);
return 0;
}
例题 \(5\) :
我们把式子拆开,得到如下式子:
式子的第一部分可以枚举 \(i\) 之后使用等差数列求和公式计算。第二部分需要用到 LCP,考虑后缀数组。
不难发现第二部分相当于对于每一对不重复的后缀求 LCP,那我们把这一部分改为枚举排名 \(i,j(i\lt j)\),求 \(LCP(sa[i],sa[j])\)。我们发现这样转换后还是对于每一对不重复的后缀求 LCP,是等价的。
然后就是枚举排名为 \(j\) 的后缀的过程中维护每一个排名为 \(i\) 的后缀满足 \(i\lt j\) 的 LCP,即 \(\min_{k=i+1}^j\{height[k]\}\)。这又是一个区间最小值问题,可以用类似例题 \(4\) 中的单调栈解决。
似乎比例题 \(4\) 简单。
#include <bits/stdc++.h>
using namespace std;
long long n,m=122,sa[600000],x[600000],y[600000],t[600000],rk[600000],h[600000],ans=0;
long long st[600000],sn[600000],top=0,sum=0;
char s[600000];
void getsa()
{
for(int i=1;i<=n;i++)x[i]=s[i],t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
long long 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;
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y),x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++)
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;
else x[sa[i]]=++num;
m=num;
if(num==n)break;
}
}
void getheight()
{
long long k=0;
for(int i=1;i<=n;i++)
{
if(k)k--;
long long j=sa[rk[i]-1];
while(s[i+k]==s[j+k])k++;
h[rk[i]]=k;
}
}
void add(long long x)
{
long long num=1;
while(st[top]>=x&&top!=0)num+=sn[top],sum-=st[top]*sn[top],top--;
st[++top]=x,sn[top]=num,sum+=st[top]*sn[top];
}
int main()
{
scanf("%s",s+1);
n=strlen(s+1);
getsa();
for(int i=1;i<=n;i++)rk[sa[i]]=i;
getheight();
for(int i=1;i<=n;i++)ans+=((n-i+1)*(n-i)+(n-i+1)*(n-i)/2);
for(int i=1;i<=n;i++)ans-=2*sum,add(h[i+1]);
printf("%lld\n",ans);
return 0;
}
例题 \(6\) :
注意到后缀数组并不能支持在线插入,因为后缀数组是已经排好序了的,没办法快速插入一个元素。
我们不妨考虑一下从左到右加入第 \(i\) 个元素时生成魔咒的数量会如何变化。我们考虑前缀,因为后缀会不断变化,不好考虑。加入第 \(i\) 个元素,相当于增加了一个长度为 \(i\) 的前缀。如果不考虑重复,生成魔咒的数量就增加了 \(i\)。
现在我们考虑去重。类比例题 \(3\) 中的做法,我们把每个前缀前后翻转一下,按照字典序排序之后减去相邻字典序翻转后的前缀的 LCP 即可。这可以用类似例题 \(3\) 中的分类讨论方式证明,其实就是换了一个顺序,把从后缀的起始位置往后比较变成了从前缀的终止位置往前比较,本质上都是为了去除重复子串,这里不多赘述。
不难发现上面的前缀前后翻转按照字典序排序其实就是把整个字符串翻转之后进行后缀排序。因此,我们考虑离线,先把所有操作读进来,离散化并翻转后对最后的字符串进行后缀排序。
之后,我们顺序处理每一次插入的前缀。我们先使用 ST 表维护任意两个前缀前后翻转后的字符串的 LCP,通过 set 动态维护前缀前后翻转的后字符串之间字典序的相邻关系,添加后现撤销后继节点的影响,再添加这个节点和更新后后记节点的影响,即可使用上面结论快速维护答案。
#include <bits/stdc++.h>
using namespace std;
struct val
{
long long p,v;
}e[200000];
long long n,m,s[200000],sa[200000],x[200000],y[200000],t[200000],rk[200000],h[200000],w[200000],ans[200000],f[200000][21],tol=0,now=0;
set<long long>p;
bool cmp(struct val a,struct val b)
{
return a.v<b.v;
}
void getsa()
{
for(int i=1;i<=n;i++)x[i]=s[i],t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
long long 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;
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y),x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++)
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;
else x[sa[i]]=++num;
m=num;
if(num==n)break;
}
}
void getheight()
{
long long k=0;
for(int i=1;i<=n;i++)
{
if(k)k--;
long long j=sa[rk[i]-1];
while(s[i+k]==s[j+k])k++;
h[rk[i]]=k;
}
}
long long getmin(long long l,long long r)
{
long long k=log2(r-l+1);
return min(f[l][k],f[r-(1<<k)+1][k]);
}
int main()
{
scanf("%lld",&n);
for(int i=1;i<=n;i++)scanf("%lld",&e[i].v),e[i].p=i;
sort(e+1,e+n+1,cmp);
for(int i=1;i<=n;i++)
{
if(i==1||e[i].v!=e[i-1].v)tol++;
s[n-e[i].p+1]=tol;
}
m=tol;
getsa();
for(int i=1;i<=n;i++)rk[sa[i]]=i;
getheight();
for(int i=1;i<=n;i++)f[i][0]=h[i];
for(int j=1;j<=20;j++)
for(int i=1;i+(1<<(j-1))<=n;i++)
f[i][j]=min(f[i][j-1],f[i+(1<<(j-1))][j-1]);
for(int i=n;i>=1;i--)
{
now+=(n-i+1),p.insert(rk[i]);
set<long long>::iterator it=p.lower_bound(rk[i]);
if(it!=p.begin())
{
long long x=*it;
it--,w[x]=getmin(*it+1,x),now-=w[x];
}
it=p.lower_bound(rk[i]);
long long x=*it;
it++;
if(it!=p.end())now+=w[*it],w[*it]=getmin(x+1,*it),now-=w[*it];
ans[n-i+1]=now;
}
for(int i=1;i<=n;i++)printf("%lld\n",ans[i]);
return 0;
}
例题 \(7\) :
我们考虑统计以某个位置 \(i\) 结尾的 \(AA\) 串的数量,记作 \(f[i]\),并统计以某个位置 \(i\) 开头的 \(AA\) 串数量,记作 \(g[i]\),最终的答案即为 \(\sum_{i=1}^n f[i]\times g[i+1]\)。
注意到 \(g[i]\) 等价于在反串上的 \(f[n-i+1]\),因此我们只用考虑求 \(f[i]\)。
重复至少两次子串统计的经典技巧是枚举重复子串的长度 \(d\),每 \(d\) 个点设置一个关键点,将序列分成若干段。如果第 \(i\) 段和第 \(i-1\) 的最长公共后缀的长度加上第 \(i\) 段和第 \(i+1\) 的最长公共前缀的长度大于等于 \(d\),那么说明这两个关键点之间存在一个长度为 \(d\) 的重复至少两次子串。

如图所示,\(d_1,d_2\) 表示分界点,绿色段表示第 \(i\) 段和第 \(i-1\) 的最长公共后缀,黄色段表示是第 \(i\) 段和第 \(i+1\) 的最长公共前缀,橙色框框表示完全相等的子段。此时,我们发现粉色段可以作为 \(AA\) 串中的 \(A\) 串,且一直滑动到蓝色段都可以作为 \(AA\) 串中的 \(A\) 串,于是 \(AA\) 串的左端点属于 \([l_1,l_2]\),进而可以算出右端点。考虑贡献,这相当于区间加 \(1\),差分后转化为单点修改,查询时一遍前缀和求出。
第 \(i\) 段和第 \(i+1\) 的最长公共前缀是经典的后缀数组问题,可以转化为两个后缀的 LCP。最长公共后缀只需要把串倒过来做就行,是等价的。由于枚举 \(d\) 划分的点数是调和级数,\(O(n\log n)\),且最长公共前缀可以在 ST 表上 \(O(1)\) 查询,因此总复杂度为 \(O(n\log n)\)。
#include <bits/stdc++.h>
using namespace std;
long long ct,n,m=122,t[50000],x[50000],y[50000],sa[50000],rk[50000],b[50000],c[50000],l[300000],r[300000],h[50000],mi[50000][18],cnt=0;
char s[50000];
void getsa()
{
memset(sa,0,sizeof(sa)),memset(x,0,sizeof(x)),memset(y,0,sizeof(y));
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n;i++)x[i]=s[i],t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
long long 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;
for(int i=1;i<=m;i++)t[i]=0;
for(int i=1;i<=n;i++)t[x[i]]++;
for(int i=2;i<=m;i++)t[i]+=t[i-1];
for(int i=n;i>=1;i--)sa[t[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y);
x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++)
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;
else x[sa[i]]=++num;
m=num;
if(num==n)break;
}
}
void getheight()
{
memset(h,0,sizeof(h));
long long k=0;
for(int i=1;i<=n;i++)
{
if(k)k--;
long long j=sa[rk[i]-1];
while(s[i+k]==s[j+k])k++;
h[rk[i]]=k;
}
}
void prework()
{
for(int i=1;i<=n;i++)mi[i][0]=h[i];
for(int j=1;j<=17;j++)
for(int i=1;i+(1<<j)-1<=n;i++)
mi[i][j]=min(mi[i][j-1],mi[i+(1<<(j-1))][j-1]);
}
long long query(long long l,long long r)
{
if(l<=0||r<=0||l>n||r>n)assert(0);
if(l>r)swap(l,r);
l++;
long long k=log2(r-l+1);
return min(mi[l][k],mi[r-(1<<k)+1][k]);
}
int main()
{
scanf("%lld",&ct);
while(ct--)
{
scanf("%s",s+1);
n=strlen(s+1);
for(int i=0;i<=n+1;i++)b[i]=c[i]=0;
cnt=0,m=122,getsa();
for(int i=1;i<=n;i++)rk[sa[i]]=i;
getheight(),prework();
for(int i=1;i<=n;i++)
for(int j=i;j<=n;j+=i)
if(j+i<=n)r[++cnt]=min(query(rk[j],rk[j+i])+j+i,j+2ll*i);
cnt=0,reverse(s+1,s+n+1),m=122,getsa();
for(int i=1;i<=n;i++)rk[sa[i]]=i;
getheight(),prework();
for(int i=1;i<=n;i++)
for(int j=i;j<=n;j+=i)
if(j+i<=n)l[++cnt]=max(1ll*i+j,j+2*i-query(rk[n-j+1],rk[n-j-i+1]));
for(int i=1;i<=cnt;i++)
if(l[i]<=r[i])b[l[i]]++,b[r[i]]--;
for(int i=1;i<=n;i++)b[i]+=b[i-1];
cnt=0,m=122,getsa();
for(int i=1;i<=n;i++)rk[sa[i]]=i;
getheight(),prework();
for(int i=1;i<=n;i++)
for(int j=i;j<=n;j+=i)
if(j+i<=n)r[++cnt]=min(query(rk[j],rk[j+i])+j+i,j+2ll*i);
cnt=0,reverse(s+1,s+n+1),m=122,getsa();
for(int i=1;i<=n;i++)rk[sa[i]]=i;
getheight(),prework(),reverse(s+1,s+n+1);
for(int i=1;i<=n;i++)
for(int j=i;j<=n;j+=i)
if(j+i<=n)l[++cnt]=max(1ll*i+j,j+2*i-query(rk[n-j+1],rk[n-j-i+1]));
for(int i=1;i<=cnt;i++)
if(l[i]<=r[i])c[l[i]]++,c[r[i]]--;
for(int i=1;i<=n;i++)c[i]+=c[i-1];
long long ans=0;
for(int i=1;i<=n;i++)ans+=b[i]*c[n-i];
printf("%lld\n",ans);
}
return 0;
}
后记
限于时间原因,这篇博客不能覆盖到后缀数组的所有用法,以后再来补吧。等补完了就把这句话删掉。
满城灯火映面丝竹不歇
一晃三更过半今夕何年
今朝有酒今朝醉倒花间
何必苦苦追寻某种机缘

浙公网安备 33010602011771号