Loading

【学习笔记】字符串后缀算法

Page Views Count

后缀数组 Suffix Array

后缀排序

使用一种基数排序结合倍增的方法,将一个字符串的所有后缀排序。

定义 \(sa_i\) 为排名为 \(i\) 的后缀起始位置,\(rk_i\) 为起始位置为 \(i\) 的后缀排名。

假设已然排出长度为 \(l\) 的全部子串,那么长度为 \(2\times l\) 的只需要按照前半排名为第一关键字,后半排名的第二关键字。

于是以长度为 \(l\) 的排名为参照,可以先按照后半段的排名得到一个结果,在此基础上按照前半段排序。

点击查看代码
int n;
char s[maxn];
int sa[maxn],rk[maxn<<1],cnt[maxn],oldsa[maxn],oldrk[maxn<<1],tmp[maxn];
inline void get_sa(){
    int m=max(n,127);
    for(int i=1;i<=n;++i) ++cnt[rk[i]=s[i]];
    for(int i=1;i<=m;++i) cnt[i]+=cnt[i-1];
    for(int i=n;i>=1;--i) sa[cnt[rk[i]]--]=i;
    for(int l=1,k;l<n;l<<=1){
        //以上一次排序的rk作为参考
        //先按照后半段排序
        memset(cnt,0,sizeof(cnt));
        for(int i=1;i<=n;++i) oldsa[i]=sa[i];
        for(int i=1;i<=n;++i) ++cnt[rk[oldsa[i]+l]];
        for(int i=1;i<=m;++i) cnt[i]+=cnt[i-1];
        //基数排序要保留原有的关键字,因此从后向前枚举排名
        for(int i=n;i>=1;--i) sa[cnt[rk[oldsa[i]+l]]--]=oldsa[i];
        //同理按照前半段排序
        memset(cnt,0,sizeof(cnt));
        for(int i=1;i<=n;++i) oldsa[i]=sa[i];
        for(int i=1;i<=n;++i) ++cnt[rk[oldsa[i]]];
        for(int i=1;i<=m;++i) cnt[i]+=cnt[i-1];
        for(int i=n;i>=1;--i) sa[cnt[rk[oldsa[i]]]--]=oldsa[i];
        for(int i=1;i<=n;++i) oldrk[i]=rk[i];
        //现在可以得到基本的顺序,大致知道排名,同时要按照这个排名为另一个数组赋值
        k=0;
        for(int i=1;i<=n;++i){
            //如果两个关键字完全相同排名应当一致
            if(oldrk[sa[i]]==oldrk[sa[i-1]]&&oldrk[sa[i]+l]==oldrk[sa[i-1]+l]) rk[sa[i]]=k;
            else rk[sa[i]]=++k;
            if(k==n) return;
        }
        m=k;
    }
}

这里每次倍增都使用的两次基数排序,然而按照后半段时已经没有必要保留原来的顺序,因此分两部分排序:后半段为空的直接放在排名最靠前的位置,剩下的根据后半段的排名依次放进去。

在此基础上进行第二次的基数排序,相当于常数优化,复杂度仍是 \(O(n\log n)\)

点击查看代码
int n;
char s[maxn];
int sa[maxn],rk[maxn<<1],cnt[maxn],oldsa[maxn],oldrk[maxn<<1],tmp[maxn];
inline bool cmp(int x,int y,int l){
    return oldrk[x]==oldrk[y]&&oldrk[x+l]==oldrk[y+l];
}
inline void get_sa(){
    int m=max(n,127);
    for(int i=1;i<=n;++i) ++cnt[rk[i]=s[i]];
    for(int i=1;i<=m;++i) cnt[i]+=cnt[i-1];
    for(int i=n;i>=1;--i) sa[cnt[rk[i]]--]=i;
    for(int l=1,k;;l<<=1){
        k=0;
        //后半部分为空直接加入
        for(int i=n;i+l>n;--i) oldsa[++k]=i;
        //剩下的按排名从小到大枚举起始位置超过l的加入
        for(int i=1;i<=n;++i) if(sa[i]>l) oldsa[++k]=sa[i]-l;
        //正常的基数排序
        memset(cnt,0,sizeof(cnt));
        for(int i=1;i<=n;++i) ++cnt[tmp[i]=rk[oldsa[i]]];
        for(int i=1;i<=m;++i) cnt[i]+=cnt[i-1];
        for(int i=n;i>=1;--i) sa[cnt[tmp[i]]--]=oldsa[i];
        for(int i=1;i<=n;++i) oldrk[i]=rk[i];
        k=0;
        for(int i=1;i<=n;++i) rk[sa[i]]=cmp(sa[i],sa[i-1],l)?k:++k;
        if(k==n) return;
        m=k;
    }
}

