首页 > 其他分享 >超级简单的后缀数组(SA)笔记!!

超级简单的后缀数组(SA)笔记!!

时间:2024-01-14 22:11:42浏览次数:23  
标签:后缀 text Rank int 笔记 数组 SA

超级简单的后缀数组(SA)!!(未完)

前言

这里选择当一手标题党。

由于刚学完这个字符串算法,本人字符串算法又比较薄弱,好不容易这一次在晚修看各种资料看得七七八八,决定趁脑子清醒的时候记录下来。

免得自己不久后忘了后又要痛苦地再看各种资料。希望这篇博客能帮到你。

前置知识:RMQ 问题、基数排序、lcp 问题

使用指南:在抽象的时候,可以选择先不看证明;先记住结论,顺一遍后再返回来补证明也是可以的。如果有草稿本和笔,是最佳的选择。

到底什么是“后缀数组”?

后缀,顾名思义,即为 \([i,..,n]\) 的区间。以 \(S\) 的第 \(i\) 个字符开始的后缀定义为 \(\text{Suffix}(i)\),下文简写为 \(\text{suf}(i)\)。

如果这玩意儿(后缀)碰上了字符串,那么后缀也就成了特殊的字串。

后缀数组又是什么呢?

“后缀数组——处理字符串的有力工具。”——国集选手罗穗骞

说到底,这是一个处理字符串的基础算法。

学会 后缀自动机(SAM) 之后是不是就可以不学 后缀数组(SA) 了?!虽然 SAM 更为强大和全面,但是有些问题 SA 将体现出优势,只单方面地掌握 SAM 是远远不够的。

例子:求一个串后缀的 lcp 方面的应用,SA 可以直接用 RMQ,但是 SAM 还要求出 LCA。等等。

那现在让我们开始吧。

后缀数组与名次数组

此“后缀数组”非彼“后缀数组”,此二级标题中的“后缀数组”是一个实打实的数组。定义为:\(\text{SA}[i]\),存储的是 \(1,...,n\) 的一个排列。

他保证 \(\text{suf}(\text{SA}[i])<\text{suf}(\text{SA}[i+1])\),就是将 \(S\) 的后缀从小到大排序后把后缀的开头按序放入 \(\text{SA}\) 中。

名次数组 \(\text{Rank[i]}\) 存储的是 \(\text{suf}(i)\) 在所有后缀中排序后的名次。

一言了之,\(\text{SA}[i]\) 表示原串中从小到大排名为 \(i\) 的是哪个后缀;\(\text{Rank}[i]\) 表示原串中后缀 \(i\) 从小到大排名后的名次。

“简单的说,后缀数组是‘排第几的是谁?’,名次数组是‘你排第几?’。”——罗穗骞

显然,这俩玩意儿就是双映射关系,说白了就是运算互逆。

怎么求出这两个数组?——倍增算法

大家都可以想到的朴素算法或时间复杂度不优秀的算法这里就不再提及了。

由于过于复杂的 \(\text{DC3}\)算法 本人又不会,所以这里只提及 \(\text{O}(n \log n)\) 的倍增算法。

主要思路:对每个字符开始长度为 \(2^k\) 的子串进行排序,求出 \(\text{Rank}\) 值。

\(k\) 从 \(0\) 开始,每次不断 \(+1\) 直到长度超过了原串的长度(\(2^k>n\))。当达到限制后,每个字符开始的长度为 \(2^k\) 的子串便相当于所有的后缀。并且此时肯定已经得到了互不相等的 \(\text{Rank}\) 值。

此刻要解决的是如何排序。

如果采用 sort 快排,时间复杂度并没有利用字符串后缀的性质。

我们在处理长为 \(2^k\) 的问题时,肯定是已经知道 \(2^{k-1}\) 的 \(\text{Rank}\) 值。那我们就可以利用这些值,把一个子串 \(\text{Sub}(i,2^k)\) (从 \(i\) 开始长度为 \(2^k\) 的串),看作由两个关键字组成:\(\text{Sub}(i,2^{k-1})+\text{Sub}(i+2^{k-1},2^{k-1})\)。由此,可用基数排序。

