浅谈后缀数组

1. 概述

后缀数组是一种解决字符串问题的有力工具。相比于后缀树,它更易于实现且占用内存更少。在实际应用中,后缀数组经常用于解决字符串有关的复杂问题。

本文大部分内容摘自参考资料[1][2]。

2. 后缀数组

2.1   几个概念

(1)后缀数组SA 是一个一维数组,它保存1..n 的某个排列SA[1],SA[2],……,SA[n],并且保证Suffix(SA[i]) < Suffix(SA[i+1]),1≤i<n。也就是将S 的n 个后缀从小到大进行排序之后把排好序的后缀的开头位置顺次放入SA 中。其中,suffix(i)表示字符串s[i,i+1…n-1],即字符串s起始于第i个字符的后缀。

(2)名次数组Rank[i]保存的是Suffix(i)在所有后缀中从小到大排列的“名次”。

简单的说,后缀数组是“排第几的是谁?”,名次数组是“你排第几?”。容易看出,后缀数组和名次数组为互逆运算。

(3)height 数组:定义height[i]=suffix(SA[i-1])和suffix(SA[i])的最长公共前缀,也就是排名相邻的两个后缀的最长公共前缀。

(4) h[i]=height[rank[i]],也就是suffix(i)和在它前一名的后缀的最长公共前缀。

