字符串-后缀树和后缀数组详解

Posted 唔仄lo咚锵

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了字符串-后缀树和后缀数组详解相关的知识,希望对你有一定的参考价值。

在这里插入图片描述

后缀树


建议先了解一下字典树

首先理解后缀的概念,后缀(suffix)即从某个位置开始到末尾的一个子串。例如字符串 s = a a b a b s=aabab s=aabab,它的五个后缀为 a a b a b aabab aabab a b a b abab abab b a b bab bab a b ab ab b b b

后缀树(suffix tree)就是把所有的后缀子串用字典树的方法建立的一棵树,如图:
在这里插入图片描述
其中根节点为空,还可以在叶子节点后用一个’$'符标识结束,从根节点出发就能到达所有的子串情况。

模板:

#include<bits/stdc++.h>
using namespace std;
const int maxn = 100005;
int trie[maxn][26];
int pos = 1, n;
char s[maxn], t[maxn];
void insert(int idx) { //构建后缀树
    int p = 0; 
    for (int i = idx; i < n; i++) {
        int u = s[i] - 'a';
        if (trie[p][u] == 0)
            trie[p][u] = pos++;
        p = trie[p][u];
    }
}
bool find() {  //查询是否是子串
    int p = 0;
    for (int i = 0; s[i]; i++) {
        int u = s[i] - 'a';
        if (trie[p][u] == 0)
            return false;
        p = trie[p][u];
    }
    return true;
}
int main() {
    scanf("%s%s", s,t);
    n = strlen(s);
    for (int i = 0; i < n; i++) {//枚举起点
        insert(i);
    }
    printf("%s子串", find() ? "是" : "不是");
    return 0;
}

但是不难发现,建树的时间和空间成本都很高。后缀数组和后缀自动机可以看作是对后缀树时间和空间上的优化,通过映射关系避免建树和提高树节点重复利用率。

后缀数组


概念

直接对后缀树构造和编程不太方便,而后缀数组(suffix array)就是更简单的替代方法。

下标i后缀s[i]下标j字典序后缀数组sa[j]
0aabab0aabab0
1abab1ab3
2bab2abab1
3ab3b4
4b4bab2

后缀数组就是字典序对应的后缀下标,即 s a sa sa(suffix array缩写)数组。比如 s [ 1 ] = 3 s[1]=3 s[1]=3,表示字典序排1的子串,是原来字符串中第3个位置开始的后缀子串,即 a b ab ab

通过后缀数组能方便的解决一些字符串问题,如在母串 s s s中查找子串 t t t,只需在 s a [ ] sa[] sa[]上做二分搜索,时间复杂度是 O ( m l o g n ) O(mlogn) O(mlogn),m子串长度n母串长度,如查找 b a ba ba

#include<bits/stdc++.h>
using namespace std;
string s, t;
int sa[] = { 0,3,1,4,2 }; //设sa[]已求出
int find() {  //t在s中位置
    int l = 0, r = s.size();
    while (r > l + 1) { //字典序里二分
        int mid = (l + r) / 2;
        if (s.compare(sa[mid], t.length(), t) < 0)
            l = mid;  //-1不相等移动左指针
        else r = mid; //0相等移动右指针
    }
    if (s.compare(sa[r], t.length(), t) == 0)
        return sa[r];  //返回原始位置
    if (s.compare(sa[l], t.length(), t) == 0)
        return sa[l];
    return -1; //没找到
}
int main() {
    s = "aabab";
    t = "ba";
    cout << find();
    return 0;
}

sa[]

那现在的问题是如何高效的求后缀数组 s a [ ] sa[] sa[],即对后缀子串进行排序?

若直接使用快排,每两个字符串间还有 O ( n ) O(n) O(n)的比较,所以总的复杂度是 O ( n 2 l o g n ) O(n^2logn) O(n2logn),显然不够友好。答案是使用倍增法

  1. 用数字替代字母,如a=0,b=1。
  2. 连续两个数字组合,如00代表aa,01代表ab,最后一个1没有后续,在尾部加上0,组成10,并不影响字符得比较。
  3. 连续4个数字组合,如0010代表aaba,同样得01和10没有后续,补0。
  4. 得到5个完全不一样的数字,可以区分大小了,进行排序,得到rk数组={0,2,4,1,3}。
  5. 最后通过排名得到后缀数组sa[]={0,3,1,4,2}。
步骤aabab
第一步00101
第二步0001100110
第三步00100101101001001000
下标i01234
排序rk[i]02413
转换sa[i]sa[0]=0sa[2]=1sa[4]=2sa[1]=3sa[3]=4
sa[i]03142

上述每一步递增两倍,总共 l o g ( n ) log(n) log(n)步,但是当字符串很长时,产生的组合数字就非常大可能溢出,这时就需要每一步都进行一个压缩,只要相对顺序不变即可,如下:

步骤aabab
第一步00101
第二步0001100110
排序rk[]01212
第三步0211221020
下标i01234
排序rk[i]02413
转换sa[i]sa[0]=0sa[2]=1sa[4]=2sa[1]=3sa[3]=4
sa[i]03142

rk[]

也就是说求后缀数组 s a [ ] sa[] sa[],需要通过一个排名 r k [ ] rk[] rk[]来求。两者是一一对应的关系,互为逆运算,可以互相推导,即 s a [ r k [ i ] ] = i sa[rk[i]]=i sa[rk[i]]=i r k [ s a [ i ] ] = i rk[sa[i]]=i rk[sa[i]]=i

  • sa[]后缀数组,suffix array缩写,记录的是位置,是字典序排名第i的是谁。
  • rk[]排名数组,rank array缩写,记录的是排名,是第i个后缀子串排名第几。

那得到倍增后的相对大小数字后,我们可以直接用快排 s o r t ( ) sort() sort()得到 r k [ ] rk[] rk[],每次快排 O ( n l o g n ) O(nlogn) O(nlogn),需要快排 l o g ( n ) log(n) log(n)次,总复杂度是 O ( n ( l o g n ) 2 ) O(n(logn)^2) O(n(logn)2)
模板:

#include<bits/stdc++.h>
using namespace std;
const int maxn = 200005;
char s[maxn];
int sa[maxn], rk[maxn], tmp[maxn + 1];
int n, k;
bool cmp_sa(int i, int j) { //直接比较,省去组合过程
    if (rk[i] != rk[j]) //比较组合数高位
        return rk[i] < rk[j];
    else { //比较组合数低位
        int ri = i + k <= n ? rk[i + k] : -1;
        int rj = j + k <= n ? rk[j + k] : -1;
        return ri < rj;
    }
}
void calc_sa() { //计算sa[](快速排序)
    for (int i = 0; i <= n; i++) {
        rk[i] = s[i]; //记录原始数值
        sa[i] = i; //记录当前排序结果
    }
    for (k = 1; k <= n; k *= 2) { //每次递增2倍
        sort(sa, sa + n, cmp_sa);
        //因为rk[]存在相同数,所以需要上一轮rk[]才能比较(即cmp_sa里)
        //所以不能直接赋给rk[],需要一个tmp[]周转
        tmp[sa[0]] = 0; 
        for (int i = 0; i < n; i++) //sa[]倒推组合数记录在tmp[]
            tmp[sa[i + 1]] = tmp[sa[i]] + (cmp_sa(sa[i], sa[i + 1]) ? 1 : 0);
        for (int i = 0; i < n; i++)
            rk[i] = tmp[i];
    }
}
int main() {
    memcpy(s, "aabab", 6);
    n = strlen(s);
    calc_sa();
    for (int i = 0; i < n; i++)
        cout << sa[i] << " ";
    // 0 3 1 4 2
    return 0;
}

除了直接用快排sort,还有一种更快的排序方式——基数排序,总复杂度只有 O ( n l o g n ) O(nlogn) O(nlogn),就是有题目卡这点时间,丧心病狂

基数排序是先比较低位再比较高位,使用哈希的思路,对于该位相同的数字直接放到相应的格子里。如排序{82,43,67,52,91,40},先按个位排序得{40,91,82,52,43,67},再按十位排序得{40,43,52,67,82,91}以此类推。

格子0123456789
个位409182,524367
十位40,4352678291

模板:

#include<bits/stdc++.h>
using namespace std;
const int maxn = 200005;
char s[maxn];
int sa[maxn], rk[maxn];
int cnt[maxn], t1[maxn], t2[maxn];
int n, k;
void calc_sa() { //计算sa[](基数排序)
    int m = 127; //ASCLL范围
    int i, * x = t1, * y = t2;
    for (i = 0; i < m; i++)cnt[i] = 0;
    for (i = 0; i < n; i++)cnt[x[i] = s[i]]++;
    for (i = 1; i < m; i++)cnt[i] += cnt[i - 1];
    for (i = n - 1; i >= 0; i--)sa[--cnt[x[i]]] = i;
    for (k = 1; k <= n; k *= 2) {
        int p = 0; //利用长度k的排序结果对长度2k的排序
        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++)cnt[i] = 0;
        for (i = 0; i < n; i++)cnt[x[y[i]]]++;
        for (i = 1; i < m; i++)cnt[i] += cnt[i - 1];
        for (i = n - 1; i >= 0; i--)sa[--cnt[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 后缀数组代码详解

后缀树和尝试。有啥区别?

后缀自动机如何限制串长

后缀数组(suffix array)

后缀数组详解+模板

后缀自动机多图详解(代码实现)