后缀数组SA入门(史上最晦涩难懂的讲解)

参考资料:victorique的博客(有一点锅无伤大雅,记得看评论区),wzz 课件(快去ftp%%%),oiwiki以及某个人的帮助(万分感谢!)

首先还是要说一句:我不知道为什么我这么菜让我讲这么大神的知识点,我理解不深刻,你们可以随时Ha(n)ck然后我可能就fix不了了你知道吧

(好吧我大概又理解了因为数学是NC讲的数据结构是skyh讲的我能讲的可能也就是这种东西了)

希望还会的大神帮我解场。。。(某bx和某牛都A穿了)

顺便「无图预警」(懒得画又懒得粘)

当时wzz讲课的时候大概也都多多少少听了点,也有一半左右的人做过题。(虽说忘的差不多了)

至少定义都还记得是啥吧?不记得就再来一遍:

suffix(x)表示从x开始的字符串后缀

rk[x]就是suffix(x)在字符串所有后缀中的字典序排名

所谓的后缀数组sa[i]其实就是Suffix Array,表示排名为i的后缀它的起始位置是哪里。

其实sa就是rk的逆数组,即rk[sa[i]]=i,求出其一就可以得到另一个。

别的忘了可以,数组定义一定要记住,不然绝对下面全程跟不上。。。(废话,讲的就是这个)

想要求出这个数组,最暴力的做法就是sort(string),字符串比较的复杂度是O(n)的所以总复杂度是O(n2logn)

这肯定接受不了。然后就需要wzz给我们讲的那个倍增算法将复杂度优化到O(nlogn)

存在线性做法,但是极大多数题目肯定不会单纯问sa其余线性求解而故意卡倍增,往往是要结合点东西的,所以O(nlogn)也就够用了。

考虑一下,暴力求解的主要复杂度瓶颈其实是在于字符串的字典序比较,然而既然它们都是同一个串的后缀,一定有什么可以利用的地方。

我们肯定要上来比较一下每个后继的第一个字符,然后有一个大致的排名,有很多串依旧是并列的。

然后对于这些并列的玩意我们要比较第二个字符了,而长度为1的所有比较结果我们其实已经有了所以并不需要暴扫一遍去求解。

这么说着有点蠢,考虑更大的数据。

对于一个长串,我们已经比较了它的所有后缀的前65536个字符得到了一个排名,接下来要比较每个后缀的131072个字符进一步细化排名。

假如有两个后缀suffix(i)suffix(j),它们的前65536个字符都相同,我们尝试通过第65537到第131072个字符来把它们区分开来。

那么其实我们真正要比较的是suffix(i+65536)suffix(j+65536)这两个后缀的前65536个字符.

因为你已经知道suffix(i)suffix(j)的前65536个字符相同了,差别只能在后边。

然而其实在上一次倍增的时候你已经有suffix(i+65536)suffix(j+65536)的前65536个字符的排名了.

你现在可以直接利用上一轮的结果来O(1)进行比较而不需要O(65536)暴扫。

倍增就是这样利用了上一轮的结果来优化这一轮的比较。

如果真的能做到O(1)比较,那么这样倍增至多log层,然后普通排序是O(nlogn)的,所以总复杂度就是O(nlog2n)

但是这个东西其实值域很小不超过n,我们用类似于桶排序的方法看看能不能把排序优化到O(n),这就是基数排序的事了

其实主要不好懂的就在这个基数排序。。。以前好像没见过。

但是桶排序你还是会的吧?不会桶排序也没关系,你好歹知道桶是啥吧?(不知道桶是啥也没关系,但是你为什么没联赛退役啊)

因为这里的基数排序只有2维(就是上面所说的suffix(i)suffix(i+65536)的前65536个字符),所以其实可以当成桶排。

(基数排序就是多次桶排,从高位到低位多次逐渐锁定排名,看完代码实现就明白它在干啥了)

但是这东西还是有点抽象不好口胡,于是向wzz学习,直接丢代码走人。