(5)LCP(i,j):对正整数i,j 定义LCP(i,j)=lcp(Suffix(SA[i]),Suffix(SA[j]),其中i,j 均为1 至n 的整数。LCP(i,j)也就是后缀数组中第i 个和第j 个后缀的最长公共前缀的长度。其中,函数lcp(u,v)=max{i|u=v},也就是从头开始顺次比较u 和v 的对应字符,对应字符持续相等的最大位置,称为这两个字符串的最长公共前缀。

2.2   几个性质

(1)LCP(i,j)=min{height[k]|i+1≤k≤j},也就是说,计算LCP(i,j)等同于询问一维数组height 中下标在i+1 到j 范围内的所有元素的最小值。

证明略。

(2)对于i>1 且Rank[i]>1,一定有h[i]≥h[i-1]-1。

证明:设suffix(k)是排在suffix(i-1)前一名的后缀,则它们的最长公共前缀是h[i-1]。那么suffix(k+1)将排在suffix(i)的前面(这里要求h[i-1]>1,如果h[i-1]≤1,原式显然成立)并且suffix(k+1)和suffix(i)的最长公共前缀是h[i-1]-1,所以suffix(i)和在它前一名的后缀的最长公共前缀至少是h[i-1]-1。按照h[1],h[2],……,h[n]的顺序计算,并利用h 数组的性质,时间复杂度可以降为O(n)。

3. 后缀数组实现

本节给出高效计算SA,Rank,height和h的算法

(1) 计算名次数组Rank与后缀数组SA

采用倍增算法,先求出名次Rank,然后在O(n)时间内求得后缀数组SA。用倍增的方法对每个字符开始的长度为2^k 的子字符串进行排序,求出排名,即rank 值。k 从0 开始,每次加1,当2k 大于n 以后,每个字符开始的长度为2^k 的子字符串便相当于所有的后缀。并且这些子字符串都一定已经比较出大小,即rank 值中没有相同的值,那么此时的rank 值就是最后的结果。每一次排序都利用上次长度为2^(k-1) 的字符串的rank 值,那么长度为2^k 的字符串就可以用两个长度为2^(k-1) 的字符串的排名作为关键字表示,然后进行基数排序,便得出了长度为2k
的字符串的rank 值。以字符串“aabaaaab”为例,整个过程如下图所示。其中x、y 是表示长度为2k 的字符串的两个关键字。

(2) 计算数组h

可以令i从1 循环到n按照如下方法依次算出h[i]:

若 Rank[i]=1,则h[i]=0。字符比较次数为0。

若 i=1 或者h[i-1]≤1,则直接将Suffix(i)和Suffix(Rank[i]-1)从第一个字符开始依次比较直到有字符不相同,由此计算出h[i]。字符比较次数为h[i]+1,不超过h[i]-h[i-1]+2。

否则,说明i>1,Rank[i]>1,h[i-1]>1,根据性质2,Suffix(i)和Suffix(Rank[i]-1)至少有前h[i-1]-1 个字符是相同的,于是字符比较可以从h[i-1]开始,直到某个字符不相同,由此计算出h[i]。字符比较次数为h[i]-h[i-1]+2。

可求得最后算法复杂度为O(n)。

4. 后缀数组应用

4.1 单个字符串相关问题

(1) 可重叠最长重复子串。给定一个字符串,求最长重复子串,这两个子串可以重叠。

『解析』只需要求height 数组里的最大值即可。

(2) 不可重叠最长重复子串。给定一个字符串,求最长重复子串,这两个子串不能重叠。

『解析』先二分答案,把题目变成判定性问题:判断是否存在两个长度为k 的子串是相同的,且不重叠。解决这个问题的关键还是利用height 数组。把排序后的后缀分成若干组,其中每组的后缀之间的height 值都不小于k。例如,字符串为“aabaaaab”,当k=2 时,后缀分成了4 组:

容易看出,有希望成为最长公共前缀不小于k 的两个后缀一定在同一组。然后对于每组后缀,只须判断每个后缀的sa 值的最大值和最小值之差是否不小于k。如果有一组满足,则说明存在,否则不存在。整个做法的时间复杂度为O(nlogn)。

(3) 可重叠的k 次最长重复子串。给定一个字符串,求至少出现k 次的最长重复子串,这k 个子串可以重叠。

『解析』 先二分答案,然后将后缀分成若干组。不同的是,这里要判断的是有没有一个组的后缀个数不小于k。如果有,那么存在k 个相同的子串满足条件,否则不存在。这个做法的时间复杂度为O(nlogn)。

(4) 最长回文子串。给定一个字符串,求最长回文子串。

『解析』 将整个字符串反过来写在原字符串后面,中间用一个特殊的字符隔开。这样就把问题变为了求这个新的字符串的某两个后缀的最长公共前缀。

(5) 连续重复子串。给定一个字符串L,已知这个字符串是由某个字符串S 重复R 次而得到的,求R 的最大值。

『解析』穷举字符串S 的长度k,然后判断是否满足。判断的时候,先看字符串L 的长度能否被k 整除,再看suffix(1)和suffix(k+1)的最长公共前缀是否等于n-k。在询问最长公共前缀的时候,suffix(1)是固定的,所以RMQ问题没有必要做所有的预处理, 只需求出height 数组中的每一个数到height[rank[1]]之间的最小值即可。整个做法的时间复杂度为O(n)。

(6) 重复次数最多的连续重复子串。给定一个字符串,求重复次数最多的连续重复子串。

『解析』先穷举长度L,然后求长度为L 的子串最多能连续出现几次。首先连续出现1 次是肯定可以的,所以这里只考虑至少2 次的情况。假设在原字符串中连续出现2 次,记这个子字符串为S,那么S 肯定包括了字符r[0], r[L], r[L*2],r[L*3], ……中的某相邻的两个。所以只须看字符r[L*i]和r[L*(i+1)]往前和往后各能匹配到多远,记这个总长度为K,那么这里连续出现了K/L+1 次。最后看最大值是多少。

穷举长度L 的时间是n,每次计算的时间是n/L。所以整个做法的时间复杂度是O(n/1+n/2+n/3+……+n/n)=O(nlogn)。

4.2 两个字符串相关问题

(1) 最长公共子串。给定两个字符串A 和B,求最长公共子串。

『解析』先将第二个字符串写在第一个字符串后面,中间用一个没有出现过的字符隔开,再求这个新的字符串的后缀数组。当suffix(sa[i-1]) 和suffix(sa[i])不是同一个字符串中的两个后缀时,max{height[i]}才是满足条件

(2) 长度不小于k 的公共子串的个数。给定两个字符串A 和B,求长度不小于k 的公共子串的个数(可以相同)。

『解析』基本思路是计算A 的所有后缀和B 的所有后缀之间的最长公共前缀的长度,把最长公共前缀长度不小于k 的部分全部加起来。先将两个字符串连起来,中间用一个没有出现过的字符隔开。按height 值分组后,接下来的工作便是快速的统计每组中后缀之间的最长公共前缀之和。扫描一遍,每遇到一个B 的后缀就统计与前面的A 的后缀能产生多少个长度不小于k 的公共子串,这里A 的后缀需要用一个单调的栈来高效的维护。然后对A 也这样做一次。

4.3 多个字符串相关问题

(1) 不小于k 个字符串中的最长子串。给定n 个字符串,求出现在不小于k 个字符串中的最长子串。

『解析』将n 个字符串连起来,中间用不相同的且没有出现在字符串中的字符隔开,求后缀数组。然后二分答案:将后缀分成若干组,判断每组的后缀是否出现在不小于k 个的原串中。这个做法的时间复杂度为O(nlogn)。

(2) 每个字符串至少出现两次且不重叠的最长子串。给定n 个字符串,求在每个字符串中至少出现两次且不重叠的最长子串。

『解析』做法和上题大同小异,也是先将n 个字符串连起来,中间用不相同的且没有出现在字符串中的字符隔开,求后缀数组。然后二分答案,再将后缀分组。判断的时候,要看是否有一组后缀在每个原来的字符串中至少出现两次,并且在每个原来的字符串中,后缀的起始位置的最大值与最小值之差是否不小于当前答案(判断能否做到不重叠,如果题目中没有不重叠的要求,那么不用做此判断)。这个做法的时间复杂度为O(nlogn)。

(3) 出现或反转后出现在每个字符串中的最长子串。给定n 个字符串,求出现或反转后出现在每个字符串中的最长子串。

『解析』这题不同的地方在于要判断是否在反转后的字符串中出现。其实这并没有加大题目的难度。只需要先将每个字符串都反过来写一遍,中间用一个互不相同的且没有出现在字符串中的字符隔开,再将n 个字符串全部连起来,中间也是用一个互不相同的且没有出现在字符串中的字符隔开,求后缀数组。然后二分答案,再将后缀分组。判断的时候,要看是否有一组后缀在每个原来的字符串或反转后的字符串中出现。这个做法的时间复杂度为O(nlogn)。

5. 总结

后缀数组实际上可以看作后缀树的所有叶结点按照从左到右的次序排列放入数组中形成的,所以后缀数组的用途不可能超出后缀树的范围。甚至可以说,如果不配合LCP,后缀数组的应用范围是很狭窄的。但是LCP 函数配合下的后缀数组就非常强大,可以完成大多数后缀树所能完成的任务,因为LCP 函数实际上给出了任意两个叶子结点的最近公共祖先,这方面的内容大家可以自行研究。

6. 参考资料

(1) 许智磊,IOI2004 国家集训队论文《后缀数组》

(2) 罗穗骞,IOI2004 国家集训队论文《后缀数组—处理字符串的有力工具》

时间: 2024-10-10 18:23:30

浅谈后缀数组的相关文章

浅谈next数组

next数组是加速字符串匹配的的一个重要工具 以下是next数组的实现过程 int* getnext(string &T) { int length = T.length(); int *next = new int[length]; next[0] = -1; next[1] = 0; int i = 1; int j = 0; while(i<length) { if(T[i] == T[j]) { next[++i] = ++j; } else if(j>0) { j = nex

浅谈动态数组原理及其实现

stl中的vector是竞赛中常用的容器,原因在于省内存,O(1)在后端插入和删除.随机下标访问,今天就来谈谈它的实现. 最简单的一个动态数组 动态数组并不是真正意义上的动态的内存,而是一块连续的内存,当添加新的元素时,容量已经等于当前的大小的时候(存不下了),执行下面3步 重新开辟一块大小为当前容量两倍的数组 把原数据拷贝过去 释放掉旧的数组 完事后再把这个元素加在后面. 那么你一定会很好奇,它为什么会是O(1).这个是均摊下来的结果,我们只需要证明总共拷贝的元素个数是O(n)级别的就好了(因

浅谈Javascript数组的使用

数组的大小 js数组可以动态调整大小,更确切点说,它没有数组越界的概念,a[a.length]没什么问题.比如声明一个数组a = [1, 3, 5],现在的数组大小是3,最后一个元素的索引是2,但是你依然可以使用a[3],访问a[3]返回的是undefined,给a[3]赋值:a[3] = 7,是给数组a添加了一个元素,现在数组a的长度是4了.你可以试试把下面这段代码放到浏览器里运行下: var a = []; for(int i = 0; i <= a.length; i++) { a[a.l

浅谈后缀自动机SAM

一下是蒟蒻的个人想法,并不很严谨,仅供参考,如有缺误,敬请提出 参考资料: 陈立杰原版课件 litble 某大神 某大神 其实课件讲得最详实了 有限状态自动机 我们要学后缀自动机,我们先来了解一下自动机到底是什么.[虽说以前也学过AC自动机,只是当一个名字罢了] 有限自动机的功能是识别字符串,作用各不相同 如果自动机A能识别串s,那么A(s) = true 自动机有一个初始状态,从初始状态出发能到达多个状态.到达终止状态表示字符串识别 后缀自动机SAM 我们略去建机原理的分析和建机过程,具体原理

浅谈差分数组的原理及简单应用

一.差分数组的定义及用途 1.定义: 对于已知有n个元素的离线数列d,我们可以建立记录它每项与前一项差值的差分数组f:显然,f[1]=d[1]-0=d[1];对于整数i∈[2,n],我们让f[i]=d[i]-d[i-1]. 2.简单性质: (1)计算数列各项的值:观察d[2]=f[1]+f[2]=d[1]+d[2]-d[1]=d[2]可知,数列第i项的值是可以用差分数组的前i项的和计算的,即d[i]=f[i]的前缀和. (2)计算数列每一项的前缀和:第i项的前缀和即为数列前i项的和,那么推导可知

浅谈差分数组的应用(二)&amp;[NOIP2012]借教室题解

[NOIP2012提高&洛谷P1083]借教室 Description 在大学期间,经常需要租借教室.大到院系举办活动,小到学习小组自习讨论,都需要向学校申请借教室.教室的大小功能不同,借教室人的身份不同,借教室的手续也不一样.面对海量租借教室的信息,我们自然希望编程解决这个问题. 我们需要处理接下来n天的借教室信息,其中第i天学校有ri个教室可供租借.共有m份订单,每份订单用三个正整数描述,分别为dj,sj,tj,表示某租借者需要从第sj天到第tj天租借教室(包括第sj天和第tj天),每天需要

浅谈JavaScript数组操作方法

JavaScript中数组有各种操作方法,以下通过举例来说明各种方法的使用: push 在数组最后添加一个元素 var arr=[3,4,5,6] console.log(arr) //[3, 4, 5, 6] arr.push("evenyao") //字符串需要加"",数字不加 console.log(arr) //[3, 4, 5, 6, "evenyao"] pop 把数组最后一位取出来,并返回,且原来数组发生变化 var arr=[3,

《浅谈JavaScript系列》系列技术文章整理收藏

<浅谈JavaScript系列>系列技术文章整理收藏 1浅谈JavaScript中面向对象技术的模拟 2浅谈javascript函数劫持[转自xfocus]第1/3页 3浅谈javascript 面向对象编程 4老鱼 浅谈javascript面向对象编程 5浅谈javascript的数据类型检测 6浅谈Javascript嵌套函数及闭包 7根据一段代码浅谈Javascript闭包 8浅谈JavaScript编程语言的编码规范 9浅谈Javascript面向对象编程 10浅谈JavaScript

浅谈二维中的树状数组与线段树

一般来说,树状数组可以实现的东西线段树均可胜任,实际应用中也是如此.但是在二维中,线段树的操作变得太过复杂,更新子矩阵时第一维的lazy标记更是麻烦到不行. 但是树状数组在某些询问中又无法胜任,如最值等不符合区间减法的询问.此时就需要根据线段树与树状数组的优缺点来选择了. 做一下基本操作的对比,如下图. 因为线段树为自上向下更新,从而可以使用lazy标记使得矩阵的更新变的高校起来,几个不足就是代码长,代码长和代码长. 对于将将矩阵内元素变为某个值,因为树状数组自下向上更新,且要满足区间加法等限制