数据结构 —— 字符串:后缀数组

由于被虐得不要不要的,所以用此文纪念一下我(秃头)爆肝弄得似懂非懂的后缀数组——一个神奇的东西。

1.需求是什么?(应用)

我们在了解一个东西之前,先问,我们为什么要这个东西?它有什么用吗?所以我们先来讲讲它到底是怎么来的,为什么需要它?先建立了目标,我们就容易引出它的概念了。

  1. 首先,我们先思考一下一个最本质的问题:将从i(1<=i<=n)开始到字符串结尾的那部分字符串排序。
  2. //待更

2.后缀数组是啥?(定义)

(1)在了解后缀数组之前,我们先来看看两个概念:子串后缀

  • 子串:字符串中任意个连续的字符组成的子序列称为该串的子串。比如:字符串S=“ABCABCAA”,那么“ABC”、“A”、“CAA”、“ABCABCAA”都是它的子串。
  • 后缀:从某一位置i开始到字符串结尾的特殊子串。比如:字符串S=“ABCABCAA”,假设我们以1为开头,那么i=3的后缀就是:“CABCAA”。我们记为suffix(s,3);

(2)有了这两个概念以后我们就可以来定义两个数组了,第一个是Rank数组,第二个就是我们的SA数组(后缀数组【Suffix Array】)。

  • SA[i]:将所有后缀按字典序排序后第 i 小的后缀在原数组中的开始编号。
  • Rank[i]:第 i 位开始的后缀在后缀按字典序排序后的排名。

举个栗子:字符串S=“ABCA”,那么它的后缀分别是:“ABCA”、“BCA”、“CA”、“A”。那么将 他们按字典序排好就是:“A”、“ABCA”、“BCA”、“CA”;对应的SA={4,1,2,3}、Rank={2,3,4,1};例:SA[1]=4:后缀排名第一的是“A”,开始位置是原数组的最后一位,也就是第 4 位。
Rank[2]=3:从第 2 位开始的后缀为:“BCA”,在排序中的排名为第 3 位。
SA[i]说的是:第 i 大的我原来在哪?也就是我的下标是多少?
Rank[i]说的是:下标为 i 的我排第几?也就是我的排名是多少?

如果弄不清楚的,多举例试试,明晰定义很重要!

(3)我们有了这两个数组之后,我们先来看看这两个的关系,如果读者细心的话,会发现这两个数组存在着类似反函数一样的性质。为了便于区分,我们SA数组的下标用 l (小L)表示,Rank数组的下标用 i 表示,那么我们能得到这两个关系:

  • Rank[SA[l]]=l
  • SA[Rank[i]]=i

怎么去理解呢?可以试着问自己问题:SA存的是第 l 大的后缀在原数组下的开始位置,那么Rank[SA[l]] 表示的就是 以 第 l 大的后缀在原数组中的开始位置 为开始位置的后缀的排名是多少?很明显,就是 l 。下一条同理有: 以 原数组中第 i 位开始的数组的排名 为排名的后缀在原数组中的开始位置是多少? 很明显,就是 i 。有了这一对关系之后,我们就可以着手来求构造其中一个数组了,然后用关系去构造出另一个数组。

3.怎么构造后缀数组?(原理)

1)首先,我们有一个字符串S。我们先来简化一下问题,一下子后缀太难了。S的单个字符的SA数组与Rank数组是不是很好求呢?这个就相当于只是把字符串S排序一下而已啦,是不是?OK,那么我们得到了长度为1的子串的SA数组与Rank数组(类似的定义,只不过不是后缀,而仅仅只是子串)。

2)我们将问题升级一下,长度为2的子串的SA数组与Rank数组要怎么求呢?为了充分利用我们已经知道的信息,我们先把长度为分为前半截以及后半截(各长度1)。因为字典序嘛,所以我们先比较前半部分,如果相同,再比较后半部分,(比如:“AA”与“AB”)。那么就相当于先按前半部分排序,再按后半部分排序。(想清楚过程哦!)这样,我们就得到了长度为2的子串的SA数组与Rank数组了。

