后缀数组(倍增)
后缀数组
在LeetCode上刷题刷到了一个重复子串的问题,写不来查大佬代码看到了
后缀数组这种用来处理重复子串的数据算法。画了半天时间查资料,看视频也一知半解糊里糊涂的。所以打算在这里写一写整理一下。
后缀数组能干什么
后缀数组是一种字符串处理方法。是用来满足获取连续重复子串(尤其是可以部分重叠的子串)信息需要的一种数据结构。也就是说,有些求公共子串的题目可能可以用到。
具体什么情况能用还得具体情况具体分析的。
一些基本概念
先提前说一下,本文中字符串下标从1开始。在编码时也是如此,为了处理方便,编码时,会先给字符串头部加一个占位符。(“abc" -> "$abc"这样)
-
子串
在字符串s中,截取的一段长度不为零(一般情况下)的字符串。 -
后缀
一种特殊的子串的,是从某个位置i到字符串末尾的子串。文中后缀数组用suff[i]表示。suff[i]表示从i开始到字符串结尾的一个后缀(s[i…len(s)])。 -
后缀数组
后缀数组就是把suff[i]按照字典顺序排列。但是我们不用一个数组来直接存储这种字符串数组。而是使用下列2个数字数组储存。- sa[i]数组
sa[i]数组用来表示排名为i的数组的后缀的起始位置。(比如sa[2] = 3表示排名第二的后缀的起始位置是3,即s[3…len(s)]排名第2) - rak[i]数组
rak[i]数组用来表示始位置是i的后缀的排名。(比如rank[2] = 3 表示后缀s[2…len(s)]排名第3)
*如下是在字符串"ababca"中sa[]和rak数组的实例(height就先别管吧)
- sa[i]数组
-
sa[i]数组和rank[i]数组能干什么?
在获取后缀子串的同时,我们其实已经相当于获取了字符串s中所有的连续子串(那些不包含末端的子串包含其中)。所以寻找重复子串只需要处理这些后缀即可。而这个寻找过程可以转换为寻找不同后缀子串的前缀。对于任意s[i…j] == s[n…m] (1 <= i <= n<= j <= m <= len(s))
记len(s[i…j]) = k
一定存在suff[i]和suff[n]前缀有k个相同
而前缀相同这个属性可以通过排序来体现出来,因为前缀相同的后缀们会紧挨着。所以通过后缀排序后得到的sa[i]和rank[i]我们可以知道每个后缀的排序顺序,也就容易获得他们的相同前缀(即字符串的重复子串)
当然这个重复前缀怎么获得的问题我们之后在讲。
那么到底怎么排序呢?
如果采用普通暴力排序的算法,一个长度为n的字符串可以划分为n个后缀子串。每个都一个个比较过去排序。哪怕使用快排都需要O(n log2n)的排序时间,而字符串比较有需要O(n)的时间。可以说是将近O(n2 log2n)的复杂度了。实在是太慢太慢了。
所以我们这里引入倍增排序
倍增排序
对于任意的字符串比较,我们人是通过逐位比较来实现的。比较*“abc””acb““cba”*我们先比较第一位,先根据第一位确定一个大概顺序,然后再比较第二位第三位…这样来逐渐完善顺序,直到最后顺序不在发生变化(或者所有位都比过去)为止。因此对于后缀子串的比较,我们也可以采用这种方法,我们先根据每个suff[i]的开头第一个字符进行排序,然后再根据第二个字符(没有的补低位)进行排序。也就是像第一关键词,第二关键词…那样排序啦。
但是一个长度为n的字符串,最长的后缀就会有n位。如果一共设计n个关键词来比较的话,那时间复杂的至少是O(n2)。没有什么办法减少关键词的数量呢?
当我们每把一个新的比较关键词(字符),比如我们把每个后缀的第j字符suff[i] [j]加入比较时,suff[i][j]其实就是suff[j]的第一个字符(对于任意j>1都满足)。也就是说,其实新比较的字符我们再之前就已经有过他们的排位顺序了。我们可以把每次排序看成:前一次排序的结果+之前只比较一个字符时的排序结果两个互相作用的结果。如果我们保留有这两个排序的结果,那我们应该就可以以利用关键词排序获得这次排序的结果。试试下,每次排序的结果可以表达为下列式子前一次排序的结果+之前比较X个字符时的排序结果。X为这次加入排序的字符数。如果X越大,我们需要比较排序的总次数就越少(次数是n/x次)。那我们为什么不把X就设定为前一次排序的字符数量?这样新一次的排序结果可以完全参考上一次排序的结果得出。而我们也只需要一共排序log2n次。即
下图是字符串“CUSTOMSTR$”执行过程的例子。
[]起来的内容所示。vs左边[]的是suff[i]的前2k个。右边的[]的内容是它需要新加入的2k个。(右边的[]的内容都可以在别人的左边的[]中找到)
第一次,我们只排序每个suff[i]的第1(20)个字符。
第二次,我们只排序每个suff[i]的前2(21)个字符。而且对于任意suff[i][1,2]它可以看成是suff{i}{1}和suff{i + 1}{1}组合的结果。
第三次,我们只排序每个suff[i]的前4(22)个字符。而且对于任意suff[i][1,2,3,4]它可以看成是suff{i}{1,2}和suff{i + 2}{1,2}组合的结果。
…
直到排序结果不在发生变化,或者2K >= N(s长度)
自此,倍增排序的方法我们已经得出了。我们把每个后缀先以开头字符为关键词排序,然后再增加X(上次排序字符的数量,即每次倍增)个字符继续排序,直到排序长度>= N(字符串长度)。它的时间复杂度是O(n log2n)
具体执行排序呢?
因为我们每次排序都可以看成两个关键词排序的结果,针对这种排序方法我们可以使用基数排序。
基数排序
基数排序又称桶子法。它是透过键值的部份资讯,将要排序的元素分配至某些“桶”中,藉以达到排序的作用。顺序是先按照最次的关键词排序,然后再前一个排序的基础上再按照倒数第二次要的关键词排序重复到以主关键词排序。
以数字排序为例就是先以个位的0~9排序后得到一个序列(同一个桶的以先进先出为序出桶)。然后再按照这个序列以十位的0到9排序。如此循环至最高位。
因为这里的倍增排序只有两个关键词,所以基数排序的效果是很快的。
基数排序是一个基于桶的排序,因此下面介绍一下桶再排序中的运用。
关于排序的桶
我们以一个数字数组a[n]为例。升序排序,且相同大小的排名不一样。其中a[n]的大小为N。然后我们再定义2个数组
cnt[n]:这个是桶,cnt[x] = y表示大小为x的数字,a数字中有y个
sa[n]:这个记录排名。sa[x] = y 排名为第X名数字再a中的下标是y。即a[y]是第X大的数字。
for(int i = 1 ; i <= N ;i++) cnt[a[i]]++; //统计每个桶中元素的个数
for(int i = 1 ; i <= N ;i++) cnt[i] += cnt[i - 1];
//统计这个桶及它之前的桶中一共有多少元素。
//也就是这个桶中元素的最大排名是多少。
for(int i = N ; i >= 1 ;i--) sa[cnt[an[i]]--] = i;
//cnt[an[i]]就是这个桶中元素最大(后)的排名,每取掉一个,最大排名要减少1
//=i 和a[i]对应,也就是当前元素的下标。
这里请允许我借用菜鸟教程里的动图来表示一下过程。(他们的动图真是好啊,太感谢了)
自此,这样我们就实现了怎么通过桶排序来继续排序。那怎么运用到我们的字符串倍增排序中呢?
关于桶排序在字符串倍增中的嵌入
我们之前说过,要对后缀字符串进行多次排序。那每次排序之后不都会留下排序序列嘛。因此,我们就可以对每次排序之后的序列使用桶排序。也就是把上面a[n]数组的内容的1,2,3,4,5…的x,理解成为后缀suff[i]在上一次排名中排名是x。这样,我们就能把桶排序运用到字符串排序中。
具体改执行的排序事情
我们使用桶排序的方法来对后缀串进行排序。然后根据基数排序的方法,我们进行两遍排序。两遍排序的内容分别是1.首先把后缀suff[i]根据其各自对应的suff[i + 2K]的顺序进行进行排序。2.然后在其基础上根据suff[i]进行排序。每次排序时,直到没有重复排名或者后缀的所有字符都被纳入排序之时。
倍增排序的代码实现
在写代码之前,还有几个需要注意的特殊情况
1.怎么处理第一次排序,即每个后缀都只采用首字母的牌?
这里我们可以一开始就把每个后缀排名位次认为是它们首字母的ASCII大小。虽然这可能会导致位次很大,但是它能反映正确的位次关系。(‘a’ = 97 < ‘b’ 98)
2.如果i + 2K超出了suff[i]的长度,怎么办呢?
那么我们就认为suff[i + 2K]是最小的,它的排名是0。因为它什么都没有嘛。确实比任何suff[]都要小。
vector<int> sa(50000,0),oldSa(50000,0);
//用来统计个数的桶
vector<int> cnt(30000,0);
//保存下标为i的suff的排名,rak的当前排名,oldrak是上一次的排名
vector<int> rak(50000,0) , oldRaK(50000,0);
vector<int> height(50000,0);
int n = s.size();
//方便处理
s = "$" + s;
int m = max(300,n + 1 );
//第一次排序处理
for (int i = 1; i <= n; ++i) ++cnt[rak[i] = s[i]];
//注意这里的i不是 i<=m。因为这里处理的是ascii码
for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; --i) sa[cnt[rak[i]]--] = i;
//k是2的幂次。
for(int k = 1 ; k < n ; k <<= 1)
{
//按照suff[i + k]的关键词的排序
cnt.assign(m + k,0); //将cnt清空,避免出错
//保存下sa,因为之后的操作会导致sa更改。
oldSa.assign(sa.begin(),sa.end());
for (int i = 1; i <= n; ++i) ++cnt[rak[oldSa[i] + k]];
for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
//因为oldSa[i]的i越大,它排名越后,进桶的时间越晚。这里让它早出桶,但是它排的位置是靠后的,所以还是以先进先出,后进后出为顺序的。
for (int i = n; i >= 1; --i) sa[cnt[rak[oldSa[i] + k]]--] = oldSa[i];
//按照suff[i]的关键词的排序
cnt.assign(m + k,0);
oldSa.assign(sa.begin(),sa.end());
for (int i = 1; i <= n; ++i) ++cnt[rak[oldSa[i]]];
for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; --i) sa[cnt[rak[oldSa[i]]]--] = oldSa[i];
oldRaK.assign(rak.begin(),rak.end());
for (int p = 0, i = 1; i <= n; ++i)
//对于本次排序两个关键词排名相同的用同一个排名
if(oldRaK[sa[i]] == oldRaK[sa[i - 1]] &&
oldRaK[sa[i] + k] == oldRaK[sa[i - 1] + k])
rak[sa[i]] = p;
else
rak[sa[i]] = ++p;
}
最长公共前缀(LCP)
总算把后缀数组怎么排序的事情给讲完了。累死我了。现在改讲一下后缀数组的灵魂,也就是后缀数组是怎么寻找最长子串的。也就是排序后的最长公共前缀。
前面已经说过,排序之后的后缀数组可以比较容易查找每个后缀子串的公共前缀,但是如果每个后缀都需要逐一的与其他后缀比也太费事费力了。有其他更好的方法噢~
总而言之,我们先定义一个概念LCP。 LCP(i,j)表示suff[i]和suff[j]的最长公共前缀。
关于LCP的一些性质
首先,显而易见的就是
LCP(i,j) = LCP(j,i)
LCP(i,i) = len(suff[i]) = n - i + 1;
这两个也没什么大用。略过~
- LCP(i,k) = min(LCP(i,j),LCP(j,k)) 对于任意i<=k<=j
证明如下
设p = min(LCP(i,j),LCP(j,k)),则LCP(i,j) >= p && LCP(j,k) >= p
因为suff[i]和suff[j]至少有p个相同前缀,suff[j]又和suff[k]至少有p个相同前缀
所以suff[i]和suff[k]也一定至少有p个相同前缀
这里我们假设LCP(i,k) = q > p <=> q >= p + 1
但是q= min(LCP(i,j),LCP(j,k))。也就是说suff[i][q+1] != suff[j][q+1] 或者 suff[j][q + 1]!= suff[k][q+1]
需要注意i<=j<=k所以也有i + p +1 <= j + p +1 <= k + p + 1。
但是LCP(i,k) = q >= p + 1 表示这里至少存在suff[i][p+1] = suff[k][p +1]
所以应该有i + p + 1 = k + p + 1那样的话i + p +1 = j + p +1 = k + p + 1 即存在suff[j][q + 1]= suff[k][q+1]与上述矛盾。
可以得出LCP(i,k) = min(LCP(i,j),LCP(j,k)) 对于任意i<=k<=j
height数组
这里我们定义height[]数组。height[i] = LCP(sa[i],sa[i - 1])即第i名后缀与他前一名的最长公共前缀。(注:height[1]视为0,height[sa[1]] = 1)。可见这里height数组的内容就是我们需要找的公共子串的数量和长度。
如下,就是在字符串“ababca”中height[]数组的值(没错,这个图又来了 )
有了这个数组,以及上述的LCP(i,k) = min(LCP(i,j),LCP(j,k)),我们可以得出LCP(i,j) = min(LCP(i,i + 1),LCP(i + 1,i + 2)…LCP(k-1,k)) = min(hieht[i+1],hieht[i+2],hieht[i+3]…hieht[j])
即LCP(i,j) = min(hieht[i+1],hieht[i+2],hieht[i+3]…hieht[j])
height数组的一个引理
height[rak[i]] >= height[rak[i - 1] ] -1
当height[rak[i - 1] ] <= 1 时显然成立。(原式变为height[rak[i]] >=0)
当height[rak[i - 1] ] > 1
有suff(sa[rak[i - 1] - 1]) < suff[sa[i- 1]]
去掉首字母LCP(sa[rak[i - 1] - 1] +1,sa[i]) = height[rak[i - 1] ] -1
suff(sa[rak[i - 1] ] +1) < suff(sa[i])
因为height[l]是suff[i]与排名紧挨着自己的后缀lcp
有suff[sa[rak[i - 1] + 1] <= suff[sa[rak[i] - 1] < suf[sa[i]]
求出LCP最长公共前缀Height[]数组
根据前面的几个定理,我们以及可以写出height[]数组计算的方法了
for(int i = 1 ;i <= N ;i++)
{
//height[rak[i]] >= height[rak[i - 1] ] -1的运用
int k = max(0,height[rak[i - 1]] - 1);
//s[sa[rak[i] - 1]]是排名在s[i]前一位后缀的首字符
while(s[i + k] == s[ sa[rak[i] - 1] + k]) k++;
height(rak[i]) = k;
}
终于结束了
总算把所有写完了,我今天画了一天的时间来看这个后缀数组了。谔谔,一天什么事情都没干了。啊啊,不过这样全部整理了一边之后,我也是搞清楚了不少。(不过关于height[rak[i]] >= height[rak[i - 1] ] -1的证明我自己也有点没搞,所以其实是抄的。等我自己搞明白之后我会回来修改的。)
就是这样,下面贴一下完整的代码。
噢对了,还有关于倍增排序的改良,但是现在我好累,不想学了不想看了。所以就先这样把,等之后有空看了在回来补。
完整代码
vector<int> sa(50000,0),oldSa(50000,0);
//用来统计个数的桶
vector<int> cnt(30000,0);
//保存下标为i的suff的排名,rak的当前排名,oldrak是上一次的排名
vector<int> rak(50000,0) , oldRaK(50000,0);
vector<int> height(50000,0);
int n = s.size();
//方便处理
s = "$" + s;
int m = max(300,n + 1 );
//第一次排序处理
for (int i = 1; i <= n; ++i) ++cnt[rak[i] = s[i]];
//注意这里的i不是 i<=m。因为这里处理的是ascii码
for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; --i) sa[cnt[rak[i]]--] = i;
//k是2的幂次。
for(int k = 1 ; k < n ; k <<= 1)
{
//按照suff[i + k]的关键词的排序
cnt.assign(m + k,0); //将cnt清空,避免出错
//保存下sa,因为之后的操作会导致sa更改。
oldSa.assign(sa.begin(),sa.end());
for (int i = 1; i <= n; ++i) ++cnt[rak[oldSa[i] + k]];
for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
//因为oldSa[i]的i越大,它排名越后,进桶的时间越晚。这里让它早出桶,但是它排的位置是靠后的,所以还是以先进先出,后进后出为顺序的。
for (int i = n; i >= 1; --i) sa[cnt[rak[oldSa[i] + k]]--] = oldSa[i];
//按照suff[i]的关键词的排序
cnt.assign(m + k,0);
oldSa.assign(sa.begin(),sa.end());
for (int i = 1; i <= n; ++i) ++cnt[rak[oldSa[i]]];
for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; --i) sa[cnt[rak[oldSa[i]]]--] = oldSa[i];
oldRaK.assign(rak.begin(),rak.end());
for (int p = 0, i = 1; i <= n; ++i)
//对于本次排序两个关键词排名相同的用同一个排名
if(oldRaK[sa[i]] == oldRaK[sa[i - 1]] &&
oldRaK[sa[i] + k] == oldRaK[sa[i - 1] + k])
rak[sa[i]] = p;
else
rak[sa[i]] = ++p;
}
for(int i = 1 ;i <= N ;i++)
{
//height[rak[i]] >= height[rak[i - 1] ] -1的运用
int k = max(0,height[rak[i - 1]] - 1);
//s[sa[rak[i] - 1]]是排名在s[i]前一位后缀的首字符
while(s[i + k] == s[ sa[rak[i] - 1] + k]) k++;
height(rak[i]) = k;
}
参考资料
- 后缀数组(倍增)算法原理及应用
- 后缀数组简介
- 0229省选课【后缀数组】
- 《算法竞赛入门经典 训练指南》,刘汝佳,陈锋著,清华大学出版社
- 后缀数组 最详细讲解
- [2004]后缀数组 by. 徐智磊
- 【ACM】【数据结构】【字符串】【湘潭大学】后缀数组
- 基数排序|菜鸟教程
最后的最后插一张二次元图片增加点二次元浓度