复制代码
 1 void Suffix_Array(char*a,int n,int m=27){
 2     //变量含义:m是字符集大小,n是字符串长度,c是一个桶数组,a[i]是字符串(下标从1开始)
 3     //rk[i]就是suffix(i)的字典序排名,sa[i]就是要求的排名为i的后缀的起始位置,即rk[sa[i]]=i
 4     for(int i=1;i<=m;++i) c[i]=0;
 5     for(int i=1;i<=n;++i) x[i]=a[i]-'a'+2;
 6     //x[i]用于存储suffix(i)的第一关键字(的排名),在刚开始长度为1的时候就是a[i]
 7     //也就是,第i个后缀的第一关键字在所有后缀里的排名,记住定义!!!
 8     //如果出于某些原因想加入一个空字符(字典序最小),那么在全局把它设置为'a'-1就好了,这也是为什么m=27而非26
 9     for(int i=1;i<=n;++i) c[x[i]]++;
10     //看起来十分草率的一个扔进桶里的过程
11     for(int i=1;i<=m;++i) c[i]+=c[i-1];
12     //把桶做一遍前缀和。这样操作后就有如果是s[i]=x,则c[x-1]<rk[i]<=c[x]
13     //也就是对于一个c[x-1]<p<=c[x],suffix(sa[p])的第一关键字为x
14     for(int i=1;i<=n;++i) sa[c[x[i]]--]=i;
15     //这句话就是对第12~13行的那句话的一种实现方式
16     for(int len=1;len<=n;len<<=1){
17         //len表示的是第一关键字与第二关键字分别的长度,所以len<<1就是本轮真正所要比较的长度
18         //定义y数组,表示第二关键字排名为i的后缀是suffix(y[i])
19         int num=0;
20         //num就是用来存储已经排好序的第二关键字的数量
21         for(int i=n-len+1;i<=n;++i) y[++num]=i;
22         //suffix(i)的第二关键字现在是a[i+len...i+2len-1]。而对于suffix(n-len+1)...suffix(n)已经没有第二关键字了。
23         //而空字符的字典序最小,所以它们第二关键字的排名最靠前
24         for(int i=1;i<=n;++i) if(sa[i]>len) y[++num]=sa[i]-len;
25         //对于所有p>len,a[p...p+len-1]这一截都会成为suffix(p-len)的第二关键字
26         //而你枚举的是sa[i],i从小到大也就是代表你从小到大枚举的所有可能出现的第二关键字
27         //现在y数组里面已经按从小到大的顺序存储好了所有可能出现的第二关键字,包括空的
28         //注意y数组的含义,和x是不一样的,x是位置i的第一关键字的排名,y是排名为i的第二关键字的位置
29         for(int i=1;i<=m;++i) c[i]=0;
30         for(int i=1;i<=n;++i) c[x[i]]++;
31         for(int i=1;i<=m;++i) c[i]+=c[i-1];
32         //都与倍增外面的部分同理。还是按照第一关键字扔桶。下面要基数排序加入第二关键字了。
33         for(int i=n;i;--i) sa[c[x[y[i]]]--]=y[i];
34         //这句话是最麻烦的一句,x[y[i]]表示第二关键字排名为i的后缀的第一关键字
35         //注意这里的循环需要倒序,因为你是从大到小枚举第二关键字,而由于你的c[]--,所以得到的排名也是从大到小
36         //这里和第12~13行的注释是同理的,注意每个按关键字排序后的后缀所在的排名区间
37         //所以也可以改写成for(int i=1;i<=n;++i) sa[++c[x[y[i]]-1]]=y[i];这样全文就都是正序枚举了不易混,但是需要清空c[0]
38         //我倾向于这个写法,实测可以AC。但是这里还是抄的wzz的原版板子,你们自行选择
39         for(int i=1;i<=n;++i) y[i]=x[i],x[i]=0;
40         //数组的回收利用,只不过是换个数组存了一下第一关键字,清空一个数组备用
41         x[sa[1]]=m=1;
42         //排名最小的串,它在2len长度比较下也最小,所以在下次len<<=1后,它对应的len第一关键字就是最小的第一关键字
43         //这里把m置为1.也就是现在要重新规定字符集大小了,这个1是是x[sa[1]],m以后表示的就是目前已知的本质不同后缀数
44         for(int i=2;i<=n;++i) x[sa[i]]=(y[sa[i]]==y[sa[i-1]]&y[sa[i]+len]==y[sa[i-1]+len])?m:++m;
45         //这时候sa在2len长度的比较下已经排好序了,可以直接按照排名枚举。只要你和你前一名的第一/二关键字不完全相同
46         //那么你们就是不同的,所以你就是一个新串,加入字符集m++,然后重新给它在下一轮的第一关键字编号为m
47         if(m==n)break;
48         //如果你的字符集个数等于后缀个数,那么就是所有的后缀都被两两区分开了,可以提前跳出了
49     }
50     for(int i=1;i<=n;++i)rk[sa[i]]=i;
51     //rk是suffix(i)的排名,从含义上就知道它是sa的逆数组
52 }
复制代码