3)因为字符串好长好长好长,我们不得不继续做下去,但是我们似乎有点思路了哦?!想想我们再求多长的子串的SA数组与Rank数组呢? 3?!嘿嘿,不是的! 是长度为4的子串的SA数组与Rank数组哦!为什么呢?虽然我们也有长度为1的信息,但是啊,这个不如我们长度为2的信息那么有用哦,毕竟我们二分要优于不对等分的。依旧分成前半截与后半截来求。这样我们就驾轻就熟了,先按前半部分排序,再按后半部分排序。这样我们就能得到长度为4的子串的SA数组与Rank数组了。

4)一般地,对于长度为N的字符串S,我们只需要分log2N次就好啦。假设我们已经知道了长度为 i 的子串的SA与Rank了,我们想知道长度为 2*i 的子串的SA与Rank,我们只需要分成前半截与后半截,分别排序就好啦!由于我们是后缀数组,所以每个后缀的长度必定唯一并且必定有N个后缀(因为开始位置 i 不同),所以排名不会重复。当我们最终的子串扩展了log2N次时(后面子串扩展超过N的部分【就是后面没东西可以补了!】默认补上’\0’,值为最小值0。),那么子串的SA数组与Rank数组就扩展成了后缀数组。

这个就是:倍增法求SA数组与Rank数组的大体思路了。(还不了解的话,对着下图手动模拟一下啦!)

数据结构 —— 字符串:后缀数组_第1张图片
(没错,又是这个大名鼎鼎的图!)

下面我们要讨论具体的啦!所谓的“排序”,排序什么,怎么排?怎么知道一个数组,利用关系得到另一个数组?

5)要弄清楚排序什么,我们先要看我们比较的是什么。首先对于同一长度的两个字符串,他们两个按字典序比较的话,分成相同的部分与不同的部分。比如:“ABCCCCAA”与“ABCDDDBB”两个,公共部分是:“ABC”,后面明显“C”要小于“D”,故前者更靠前。所以我们二分的时候,两个字符串先比较前半部分。就是比较前半部分的字典序的排名,那么我们什么量描述排名呢?就是我们的Rank数组啦(不清楚的回过头去再看看Rank数组的定义)。所以我们比较的是第一个字符串S1前半部分的Rank与第二个字符串S2前半部分的Rank。而如果相同,那么就比较后半部分。

6)如果我们弄成pair对,丢进去排序的话,我们复杂度是nlogn的,而字符串比较是logn的,那么就会变成O(nlog2n),似乎也足够了。但是如果我们采用一个更好的线性排序的话,我们就可以降成O(nlogn),是不是帅气很多?!(对于大数据点我们还是得认怂啊。)那么我们的排序方法呢,就是大名鼎鼎(很少使用 )的基数排序啦!

7)最后一个问题,我们怎么由一个数组来得到另一个呢?假设我们知道了Rank[i],那么我们的SA[Rank[i]]=i啦!

(补充)基数排序

如果你已经很熟悉基数排序了,那么就直接看实现的代码吧。

1)基数排序是一种类似桶排一样的排序方法,就比如我们现在要排几个3位数,A={1,258,323,43,76};你会发现如果直接桶排,就要浪费好多好多的内存,毕竟你要开324个桶。那么有没有什么更好的方法能既高效又不用浪费那么大的内存呢?肯定有的啦,毕竟标题摆在这里 ,那就是基数排序了。基数排序具体来说:就是先按照低位排好序,然后再按更高位的排序,比如整数排序:我们开0-9个队列(数组模拟也可),分别按照个位、十位、百位顺序排序(不足的默认补0)。我们来模拟一次。

先按个位排序:1 323 43 76 258;
再按十位排序:01 323 43 258 76;
最后百位排序:001 043 076 258 323;

个位、十位、百位可以抽象成优先度,按照第一、第二、第三优先度排序。每次都用上次的排好序结果来再按另外的顺序排序,这种排序方法是稳定且高效的。

2)由于我们是需要基数排序来求SA数组,所以我们就给出求SA数组的代码实现,对整数排序的原理也是一样的(有兴趣自行百度):

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

我们来分析一下这段代码:
1.变量:
a[i]:待排序数组
cnt[i]:计数数组
sa[i]:排名为 i 的元素在原数组里的下标
cnt[a[i]]:表示我前面有都是个比我小的数
n:数据量
m:值域(相当于0~9)

