【字符串】后缀数组

后缀排序

我的字符串全部都是下标从1开始的, 千万要小心。
字符串 \(s[1, n]\) 中,有 \(n\) 个后缀,他们分别是 \(s[1, n], s[2, n], s[3, n], ..., s[i, n], ..., s[n, n]\)
对这 \(n\) 个后缀进行排序,由于他们长度不同所以他们的排序结果一定是 \([1, n]\) 的一个排列,这个排列(rk数组)就称为后缀数组。

后缀数组表示为两个对应的数组,主要是sa,在下面有解释:
\(sa[i]\) 排名为 \(i\) 的后缀的起始位置为sa[i]。
也就是说,在全部的n个后缀中,s[sa[1], n] 是其中字典序最小的后缀。通常,sa是被用来二分的对象,它是后缀数组本身。

\(rk[i]\) 起始位置为 \(i\) 的后缀,也就是s[i, n]的排名。rk是用来解决字符串被复制了一次之后,需要识别原串的情况使用的。

这两个数组满足性质 \(sa[rk[i]] = i, rk[sa[i]] = i\)

看图比较好直观理解:https://oi-wiki.org/string/sa/

用法简介:
求字符串S的某个子串或者反向子串是否相等或者比较大小:查询O(1)
求模式串出现的所有位置:后缀数组上二分然后暴力匹配,查询O(|T|log|S|)
求出现k次的子串:height数组上的连续k-1个位置的滑动窗口
互不相交的子串:求两个子串在后缀数组中的最小值和最大值,变成RMQ问题
本质不同的子串:去掉height数组中表示重复子串的LCP部分

倍增算法

\(n\) 字符串的长度。
\(m\) 当前后缀(离散化后)的值域。对于char可以跳过离散化,初值取128即可,对于int要离散化,初值取n即可,初值要保证覆盖整个值域。
\(sa[i]\) 排名为 \(i\) 的后缀的起始位置。也就是说,在全部的n个后缀中,s[sa[1], n] 是其中字典序最小的后缀。
\(rk[i]\) 起始位置为 \(i\) 的后缀的排名。

验证:https://www.luogu.com.cn/problem/P3809

const int MAXN = 1000000 + 10;
int n, m, ct[MAXN], tp[MAXN];
int sa[MAXN], rk[MAXN], ht[MAXN];

void RadixSort() {
    for(int i = 0; i <= m; ++i)
        ct[i] = 0;
    for(int i = 1; i <= n; ++i)
        ++ct[rk[i]];
    for(int i = 1; i <= m; ++i)
        ct[i] += ct[i - 1];
    for(int i = n; i >= 1; --i)
        sa[ct[rk[tp[i]]]--] = tp[i];
}

// 比较排名为i的后缀和排名为j的后缀,在长度不超过l的前提下,谁更小?i是否小于j。
bool Compare(int i, int j, int l) {
    if(tp[sa[i]] == tp[sa[j]]) {
        if(sa[i] + l <= n && sa[j] + l <= n) {
            if(tp[sa[i] + l] == tp[sa[j] + l])
                return 1;
        }
    }
    return 0;
}

void SuffixSort(char *s) {
    n = strlen(s + 1), m = 128;
    for(int i = 1; i <= n; ++i) {
        rk[i] = s[i];
        tp[i] = i;
    }
    RadixSort();
    for(int l = 1;; l <<= 1) {
        m = 0;
        for(int i = n - l + 1; i <= n; ++i)
            tp[++m] = i;
        for(int i = 1; i <= n; ++i) {
            if(sa[i] > l)
                tp[++m] = sa[i] - l;
        }
        RadixSort();
        swap(tp, rk);
        m = 1;
        rk[sa[1]] = 1;
        for(int i = 2; i <= n; ++i) {
            if(Compare(i - 1, i, l) == 0)
                ++m;
            rk[sa[i]] = m;
        }
        if(m == n)
            break;
    }
}

线性复杂度算法

TODO

使用后缀数组本身的性质完成的题目

最小循环表示

把字符串S循环移动,找字典序最小的那个表示。

