ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

[字符串相关]后缀数组 - 倍增算法

2021-07-06 22:03:53  阅读:175  来源: 互联网

标签:排序 后缀 ++ 关键字 算法 数组 sa 倍增


我们约定,下文所提到的字符串下标都从 \(0\) 开始。下文中的“第 \(x\) 后缀”指的是从下标 \(x\) 开始的后缀。

对于后缀数组的学习,本人建议可以自己随便写一个字符串,然后按照相应的过程进行模拟,会很方便的理解每一步的作用以及原理。

#1.0 何为后缀数组

#1.1 后缀树

我们知道,像 \(\texttt{AC}\) 自动机 这样的多模式串匹配需要预处理的是模式串,但是在很多时候,我们会先知道文本串,而非模式串,对于每个模式串的询问要求在线回答,那怎么办?我们引入后缀树这个概念。

所谓后缀树,就是将一个字符串的所有后缀建成一棵树,设 \(S='abaca'\),\(S\) 的后缀树为:

$ 符号是为了标记每一个后缀的结束位置,每个叶节点下面的数字表示该后缀从原字符串的第几位开始,我们已经把所有后缀按字典序从左向右摆放(默认 $ 符号的字典序最小),显然从上面寻找模式串是非常方便的,但我们发现整个后缀树的结构非常庞大,有没有合适的替代品呢?

#1.2 后缀数组

将上面的叶结点下的数字从左往右取出放在数组中,便得到了后缀数组(\(\texttt{Suffix Array}\)),

由此便不难得到后缀数组的定义:所有的后缀按字典序排序后的编号数组。

#2.0 后缀数组的构建

#2.1 朴素算法

一个很简单的算法便是直接采用快排 sort(),快排的时间复杂度为 \(O(n\log n)\),但由于字符串比较需要按位比较,所以实际时间复杂度为 \(O(n^2\log n)\),显然不能接受。

#2.2 倍增算法

这里采用倍增的双关键字排序。大致流程如下:

  1. 给所有后缀的第一个字符排序;
  2. 给所有后缀的前两个字符排序,实际是双关键字排序;
  3. 枚举长度 \(l=2^k\),给所有后缀的前 \(l\) 个字符排序,实际依旧是双关键字排序,因为可以借助上一次排序的结果(\(l=2^k=2^{k-1}+2^{k-1}\)),即错开 \(l-1\) 位进行双关键字排序;重复这一步直到所有后缀的排名不同。

显然,上面的过程最多只需要 \(\log n\) 次,如果这里的双关键字排序采用快排的话,时间复杂度为 \(O(n\log^2 n)\)

#2.3 基数排序优化

我们注意到最多只有 \(n\) 个后缀,所以可以采用基数排序(我们常用的桶排序便是单关键字的基数排序),这里需要采用双关键字的基数排序。

直接讲解并没有非常好讲,所以这里先贴出代码,我们来一行一行的分析。

char s[N];
int sa[N], t[N], t2[N], c[N], n;

inline void build_sa(int m) {
    //m 是编号的最大值
    int i, *x = t, *y = t2;
    for (i = 0; i < m; ++ i) c[i] = 0;
    for (i = 0; i < n; ++ i) c[x[i] = s[i]] ++;
    for (i = 1; i < m; ++ i) c[i] += c[i - 1];
    for (i = n - 1; i >= 0; i --) sa[-- c[x[i]]] = i;
    for (int k = 1; k <= n; k <<= 1) {
        int p = 0;
        for (i = n - k; i < n; i ++) y[p ++] = i;
        for (i = 0; i < n; i ++) if (sa[i] >= k)
          y[p ++] = sa[i] - k;
        for (i = 0; i < m; ++ i) c[i] = 0;
        for (i = 0; i < n; ++ i) c[x[y[i]]] ++;
        for (i = 1; i < m; ++ i) c[i] += c[i - 1];
        for (i = n - 1; i >= 0; i --)
          sa[-- c[x[y[i]]]] = y[i];
        swap(x, y); p = 1; x[sa[0]] = 0;
        for (i = 1; i < n; ++ i)
          x[sa[i]] = y[sa[i-1]] == y[sa[i]] && y[sa[i - 1] + k] == y[sa[i] + k] ? p - 1 : p ++;
        if (p >= n) break;
        m = p;
    }
}

首先是第一部分

for (i = 0; i < m; ++ i) c[i] = 0;
for (i = 0; i < n; ++ i) c[x[i] = s[i]] ++;
for (i = 1; i < m; ++ i) c[i] += c[i - 1];
for (i = n - 1; i >= 0; i --) sa[-- c[x[i]]] = i;

这里是先对每个后缀的首字母进行排序,x[i] 在这里表示的是第 i 个字符的编号(此时的第一关键字),sa[i] 表示的是按第一关键字排序后的第 i 个后缀在原字符串的起始位置。


然后是循环中的这一部分

int p = 0;
for (i = n - k; i < n; i ++) y[p ++] = i;
for (i = 0; i < n; i ++) if (sa[i] >= k)
  y[p ++] = sa[i] - k;

这一部分是按第二关键字排序,y[i] 储存的是按第二关键字排序后第 i 个后缀在原字符串上的开始位置。

其中第一个 for 是将没有第二关键字的后缀的第二关键字设置为无限小,所以要放在 y[] 数组的最前面。

