后缀数组 & 后缀平衡树


后缀数组 & 后缀平衡树

PPT:【腾讯文档】后缀数组——钱贵宁

后缀数组

是什么

本质上是对一个字符串的所有后缀进行排序

例如字符串 abbcaba,我们按长度顺序列出它的所有后缀

1: a
2: ba
3: aba
4: caba
5: bcaba
6: bbcaba
7: abbcaba

然后我们按照字典序将它们排好序,用 sa[i] 表示第 i 小的后缀编号,rk[i] 表示第 i 个后缀的排名。显然 sa 数组和 rk 数组存在“互逆”的关系,即 sa[rk[i]] = i

sa[1] = 1: a       -> rk[1] = 1
sa[2] = 3: aba     -> rk[3] = 2
sa[3] = 7: abbcaba -> rk[7] = 3
sa[4] = 2: ba      -> rk[2] = 4
sa[5] = 6: bbcaba  -> rk[6] = 5
sa[6] = 5: bcaba   -> rk[5] = 6
sa[7] = 4: caba    -> rk[4] = 7

sa 数组和 rk 数组就是后缀数组中最常用的两个数组

例题:JSOI2007 字符加密

给一个字符串,将其首尾相连之后显然可以得到 n 个长度为 n 的新字符串,将这些新字符串依次排序,依次输出他们的末尾字符。

在输入的字符串后接上它本身,对新字符串利用后缀数组排序,只保留长度大于原字符串的后缀,就是所有待求字符串。

实现

P.S. 你可能听说过在大多数情况下后缀数组可以当作黑箱使用,不必完全理解实现方法。但是如果你打算尽量理解其含义,请不要尝试直接去看最优解法的代码,其代码的实现方法大概率会让你一头雾水。

这一部分主要是讨论如何求得 sa 和 rk 数组。

显然根据定义,我们可以直接去尝试排序。

char s[MAXN];//字符串
int len;//字符串长度
int sa[MAXN];
bool cmp(const int& a, const int& b)
{
    int ta = a, tb = b;
    while(ta <= len && tb <= len)
    {
        if(s[ta] != s[tb]) return s[ta] < s[tb];
        ta++;
        tb++;
    }
}
int main()
{
    ///...
    sort(sa+1, sa+len+1, cmp);
    ///...
}

因为字符串比较的时间复杂度是 $ O(N) $ 的,所以整个算法是时间复杂度是 $ O(n^2logN) $ ,难以接受,所以要寻找更优秀的做法

倍增算法

首先尝试优化字符串比较的时间复杂度,我们可以引入倍增算法。倍增算法的主要思想是进行多轮排序,其中第 k 轮是将所有以每个位置为起点、长度为 $ 2^k $ 的子串进行排序(长度不足则用空字符填充)。其中每个子串都可以拆成两个长度为 $ 2^{k-1}$ 的小子串,他们在上一轮都已经排好序了,所以对原本长度为 $ 2^k $ 的子串排序变成了对 n 个双关键字组合排序。当 $ 2^k >= n $ 时可以看出所有的待排序元素都是原字符串的后缀,所以完成这次排序之后就可以得到正确的 sa 数组

单纯的文字可能难以理解,罗穗骞的论文中有一张形象的图片描述了这个排序的流程

后缀数组 & 后缀平衡树

再考虑优化排序的时间复杂度。众所周知,字符串中的字符种类总数是很少的,所以我们可以尝试使用基数排序来实现双关键字排序。我们不妨用一个实例来模拟这个排序的过程。

基数排序

为了说明白基数排序的用法,我们不妨直接上实例。

尝试对以下几个二元组排序

1:{1, 3}   2:{2, 1}   3:{1, 1}   4:{3, 1}

首先我们暂时无视第一维,只对第二维进行计数排序,此时的顺序可能为:

3:{1, 1}   2:{2, 1}   4:{3, 1}   1:{1, 3}

再只针对第一维进行计数排序,因为计数排序是稳定的,所以第一维相同的元素之间第二维的相对有序关系不会发生变化

3:{1, 1}   1:{1, 3}   2:{2, 1}   4:{3, 1}

因为基数排序是 $ O(N*M) $ 的,其中 M 代表字符集大小,可以视为常数,所以采用基数排序的倍增做法整体时间复杂度是 $O(NlogN)$