顺带附luogu模板题链接,讲完之后可以自行试验。

 

 

 

然后假装我们已经会求sark数组了好吧?

板子反正就放在博客里,几十行的注释等会也可以慢慢去看。先假装你会求出它们了,然后才好进行下一步。

理论上现在该讲height数组了,但是我个人认为直接换知识点最后听下去就啥也不会了。

于是先上几个不用height直接sa/rk爆干的例题,让你们切一切。(反正我是没切掉)

不用考虑代码实现,毕竟口胡一遍基本不可能直接记住板子。

对于接下来的例题,你可以认为rksa是题目的读入。只要想明白怎么用就好不用考虑代码。

可能有人没跟上上面讲代码的部分,但是这个可以补。不要连例题也丢了。

 

 

 

Problem 1:[JSOI2007]字符加密

Description

喜欢钻研问题的JS 同学,最近又迷上了对加密方法的思考。一天,他突然想出了一种他认为是终极的加密办法:

把需要加密的信息排成一圈,显然,它们有很多种不同的读法。

例如‘JSOI07’,可以读作: JSOI07 SOI07J OI07JS I07JSO 07JSOI 7JSOI0。把它们按照字符串的大小排序:

07JSOI

7JSOI0

I07JSO

JSOI07

OI07JS

SOI07J

读出最后一列字符:I0O7SJ,就是加密后的字符串(其实这个加密手段实在很容易破解,鉴于这是突然想出来的,那就^^)。

但是,如果想加密的字符串实在太长,你能写一个程序完成这个任务吗?

n105

难度不是很高,考虑怎么利用你求出来的那两个数组。

复制代码
伟大的哲人Paris曾经说过:断环成链是常识。
然后就断开,把字符串拷贝一份直接接在后面,然后跑sa。
得到rk了,然后把所有以区间[1,n]以内为起始的长度为n的子串的最后一个字母按照rk的大小关系顺序输出。
AC。
我猜mikufun会说这道题是傻逼题。
Solution
复制代码

 

 

 

Problem2:谁是垃圾话之王

Description

为了弄这个知识点而编的题,难不成还要让我写一个题目背景?

那就写吧。

kx考了HEOIrank2之后非常kx,然后继续保持了说话极有素质的习惯,开始大声BB,说出了一句长度为n的字符串。

然后正在扩脸的skyh就非常烦,觉得他说的是垃圾话.

为了有充足的理由来证明kx没素质,他想知道某些敏感词汇(例如face)在kx的话里出现了多少次。

然而他在正式开始批判kx之前并不想让kx知道他在进行这个统计,否则他会被kx暴打。

所以他加密了他的询问,只有在你正确回答出他的上一个询问之后,他才会继续问下去(也就是强制在线),否则他会说你是垃圾而给你0分。

因为kx说的垃圾话不是很多,也就几百万个字,所以保证n106

因为skyh脑子里的词汇太少(不像我的blog标题),所以保证所有询问的字符串长度之和106

如果你不会做这道题,那么请把skyh暴打一顿。所以为了skyh的身心健康,请大家切掉这道题。

一句话题意:在线查询一个特定串中某子串的出现次数。

最优做法肯定不是SA(被SAM暴切了),但是SA可做,大家尽量往这个方向上想。