考虑第二个 for 为什么可以直接借助 sa[] 数组进行赋值,我们知道,sa[i] 表示的是在长度为 \(\frac k 2\) 时,按当时的双关键字排序后的第 i 个后缀在原字符串的起始下标,这时对应的也就是在此时长度为 \(k\) 的情况下按第一关键字排序后的第 i 个后缀在原字符串的起始下标。

sa[i] - k 就相当于按第一关键字排序后的第 i 个后缀在原字符串的下标的第前 k 个位置,那么如果将 sa[i] 看作第二关键字,那么 sa[i] - k 便是所对应的第一关键字在原字符串中的下标位置,那么数组 y[] 中的数恰好便是按第二关键字排序后的第 i 个后缀在原字符串的起始下标。


循环中的第二大部分梦幻四重循环

for (i = 0; i < m; ++ i) c[i] = 0;
for (i = 0; i < n; ++ i) c[x[y[i]]] ++;
for (i = 1; i < m; ++ i) c[i] += c[i - 1];
for (i = n - 1; i >= 0; i --) sa[-- c[x[y[i]]]] = y[i];

这里 x[i] 表示的是第 \(i\) 后缀在按第一关键字排序后对应的 sa[] 数组上的排名(注意这样的排名的大小只是相对性的,可能并不连续)。

在整个算法的大部分时间里,x[] 都是从后缀映射到排名,sa[] 则是从排名映射到后缀。

不难发现这里和第一部分几乎一模一样。这里是结合两个关键字的分别排序,构建出当前的双关键字排序后的结果,也是下一次长度为 \(2k\) 时按第一关键字排序后的结果。

原因不难想到:y[] 本身具有顺序性,即第二关键字小的在前面,而本身这个排序则是将第一关键字小的排在前面,而不改变第一关键字相同的数之间的相对位置,综合起来,得到的答案便是按双关键字排序后的结果。


这是循环中的最后一部分

swap(x, y); p = 1; x[sa[0]] = 0;
for (i = 1; i < n; ++ i)
  x[sa[i]] = y[sa[i-1]] == y[sa[i]] && y[sa[i - 1] + k] == y[sa[i] + k] ? p - 1 : p ++;
if (p >= n) break;
m = p;

注意在开始前我们先交换了指针 x 和指针 y 所指向的地址,所以此时 x[i] 指的是按第二关键字排序后的第 i 个后缀在原字符串的起始下标,这个数据对于我们来说已经没有用处了;y[i] 表示的是第 \(i\) 后缀在按第一关键字排序后对应的 sa[] 数组上的排名(注意这样的排名的大小只是相对性的,可能并不连续)。

这里是统计按现在的结果,还会有多少个不同的排名,如果排名数达到了后缀的总数,那么就没有必要继续进行下去了,如果不够,那么同时为各个不同的后缀重新编号(记录按双关键字排序后对应的 sa[] 数组上的排名),这样编号最大为 \(p-1\)。

中间的三目运算符写成 if 如下

if (y[sa[i-1]] == y[sa[i]] && y[sa[i - 1] + k] == y[sa[i] + k])
  x[sa[i]] = p - 1;
else x[sa[i]] = p ++;

这里判等的条件分别是按长度为 \(2k\) 时的第一关键字相等(y[sa[i-1]] == y[sa[i]])和第二关键字相等(y[sa[i - 1] + k] == y[sa[i] + k]),至于为什么 sa[] 的下标是连续的:两个双关键字都相等,而 sa[i] 中是当前双关键字排序后时排名为 \(i\) 的后缀的起始位置,双关键字排序的要求便是先满足第一关键字从小到大,再满足第二关键字从小到大,所以如果两个都相等,那么在 sa[] 中的排名必然是相邻的。

笔者中午在宿舍里思考这里时脑子抽了,是这样断的句:(((x[sa[i]] = y[sa[i-1]]) == y[sa[i]]) && (y[sa[i - 1] + k)] == y[sa[i] + k]) ? p - 1/*错误地以为是 p --*/ : p ++,当时愣是搞不明白为啥这样做是对的,吹起床哨了才发现我™又断错句了. QnQ


整体来说比较复杂,一遍不懂就需要多啃几遍。

#3.0 后缀数组的应用

#3.1 在线模式串匹配

采用二分查找的方式,在文本串中寻找是否存在一个位置开始的后缀的前缀是模式串。

我们可以二分所属的后缀的排名,并与模式串比较大小,如果小了就让排名增大,反之亦然。

设模式串长度为 \(m\),文本串长度为 \(n\),那么单次询问时间复杂度为 \(O(m\log n).\)

int m;
int cmp_suffix(char* pattern, int p) {
    return strncmp(pattern, s + sa[p], m);
}

int find(char* P) {
    m = strlen(P);
    if (cmp_suffix(P, 0) < 0) return -1;
    if (cmp_suffix(P, n - 1) > 0) return -1;
    int l = 0, r = n - 1;
    while (r >= l) {
        int mid = l + (r + l) / 2;
        int res = cmp_suffix(P, mid);
        if (!res) return mid;
        if (res < 0) r = mid - 1;
        else l = mid + 1;
    }
    return -1;
}

参考资料

[1] 刘汝佳, 陈锋. 算法竞赛入门经典:训练指南. 北京: 清华大学出版社, 2012.

[2] 后缀数组——倍增算法 - hyl天梦

[3] 后缀数组简介 - OI Wiki

标签:排序,后缀,++,关键字,算法,数组,sa,倍增
来源: https://www.cnblogs.com/Dfkuaid-210/p/sa.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有