2.语句:
第一个循环:相当于在第a[i]大的数的桶里计数
第二个循环:算前缀和
第三个循环:记录下原数组的位置

【解释:我前面有几个元素,我就排第几嘛(比如我跑步第四名、就意味着我的前面有4个数,不管他们并列与否,这个就是求前缀和的意义。)。“- -” 是因为我自己已经记录了,相当于把我自己pop出去。】

(图解:)
数据结构 —— 字符串:后缀数组_第2张图片

4.如何实现?(代码)

//后缀数组
#include 
#include 
#include 
#include 

using namespace std;

const int N = 1000010;

char s[N];
int n, sa[N], rk[N], oldrk[N << 1], id[N], px[N], cnt[N];
// px[i] = rk[id[i]](用于排序的数组所以叫 px)

bool cmp(int x, int y, int w)
{
  return oldrk[x] == oldrk[y] && oldrk[x + w] == oldrk[y + w];
}

int main() {
  int i, m = 300, p, w;
  scanf("%s", s + 1);
  n = strlen(s + 1);
  for (i = 1; i <= n; ++i) ++cnt[rk[i] = s[i]];
  for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
  for (i = n; i >= 1; --i) sa[cnt[rk[i]]--] = i;

  for (w = 1; w < n; w <<= 1, m = p)  // m=p 就是优化计数排序值域
  {
    for (p = 0, i = n; i > n - w; --i) id[++p] = i;
    for (i = 1; i <= n; ++i)
    	if (sa[i] > w) id[++p] = sa[i] - w;
    memset(cnt, 0, sizeof(cnt));
    for (i = 1; i <= n; ++i) ++cnt[px[i] = rk[id[i]]];
    for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
    for (i = n; i >= 1; --i) sa[cnt[px[i]]--] = id[i];
    memcpy(oldrk, rk, sizeof(rk));
    for (p = 0, i = 1; i <= n; ++i)
    	rk[sa[i]] = cmp(sa[i], sa[i - 1], w) ? p : ++p;
  }
  
  for (i = 1; i <= n; ++i) printf("%d ", sa[i]);

  return 0;
}

(没错,就是恬不知耻从oi-wiki上copy下来的!)

因为板子太好了,所以博主就自作主张的拉下来了。下面我主要是帮助大家理解这段代码,如果碰到思路上不懂的,就回头看看,或者去oi-wiki,或者其他地方找资料反复看(肝),博主毕竟只是个小白,欢迎大神评论指正!!!

1.变量:
s[N]:字符串
sa[N]:SA数组
rk[N]:Rank数组
oldrk[N]:上一次的Rank数组
px[N]:排序数组
id[N]:按第二部分排好序后的数组下标(可以看成小SA数组)
cnt[N]:相当于桶排序里面的桶,就是值域桶。
n:字符串长度
m:值域
w:倍增长度
p:用于缩小值域的中间变量

2.语句块:

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

我们一开始,把单个变量的ascii码当做他们的rank,然后直接基数排序得到长度为1的rank数组和sa数组

for (w = 1; w < n; w <<= 1, m = p)  // m=p 就是优化计数排序值域
  {
    for (p = 0, i = n; i > n - w; --i) id[++p] = i;
    for (i = 1; i <= n; ++i)
    	if (sa[i] > w) id[++p] = sa[i] - w;
    memset(cnt, 0, sizeof(cnt));
    for (i = 1; i <= n; ++i) ++cnt[px[i] = rk[id[i]]];
    for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
    for (i = n; i >= 1; --i) sa[cnt[px[i]]--] = id[i];
    memcpy(oldrk, rk, sizeof(rk));
    for (p = 0, i = 1; i <= n; ++i)
    	rk[sa[i]] = cmp(sa[i], sa[i - 1], w) ? p : ++p;
  }

然后这里就是倍增了,把w扩张2倍,然后更新值域,就是最外层的for了。我们把倍增的代码内部看看。

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