把字符串 \(S\) 复制变成字符串 \(S+S\) ,然后变成后缀排序的问题,但是只能取出前n个字符的排序,而不能取后n个字符的,因为后n个字符开头的后缀并不是一个完整的循环表示,由定义知道,如果rk数组中的最小值是rk[i](位置在i的是排名最小的后缀),那么后缀[i,n]就是最小的后缀,那么从i位置开始输出其前n个字符即可。后缀数组由于字符串被复制过,长度为2n,后一半的n个都是无用的,因为他们不满n个字符。所以在前一半的前[1, n]的后缀数组中的最小值的位置(记为i),那么s[i, i + n - 1]就是答案。当然可能会有多个循环表示的串相等,他们会排在后缀数组中相邻的位置(最短的后缀排前面),要注意题目的特殊限制(比如找出所有的最小循环表示),也可以利用这个最小循环的串构造出来之后在 \(S+S\) 中查找第一个匹配位置和最后一个匹配的位置(见下面的使用后缀数组二分查找子串)。

验证:https://www.luogu.com.cn/problem/P4051

这一题只需要把最小表示串找出来,所以就找任意一个即可。注意n的取值要和后缀数组中字符串相匹配。

        int n = strlen(str + 1);
        for(int i = 1; i <= n; ++i)
            str[i + i] = str[i];
        str[n + n + 1] = '\0';
        SuffixSort(str);
        int pos = min_element(rk + 1, rk + 1 + n) - rk;    // 找前n个字符开头的后缀的排名最小的那个,也就是最小的那个后缀,它是在pos位置开始。
        for(int i = 1; i <= n; ++i)
            putchar(str[pos + i - 1]);
        putchar('\n');

假如字符集很大,那么后缀自动机就会失效,这个时候后缀数组可以通过离散化解决,然后字符串取离散化后的结果,m初始值就取n(而不是128)。

所有循环表示

找出S的所有循环表示,并把他们按字典序排列。

验证:https://www.luogu.com.cn/problem/P4051

对于后缀数组来说,这个问题和上面的一模一样,直接取前n个的rk(因为只有前n个是完整的循环表示),对其按数对(rk[i],i)重新排序,rk升序就说明rk[i]代表的后缀已经成为升序,那么取第二关键字i就知道它起始于何处。

        int n = strlen(str + 1);
        for(int i = 1; i <= n; ++i)
            str[i + n] = str[i];
        SuffixSort(str);
        for(int i = 1; i <= n; ++i)
            p[i] = {rk[i], i};
        sort(p + 1, p + 1 + n);
        for(int i = 1; i <= n; ++i)
            putchar(str[p[i].second + n - 1]);
        putchar('\n');

在文本串S中查找模式串T的所有出现位置

验证:https://www.luogu.com.cn/problem/P3375

对文本串S构造后缀数组,然后在后缀数组sa上面二分,二分枚举到一个位置M,排名为M的后缀的起始位置是sa[M],然后对sa[M]的后缀和模式串T暴力比较,得出的结果可能是M偏大、M偏小,或者相等。二分找到第一个相等的位置,然后同理找到最后一个相等的位置,中间的就是所有的出现次数。因为寻找的是以T开头的所有后缀的起始位置,所以他们必定是在后缀数组中连续的一个区间。

这是一个在线的算法,比起AC机的优势。单次匹配复杂度可能优于KMP。

int sl, tl;
char s[MAXN];
char t[MAXN];

int Check(int pos) {
    return strncmp(s + sa[pos], t + 1, tl);
}

int FirstEqual() {
    int L = 1, R = sl;
    while(L < R) {
        int M = (L + R) / 2;
        if(Check(M) >= 0)
            R = M;
        else
            L = M + 1;
    }
    return Check(L) == 0 ? L : sl + 1;
}

int LastEqual() {
    int L = 1, R = sl;
    while(L < R) {
        int M = (L + R + 1) / 2;
        if(Check(M) <= 0)
            L = M;
        else
            R = M - 1;
    }
    return Check(L) == 0 ? L : 0;
}

vi GetAllOccurences() {
    sl = strlen(s + 1);
    tl = strlen(t + 1);
    SuffixSort(s);
    int L = FirstEqual();
    int R = LastEqual();
    vi ans;
    for(int i = L; i <= R; ++i)
        ans.eb(sa[i]);
    srt(ans);
    return ans;
}

比较子串的字典序

子串A[a,b] 和B[c,d]

若lcp(sa[a],sa[c])>=min(|A|,|B|) ,则A<B等价于|A|<|B|
否则,A<B等价于rk[a]<rk[c]

在字符串T中查找子串S

https://codeforces.com/contest/149/problem/E

