【2017.10.26-】后缀数组学习笔记
后缀数组是一个比较强大的处理字符串的算法,是有关字符串的基础算法,所以必须掌握。
学会后缀自动机(SAM)就不用学后缀数组(SA)了?不,虽然SAM看起来更为强大和全面,但是有些SAM解决不了的问题能被SA解决,只掌握SAM是远远不够的。
我刚刚学习的时候是这样理解的
1、构造后缀数组SA
先定义一些变量的含义
str :需要处理的字符串(长度为Len)
suffix[i] :str下标为i ~ Len的连续子串(即后缀) (这个算法在实际设计中不需要写的,这里讲解时用来表示)
rank[i] : ruffix[i]在所有后缀中的排名
sa[i] : 满足rank[sa[1]] < rank[sa[2]] …… < rank[sa[Len]]的一个记录排名的数组(最前面是连续一段排名第一的子串的后缀位置,接着是连续一段排名第二的子串的后缀位置,然后以此类推,一直存到排名最后的子串的后缀位置,简单地说就是排行榜)
。。。你不知道后缀位置是啥?。。。比如说一段下标为i ~ Len的后缀串,它的后缀位置就是第一个字符的位置,也就是i。
前三个数组相信好理解,但sa数组如果没理解的话,可以先看看下面(我第一次也没理解sa数组是干吗的,书上没写)
形象一点,出个图(这个图好像在网上横飞)
后缀数组指的就是sa[i]。所有后缀串按照字符串顺序排序后,sa[i]表示排名第i的串的后缀位置(前面已经说了后缀位置是啥了)。
下面介绍算法中还会提到sa数组的。
有的人可能还不知道字符串序是啥。。好吧花几行说一下↓
字符串比大小的方法是:将这两个相同长度的字符串从左往右遍历相同位置的字符,找到的第一位不相同的字符,哪个串在这个位置的字符小(照正常思路就是按ASC码比),哪个串就排在前面。如果遍历到了其中一个串的末尾(即遍历完了长度短的串,且长度长的串在相应前缀段的字符和它都相同),则长度短的串排在前面。
比如abc ab两串。
因为abc有前缀ab,ab长度短,所以ab<abc。
再比如abc aabdef两串。
两串公共前缀为a,第二个字符'b'>'a',所以abc>aabdef。
注意:先遍历对照,对照到一个串的末尾时再判长度,两个步骤后别反了!
一群字符串排序就按这个顺序分前后。
回到正题。有了后缀数组,我们就可以实现一些很强大的功能(如不相同子串个数、连续重复子串等)。如何快速的到它,便成为了这个算法的关键。而sa和rank是互逆的,只要求出任意一个,另一个就可以O(Len)得到。
现在比较主流的算法有两种,倍增和DC3,在这里,就主要讲一下稍微慢一点,但比较好实现以及理解的倍增算法(虽说慢,但也是O(Len log Len))的。
倍增算法
倍增算法的主要思想:对于一个后缀suffix[i],如果想直接得到rank比较困难,但是我们可以对每个字符开始的长度为2k的字符串求出排名,k从1开始每次递增1倍(每递增1就成为一轮),当2k大于Len时,所得到的序列就是rank,而sa也就知道了。用O(logLen)枚举k。
这样做有什么好处呢?
设每一轮得到的序列为rank。有一个很美妙的性质就出现了!第k轮的rank可由第k - 1轮的rank快速得来!
为什么呢?为了方便描述,设Substr(i, len)为从第i个字符开始,长度为len的字符串我们可以把第k轮Substr(i, k)看成是一个由SubStr(i, k/2−1)和Substr(i + k/2, k - 1)拼起来的东西。学过倍增的人都知道,它类似rmq算法,这两个长度而2k−1的字符串是上一轮遇到过的!当然上一轮的rank也知道!那么吧每个这一轮的字符串都转化为这种形式,并且大家都知道字符串的比较是从左往右,左边和右边的大小我们可以用上一轮的rank表示,那么……这不就是一些两位数(也可以视为第一关键字和第二关键字)比较大小吗!再把这些两位数重新排名就是这一轮的rank。
我们用下面这张经典的图理解一下:
模拟一下过程,将Substr(i,2k)中的两半子串(SubStr(i, k)和Substr(i + k, k))中前半段字符串记为a,后半段字符串记为b。由图可知,那个x、y数组记录的就是每次a串和b串的排名(由上次循环得来)。如果你理解了前面我提到的字符串排序的话,你应该知道在两字符串长度相等的情况下,在两串的第一个对应位置字符不相同的地方,哪个串在相应位置的字符更小,哪个串的排名就更靠前,也就是说比较字符串优先看前面部分的大小关系。然而在每次循环中k都是相等的(最外面的大循环是枚举k的,每次将k倍增),我们处理的都是长度都为k的子串。因此在这里的两串排名就是先看两串的前半段子串a的排名大小,谁的a排名靠前,谁的总排名就靠前。如果a排名相同,就看两串的后半段子串b的大小,谁的b排名靠前,谁的总排名就靠前。
这里贴一下基数排序是啥:
把数字依次按照由低位到高位(个位到高位)依次排序,排序时只看当前位。对于每一位排序时,因为上一位已经是有序的,所以这一位相等或符合大小条件时就不用交换位置,如果不符合大小条件就交换,实现可以用”桶”来做。(具体可以上网查有关资料)。
大多数人应该知道这个原理,再看下面一段话(摘抄):
思考一个问题:既然我们可以从最低位到最高位进行如此的分配收集,那么是否可以由最高位到最低位依次操作呢? 答案是完全可以的。
基于两种不同的排序顺序,我们将基数排序分为LSD(Least significant digital)或MSD(Most significant digital),
LSD的排序方式由数值的最右边(低位)开始,而MSD则相反,由数值的最左边(高位)开始。
LSD的基数排序适用于位数少的数列,如果位数多的话,使用MSD的效率会比较好。
由此可见,基数排序从高位到低位做完全是可以的。这样一来,两位数排序也是优先比较高位大小,再比较低位大小了,跟前面所提到的通过比较a、b给字符串排名的方法一样。实际操作中,字母最多有26个,因此前面和后面的排名的最大值是26。我们可以把它看成27进制数啊!只是用数组存两位时依然把两位数原样存进去即可(存字母反而绕弯),然后依然优先比较前面的排名,然后再比后面的排名,这样做的效果是一样的。由此可见排名的大小不会因每位不是一位数而受影响。
综合上面的论述,我们可以知道——按照基数排序的性质,实际上对于本题中的每个子串,也可以通过先比较b的名次,再比较a的名次来确定总名次,比较次序是无关紧要的(当然你实在想先排a后排b也可以的)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | //后缀数组(suffix array)倍增构造。 #include<iostream> #include<cstdio> #include<cstring> #define maxn 100001 using namespace std; char s[maxn]; int sa[maxn],c[maxn],x[maxn*2],y[maxn*2],rank[maxn],tmp[maxn*2]; //x用于表示每轮排序后的名次(从1到n),y用于临时存放某轮中给第二关键字排序后的名次 int read(){ int x=0; bool f=1; char c= getchar (); for (;! isdigit (c);c= getchar ()) if (c== '-' ) f=0; for (; isdigit (c);c= getchar ()) x=x*10+c- '0' ; if (!f) return 0-x; return x; } void build_suffix( char * s){ //n表示字符串长度,m表示每次离散后的排名编号数(换句话说就是排名编号最大的是几) int i,p,n= strlen (s),m=0; memset (x,0, sizeof (x)); //基数排序,把新的二元组排序。 for (i=0;i<n;i++){ x[i]=s[i]- 'a' +1; if (!c[x[i]]) m++; //统计一开始有几个不同的字符,字符种数就是排名编号数 c[x[i]]++; //c数组存储每个字符出现的次数 } for (i=2;i<=m;i++) c[i]+=c[i-1]; for (i=n-1;i>=0;i--) sa[--c[x[i]]]=i; //读者应该手算一下上图中的对应例串中,这个数组的每位是几。文章的开头说了,这就是排行榜。 for ( int k=1;k<=n;k<<=1){ p=0; //基数排序二元组中的个位(即后半部分的rank值) for (i=n-k;i<n;i++) y[p++]=i; //长度越界,第二关键字为0,即排在最前面 for (i=0;i<n;i++) if (sa[i]>=k) y[p++]=sa[i]-k; //记录排名为i的后半段后缀所对应的前半段后缀的位置,位置由后半段起始位置-k即可得到 //基数排序二元组中的十位(即前半部分的rank值) memset (c,0, sizeof (c)); for (i=0;i<n;i++) c[x[y[i]]]++; for (i=2;i<=m;i++) c[i]+=c[i-1]; for (i=n-1;i>=0;i--) sa[--c[x[y[i]]]]=y[i]; //根据sa和y数组计算新的x数组 这里反过来做方便理解 //交换x、y数组 memcpy (tmp,x, sizeof (tmp)); memcpy (x,y, sizeof (x)); memcpy (y,tmp, sizeof (y)); p=1,x[sa[0]]=1; //排第0位的排名为1,p用于存放名次 for (i=1;i<n;i++) if (y[sa[i-1]]==y[sa[i]] && y[sa[i-1]+k]==y[sa[i]+k]) x[sa[i]]=p; //特别注意y的下标+k,越界了(排名)自然就是0,但千万注意要开两倍数组以防RE else x[sa[i]]=++p; if (p>=n) break ; //本轮更改后名次没改变,那么以后的倍增中名次都不会改变了。可以想一想为啥。。(想想x[i]是由什么组成的) m=p; //更新下次基数排序的最大值(当前总共有几种名次) } /*下面这段其实相当于上面最后输出的sa数组 for(i=0;i<n;i++) rank[x[i]]=i;//算出每个后缀的最终名次 for(i=1;i<=n;i++) printf("%d ",rank[i]); putchar('\n'); */ } int main(){ scanf ( "%s" ,s); build_suffix(s); return 0; } |
此贴未完,等考完noip2017后继续更
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 解答了困扰我五年的技术问题
· 为什么说在企业级应用开发中,后端往往是效率杀手?
· 用 C# 插值字符串处理器写一个 sscanf
· Java 中堆内存和栈内存上的数据分布和特点
· 开发中对象命名的一点思考
· DeepSeek 解答了困扰我五年的技术问题。时代确实变了!
· PPT革命!DeepSeek+Kimi=N小时工作5分钟完成?
· What?废柴, 还在本地部署DeepSeek吗?Are you kidding?
· DeepSeek企业级部署实战指南:从服务器选型到Dify私有化落地
· 程序员转型AI:行业分析