\(\mathrm{lcp}\)

\(\mathrm{lcp}(i,j)\) 定义为后缀排序之后,排名为 \(i\) 的后缀与排名为 \(j\) 的后缀的最长公共前缀。

显然后缀排序后,排名越靠近的越相像,可以理性理解即得到:

\[\mathrm{lcp}(i,j)=\min_{k=i+1}^j \mathrm{lcp}(k-1,k) \]

也就是在已知排名相邻的 \(\mathrm{lcp}\) 后,可以借助数据结构快速求出任意的 \(\mathrm{lcp}\),目前预处理复杂度已经降到了 \(O(n^2)\)

这里定义相邻的 \(\mathrm{lcp}\)\(height_i=\mathrm{lcp}(i-1,i)\),辅助引入一个 \(h_i=height_{rk_i}\),接下来证明:\(h_i\ge h_{i-1}-1\)

考虑后缀 \(i\) 实际是后缀 \(i-1\) 去掉第一个字符,假设后缀 \(i-1\) 排名前一位后缀是 \(j-1\),那么 \(h_{i-1}-1=\mathrm{lcp}(rk_i,rk_j)\),考虑 \(i-1\)\(j-1\) 都去掉开头字符后,得到的两个后缀的排名距离不会缩小。

也就是说,\(\mathrm{lcp}(rk_i,rk_j)\) 应当是一个多个相邻 \(\mathrm{lcp}\)\(\min\) 的结果。而在这其中,就包括了 \(\mathrm{lcp}(rk_i-1,rk_i)=h_i\),即证:\(h_i\ge h_{i-1}-1\)

由于只回退 \(O(n)\) 次,只增加 \(O(n)\) 次,求 \(height\) 数组就是 \(O(n)\) 的。

点击查看代码
for(int i=1,k=0;i<=n;++i){
    if(k) --k;
    while(s[i+k]==s[sa[rk[i]-1]+k]) ++k;
    height[rk[i]]=k;
}

一些技巧

  • 求出 \(heihgt\) 后基本上可以把 \(\mathrm{lcp}\) 转化成与区间 \(\min\) 有关的问题。

    最基本的是 \(O(1)\) 查询的 ST 表,可以快速求得 \(\mathrm{lcp}\)

    其次统计所有 \(\mathrm{lcp}\) 时,一共 \(O(n^2)\) 对后缀只有 \(O(n)\) 个答案,于是枚举这些答案的影响范围,实际就是单调栈求作为最小值的区间。

    还有一种操作是考虑对排名相邻的连边权为 \(height\) 的边,任意 \(\mathrm{lcp}\) 都是树上路径的 \(\min\),类似瓶颈路问题。求与 \(\mathrm{lcp}\) 值对应方案数,考虑从大到小连边,两棵树彼此之间的 \(\mathrm{lcp}\) 都是当前连边的边权。同时可以考虑重构树,新建节点的权值代替边权。

一些题

CodeForces-822E Liar *2400

贪心的去想,且找到一个符合 DP 的状态设计。

\(k\) 很小,可以枚举。显然 \(s\) 中同一段前缀,能分段匹配 \(t\) 的前缀越多越好,于是可以 DP 的目标就是分段匹配到的最大前缀。

找到 \(j-1\) 次分段后 \([1,i]\) 匹配到的位置 \(p\),从 \(i+1\) 开始分出一段,需要求两个串从固定位置开始的最大公共子串,加一个分隔符求一下 \(\mathrm{lcp}\) 就好了。

由于是分段匹配,\(dp_{i,j-1}\) 中并不要求 \(i\) 是最后一段的结尾,而转移到的 \(dp_{i+\mathrm{lcp},j}\) 与之相反,解决方法是取前缀 \(\max\)

Luogu-P1117 NOI 2016 优秀的拆分

神仙题。

第一步转化是 AABB 的形式相当于两个 AA 拼接,只需求出每个位置作为 AA 的开头或结尾的方案数,答案就是 \(\sum_{i=1}^{n-1} f_ig_{i+1}\)