// s[]:字符串  cnt[]:计数排序计数用   id[] 和 oldrk[] 用于存储临时的旧变量   n:字符串长度  m:字符集大小
void DA(int *s, int *sa, int *rk, int *cnt, int *id, int *oldrk, int n, int m)
{
    int i, p;
    // 初始化数组,也可以理解为对字符排序
    // 这里也用了计数排序,可以理解为求出桶的状态后对桶求前缀和
    // 这样就可以直接求出每个元素排好序之后所在的位置
    // 后面的计数排序也是这个思想
    for(i=1; i<=m; i++) cnt[i] = 0;
    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(int w=1; w<n; w<<=1)
    {
        // 先对第二维排序
        memset(cnt, 0, sizeof(cnt)); // cnt数组大小相当于字符集大小,不会太大,所以memset没问题
        for(i=1; i<=n; i++) id[i] = sa[i];
        for(i=1; i<=n; i++) cnt[rk[id[i] + w]]++;
        for(i=1; i<=m; i++) cnt[i] += cnt[i-1];
        for(i=n; i>=1; i--) sa[cnt[rk[id[i] + w]]--] = id[i];
        // 再对第一维排序
        for(i=1; i<=n; i++) id[i] = sa[i];
        for(i=1; i<=n; i++) cnt[rk[id[i]]]++;
        for(i=1; i<=m; i++) cnt[i] += cnt[i-1];
        for(i=n; i>=1; i--) sa[cnt[rk[id[i]]]--] = id[i];
        // 更新 rk 数组
        memcpy(oldrk, rk, sizeof(rk));
        for(p=0, i=1; i<=n; i++)
        {
            if(oldrk[sa[i]] == oldrk[sa[i-1]] && oldrk[sa[i] + w] == oldrk[sa[i-1] + w]) rk[sa[i]] = p; // 判重
            else rk[sa[i]] = ++p;
        }
    }
}
一些优化

也许你看懂了上面这份代码,不要直接用它去当作黑箱,因为他还有很多地方可以进行常数优化

  1. 第二关键字无需计数排序

第二关键字在 sa[] 里本来就是有序的,我们只需要把空串放在最前面(无限小),再把那些依旧要参加排序的关键字依序放进去就好了

  1. 优化值域

在更新 rk[] 时,我们得到了一个 p ,即为 rk[] 的值域。所以在下一轮我们可以直接把 p 赋值给 m

  1. rk[id[i]] 存下来,减少不连续内存访问

  2. 把判重部分放进函数中,减少不联系内存访问

  3. 若排名都不同(即 p == n) 则则可以直接生成后缀数组

优化后的代码如下

// s[]:字符串  cnt[]:计数排序计数用   id[] 和 oldrk[] 用于存储临时的旧变量   n:字符串长度  m:字符集大小
int cmp(int *oldrk, int x, int y, int w)
{
    return oldrk[x] == oldrk[y] && oldrk[x + w] == oldrk[y + w];
}
// s[]:字符串  cnt[]:计数排序计数用   id[] 和 oldrk[] 用于存储临时的旧变量   px[]:rk[id[i]]   n:字符串长度  m:字符集大小
void DA(int *s, int *sa, int *rk, int *cnt, int *id, int *oldrk, int n, int m)
{
    int i, p;
    // 初始化数组,也可以理解为对字符排序
    // 这里也用了计数排序,可以理解为求出桶的状态后对桶求前缀和
    // 这样就可以直接求出每个元素排好序之后所在的位置
    // 后面的计数排序也是这个思想
    for(i=1; i<=m; i++) cnt[i] = 0;
    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(int w=1;; w<<=1, m = p) // m=p即为优化值域 w<n 被省掉是因为有了更好的结束条件
    {
        // 先对第二维排序
        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; // 前 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];
        // 更新 rk 数组
        memcpy(oldrk, rk, sizeof(rk));
        for(p=0, i=1; i<=n; i++)
        {
            rk[sa[i]] = cmp(oldrk, sa[i], sa[i-1], w) ? p : ++p; // 判重
        }
        if(p == n)
        {
            for(i=1; i<=n; i++) sa[rk[i]] = i;
            return;
        }
    }
}

$O(N)$做法

在大多数情况下,出题人不会去卡倍增做法,所以这里不做解释。想要详细了解的同学可以去查阅文末引用的罗穗骞的论文。

题单

题号 标签 难度 题解
LOJ-111 模板

原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/281500.html

(0)
上一篇 2022年8月22日
下一篇 2022年8月22日

相关推荐

发表回复

登录后才能评论