后缀数组
Meaning
所谓后缀数组,就是将一个字符串的所有后缀形式化的一个数组。由于所有子串都必定是某个后缀的前缀,所以利用后缀数组我们可以处理不少字符串题目。
General
对于所有后缀,我们一般先针对它们按字典序排好序,那么这样就能保证相邻后缀的重合部分最大,对于一些重叠相同的子串一类,我们就能迅速地找出它们。
Sort
好的,接下来我们研究如何排序!
Power
暴力的排法自然是将所有后缀记录下来并直接 \(sort\) 一发,不过此方法空间复杂度达到了 \(n^2\) ,且时间复杂度达到了 \(n^2log\) ,在 \(nlog\) 横行的今天显然是撑不住的。那么有没有什么好方法呢?答案是肯定的!
Multiplication
\(O(n*log^2)\)–\(QuickSort\)
倍增算法自然为人所皆知,这是一个巧妙的算法,应用范围也还算广,只需要变化规则相同且有规律,那么这些变化则可以使用倍增来加速状态的推导。
打个比方,变化矩阵单调的 \(DP\) ,我们就可以使用倍增来加速,同时譬如 \(LCA\) 的变化也很单调,那么我们就可以通过 \(2^i=2^{i-1}\ and\ 2^{i-1}\) 这样两个状态的叠加来快速得到结果。一般多用二进制优化,因为二进制下只需要 \(1/0\) 表示即可,即有与无。偶尔我们也可能需要 \(k\) 进制优化 = =,譬如固定底数的幂次方,那么我们可以通过对 \(65536\) 进制优化的预处理做到 \(O(1)\) 求解。
对于后缀排序,自然也是符合单调变化的,因为变化规律仅仅为 “谁大谁小” 。
考虑如何倍增。
我们依次对每个后缀前 \(2^i\) 长度的字符来排序即可。由于变化规律的单调性,我们发现,对于两个字符串 \(A\) 与 \(B\),我们可以直接比较大小来得到它们的相对大小。但由于比较数的时候,如果 \(A\) 的前半部分本来就大于 \(B\) 的前半部分,那么根据字符串的比较规律,后半部分压根就不用比了,\(A\) 必定大于 \(B\) ,这就是变化规律的单调性所在。
那么如何利用这个单调性呢?
对于后缀 \(S\) ,设起点为 \(s\),长度为 \(2^i\) 的前半部分,即 \(s\) 至 \(2^{i-1}-1\) 的部分已经在上一轮排好序了,那么后半部分呢?不就是 \(s+2^{i-1}\) 往后 \(2^{i-1}\) 个位置的排名吗?这不就是这个位置上次排序的排名?
那么对于每次倍增的长度,我们只需要以当前位置上一轮的排名为第一关键字,\(2^{i-1}\) 个位置之后的上一轮排名为第二关键字排序即可得到这次倍增的长度的排名!
这样我们只需要最多排序 \(log\) 次,直到比较出来每个数的排名都不同为止即可。
那么时间复杂度为 \(nlog^2\) ,看起来充满了脑洞,能应付大多数问题了,但还是不够。
\(O(n*log)\)—\(BaseSort\)
注意到我们每次排序的时间复杂度是 \(nlog\) ,我们之所以选择倍增,就是因为字符串的 \(sort\) 会额外带来 \(O(n)\) 的复杂度。我们利用倍增的 \(log\) 代替了这个 \(O(n)\) 那么还剩下的这个 \(log\) ,我们可不可以去除呢?
答案是显然的。
我们来选择一个神奇的排序算法之–基数排序。
首先我们想想我们排好的序,我们对于两个数是如何比较大小的?
从最高位比起,如果相同就一位位比下去,直到不同为止,然后第一个不同的那个位置上的数大的那个更大,没错吧?
那么我们来想如何根据这个原理排序。
换而言之,如果排好序了,对于两个数而言,设都有 \(x\) 位,设它们的重合前缀长度为 \(k\) ,那么第 \(x-k\) 位大者大。对吧?换而言之,在前缀相同的情况下,对于已经排好的序,相邻两个数的 \(x-k\) 位必定是有序的。
那么我们考虑如何做到这一点。
我们发现可以这样做,且一定是对的:
我们先对最低位把所有数排序。
之后逐步向高位走,对于所有的数排序,对于当前位相同的,我们取上一轮排序排名更大者为大者。如果上一轮排名与此轮排名两者都比较比较了,两者都一样,那么排名相同。
直到所有数的排名都不相同即可。
大概就是这个意思。
你可能会问,对于每一位的排序不还是 \(nlog\) 的吗?
不不不,我们对于每一位的排序使用桶排即可。只需要开大小为 \(10\) 的桶就好了,笑。
那么对于每一位的排序就是常数级了。而位数也是常数级的。
那么我们就得到了常数略大的 \(O(n)\) 排序算法之–基排。
接着,我们对于每个后缀都得到了两个关键字的排名。那么我们将其看成一个两位的 \(inf\) 进制的数就好了,先比较后一位,再比较前一位即可。
桶的大小为不同排名的数量。当不同排名的数量为 \(n\) 的时候就不用倍增下去了。
Code
后缀排序的代码还是有点小神奇的,中间有些点估计理解难度挺高。
我先大致讲一下代码中的小优化啥的。
我们用 \(rk[i]\) 记录第 \(i\) 个后缀的排名,使用 \(sa[i]\) 记录排名第 \(i\) 的后缀是谁。
简而言之,\(rk\) 数组是你排第几,\(sa\) 数组是排第几的是谁。
那么就有:\(sa[rk[i]]=i,rk[sa[i]]=i\) ,所以我们只需要求出其中一个数组,就能很快地推出另一个数组啦。
在排序过程中,我们使用 \(tmp\) 数组来按已经按第二关键字排好序的后缀下标。当我们排完序得出 \(sa\) 数组之后,我们再将上一轮的 \(rk\) 数组存到 \(tmp\) 数组里,然后利用上一轮排名判断排名相等的情况,同时利用 \(sa\) 数组更新当前轮 \(rk\) 即可。
当然,我们可以优化一个 \(\frac{1}{2}\) 的小常数,那就是其实第二关键字并不需要专门排一次序 = = 。我们直接按第二关键字从小到大的顺序将其下标加入 \(tmp\) 数组就好了 \(QwQ\) 。
那么怎么按照这个顺序加呢?首先我们观察到,以第 \(n-2^{i-1}+1\) 至 \(n\) 为开头的后缀,它们是没有第二关键字的 (换而言之它们的序已经排好了) ,那么它们就是第二关键字最小的那一批,我们先将它们的下标加入 \(tmp\) 数组。然后,由于第二关键字其实就是某些位置上一轮的排名,我们完全可以利用上一轮的排名,也就是按 \(sa\) 数组加入来得到加入顺序!
这个部分的代码如下:
\(len=2^{i-1}\)
for(int i=1;i<=n;i++)
if(sa[i]>len)
tmp[++cnt]=sa[i]-len;
对于 \(sa[i]<=len\) 的,即后缀开头的位置不够远的,它们并不能贡献自己的第二关键字。
至于基数排序部分,共计四个循环。
\(tag\) 为桶数组,\(M\) 为桶的大小。
for(int i=1;i<=M;i++)tag[i]=0;
for(int i=1;i<=n;i++)tag[rk[i]]++;
for(int i=1;i<=M;i++)tag[i]+=tag[i-1];
for(int i=n;i>=1;i--)sa[tag[rk[tmp[i]]]--]=tmp[i];
第一个循环:清空桶。
第二个循环:桶中加入元素。
第三个循环:记录前缀和,即使桶的含义由 大小 \(=i\) 的数有多少个 变为 大小 \(\le i\) 的数有多少个 。那么对于一个数的排名,实际上可以表示为 \(<i\) 的数的数量 \(+1\) ,也就是排在前面的数有多少个。
第四个循环:这里也许会有两个问题最奇怪:
- 为什么要执行一个 \(tag–\)
因为虽然 \(rk\) 数组对应的值可能会有相同的,但是原则上 \(sa\) 数组是不能有重复的。
- 为什么要倒过来加入
因为我们的数组内存的下标已经对第二关键字排过序了。那么对于从后往前的后缀,它们的第一关键字即便相同,但是第二关键字更大的那个已经把 \(tag–\) 过了,所以当前这个第二关键字更小的数它的排名还是会更高些。
当然我承认这个四重下标挺瞎眼的,但是好好理解就行 = = 。
最后处理一遍,更新 \(rk\) 数组就好了。
swap(tmp,rk);
rk[sa[1]]=cnt=1;
for(int i=2;i<=n;i++)
rk[sa[i]]=tmp[sa[i]]==tmp[sa[i-1]]&&tmp[sa[i]+len]==tmp[sa[i-1]+len]?cnt:++cnt;
首先 \(sa[1]\) 位置的排名肯定是为 \(1\) 的,由于我们已经 \(swap\) 了数组,那么 \(tmp\) 数组此刻存的自然已经是上次的排名了。那么我们按 \(sa\) 的顺序去更新 \(rk\) 数组,如果当前排名的数与上个排名的数的两个关键字都相等的话,自然排名相同啦。
\(Latest Code\):
#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std;
int n,M,cnt,rk[1000001],tmp[1000001],sa[1000001],tag[1000001];
char s[1000001];
void Sort()
{
for(int i=1;i<=M;i++)tag[i]=0;
for(int i=1;i<=n;i++)tag[rk[i]]++;
for(int i=1;i<=M;i++)tag[i]+=tag[i-1];
for(int i=n;i>=1;i--)sa[tag[rk[tmp[i]]]--]=tmp[i];
}//四重循环之基排
int main()
{
M=76;
scanf("%s",s+1);
n=strlen(s+1);
for(int i=1;i<=n;i++)
rk[i]=s[i]-'0',tmp[i]=i;//第一轮排序先将每个字母作为第一关键字,第二关键字全部为 0
Sort();
for(int len=1;cnt<n;len<<=1,M=cnt)//倍增确定长度,M 确定桶大小
{
cnt=0;
for(int i=n-len+1;i<=n;i++)tmp[++cnt]=i;
for(int i=1;i<=n;i++)
if(sa[i]>len)
tmp[++cnt]=sa[i]-len;//按第二关键字的顺序加入下标
Sort();
swap(tmp,rk);//利用 tmp 数组储存上轮排名
rk[sa[1]]=cnt=1;
for(int i=2;i<=n;i++)
rk[sa[i]]=tmp[sa[i]]==tmp[sa[i-1]]&&tmp[sa[i]+len]==tmp[sa[i-1]+len]?cnt:++cnt;//计算当前轮排名
}
for(int i=1;i<=n;i++)
printf("%d ",sa[i]);
}
Example–不同子串个数
Problem
给你一个长度为 \(n\) 的字符串,问它的不同的子串的数量。对于两个子串,当且仅当对于任意一个位置上的字符不同时,我们说它们是不同的。
\(n\le 10^6\)
Mentality
首先想到,对于任意的一个子串,它都能被表示为某一个后缀的前缀。
那么对于任意一个后缀,它有许多前缀,当且仅当它的前缀与另一个后缀的前缀相等时,这两个子串是相同的。
不难想到,对于某一个前缀,它只能被计算一次贡献。那么我们只需要将前缀相等的尽可能放在一起处理就好啦。
那么根据直觉–我们把所有后缀排个序,它们的前缀重合情况就能记全。
抱歉我无法证明 (毕竟直觉) 。
那么我们把所有后缀排序。接着我们记录数组 \(height[i]\) 代表排名第 \(i\) 位的后缀与排名第 \(i-1\) 位的后缀,它们的公共前缀长度。
那么不难发现,我们对于当前后缀,它能产生贡献的前缀只会是以 \(height[i]+1\) 至 \(n\) 结尾的那些,因为之前的那些前缀已经被统计过了。即便 \(height[i-1]\) 也很大,\(i-1\) 统计不完,但是 \(i-2\) 及往前的前缀总能统计完的。
那么这题就 A 掉了!
然而并没有做完。
因为 \(height\) 数组还没求呢!如果暴力求的话,那么时间复杂度将会达到 \(n^2\) ,显然超时。那么我们就需要仔细动动脑子了。
通过思考,我们可以得到一个有趣 (但不完整) 的结论:\(height[rk[i]]=height[rk[i-1]]-1\)
即:第 \(i\) 个后缀的排名的 \(height\) 为第 \(i-1\) 个后缀的 \(height-1\) 。
这个结论很好证明,假设第 \(i-1\) 个后缀为:
\(abcdefgggg\)
而排名在它前一位的后缀为:
\(abcdabcdefgggg\)
设其为 \(k\) ,那么它们的重合部分为 \(abcd\) ,则对于第 \(i\) 个后缀,与其重合部分最大的肯定为 \(k+1\) ,因为若非如此,排在 \(i-1\) 前的后缀也不会是 \(k\) = = 。
当然这个结论是不完整的,因为如果 \(height[i-1]=0\) ,还有一些神奇的情况譬如断开的公共前缀,那么就不适用了。那么我们完整一下,得到了一个新的结论:
\[
height[rk[i]]>=height[rk[i-1]]-1
\]
那么我们只需要拿根指针在 \(i+height[rk[i-1]]-1\) 的位置开始扫,与 \(k+1\) 的后缀比较,如果相同就不停 \(++\) 就好了。
Code
#include<iostream>
#include<cstdio>
using namespace std;
int n,M,cnt,rk[100001],tmp[100001],sa[100001],tag[100001],height[100001];
long long ans;
char s[100002];
void Sort()
{
for(int i=1;i<=M;i++)tag[i]=0;
for(int i=1;i<=n;i++)tag[rk[i]]++;
for(int i=1;i<=M;i++)tag[i]+=tag[i-1];
for(int i=n;i>=1;i--)sa[tag[rk[tmp[i]]]--]=tmp[i];
}
int main()
{
cin>>n;
scanf("%s",s+1);
M=76;
for(int i=1;i<=n;i++)
rk[i]=s[i]-'0',tmp[i]=i;
Sort();
for(int len=1;cnt<n;len<<=1,M=cnt)
{
cnt=0;
for(int i=n-len+1;i<=n;i++)tmp[++cnt]=i;
for(int i=1;i<=n;i++)
if(sa[i]>len)
tmp[++cnt]=sa[i]-len;
Sort();
swap(rk,tmp);
rk[sa[1]]=cnt=1;
for(int i=2;i<=n;i++)
rk[sa[i]]=tmp[sa[i]]==tmp[sa[i-1]]&&tmp[sa[i]+len]==tmp[sa[i-1]+len]?cnt:++cnt;
}//以上全是后缀排序
for(int i=1,len=0;i<=n;i++)
{
if(len)len--;
int now=sa[rk[i]-1];
while(s[now+len]==s[i+len])len++;//指针处理
height[rk[i]]=len;
}//O(n) 求 height 数组
for(int i=1;i<=n;i++)
ans+=n-sa[i]+1-height[i];//计算答案
cout<<ans;
}