之后是非常强大的做法:枚举长度设置关键点。假设当前的 A 长度为 \(L\),当我们每 \(L\) 个位置设置一个关键点时,满足 AA 的串一定包含两个关键点,考虑求出这两个关键点 \(\mathrm{lcs}\) 以及 \(\mathrm{lcp}\),当二者的公共部分区间有交集时,说明至少存在一个 AA,简单计算可以确定开头的取值区间和结尾的取值区间。

\(\mathrm{lcs}\) 以及 \(\mathrm{lcp}\) 可以正反 SA,区间加最后单点查询直接差分即可。

Luogu-P2178 NOI 2015 品酒大会

结合图论去思考,\(\mathrm{lcp}\)\(\min\) 的性质不仅适合 ST 表、单调栈等等数据结构,也可以考虑树上瓶颈路的问题。

即对于将 \(height\) 值作为边权,从大到小连边,每次连边的两个连通块之间的 \(\mathrm{lcp}\) 就是当前枚举边的边权,合并过程中即可维护方案数以及最大乘积。

Luogu-P4248 AHOI 2013 差异

\(\mathrm{lcp}\) 的来源是某个 \(height\)\(height\) 的贡献是作为最小值的区间,单调栈解决问题。

Luogu-P7409 SvT

多测且选取的范围为集合,对直接查询和单调栈并不友好,考虑使用与瓶颈路有关的做法。

建出重构树后,对于非叶子节点,其权值与左右子树大小之积就是贡献。由于只需要在有左右儿子的节点贡献答案,建虚树即可。

CodeForces-1073G Yet Another LCP Problem *2600

同上,建出虚树后改变一下求解的式子即可。

Luogu-P5028 Annihilate

先插分隔符求出做总的后缀排序。

对于每个位置,钦定其最小值来源的串,那么一定是找到排名的前驱后继求解,使用 set 以及 ST 表。这样的复杂度是 \(O(n|s|\log|s|)\)

这样的做法不够优秀,主要是分开枚举两个后缀的来源限制了复杂度。假定一个后缀的来源,对应的后缀在排名中打上标记,那么每个没有被打上标记的(即来自别的串的后缀),与当前串所有后缀的最大 \(\mathrm{lcp}\) 同样也是在找前驱后继,于是直接正反各扫一遍,每个位置的答案更新为到上一个标记点的答案,时间复杂度是 \(O(|s|\log|s|+n|s|)\)

分隔符不能使用同样的

Luogu-P2852 USACO 2006 DEC Milk Patterns G

同样处理瓶颈路的套路,出现次数等价于连通块的大小。

后缀自动机 Suffix Automaton

后缀自动机与 \(\mathrm{Parent}\) 树的构建

简单定义

  • \(\mathrm{endpos}(t)\):子串 \(t\) 在原串 \(s\) 中所有出现位置(最后一个字符位置)的集合。

  • 等价类:\(\mathrm{endpos}\) 相同的所有子串构成一个等价类。

  • 后缀自动机:使得所有子串都能从初始状态出发得到,并通过等价类压缩状态的自动机。

  • \(\mathrm{len}(u)\):一个等价类中的最大子串长。

  • \(\mathrm{minlen}(u)\):一个等价类中的最小子串长。

  • \(\mathrm{link}(u)\):后缀链接,定义为所有 \(\mathrm{endpos}\) 等价类中,规模最小且是 \(\mathrm{endpos}(u)\) 超集的状态,或者表述为将 \(\mathrm{minlen}(u)\) 对应子串去掉第一个字符得到的子串所在等价类。

  • \(\mathrm{Parent}\) 树:后缀链接 \(\mathrm{link}\) 构成的一棵树。

一些定理

  • 处于同一 \(\mathrm{endpos}\) 等价类的子串两两为后缀关系。

  • 处于同一 \(\mathrm{endpos}\) 等价类的子串长度恰好覆盖区间 \([\mathrm{minlen}(u),\mathrm{len}(u)]\)

  • \(\mathrm{Parent}\) 树上,父子关系节点的 \(\mathrm{endpos}\) 等价类为包含关系,兄弟关系节点的 \(\mathrm{endpos}\) 等价类没有交,这构成了一棵划分集合的树。

  • \(\mathrm{Parent}\) 树的叶子节点都是原串一个前缀。

  • 反串建出的 \(\mathrm{Parent}\) 树等价于正串的后缀树,也就是正串所有后缀构成的 \(\mathrm{Trie}\)

