字符串学习笔记2——后缀数组SA及其排序

后缀数组和排序

上次我们讲到了前缀数组,那后缀数组又是什么呢?

首先定义后缀:

对于一个字符串 s s s,定义后缀 i i i从第 i i i位开始一直到字符串末尾的子串

举个例子:对于字符串 a b a b a \tt ababa ababa(这个例子要在后文反复出现的),它的后缀 i i i依次如下:

后缀1 a b a b a \tt ababa ababa

后缀2 b a b a \tt baba baba

后缀3 a b a \tt aba aba

后缀4 b a \tt ba ba

后缀5 a \tt a a

这个概念非常重要。

接下来,我们就要引出这篇文章的主角: R K RK RK数组和 S A SA SA数组

引出之前,我们还是拿刚刚那个字符串举例子。要利用那两个数组,首先就要对这一堆后缀按字典序排序。排序结果如下:

a , a b a , a b a b a , b a , b a b a \tt a,aba,ababa,ba,baba a,aba,ababa,ba,baba

排完序之后我们就会得到这一堆后缀的一个新的排名,例如后缀5 a \tt a a的排名就是第一位,而后缀4 b a \tt ba ba的排名是第4位。 R K RK RK数组的功用就是这个:刻画后缀 i i i在排序后的排名。因而我们整理出来整个的 R K RK RK数组:

后缀 i i i 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5
R K RK RK 3 3 3 5 5 5 2 2 2 4 4 4 1 1 1

这是 R K RK RK数组的定义。

还有一个数组: S A SA SA数组。 S A SA SA全称为 s u f f i x suffix suffix a r r a y array array,即后缀数组。但是这个名字和功用并不配套:它其实是 R K RK RK数组的逆数组

这个概念和反函数很像:对于一个已知函数 f f f,在其定义域区间内,任给一个自变量 x x x,我们都可以得到对应的函数值 f ( x ) f(x) f(x)。那这时我已知 f ( x ) f(x) f(x),我想知道 x x x,那么就需要 f f f存在反函数 f − 1 ( x ) f^{-1}(x) f1(x),然后再调用该函数,我们就可以得到对应的 x x x了。即,

f ( f − 1 ( x ) ) = x , f − 1 ( f ( x ) ) = x f(f^{-1}(x))=x,f^{-1}(f(x))=x f(f1(x))=x,f1(f(x))=x

回到 S A SA SA数组,我们已知了一个后缀 i i i的排名,那么它是哪一个后缀呢?这时就要 S A SA SA数组帮忙了。 S A SA SA数组就是干的查询排名为 i i i的后缀是哪一个。例如,排名为3的后缀是后缀 1 1 1,排名为2的后缀是后缀 3 3 3

对应于最上面的例子,我们整理出对应的 S A SA SA数组:

排名 i i i 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5
S A SA SA 5 5 5 3 3 3 1 1 1 4 4 4 2 2 2

由反函数的性质,我们容易得到:

r k [ s a [ i ] ] = i , s a [ r k [ i ] ] = i rk[sa[i]]=i,sa[rk[i]]=i rk[sa[i]]=i,sa[rk[i]]=i

那么,我们如何计算这个 R K RK RK S A SA SA数组呢?

朴素方法

每个后缀都写出来再排序, O ( n 2 l o g n ) O(n^2logn) O(n2logn),比较字符串是 O ( n ) O(n) O(n)的代价。该方法非常简单因而不再赘述。而且这个方法在时间上也过于鸡肋。

倍增

思考一个问题:如果我要得到原来全部后缀的排序结果,我能否通过原长度一半后缀的排序结果转移得到?换言之,能否通过长度为 ω \omega ω的后缀数组排序得到长度为 2 ω 2\omega 2ω的后缀数组排序?

在理解上面这个想法之前,先得控制后缀的长度。从上面我们可以看到,每个后缀的长度都不一样。为了控制长度,我们可以通过在后面添\0(下文中用0代替)来让每个字符串长度相等的同时还不改变字符串相对大小关系。

举个例子:

现在令长度 ω = 2 \omega=2 ω=2,长度为2的后缀数组排序就是对这样的一些字符串排序:

后缀1 a b \tt ab ab

后缀2 b a \tt ba ba

后缀3 a b \tt ab ab

后缀4 b a \tt ba ba

后缀5 a 0 \tt a0 a0

说白了,就是在第 i i i个字符的基础上往后延申 ω \omega ω位得到的字符串。显然,当 ω = l e n \omega=len ω=len的时候,就是我们要的最终结果。

对上面进行排序并存在 R K RK RK数组中,结果如下:

后缀 i i i 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5
R K RK RK 2 2 2 3 3 3 2 2 2 3 3 3 1 1 1

以及对应的 S A SA SA数组。 R K RK RK相同时按照先后次序再排序。

排名 i i i 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5
S A SA SA 5 5 5 1 1 1 3 3 3 2 2 2 4 4 4

同理,整理出 2 ω 2\omega 2ω的排序结果。

后缀1 a b a b \tt abab abab

后缀2 b a b a \tt baba baba

后缀3 a b a 0 \tt aba0 aba0

后缀4 b a 00 \tt ba00 ba00

后缀5 a 000 \tt a000 a000

后缀 i i i 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5
R K RK RK 3 3 3 5 5 5 2 2 2 4 4 4 1 1 1

以及 S A SA SA数组:

排名 i i i 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5
S A SA SA 5 5 5 3 3 3 1 1 1 4 4 4 2 2 2

我知道你这儿肯定没看出什么规律来

其实还是有点规律的:至少,一开始相同的 R K RK RK被拉开差距了。那这是为什么呢?

我们来回顾一下 R K RK RK数组的功能:给不同后缀一个排名。那么对于 ω \omega ω长度的 R K RK RK,其实我们是知道了什么?是现在它所处的排名。那么我们为了延长到 2 ω 2\omega 2ω,其实我们所需要做的,就是给每一个后缀增加一倍的长度,这个长度来源于它接下来的 ω \omega ω位。由字符串排序的特性,前面如果都比出大小关系了,那么后面不再看了,也就是说,再接 ω \omega ω位对排名毫无影响;只有当前排名并列,才需要看后面的。而这时,后面 ω \omega ω位的排序我们刚好是有的——这就是当前的 R K RK RK数组,即 r k [ i + ω ] rk[i+\omega] rk[i+ω]。因此,我们需要做的,就是将 r k [ i ] rk[i] rk[i] r k [ i + ω ] rk[i+\omega] rk[i+ω]绑在一起做排序,前者做第一关键字,后者做第二关键字,进行新的排序,这时排出来的就是 2 ω 2\omega 2ω的结果

用一个图来表示:

sxF6aT.png

每一次都是这样,按照这两个元素进行排序,倍增得到结果。

但是,这样会产生一个问题:你这样排序之后,值的位置不对了——如果只用一个数组,我没法轻松的给 R K RK RK排名,反而会搅乱 R K RK RK数组的位置。这个时候就是 S A SA SA数组出场的时候了!

(其实这个 S A SA SA 数组就是个排序的工具人)

因为 S A SA SA 数组里边永远都是 1 − N 1-N 1N 的数,因而天生就是个排序标号的料。我们就利用 S A SA SA 来排序——即,拿着 r k [ i ] rk[i] rk[i] r k [ i + ω ] rk[i+\omega] rk[i+ω] 去排序 S A SA SA(其实就是重新编号),然后再反着标回来。这样就不会搅乱 R K RK RK 数组的顺序了。

所以整个的流程就是这样:

先对单字符排序,然后递增,每次按照上述规则排序, O ( log ⁡ n ) O(\log n) O(logn) 级别次数后就完成全部排序。

由于正常排序是 O ( n log ⁡ n ) O(n \log n) O(nlogn),因而总复杂度 O ( n log ⁡ 2 n ) O(n \log^2n) O(nlog2n)

for (gap = 1; gap < len; gap <<= 1)//gap就是此处的omega
{
	sort(sa + 1, sa + len + 1, [](int x, int y) { return rk[x] == rk[y] ? rk[x + gap] < rk[y + gap] : rk[x] < rk[y]; });//这是个lambda,C++11的特性,用rk而非sa内部的元素来对sa进行排序
	//现在这个sa其实已经就是每个rk对应的排序后编号了
        memcpy(oldrk, rk, sizeof(rk));//抄一份,要不然后面用sa和rk更新会互相影响
        int place = 0;
        for (int i = 1; i <= len;i++)
        {
            if(oldrk[sa[i]]==oldrk[sa[i-1]] && oldrk[sa[i]+gap]==oldrk[sa[i-1]+gap])//去重。当前面的rk和gap后的rk相同时,那么这两个前后缀相同,应该给予同样的编号
                rk[sa[i]] = place;
            else
                rk[sa[i]] = ++place;
        }
    }

这样就可以通过模板后缀数组了。但是还是太慢,怎么办?

倍增+基数排序+计数排序

从上面的分析得到,其实我们排序的时候有两个关键字: r k [ i ] rk[i] rk[i] r k [ i + ω ] rk[i+\omega] rk[i+ω],我们就可以考虑使用基数排序了:从最小关键字排起,直到最大关键字。因为只有这样才不会导致大关键字相同的内部小关键字数目相反。