复制代码
其实这个东西用SA做真的挺蠢的,但是只是为了体现知识点。
子串都是某个后缀的前缀。
你已经得到了所有后缀的排名,在所有后缀上二分,找到模式串是后缀的前缀的rk区间。
检查大小关系的方法就是暴力匹配。
总复杂度是 n log n + skyh log n的
Solution
复制代码

 

 

 

Problem3:「USACO07DEC」Best Cow Line

Description

USACO的英文题,不给你们翻译背景了。

给定一个长度为n的字符串,你每次可以从首或尾取出一个字符放到新的字符串末尾,要求新字符串的字典序尽量小。

n106。不要想简单,怎么处理两端字符都相同的情况?例如XXXXXCBXXXXX

这个稍微难一些。但就是去优化那个暴力的方法。想一会?

其实是个大套路,cbxxx早就会了。

复制代码
其实就是在字符相同的时候比较左端点的后缀与右端点的前缀哪个小就选哪个。
暴力比较是O(n)的。
因为串是确定的,所以你可以预处理出它们的排名,就可以O(1)比较了。
我上来的思路是把字符串正反都SA一下就好了。
但是怎么把前缀的排名和后缀的排名放在一起。
于是正解就是把正反字符串接起来,中间加上一个空字符,然后跑后缀排名就可以得到相对rank了。
Solution
复制代码

 

 

 

三道例题应该够了。所以你已经熟练掌握SA了。那就没我的事了再见

接下来就是我咕掉的height

定义lcp(string1,string2)的含义为两个字符串的最长公共前缀。(下面有些地方省略了suffix(x)而直接写x,不然公式太长)