举个例子:

以字符串“aabaaaab”为例,整个过程如图所示。其中 \(x,y\) 是表示长度为 \(2^k\) 的字符串的两个关键字 。

怎么样?这个图看不懂?静下心来,细细揣摩。这里要嚼透了(尤其是 \(\text{SA},\text{Rank}\) 数组的差别),才能理解后面更为抽象的代码实现。

求 SA 代码具体实现

以往很多算法都是贴个模板 code 就走人了,但今天不行。

放一下板子,你们就明白(为什么不行)了。

int sa[MAXN],rk[MAXN],oldrk[MAXN<<1],id[MAXN],key1[MAXN],cnt[MAXN];
bool cmp(int x,int y,int w)
{return oldrk[x]==oldrk[y]&&oldrk[x+w]==oldrk[y+w];}
void SA()
{
    for(int i=1;i<=n;i++) ++cnt[rk[i]=s[i]];
    for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
    for(int i=n;i>=1;i--) sa[cnt[rk[i]]--]=i;
    int p;
    for(int w=1;;w<<=1,m=p)
    {
        p=0;
        for(int i=n;i>=n-w+1;i--) id[++p]=i;
        for(int i=1;i<=n;i++)
            if(sa[i]>w)
                id[++p]=sa[i]-w;
        for(int i=1;i<=m;i++) cnt[i]=0;
        for(int i=1;i<=n;i++)
            ++cnt[key1[i]=rk[id[i]]];
        for(int i=1;i<=m;i++)
            cnt[i]+=cnt[i-1];
        for(int i=n;i>=1;i--)
            sa[cnt[key1[i]]--]=id[i];
        for(int i=1;i<=n;i++) oldrk[i]=rk[i];
        p=0;
        for(int i=1;i<=n;i++)
            rk[sa[i]]=cmp(sa[i],sa[i-1],w)?p:++p;
        if(p==n) break;
    }
}

如果你能直接看懂此代码是如何求出 \(\text{SA}\) 数组——

那么您可以直接离开此寒舍了。并接受在下一膜拜~ Orz

如果你留了下来,别着急,我来分步讲解。

对长度为 \(1\) 的子串排序

for(int i=1;i<=n;i++) ++cnt[rk[i]=s[i]];
for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
for(int i=n;i>=1;i--) sa[cnt[rk[i]]--]=i;

第 1 行,就是把长为 \(1\) 的子串(就是字符啦)全部扔进桶里。

第 2 行,把这个桶转换成前缀和,就可以知道字符 \(i\) 在原串是第几个了。

第 3 行,由于桶内的元素也要分个排名,所以这里的写法选择了倒序循环、cnt--

其实这 3 行就是在做基数排序

对长度为 \(w\) 的子串排序

先搞定第二关键字的排序

p=0;
for(int i=n;i>=n-w+1;i--) id[++p]=i;
for(int i=1;i<=n;i++) if(sa[i]>w) id[++p]=sa[i]-w;

第 1 行,先把那些以自己开头,长度不足 \(w\) 的 \(i\) 放在排序数组 \(id\) 的前端。这是因为他们根本没有第二关键字,可视为 \(0\),自然是最小的。这里倒不倒序是一样的,你也可以正序写。

第 2 行,把那些有第二关键字的,记录他们第一关键字的开头,即 sa[i]-w

分析一下,第 1 行的操作其实也是在记录第一关键字的开头。

处理出新的 SA 数组

for(int i=1;i<=m;i++) cnt[i]=0;
for(int i=1;i<=n;i++) ++cnt[key1[i]=rk[id[i]]];
for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
for(int i=n;i>=1;i--) sa[cnt[key1[i]]--]=id[i];

第 1 行,置空 \(\text{cnt}\),当然可以 memset(cnt,0,sizeof(cnt))