然后,计数排序复杂度仅 O ( n ) O(n) O(n),优于 O ( n log ⁡ n ) O(n \log n) O(nlogn)。因而可以进一步加速这个算法。而且,这里符合计数排序的条件:待排序关键字范围有限,只有百万级别,完全可以使用。

先来讲讲计数排序的原理(使用算法导论的代码):

for(int i=0;i<=k;i++)//k为解空间
	cnt[i]=0;//此处cnt代表了下标为i,即关键字为i时在最终数组中下标的最后位置
for(int i=1;i<=n;i++)
	cnt[a[i]]++;//统计每个数有多少个
for(int i=1;i<=k;i++)
	cnt[i]+=cnt[i-1];//求前缀和,得到排序后数组的最终位置
for(int i=n;i>=1;i--)//倒序遍历出来,一定要倒序
{
	b[cnt[a[j]]]=a[j];//打印出来
	cnt[a[j]]--;//最后位置由于一个元素的填入需要前移
}

请理解这个代码,因为马上要灵活运用了。要维护、排序什么元素,就把它放在 cnt 中;最终要打印到哪里去,cnt 就做它的下标。

那么优化后的代码就来了:

    for (int i = 1; i <= len;i++)//初始化
    {
        rk[i] = a[i];//rk数组通常直接拿原字符串a[i]进行初始化
        cnt[rk[i]]++;//计数单字符
    }
    for (int i = 1; i <= max(len, 300);i++)
        cnt[i] += cnt[i - 1];//累加操作
    for (int i = len; i >= 1; i--)//记得倒序!
    {
        sa[cnt[rk[i]]] = i;//对原数组进行单字符排序
        cnt[rk[i]]--;
    }
    for (int gap = 1; gap <= len;gap<<=1)
    {
        //基数排序的两轮,首先是对第二关键字排序
        memset(cnt, 0, sizeof(cnt));
        memcpy(oldsa, sa, sizeof(sa));//先备份一份,防止后面互相影响
        for (int i = 1; i <= len;i++)
            cnt[rk[oldsa[i] + gap]]++;//维护什么就把什么塞到cnt下
        for (int i = 1; i <= max(len, 300); i++)
            cnt[i] += cnt[i - 1];
        for (int i = len; i >= 1; i--)
        {
            sa[cnt[rk[oldsa[i] + gap]]] = oldsa[i];//排序,将1-n的标号按照上述规则重新填入sa数组
            cnt[rk[oldsa[i] + gap]]--;
        }
        //第二轮排序:第一关键字排序。维护的是rk[sa[i]]
        memset(cnt, 0, sizeof(cnt));
        memcpy(oldsa, sa, sizeof(sa));
        for (int i = 1; i <= len;i++)
            cnt[rk[oldsa[i]]]++;
        for (int i = 1; i <= max(len, 300); i++)
            cnt[i] += cnt[i - 1];
        for (int i = len; i >= 1; i--)
        {
            sa[cnt[rk[oldsa[i]]]] = oldsa[i];
            cnt[rk[oldsa[i]]]--;
        }
        memcpy(oldrk, rk, sizeof(rk));//这一部分和上面一样
        int place = 0;
        for (int i = 1; i <= len;i++)
        {
            if(oldrk[sa[i]]==oldrk[sa[i-1]] && oldrk[sa[i]+gap]==oldrk[sa[i-1]+gap])
                rk[sa[i]] = place;
            else
                rk[sa[i]] = ++place;
        }
    }

还可进行其他优化,这里不再赘述。

height数组与lcp

首先介绍一下什么是 l c p \rm lcp lcp:Longest Common Prefix,最长公共前缀长度。即给定两个字符串 S , T S,T S,T,找到最大的 t t t 满足 ∀ i ∈ [ 1 , t ] , S i = T i \forall i \in [1,t],S_i=T_i i[1,t],Si=Ti,记 l c p { S , T } = t {\rm lcp} \{ S,T\}=t lcp{S,T}=t

引入 h e i g h t \rm height height 数组:在 S A SA SA 数组上排名为 i i i 的后缀与排名为 i − 1 i-1 i1 的后缀的最长公共前缀长度。注意:这里不是直接对后缀 i i i 进行的,而是对排序后的后缀进行的。

快速计算 h e i g h t \rm height height 数组需要一个引理: h e i g h t [ r k [ i ] ] ≥ h e i g h t [ r k [ i − 1 ] ] − 1 {\rm height}[rk[i]] \geq {\rm height}[rk[i-1]]-1 height[rk[i]]height[rk[i1]]1。即,后缀 i i i后缀数组排序上的上一位后缀的最长公共前缀长度大于等于后缀 i − 1 i-1 i1 与其上一位的最长公共前缀长度减一。

