后缀数组(倍增)

  • 后缀数组
    • 后缀数组能干什么
    • 一些基本概念
  • 那么到底怎么排序呢?
    • 倍增排序
    • 具体执行排序呢?
      • 基数排序
        • 关于排序的桶
        • 关于桶排序在字符串倍增中的嵌入
      • 具体改执行的排序事情
    • 倍增排序的代码实现
  • 最长公共前缀(LCP)
    • 关于LCP的一些性质
    • height数组
    • 求出LCP最长公共前缀Height[]数组
  • 终于结束了
  • 完整代码
  • 参考资料

后缀数组

在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]数组和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;elserak[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;elserak[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】【数据结构】【字符串】【湘潭大学】后缀数组
  • 基数排序|菜鸟教程

最后的最后插一张二次元图片增加点二次元浓度

后缀数组(倍增)学习记录,我尽可能详细的讲了相关推荐

  1. 【bzoj3879】SvT 后缀数组+倍增RMQ+单调栈

    题目描述 (我并不想告诉你题目名字是什么鬼) 有一个长度为n的仅包含小写字母的字符串S,下标范围为[1,n]. 现在有若干组询问,对于每一个询问,我们给出若干个后缀(以其在S中出现的起始位置来表示), ...

  2. 后缀数组的学习(三):SA数组实现代码分析

    在前面的博文里面分析了SA数组和rank数组的实现过程,实际上也就是倍增算法的思想分析!虽然思想上面懂了,但是代码实现还是很难理解的!因为代码里面做了太多的优化. 整个代码的实现可以分成两部分:1.对 ...

  3. 后缀数组的学习(一):学习的预备知识

    去年的时候弄了一阵子的后缀数组,当时一直都没有弄懂!今年再看后缀数组,似懂非懂!其实还是没懂!但比去年的完全不懂,还是有进步的! 下来把思路理了理,要看明白后缀数组,是需要一些知识储备的! 1.基数排 ...

  4. 后缀数组 倍增法详解

    算法思路 主要是基于基数排序,如果基数排序没弄懂代码就会很难理解: 首先从k=0开始,从后缀数组里面选取步长为2^k的后缀数组的前子串 然后进行基数排序 如果排序后所有的名次数组的值都不相同,那么排序 ...

  5. 后缀数组(学习心得)

    后缀数组 后缀数组是一种处理字符串的利器,很多字符串的问题都可以通过后缀数组来实现. 后缀数组说简单一点就是对一个字符串的所有后缀子串进行排序. 我来举个例子,比如字符串banana 刚开始的时候它的 ...

  6. UOJ.35.[模板]后缀排序(后缀数组 倍增)

    题目链接 论找到一个好的教程的正确性.. 后缀数组 下标从1编号: //299ms 2560kb #include <cstdio> #include <cstring> #i ...

  7. POJ.2774.Long Long Message/SPOJ.1811.LCS(后缀数组 倍增)

    题目链接 POJ2774 SPOJ1811 LCS - Longest Common Substring 比后缀自动机慢好多(废话→_→). \(Description\) 求两个字符串最长公共子串 ...

  8. 学习记录-视觉SLAM十四讲第2版(二)

    文章目录 前言 一.问题是什么? 二.工具是什么? 1.分类 2.三种相机 (1)单目相机 (2)双目相机 (3)深度相机 三.流程是什么? 1.总的流程框架 2.每个步骤说明 3.补充 四.尺度不确 ...

  9. 【C语言进阶深度学习记录】二十六 C语言中的字符串与字符数组的详细分析

    之前有一篇文章是学习了字符和字符串的,可以与之结合学习:[C语言进阶深度学习记录]十二 C语言中的:字符和字符串 文章目录 1 字符串的概念 1.1 字符串与字符数组 1.2 字符数组与字符串代码分析 ...

最新文章

  1. WP7基础---补充
  2. 在JavaScript中使用正好两位小数格式化数字
  3. linux OOM-killer机制(杀掉进程,释放内存)
  4. 控制台 - 网络管理之华为交换机 S系列端口限速
  5. 你可能会用到的 Mock 小技巧
  6. pynput模块—键盘鼠标操作和监听
  7. jquery 树形框 横_利用jQuery设计横/纵向菜单
  8. 基于python的分布式扫描器_一种基于python的大数据分布式任务处理装置的制作方法...
  9. import package java_java初学者,如何理解package和import?
  10. Java jar 包免费下载(全)
  11. web渗透学习目录-新手打开思路
  12. [源码和文档分享]基于JAVA实现的图形化页面置换算法
  13. 微信小程序分享小程序码的生成,多参数以及参数的获取
  14. Android实现隐藏手机底部虚拟按键
  15. 简述Android操作系统和IOS系统的区别;
  16. vivo Y85的usb调试模式在哪里,打开vivo Y85usb调试模式的方法
  17. 管理模型 - RACI模型
  18. E - EXCEL排序
  19. 5G WiFi的信号难题:穿墙性能太差
  20. 一图搞懂扫码登录的技术原理

热门文章

  1. 75佳精美的 CSS 网页设计作品欣赏(系列一)
  2. Android删除系统的WIFI功能
  3. 视频教程-Oracle数据库开发技巧与经典案例讲解一-Oracle
  4. 张艺谋眼中的2020:科技的人间烟火味
  5. 第七期 | 网约车司机的“捞偏门”手段:作弊抢单、空跑刷单
  6. plsql快速导入sql文件
  7. Python中zip函数的用法
  8. TCP套接口的sk_backlog接收队列
  9. facenet 搭建人脸识别库
  10. Linux的软件包封装格式有,RED HAT LINUX所提供的安装软件包,默认的打包格式为( )。...