如何构建

按照以下算法流程:

  1. 记上次新建的节点 \(last\),当前新建的节点 \(cur\),增加字符 \(c\),令 \(\mathrm{len}(cur)=\mathrm{len}(last)+1\),接下来要处理 \(\mathrm{link}(cur)\)

  2. \(last\) 开始跳后缀链接,直到初始状态或找到一个有 \(c\) 转移的状态。

  3. 若到初始状态,说明这个字符之前没有出现,\(\mathrm{link}(cur)=0\)

  4. 另一种情况,设找到状态 \(p\),转移 \(c\) 到达 \(q\),分情况讨论。若 \(\mathrm{len}(p)+1=\mathrm{len}(q)\),说明 \(q\) 代表的等价类只包含一个子串,此时可以直接修改 \(\mathrm{endpos}(q)\),令 \(\mathrm{link}(cur)=q\),即增加了当前字符的位置;反之则新建节点 \(clone\),继承 \(q\) 的全部信息,同时使 \(\mathrm{link}(cur)=\mathrm{link}(q)=clone\)

  5. \(last\) 修改为 \(cur\)

对新建 \(clone\) 节点的补充:

假设当时的位置 \(i\),跳后缀链接时,我们只关心 \(i-1\) 出现的等价类集合,而添加一个字符时,对应的子串应当为上文中的 \(\mathrm{len}(p)+1\),当这个子串并不单独存在一个状态中,就需要新建 \(clone\) 节点。

对新建 \(clone\) 节点的再补充:

考虑字符串 \(\texttt{aabaabab}\),加入第 \(6\) 个字符时,\(\texttt{aabaa}\) 的后缀链接为 \(\texttt{aa}\),其字符 \(\texttt{b}\) 的转移为 \(\texttt{aab}\),此时属于第一种情况,可以直接增加;加入第 \(8\) 个字符时,\(\texttt{aabaaba}\) 的后缀链接为 \(\texttt{aaba}\),再跳到 \(\texttt{a}\) 才存在 \(\texttt{b}\) 的转移 \(\texttt{aab}\),而实际上我们是由 \(\texttt{a}\) 转移到 \(\texttt{ab}\),而 \(\texttt{ab}\) 并不是最长的后缀,这使得并不是整个状态的出现次数都有所增加,因此要拆点。

对新建 \(clone\) 节点的再再补充:

跳后缀链接至 \(p\) 前经过的节点为 \(p'\),则 \(p'\)\(cur\) 有连边,由于所有到 \(cur\) 有转移的状态子串长度应当恰好填满 \([\mathrm{minlen}(cur)-1,\mathrm{len}(cur)-1]\),且 \(p'\) 子串是最小的(\(p'\) 之上不再有连到 \(cur\) 的节点),因此 \(\mathrm{minlen}(cur)=\mathrm{minlen}(p')+1=(\mathrm{len}(p)+1)+1\),当 \(\mathrm{len}(p)+1=\mathrm{len}(q)\) 时,就有 \(\mathrm{minlen}(cur)=\mathrm{len}(q)+1\),这符合定义,反之不符合定义需要拆点。

点击查看代码
struct SuffixAutomaton{
    int ch[maxn<<1][26],tot,last;
    int len[maxn<<1],link[maxn<<1];
    SuffixAutomaton(){
        tot=0,last=0;
        len[0]=0,link[0]=-1;
    }
    vector<int> E[maxn<<1];
    inline void extend(int c){
        int cur=++tot;
        len[cur]=len[last]+1;
        int p=last;
        while(p!=-1&&!ch[p][c]){
            ch[p][c]=cur;
            p=link[p];
        }
        if(p==-1) link[cur]=0;
        else{
            int q=ch[p][c];
            if(len[p]+1==len[q]) link[cur]=q;
            else{
                int clone=++tot;
                len[clone]=len[p]+1,link[clone]=link[q];
                for(int i=0;i<26;++i) ch[clone][i]=ch[q][i];
                while(p!=-1&&ch[p][c]==q){
                    ch[p][c]=clone;
                    p=link[p];
                }
                link[cur]=link[q]=clone;
            }
        }
        last=cur;
    }
}SAM;

解决各类问题

由于后缀自动机在线性复杂度内表示出一个字符串的所有子串,可以解决很多字符串问题,一些复杂度比 SA 优秀。

  • 求本质不同子串个数:自动机就是关于本质不同子串建的,对于每一个等价类集合,其包含子串个数为 \(\mathrm{len}(u)-\mathrm{minlen}(u)+1=\mathrm{len}(u)-\mathrm{len}(\mathrm{link}(u))\),求和即可(若在线查询则每次都增量在新建的节点 \(cur\))处。

    例题:Luogu-P4070 SDOI 2016 生成魔咒

  • 求子串出现次数:找到子串所在等价类集合,相当于求这个集合的等价类规模。不难发现所有的新建的节点 \(cur\) 都是一个前缀,在 \(\mathrm{Parent}\) 树上,子树内前缀个数也就是等价类规模,建出 \(\mathrm{Parent}\) 树遍历一遍即可。

    例题:Luogu-P5341 TJOI 2019 甲苯先生和大中锋的字符串SPOJ-NSUBSTR Substrings

  • 字典序为 \(k\) 的子串:按照正常 Trie 树遍历即可

    例题:Luogu-P4341 BJWC 2010 外星联络P3975 TJOI 2015 弦论

  • 区间单模式串匹配:对于查询串,区间左端点右移即考虑仍在当前状态或是跳 \(\mathrm{link}\),区间右端点右移则是直接转移

    例题:CodeForces-235C Cyclical Quest *2700

  • 线段树合并维护 \(\mathrm{endpos}\) 集合:暴力向上维护,时间空间复杂度是都是 \(O(n\log n)\),时间复杂度的证明与正常线段树合并相同,线段树的节点数为 \(O(n)\),每次合并时,重复节点个数不超过小的部分,因此类比启发式,得到 \(O(n\log n)\) 的时间复杂度,空间复杂度同理,每次遇到重复节点才会新开节点。

    例题:CodeForces-1037H Security *3200

广义后缀自动机 General Suffix Automaton

对多个串处理,需要建广义后缀自动机。

建出之后处理问题与单串没有太多的区别。

离线构建方法

要求保证每个串之间的独立性以及串内字符的连续性,简单的离线做法是建出 Trie 树,记录下树上节点对应自动机上编号,BFS 建树即可。

点击查看代码
struct SuffixAutomaton{
    int ch[maxn<<1][26],tot;
    int len[maxn<<1],link[maxn<<1];
    SuffixAutomaton(){
        tot=0;
        len[0]=0,link[0]=-1;
    }
    inline int extend(int last,int c){
        int cur=++tot;
        len[cur]=len[last]+1;
        int p=last;
        while(p!=-1&&!ch[p][c]){
            ch[p][c]=cur;
            p=link[p];
        }
        if(p==-1) link[cur]=0;
        else{
            int q=ch[p][c];
            if(len[p]+1==len[q]) link[cur]=q;
            else{
                int clone=++tot;
                len[clone]=len[p]+1,link[clone]=link[q];
                for(int i=0;i<26;++i) ch[clone][i]=ch[q][i]; 
                while(p!=-1&&ch[p][c]==q){
                    ch[p][c]=clone;
                    p=link[p];
                }
                link[cur]=link[q]=clone;
            }
        }
        return cur;
    }
}SAM;

struct Trie{
    int tr[maxn][26],tot;
    int fa[maxn],last[maxn],C[maxn];
    inline void insert(){
        int len=strlen(s+1);
        int u=0;
        for(int i=1;i<=len;++i){
            int c=s[i]-'a';
            if(!tr[u][c]) tr[u][c]=++tot;
            fa[tr[u][c]]=u,C[tr[u][c]]=c;
            u=tr[u][c];
        }
    }
    inline void build(){
        queue<int> q;
        for(int i=0;i<26;++i){
            if(tr[0][i]) q.push(tr[0][i]);
        }
        while(!q.empty()){
            int u=q.front();
            q.pop();
            last[u]=SAM.extend(last[fa[u]],C[u]);
            for(int i=0;i<26;++i){
                if(tr[u][i]) q.push(tr[u][i]);
            }
        }
    }
}T;

参考资料

后缀数组 Suffix Array

后缀自动机 Suffix Automaton

posted @ 2022-12-30 19:42  SoyTony  阅读(203)  评论(0编辑  收藏  举报