用一张表来解释:

形式化表示
后缀 i i i,即 s a [ r k [ i ] ] sa[rk[i]] sa[rk[i]] A D AD AD
后缀 i − 1 i-1 i1,即 s a [ r k [ i − 1 ] ] sa[rk[i-1]] sa[rk[i1]] a A D aAD aAD
后缀 i − 1 i-1 i1 在后缀数组排序上的上一位后缀 s a [ r k [ i − 1 ] − 1 ] sa[rk[i-1]-1] sa[rk[i1]1] a A B , B < D aAB,B<D aAB,B<D
后缀 i − 1 i-1 i1 与后缀数组排序上的上一位后缀 s a [ r k [ i − 1 ] − 1 ] sa[rk[i-1]-1] sa[rk[i1]1] 的最长公共前缀 a A aA aA
后缀 i − 1 i-1 i1 在后缀数组排序上的上一位 s a [ r k [ i − 1 ] − 1 ] sa[rk[i-1]-1] sa[rk[i1]1] 在原序上的下一位后缀,即后缀 s a [ r k [ i − 1 ] − 1 ] + 1 sa[rk[i-1]-1]+1 sa[rk[i1]1]+1 A B , B < D AB,B<D AB,B<D
后缀 i i i 在后缀数组排序上的上一位 s a [ r k [ i ] − 1 ] sa[rk[i]-1] sa[rk[i]1] A C , B ≤ C AC,B \leq C AC,BC
后缀 i − 1 i-1 i1 与后缀数组排序上的上一位后缀 s a [ r k [ i − 1 ] − 1 ] sa[rk[i-1]-1] sa[rk[i1]1] 的最长公共前缀 A X , X ≥ B AX,X \geq B AX,XB

注: A , B , C , D A,B,C,D A,B,C,D 等大写字母表示一个字符串(可为空),小写字母如 a a a 表示单个字符。最后一行可以理解为:首先 h e i g h t [ i ] {\rm height}[i] height[i] 至少有 A A A 的长度;此外, X X X 中可能包含了能接着和后缀 i i i 匹配的部分,而 B B B 中不具有,因而更长。

因而我们可以通过这个引理 O ( n ) O(n) O(n) 的暴力求出整个序列的 h e i g h t \rm height height 数组:

		for (int i = 1, place = 0; i <= n; i++)
        {
            if (place)
                place--;
            while (a[i + place] == a[sa[rk[i] - 1] + place] && i+place<=n)
                place++;
            height[rk[i]] = place;
        }

那么 h e i g h t \rm height height 数组表示了在后缀数组排序上相邻两个后缀的最长公共前缀长度,那么它有什么用呢?

  1. 统计本质不同子串个数:由于全部的子串等于全部的后缀的全部的前缀,因而我们可以将全部的子串根据其后缀分成 n n n 类,首先统计每一类下有多少个前缀——显然后缀 i i i n − i + 1 n-i+1 ni+1 个后缀,然后针对相邻的后缀 i i i 与后缀 i − 1 i-1 i1,因为只有它们之间会创造重复,因而减掉重复项,共有 h e i g h t [ i ] {\rm height} [i] height[i] 个。因而本质不同的子串个数等于 n ( n + 1 ) 2 − ∑ i = 1 n h e i g h t [ i ] \displaystyle \frac{n(n+1)}{2}-\sum_{i=1}^{n} {\rm height}[i] 2n(n+1)i=1nheight[i]
  2. 求出两个子串的最长公共前缀长度。沿用刚刚的思想——子串即是某一个后缀的一个前缀。因而考虑这两个子串 S [ a ⋯ b ] S[a \cdots b] S[ab] S [ c ⋯ d ] S[c \cdots d] S[cd] 所属后缀——后缀 a a a 与 后缀 b b b。由于 l c p ( s a [ i ] , s a [ j ] ) = min ⁡ i + 1 ≤ k ≤ j h e i g h t [ k ] \displaystyle {\rm lcp}(sa[i],sa[j])= \min_{i+1 \leq k \leq j} {\rm height [k]} lcp(sa[i],sa[j])=i+1kjminheight[k],因而即可求出这两个后缀的最长公共前缀长度,再对两个子串长度求最小值即可。
  3. 同理可以求出两个子串的大小关系。先比较对应后缀的最长公共前缀长度,若大于等于最小子串长度,则直接比较长度;否则,比较前缀的排名即可。
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值