题意:给定n=1e5长的字符串S,t=100次询问,每次询问m=1e3长的字符串T,问是否可以从S中选择两个不相交的非空子串s1,s2使得s1+s2=T。有一个哈希的做法,不过TLE22了,枚举s1的长度那么可以直接算出s2的长度,总共有1e3种长度,在S上跑1e3次尺取哈希,然后用数据结构找出第一个哈希值等于s1的位置和最后一个哈希值等于s2的位置比较(要求他们不相交),复杂度最差为O(nmt)。

要先转化问题,s1+s2=t,必有s1=t1, s2=t2, t1+t2=t,在原串S中选择两个不相交的s1和s2即可。对S和rev(S)分别构造后缀数组(不需要连在一起构造),暴力跑一遍求出T和S的LCP的变化情况和rev(T)和S的LCP的变化情况。易知LCP是先增后减的,所以该过程是O(n)的。对于T串中的每一个LCP的值len,在反串中一定要和len加起来超过T的原长才有可能拼出T,这个明显是一个双指针的问题,然后对于满足此条件的rev(S)的LCP来说要求里面的sa[i]的最小值(在反串中排名最靠前,也就是在原串中排名最靠后),对原串也是求sa的最小值。复杂度是O(t(n+m))。

另一种办法是,直接对S构造后缀数组,然后在后缀数组上初始化一个RMQ的查询结构(比如ST表)。然后枚举T=t1+t2中t1的长度,以t1为模式串在S的后缀数组上二分,找到最早和最晚出现的连续位置,然后在RMQ里面查出他们最小值,也就是在原字符串中最靠左出现的t1。然后由t的长度减去t1的长度得到t2的长度,再进行一次后缀数组上的二分找到最早和最晚出现的连续位置,在RMQ中查他们的最大值,也就是在原字符串中最靠右出现的t2,判断他们之间是否相交即可。

原字符串和反字符串比较

问题可以归结为:在原字符串中取出一个子串s[l, r],然后再从s的反字符串rev(s)中取出另一个子串rev(s)[x, y],求他们之间比较的结果。

构造字符串 S + '#' + rev(S),然后跑一次后缀数组,也就得到了原字符串的所有后缀和反字符串的所有后缀相互排序得到的结果。

由于子串相当于是后缀的前缀,也就是s[l, r]其实是s[l, n]的长度为(r - l + 1)的前缀,而反字符串rev(s)[x, y]也是rev(s)[x, n]的长度为(y - x + 1)的前缀。

很明显,前者的排名是rk[l],后者的排名是rk[n + 1 + x]。

典型例题是从一个字符串的两端取字符,求能构成的最小字典序的字符串。

原字符串中的某个子串是否是回文串

是上一问的特殊情形,问他们之间比较的结果是否相等。

某个子串在所有子串中排第几名

某个子串[l, r]在所有子串构成的字典序中排第几名?这个问题我想到的办法只能是二分。把该子串当成是模式串T,在后缀数组上二分找到该模式串第一次出现的位置和最后一次出现的位置,在这两个位置之间的,就是文本串中出现了该模式串的所有位置。比第一次出现的位置靠前的后缀的所有前缀都会比当前子串要小,在最后一次出现的位置靠后的后缀的所有前缀都会比当前子串要大(如果相等那么会再出现一次,与“最后一次出现”矛盾)。而对于中间的相等这一段,比当前子串长度小的都会排在他前面,比他长度大的都会排在他后面。可以统计后缀数组中的长度的前缀和加速计算。注意这个问题和单纯的比较两个子串的大小是O(1)的并不一样,因为相等的子串可能会很多,而单纯比较大小的话只需要输出相等即可,现在还要求有多少个相等。

多字符串的所有子串比较

完全是一致的,用 '#' 把所有字符串连起来求一次后缀数组,就得到了每个字符串的后缀之间的排名,再注意截断他们对应的长度。


最长公共前缀LCP

光是后缀数组本身能解决的问题主要都是子串和反串的子串之间的比较问题。

要引入h和height,用于计算更多的东西。

下面的约定中,lcp的参数是字符串
以下,用后缀s[i, n]的起始下标i来表示s[i, n]
同理,sa[i]表示在在表示在所有后缀中排第i名的后缀的起始位置为sa[i],也就是说这个后缀是s[sa[i], n]

显然,满足交换律:

\[lcp(i,j)=lcp(j,i) \]

自己和自己的LCP当然是等于自己的串长。
lcp(i,i)=len(i)=n-i+1