第 2-3 行,性质上是等价于对单个字符处理出 \(\text{SA}\) 的操作的,都是在做基数排序。这里的 key1[i]=rk[id[i]] 是一个减少访问空间来节约时间的小 Trick(因为第 4 行再次出现 key1[i])。注意这里 \(key1[i]\neq i\),这是因为此刻的 \(\text{Rank}\) 并非此轮的新值,而是上一轮的旧值。

第 4 行,处理出新的 \(\text{SA}\) 数组。

根据新的 SA 得出新的 Rank

for(int i=1;i<=n;i++) oldrk[i]=rk[i];
p=0;
for(int i=1;i<=n;i++) rk[sa[i]]=cmp(sa[i],sa[i-1],w)?p:++p;
if(p==n) break;

第 1 行,之所以要拷贝一份上一轮的 \(\text{Rank}\),这是因为在得到新的 \(\text{Rank}\) 时,需要上一轮的值。但是为了避免冲突,这里才会拷贝出一份。当然,代码你也可以写成 memcpy(oldrk+1,rk+1,n*sizeof(int))

第 2-3 行,得出新值。注意,由于 \(\text{Rank}\) 是可能有相同的,所以需要比较两个字符串是否完全相同。即 cmp(sa[i],sa[i-1],w)

第 4 行,由于 \(p\) 表示不同的字符串的个数,所以一旦 \(p=n\),意味着再没有相同的字符串,再往下做排序已不会改变 \(\text{Rank}\) 值。例如下图,第 4 次排序就是没有必要的。

img

for 循环为什么这么写?

for(int w=1;;w<<=1,m=p)

变量 \(w\) 代表长度,这里不多说了。至于这个 m=p,首先要明确的一点是,\(m\) 表示桶可装的元素最大值。而排序后,\(\text{Rank}\) 数组中的最大值小于 \(p\),所以可以改变最大值。也就是优化基数排序值域。