(如果你要从英文含义来理解,lcp就是longest common prefix

注意定义的lcp(a,b)lcp(suffix(sa[a]),suffix(sa[b]))而不是lcp(suffix(a),suffix(b))

那么定义height(i)=lcp(suffix(sa[i1]),suffix(sa[i]))。就是排名相邻的两个串到底有多少位相同。

再进一步的说,其实height[i]就表示排名为i的串和排名为i1的串的最长公共前缀。

怎么求啊?然后就需要大量的证明了。。。

其实SA的过程中,你已经把所有的后缀都排序好了,所以你可以想象一下。。。一本英文词典。。。(字典序。。。)

前几个字符相同的当然都在词典上聚在一起,和某个串前缀匹配最多的串应该就在这个串的前后。。。

多想想词典,也许有利于你对下面这些东西的理解。

首先比较显然的是(感性理解一下,大概就是所谓的短板理论):

LCPLemma:lcp(i,k)=min(lcp(i,j),lcp(j,k))。对于任意ijk

(Lemma是引理的意思)

非要解释一下含义的话,那就是对于排名递增的三个后缀,两端的后缀的lcp就是两端后缀分别与中间的那个后缀的lcp的较小的那个。

还是简要证明一下吧:设min(lcp(i,j),lcp(j,k))=p,A=suffix(i),B=suffix(j),C=suffix(k)

则若lcp(i,j)plcp(j,k)=p,则分别有A[p+1]=B[p+1],B[p+1]C[p+1],得到A[p+1]C[p+1],所以lcp(i,k)=p

反过来同理。

还有一种情况是lcp(i,j)=lcp(j,k)=p,这样的话得到A[p+1]B[p+1],B[p+1]C[p+1],这样的话如果A[p+1]C[p+1]就和上面一样。

否则,A[p+1]=C[p+1]B[p+1],这样Brk不会夹在A,C之间,与题设矛盾,不存在这种情况。

得证。

进而得到:

LCP Theoremlcp(i,k)=min(lcp(j,j1))对于任意1i<jkn

(Theorem是定理的意思)

比较好说的是height[1]=0。然后就有height[i]=lcp(suffix(sa[i]),suffix(sa[i1]))

接下来就是最烦人而重要的结论:

height[rank[i]]height[rank[i1]]1

设字典序排名比suffix(i1)1的那个字符串是suffix(x)lcp(suffix(x),suffix(i1))=height[rk[i1]]

如果suffix(x)suffix(i1)首字母都不同,那么height[rk[i1]]=0height[rk[i]]0,所以一定满足上面的结论。

如果相同,那就考虑,suffix(x)去掉首字母会得到suffix(x+1)suffix(i1)去掉首字母得到suffix(i)

去掉首字母大小关系不变,所以因为rk[x]<rk[i1],就有rk[x+1]<rk[i]

这样的话,x+1ilcp也只是相较于xi1lcp去掉了首字母。

lcp(x+1,i)=lcp(x,i1)1=height[rk[i1]]1

我们已经知道对于任何rank[j]<rank[i]height[rk[i]]=lcp(i,sa[rk[i]1])lcp(i,j)

所以大小关系传递一下,就得到height[rk[i]]height[rk[i1]]1

所以我们每次都1再让它尽量去匹配,就可以线性推出height数组了。

(注意下面这个板子是wzz的,数组下标从0开始的,和上面我的板子不是很适配,稍改一下就能用了)

1 for(int i=0,k=0;i<n;height[rk[i++]]=k,k=k?k-1:0)
2     while(a[i+k]==a[sa[rk[i]-1]+k])++k;

 

 

 

然后好像是讲完了的样子。懒得上例题了我在咱OJ上做的题那么少啥也不会啊。。。要不让NC来讲?

然后还是为了防止你们说我给你们颓题解(因为我自己也没做我也还不想颓题解),我自己去找几道题吧。。。

真的是水题,都是可能用上的套路。随便切不要多想。

 

 

 

Problem4:谁是垃圾话之王2

Description

为什么没有这个知识点的例题啊!写题目背景真累。

书接上回,skyh总算凑齐了证据,于是去找kx理论。

但是kx表示不想跟弱智(skyh自己写在博客副标题的)讲道理,然后捶了skyh一下。

skyh觉得很委屈,他就kuku了。

然后kx抓住机会,说:“你才说垃圾话呢,你看你说的kukuku出现了两次,你重复罗嗦才是垃圾话”

skyh说:“不,我说的话重复的不多啊,你个gpgp啊”

然后他俩掐起来了,你在看戏的同时,裁决一下skyh说的垃圾话多不多。

给定skyh说的一长段话,进行q次裁决,每次摘出两段只言片语(文字狱?其实就是子串),问它们从头开始匹配了多少个字。

n,q105

一句话题意:给定一个字符串,每次询问求两个子串的lcp

wzz讲过的。我稍微扩句两下就行了。

根据LCP Lemma和LCP theorem可以得到推论
lcp(sa[i],sa[j])=min(lcp(sa[i],sa[i+1]),lcp(sa[i+1],sa[i+2]),...)=min{height[i+1..j]}。
然后就只剩下$RMQ$了。随便弄个数据结构就好了。
Solution

 

 

 

Problem5:谁是垃圾话之王3

Descroption:

啊。。。还是没有例题。口胡挺累的。况且我文采不足,你们将就看看吧。

话说这两个人掐起来之后没完没了,到了后来画风越来越诡异。

“我说的话字典序都比你大,怎么能是垃圾话呢?”

“你脸大就脸大,你好好看看谁的字典序大!”

“嘿,你看什么呢?还看戏呢?你如果不把我俩说的话比出字典序大小,nmsl。”

然后你就背锅了。给定他俩说的一大段话,每次摘出两段,比较字典序大小。

n,q105

一句话题意:比较两子串字典序大小。

这个wzz都懒得讲了。。。我估计你们想到正解比看完题目背景用的时间还短。

复制代码
你都会lcp了这还有什么好说的?
设要求解的子串为A[a..b],B[c..d]
那么你先算出lcp(a,c),如果发现它们匹配的长度大于A或B的长度,那么就表示一个是另一个的子串,直接根据长度判断。
否则就证明并没有子串关系,直接根据rk数组就可以比较了。
Solution
复制代码

 

 

 

Problem6:谁是垃圾话之王4

Description

抱歉哈还是没有找到这么裸的题。

这俩人吵得正欢,这时候LNC来了。看见kx想都不用想直接来一句:

HErank2,巨~~~~~”

然后skyh开心了,问LNC:“你说这个人,他说我说垃圾话,他说我罗嗦重复asas

LNC瞥了他一眼说:“这as就是重复。你除了那几句垃圾话还说过啥?”

然后skyh就自闭了,跑到一边去数自己说过哪几种话了。

为了体恤一下skyh,对于他说的一大段话,任意长度选其中一段,只要有一个字不同就算两种。

问题就是,他一共说过几种话。他这么可怜你一定会告诉他的对吧。

在你知道n106之前,你一定会的。

一句话题意:本质不同的子串数。

这个也不难,稍微想一会。好像wzz也讲过。。?

复制代码
算不同的麻烦,但是算完全相同的稍微简单一些。
先把所有的n(n-1)/2个子串都算上,再减去重复的。
重复的有多少?考虑到子串是前缀的后缀。
既然后缀可以用SA排好序,那么就可以发现:
排名相邻的两个串,只有高于height的部分才会形成新的子串。
所以答案就是n(n-1)/2-sum(height)
Solution
复制代码

 

 

 

Problem7:「USACO06DEC」Milk Patterns 

Description

总算有题面了,但还是USACO的。这是wzz课件上的原题,直接粘翻译了。

John 的牛奶按质量可以被赋予一个 0106 之间的数。并且John记录了 N(1N20000) 天的牛奶质量值。

他想知道最长的出现了至少 K(2 ≤ K ≤ N) 次的模式的长度。

比如 1 2 3 2 3 2 3 12 3 2 3出现了两次。当 K=2 时,这个长度为 4

一句话题意:求出现次数超过K的最长子串。

这个东西在wzz课件里就有啊。

一个比较显然的结论是:当连续选择的串数一定时,排名连续的子串,它们的lcp最大
如果你选的不是连续的一段,那么根据problem4的结论,你取它们的lcp还是会对中间的所有height取min
所以,二分答案,问题转化为判断height数组上是否有长度为K的连续区间,它们height的min大于等于mid。
Solution

 

 

 

Problem8:谁是垃圾话之王5

Description

这次是懒得找题了。

skyh很不服气,跟那两个人说:

“我感觉你们对重复的定义不对,假如我说一句:‘我的脸是不是不是很大’这句话,你们就认为我说了两次‘是不是’,这样有重叠部分的不算啊,所以我说话不重复”

然后LNC就稍微动用了一下教GK做人的本事:“第一点,回答你的问题,你脸就是大。第二点,就算不算重叠部分,你也重复”

然后kx说:“这我瞎写个代码就能判了,不就是检查是否存在无重叠部分的相同子串么?O(nlogn)瞎写就完了。顺便算一下最长的长度也不是事。DeepinC出的真是大水题”

我:“???串帮了???我还是不出题了”

一句话题意:求完全不相交的相同子串的最长长度。

其实还不是特别难,但是wzz好像没有讲的样子。

复制代码
预处理一下对于每个height它对应的sa。
然后二分答案。
对于mid,我们提取出height数组里所有大于等于mid的位置。
这会构成若干个不连续的区间,对于每个区间,我们求出它们sa值的最大最小值,RMQ解决。
如果最大最小值的差值大于mid,就证明它们已经没有交集了,就是一个合法串,return true
Solution
复制代码

 

 

 

到这里为止,差不多能遇到的裸题型已经说得差不多了。

再之就是要与线段树,并查集,单调队列之类的数据结构结合了。(上面你已经看到它多次和二分答案结合了)

然而这部分的难度比较高(我不会),而且好多都是咱们OJ里的题目了(我也没做),我就不爆题解了。

让我讲数据结构是不可能哒!

(其实题解都在wzz的课件里面,想颓可以去拿啊,但是大部分套路我在上面都说完了,如果不自己想一些的话就没什么意义了吧)

 

 

 

附:谁是垃圾话之王大结局

过了两天,这几个去WC的人回来了。

他们发现了这篇博客,一人拿着一个一本约跟DeepinC说:

“你个垃圾WC都去不了还在这里yy我们,出了一大堆大水题凑字数,你才是垃圾话之王”

全剧终。

posted @   DeepinC  阅读(1505)  评论(29编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示