sa[i]和sa[j]是排名靠近的两个后缀,其中可能隔着一些其他的后缀k。可以从前面的“二分找模式串”大概理解一下,这两个后缀的lcp一定布满了这整个区间。
比如
aaa..., aaba.., aabb..., aac... 是排名连续的一组后缀,他们之间的lcp是aa。虽然第二个和第三个之间的lcp长度有3,但是显然要取最小值。

$lcp(sa[i],sa[j])=\min\limits_{k\in[i+1,j]}(lcp(sa[k],sa[k-1])) $
即两个相隔甚远的后缀的lcp,可以用相邻后缀的lcp的rmq(区间最小值查询)求出来。

那么可以保存相邻两个后缀的lcp的长度,以供快速查询,这个数组一般别人的资料叫做 height 数组,我喜欢偷懒写成ht数组。

设 ht[i]=lcp(sa[i],sa[i-1]) ht[1]=0 即第i名的后缀和它前1名的LCP的长度。

那么 \(lcp(sa[i],sa[j])=\min\limits_{k\in[i+1,j]}ht[k]\)

求两个后缀的最长公共前缀就变成了在ht数组上求最小值的问题,可以用类似ST表的办法去做。

一个引理: ht[rk[i]]>=ht[rk[i-1]]-1

注意rk[i]表示的是后缀s[i, n]的排名,而rk[i-1]表示后缀s[i - 1, n]的排名,两个后缀之间相差了一个字符i。他们在后缀数组和ht数组中可能相距甚远,但是在原串和rk数组中是相邻的。
具体的证明去看oiwiki吧。

求ht数组 代码 TODO

然后,求两个后缀的lcp就变成RMQ问题,可以用ST表加速到查询时的时间复杂度O(1),或者用单调栈、线段树。由于RMQ问题可以通过笛卡尔树转化成LCA问题,然后LCA问题再通过欧拉序转化成加减1的RMQ问题,预处理O(n),单次查询O(1),在线算法,常数较大。如果是离线算法的话可用tarjan lca去做。

TODO RMQ问题和LCA问题转换

本质不同子串的数目

验证:https://www.luogu.com.cn/problem/P2408

所有的子串一共有\(\frac{1}{2}n(n+1)\)个,其中对于每个后缀,重复的恰好是ht的数量(很明显,就是当前后缀和前一名的后缀之间,有多长的公共前缀就是重复了多少个子串)。

\(\frac{1}{2}n(n+1)-\sum\limits_{i=2}^{n} ht[i]\)

这个用后缀数组进行计算的速度是比较惊人的。当然使用前缀函数的解法是O(n^2)的,只是前缀函数的解法是每次基于原串从头部或尾部增加或减少一个字符(相对而言,后缀数组则是离线的),然后用O(当前长度)的时间重新计算最大的前缀函数,改变的本质不同的子串其实跟这个最大的前缀函数的值有关。

出现至少k次的子串的最大长度

出现至少k次,意味着至少连续k个后缀的LCP是这个串。

故是连续k-1个ht的最小值,是一个窗口长度为k - 1滑动窗口的最小值问题,典型的做法是单调队列或者两个栈实现的队列。

字符串中最长的不重叠地出现了两次的子串

容易知道,如果长度为1的短串是很容易判断的,只需要判这个字符是否出现了两次即可。对于长度为len的串,可能会出现多次但是如何判断是否重叠?第一次出现的位置和最后一次出现的位置的距离超过len即可。二分枚举答案的长度,假设为k。

注意,height数组中的数值代表LCP的长度,height数组上滑动窗口的大小代表某个子串出现在不同位置的次数。

那么仿照上一题,长度为k的子串其实要求的就是height数组上的最小值为k滑动窗口中,最左边和最右边的后缀,变成ST表问后缀数组的最小最大值的问题。注意其实右边界如果出现某个元素<k之后,左边距也要移动到吐出这个元素才行。所以本质上这里并不是滑动窗口,而是一系列互不重叠的区间。注意到k越小,这些区间越容易连续,也越容易出现最小值和最大值(最左和最右的后缀)的距离超过k,所以的确是可以二分的。

如果不进行二分,要对所有的k进行求解,那么枚举最长的k,这时候区间很容易断开。逐步减小k的长度,会加入height为k的元素,使得断开的区间会逐渐连续在一起而且不会再断开,也就意味着可以用类似并查集来合并这些区间并维护区间的最小值和最大值(最早和最右的后缀),这样复杂度比上面那个更好,不需要log(k)。