这里是对第二部分进行排序,得到id数组。第一个for,是把 n-w < i <= n 拿出来,为什么呢,因为这部分是没有第二部分的。具体的来讲就是:i + w >= n。所以后面都是补‘\0’也就是0的。所以我们可以直接先把他们放进id数组里面。第二个for是把上一次的第二部分排序的内容,按照已经排好的顺序放进id数组里。(sa[i]-w,就是上一次的值嘛,你上次扩了一倍,现在减掉。)【这部分是优化以后的部分,实在不懂也可以用原始的基数排序对第二部分排序代替。】

    memset(cnt, 0, sizeof(cnt));
    for (i = 1; i <= n; ++i) ++cnt[px[i] = rk[id[i]]];
    for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
    for (i = n; i >= 1; --i) sa[cnt[px[i]]--] = id[i];

这部分是**对第一部分进行排序,得到sa数组。**结合第二部分的顺序,我们将Rank[id[i]]转换成px[i],减少不连续内存的访问,优化。【oi-wiki写的,优化原因博主也有点小懵,但是确实这样操作不容易错和TLE,至少不用套三个[],嘻。】

bool cmp(int x, int y, int w)
{
  return oldrk[x] == oldrk[y] && oldrk[x + w] == oldrk[y + w];
}

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

这部分是利用已知的sa数组,更新rank数组。我们着重看看cmp函数:如果我的前半部分和上一个的前半部分一样;我们就看后半部分,如果还是一样,我们就一样的排序嘛。(只考两科,一科语文,一科英语。两个人都是300分,各自都是150。不就并列第一了嘛。)由于后缀开始位置各不相同,所以排到最后肯定排名不同,那么当排名不再更新的时候,我们就得到了我们的sa数组和rank数组啦!p是本次的最高分,用于缩小每次基数排序的值域桶。

5.Height数组(拓展)

一般对于一些后缀数组的题目,它并不是简单地要求对后缀进行排序,而是要用这个排序去O(n)的获得一个数组,Height数组。
1)首先,我们先了解一个概念:最长公共前缀(LCP【The longest common prefix】)。

- LCP:字符串S与T的最长公共前缀就是:对于xmax<=min(|S|,|T|)[xmax:最大的x];使得,对于任意1<=i<=x,S[i]==T[i];

举个栗子:S=“ABCAA”,T=“ABCDD”,LCP(S,T)=“ABC”。

2)了解了LCP之后,我们就可以定义Height数组了。

- Height[i]:LCP(SA[i],SA[i-1]),即第 i 名的后缀与它前一名的后缀的最长公共前缀的长度,其中,Height[1]=0;

举个栗子:S=“ABCA”,那么排序过后呢,就是:“A”、“ABCA”、“BC”、“C”。那么Heigh={0,1,0,0};

3)如何求Height数组?这里我们给出一个定理(证明在后面,有兴趣的可以去康康):

  • Heigh[Rank[i]] >= Height[Rank[i-1]]-1;

我们怎么理解呢,把Rank代入到Height的定义里面,得到的是 |LCP ( i , i - 1)| >= |LCP ( i-1,i-2 )|-1(这里的 i 指的是以 i 开头的后缀);其实就是相邻的两个后缀,后面不会短于上一个的长度减一。

4)代码实现:

for (i = 1, k = 0; i <= n; ++i)
{
  if (k) --k;
  while (s[i + k] == s[sa[rk[i] - 1] + k]) ++k;
  ht[rk[i]] = k;  // height太长了缩写为ht
}

(没错,又是直接拉的!!)

我们来分析一下吧:
k:LCP的长度。
if语句:我们至少是上一个的长度减一嘛,那我直接减一就好啦。
while语句:前面k个保证相等了(此时k已经减过了),我只要去对比后面。那么等号前面可以看成是以第 i 位开头的后缀的第 k 个字符,与以第 i 个字符开头的后缀的按字典序排序好的数组里面的上一位的第 k 个字符,如果相同,那么长度加一。
最后一句就是记录值了。
(图解):
数据结构 —— 字符串:后缀数组_第3张图片
(画得不是很好,如果错误还请指正!!)

5)复杂度分析:
我们的k是不会超过n的,那么最多减n次,最多加2n次,所以复杂度就是O(n)的啦。

6):证明:
//待补

你可能感兴趣的:(数据结构)