一些常数优化 Trick

  1. 第二关键字无需基数排序。正如代码中所打,按照他的实质,把无第二关键字的丢到最前面,再把剩下的依次放入。
  2. 优化基数排序的值域。代码中的 \(p\) 即是 \(\text{Rank}\) 的值域,可以优化值域。
  3. 利用 \(\text{key1}\) 数组存储,减少不连续内存访问。这个在大数据效果明显。
  4. 利用 \(\text{cmp}()\) 代替计算。原理同 3。
  5. 利用指针直接交换的性质。 在 \(\text{oldrank}\) 数组拷贝时,是循环拷贝,常数较大。如果在定义时定义成指针形式,那就可以直接 t=x,x=y,y=t 进行一步交换。(但是这玩意儿很容易打错,编译器的警告也奇奇怪怪,慎用!!

注:本贴提供的局部代码,除第 5 点外,其余 4 点均已运用。

这个后缀数组能干什么?

在上文,我们用了大量的篇幅来介绍什么是后缀数组、怎么代码实现求出后缀数组。那我们学会了后缀数组后,能用它解决什么问题呢?

回答这个问题之前,先来了解一下 \(\text{height}\) 数组。

\(\text{height}\) 数组

定义 \(\text{height}[i]=\text{lcp}(\text{suf}(\text{SA}[i-1]),\text{suf}(\text{SA}[i]))\),\(\text{lcp}(a,b)\) 即为 \(a\) 和 \(b\) 的最长公共前缀。

标签:后缀,text,Rank,int,笔记,数组,SA
From: https://www.cnblogs.com/wang-holmes/p/17964278

相关文章

  • The 2nd Universal Cup Stage 18: Dolgoprudny H
    题意大概是说求有所有有标号有根树及其黑白染色方案使得定义\(S_{x}\)为\(x\)和其儿子节点构成的集合,则\(S_{x}\)中的黑色节点个数要求不少于白色节点个数,且定义\(x\)的白色节点个数为\(cnt_{x}\),则其方案的贡献为\(\sum_{i=1}^{n}cnt_{i}!\)(原题意这里似乎说的非常抽......
  • PostgreSQL 数据库日志收集功能开启一什么时候写-参数 log_min_messages 等其他参数设
    log_min_messages(enum)控制将哪些消息级别写入服务器日志。可以取值为:DEBUG5、DEBUG4、DEBUG3、DEBUG2、DEBUG1、INFO、NOTICE、WARNING、ERROR、LOG、FATAL、PANIC。每个关卡都包含了它之后的所有关卡。级别越高,发送到日志的消息就越少。默认值是WARNING。注意,这里的LOG......
  • 学习Java笔记 - Day2
    Java特性优势简单性:基于C,纯净版的C++面向对象:一切皆对象可移植性:Writeonce,runanywhere-跨平台高性能:及时编译,效率分布式:为网络分布式环境设计,可处理TCP/IP协议,通过URL,访问网络资源,相当于本地资源,简单。支持远程的方法调用。动态性:反射机制,有了动态性。多线程:看视频,......
  • 【动手学深度学习_李沐】笔记:(七)循环神经⽹络
    【七、循环神经⽹络】1.序列模型序列模型估计方法有自回归模型和隐变量自回归模型。在统计学中,前者(超出已知观测值的预测)称为外推(extrapolation),后者(在现有观测值之间进⾏估计)称为内插(interpolation)。内插和外推在难度上有很⼤差别,因此,在训练时要尊重数据的时间顺序,不要对未来......
  • 【动手学深度学习_李沐】笔记:(六)现代卷积神经⽹络
    【六、现代卷积神经⽹络】1.深度卷积神经⽹络(AlexNet)在2012年以前,神经⽹络往往被其他机器学习⽅法超越,如支持向量机(supportvectormachines)。而AlexNet在2012年ImageNet挑战赛中取得了轰动⼀时的成绩,在⽹络的最底层,模型学习到了⼀些类似于传统滤波器的特征抽取器。论......
  • 【动手学深度学习_李沐】笔记:(五)卷积神经⽹络(convolutional neural network,CNN)
    【五、卷积神经网络】笔记1.从全连接层到卷积特点(沃尔多检测器):①平移不变性:不管出现在图像中的哪个位置,神经⽹络的底层应对相同图像区域做出类似的响应,因此能够以相同的⽅式处理局部图像②局部性:神经⽹络的底层只探索输⼊图像的局部区域,这些局部特征可以融会贯通,在整个......
  • 搜索学习笔记+杂题 (基础二 dfs/bfs的拓展)
    搜索杂题:博客中讲述的题的题单:戳我二、dfs/bfs的各种变式1、深搜深搜以指数级的时间复杂度闻名,稍不注意时间就会爆炸,所以一般会用到剪枝的技巧(这个技巧基本上是因题而异,需要平时的刷题与积累)。深搜同样也是一种可变性极高的算法(其实都可以不叫做一种算法,深搜已经是一种做题的......
  • 【动手学深度学习_李沐】笔记:(四)深度学习计算
    【四、深度学习计算】笔记1.层和块速度极快的GPU可能要等到CPU运⾏Python代码后才能运⾏另⼀个作业,提⾼Python速度的最好⽅法是完全避免使⽤Python。Gluon允许混合式编程(hybridization),Python解释器在第⼀次调⽤块时执⾏它,Gluon运⾏时记录正在发⽣的事情,以及下⼀次......
  • 【动手学深度学习_李沐】笔记:(三)多层感知机
    【三、多层感知机】笔记1.多层感知机:合并隐藏层:通过合并⼀个或多个隐藏层来克服线性模型的限制多层感知机(multilayerperceptron):MLP,在输出层和输⼊层之间增加⼀个或多个全连接的隐藏层,并通过激活函数转换隐藏层的输出。最简单是将许多全连接层堆叠,每⼀层都输出到上⾯的层,......
  • 学习进度笔记
    在本次安卓开发中,我已经完成了以下任务:创建项目:我使用AndroidStudio创建了一个新的安卓项目,并选择了最新的API级别。设计用户界面:我使用XML文件定义了应用程序的用户界面。我添加了几个布局和视图组件,如TextView、Button和ImageView,并使用约束布局对它们进行了适当的排列。......