用来解决后缀自动机的例题

后缀数组是对一个字符串的所有的后缀按字典序进行排名,而字典序是和前缀比较有关系的。又因为子串其实就是某个后缀的前缀,所以可以利用这些性质快速计算某些东西。与后缀自动机相比,后缀数组的特点是在查询某个字符串时经常要二分然后暴力(利用后缀数组的有序性进行二分),所以会多带一个log。而后缀自动机则是直接从t0状态转移,时间复杂度更好。后缀自动机的缺点是:数组实现会需要sigma倍的空间,而map实现的话会带上一个巨大的数据结构常数以及log sigma的复杂度,后缀数组则不受此限制,对于sigma非常大比如达到100+的题目,数组实现的后缀自动机的空间不可靠,而对于字符的值域规模达到n的题目,这两种数据结构的速度就几乎一样了。

后缀数组会简单很多。而必须使用ht数组(相邻后缀的LCP长度)的题目,基本就是本质不同的子串类型的题目,这种题目可以直接抄后缀自动机的len[u]-len[link[u]]比较简单。

文本串T的某个子串P出现的次数/文本串T是否包含某个子串P/在文本串T中找到模式串P所有出现的位置

对文本串T构造后缀数组,然后在T的后缀数组上根据字典序二分,check函数暴力匹配模式串P的字典序。若check成功则找到了这个子串P开头的左端点,由于后缀数组中相邻字典序的都会排在一起,所以是一堆以P为前缀的后缀排在一起,二分找到另一个右端点,即可知道出现的次数。

本质不同的子串的个数/本质不同的子串的总长度

见上面height数组的解法

所有的子串一共有\(\frac{1}{2}n(n+1)\)个,其中对于每个后缀,重复的恰好是ht的数量。

\(\frac{1}{2}n(n+1)-\sum\limits_{i=2}^{n} ht[i]\)

如果要算总长度,其实就是把每个重复串的贡献换一下。先算出长度为i的子串的数量(简单组合数学?n,n-1,n-2,...,他们的长度贡献分别是1,2,3,...)暴力跑一次就行。

重复的子串数量就是ht的值,对某个位置i,也是一个求和 \(\sum\limits_{j=1}^{ht[i]} j\) 是个等差数列,可以简单算一下。

字典序第k大的子串

有个很暴力的思路,每次二分一个字符c,询问当前串cur加入字符c之后,比cur严格小的串是否不超过k个,所以就是二分cur出现在后缀数组的哪里,每个后缀贡献的子串数量恰好就是它的长度,所以预处理一个长度的前缀和就可以拿来二分。

最小循环移位

见上面

两个字符串的最长公共子串

给定一个文本串T,每次询问一个模式串P,询问模式串P跟文本串T的最长公共子串的长度。对T构造后缀数组,然后用“查找模式串P的出现位置”的方法在后缀数组上二分,check的时候暴力匹配看看是小了还是大了,每次二分会让已经匹配的长度逐渐变长,收敛了之后就是最长的公共子串位置,复杂度是单次|P|*log|T|。

多个字符串的最长公共子串

对于多个字符串,可以在中间加入'#'将他们连接起来,求后缀数组,然后他们的字典序相近的前缀就会排在一段连续的区间中,通过求这段区间的LCP就能取出这堆字符串的最长公共子串(前提是这段区间被每个字符串至少贡献一次前缀,这个可以用尺取取出这样的最短的字符串,然后求他们的LCP)。

不在文本串T中出现的字典序最小的串/不在文本串T中出现的最短串中字典序最小的

对T求后缀数组,后缀们的前缀就是所有的子串,如果某个串在T中出现,那么它一定是某个后缀的前缀,所以不在文本串T中出现的串其实就是相邻的两个后缀的LCP+一个无法匹配两个后缀的最小的字符?这么看来也不需要每相邻的两个都算一次,直接往最长的"aaa...a"后面加个'a'就是字典序最小的串,感觉很无聊。所以这其实求的是“不在文本串T中出现的最短串中字典序最小的”。按上面的方法扫描出最短的LCP之后,再找到这个最短的LCP中最左边的那个(重新扫一次也行)。

posted @ 2021-01-17 18:49  purinliang  阅读(138)  评论(0编辑  收藏  举报