后缀自动机 SAM

理论构造

SAM

考虑一个 SAM 需要满足什么性质。

  • 是一个 DFA:每个状态所有转移的标号不同,且每个状态包含初始状态到当前状态的所有路径代表的字符串。
  • 接受原串所有后缀:原串任意后缀均可表示为 DFA上一条到某个终止状态的路径,任意到终止状态的路径上的转移连接起来也必然为原串一个后缀。

然后有个很精妙的构造,我们定义一个子串在原串中所有出现位置的结束位置集合为 \(\mathrm{endpos}\),把 \(\mathrm{endpos}\) 相同的子串归为一个等价类,然后令 SAM 的状态为所有这些等价类加上一个初始状态 \(s\)。

  • 显然,一个等价类后插入一个新字符,只会转移到唯一一个等价类,并且新等价类的 \(\mathrm{endpos}\) 集合大小不超过这个等价类的 \(\mathrm{endpos}\) 集合大小,容易反证。
  • 等价类内包含的所有子串均可表示为从初始状态转移到该等价类所在的状态的一条路径。我们将所有 \(\mathrm{endpos}\) 集合包含 \(|S|\) 位置的令作终止状态即可满足 SAM 的性质。

这么构造的 SAM 满足很多优秀的性质。

  • 足够小。状态数不超过 \(2n-1\),转移数不超过 \(3n-4\)。事实上如此构造的 SAM 是最小的。
  • 每个 \(\mathrm{endpos}\) 集合内的所有子串长度是一段连续区间。对于一个子串,向其左侧删点 \(\mathrm{endpos}\) 集合大小不降,左侧加点 \(\mathrm{endpos}\) 集合大小不增。因此我们考虑对每个状态存下其内部最长子串长度 \(len\) 与子串个数即可表示其包含的所有子串。

后缀链接树

当我们删去当前等价类 \(i\) 内最小的子串的首字母,记得到的新子串所在等价类为 \(j\),定义 \(link(i)=j\) 为 \(i\) 的后缀链接。于是我们只需要对每个状态存 \(len\) 即可,子串个数可以表示为 \(len(i)-len(link(i))\)。

所有后缀链接形成了树形结构,即为后缀链接数,也就是 \(link\) 树。\(link\) 树也满足很多优秀的性质。

  • 从根往下走相当于不断添加首字母,从下往根走相当于不断删除首字母——利用该理解可以简单证明下述性质。
  • 一个状态的 \(\mathrm{endpos}\) 集合包含于其所有祖先的 \(\mathrm{endpos}\) 集合(令初始状态 \(\mathrm{endpos}\) 集合为全集)。
  • 兄弟节点的 \(\mathrm{endpos}\) 集合无交。注意父节点的 \(\mathrm{endpos}\) 集合大小不一定等于所有子节点的 \(\mathrm{endpos}\) 集合之和,还需要考虑原串上的前缀的 \(\mathrm{endpos}\)。
  • 终止状态为 \(\{|S|\}\) 的所有祖先。
  • \(link\) 树即为反串的后缀树。故而可以用 SAM 实现后缀树和 SA。

算法实现

构造 SAM 的算法为在线的增量构造过程,也就是说,它是逐个字符插入的。

我们记 \(lst\) 表示原串 \(\mathrm{endpos}=\{|S|\}\) 的状态,新插入了字符 \(c\),新建状态 \(np\) 表示新串中 \(\mathrm{endpos}=\{|S|\}\) 的状态,显然有 \(\delta(lst,c)=np\)。

接下来我们开始从 \(lst\) 跳 \(link\),记当前所在点为 \(p\),只要原先不存在 \(\delta(p,c)\),执行 \(\delta(p,c)=np\),正确性显然。如果一路跳到 \(s\),直接执行 \(link(np)\gets s\) 并结束流程即可。

否则,我们跳到了一个状态 \(p\) 存在 \(\delta(p,c)\),记 \(\delta(p,c)=q\),分类讨论:

  • 若 \(len(q)=len(p)+1\),直接在 \(\mathrm{endpos}(q)\) 内插入 \(|S|\) 位置即可,实现上只需要执行 \(link(np)\gets q\) 并结束流程即可。

  • 否则,则说明 \(|S|\) 位置前第 \(len(p)+1\) 个字符不同于 \(\mathrm{endpos}(q)\) 中其余位置前第 \(len(p)+1\) 个字符,我们需要将 \(q\) 状态中最短的子串分裂出来形成一个新的状态 \(nq\),有 \(\mathrm{endpos}(nq)=\mathrm{endpos}(q)\cup\{|S|\}\)。

    显然 \(nq\) 的 \(link\) 与 \(\delta\) 直接继承自 \(q\),然后令 \(link(np)\gets nq\) 以及 \(link(q)\gets nq\),也就是 \(|S|\) 位置前第 \(len(p)+1\) 个字符的不同导致了此处存在的两条后缀链接。

    最后我们从 \(p\) 继续跳 \(link\),对 \(\delta(p,c)=q\) 的所有状态执行重定向 \(\delta(p,c)\gets nq\)。实现上当首次 \(\delta(p,c)\ne q\) 时就可以直接退出了,因为之后的 \(\delta\) 必定不为 \(q\)。

模版

namespace SAM{
    const int N=1e6+5,A=26;
    int cnt=1,lst=1,nxt[N<<1][A],link[N<<1],len[N<<1];
    void extend(int c){
        int p=lst,np=lst=++cnt;
        len[np]=len[p]+1;
        for(;p&&!nxt[p][c];p=link[p])nxt[p][c]=np;
        if(!p){link[np]=1;return;}
        int q=nxt[p][c];
        if(len[q]==len[p]+1){link[np]=q;return;}
        int nq=++cnt;
        memcpy(nxt[nq],nxt[q],sizeof nxt[q]),link[nq]=link[q];
        len[nq]=len[p]+1,link[np]=link[q]=nq;
        for(;p&&nxt[p][c]==q;p=link[p])nxt[p][c]=nq;
    }
    void build(string s){
        rep(i,1,cnt)memset(nxt[i],0,sizeof nxt[i]);cnt=lst=1;
        for(auto c:s)extend(c-'a');
    }
}using namespace SAM;

以及比较常用的,主席树合并维护 \(\mathrm{endpos}\) 集合封装:

namespace SAM{
    const int N=1e5+5,A=26;
    int n,rt[N<<1];
    struct PST{
        int cnt,dat[N*40],ls[N*40],rs[N*40];
        void modify(int k,int &x,int l=1,int r=n){
            x=++cnt;
            if(l==r)return dat[x]=1,void();
            int m=l+r>>1;
            if(k<=m)modify(k,ls[x],l,m);
            else modify(k,rs[x],m+1,r);
            dat[x]=dat[ls[x]]+dat[rs[x]];
        }
        int merge(int x1,int x2){
            if(!x1||!x2)return x1+x2;
            int x=++cnt;
            ls[x]=merge(ls[x1],ls[x2]);
            rs[x]=merge(rs[x1],rs[x2]);
            dat[x]=dat[ls[x]]+dat[rs[x]];
            return x;
        }
        int query(int lq,int rq,int x,int l=1,int r=n){
            if(!x||lq>rq)return 0;
            if(lq<=l&&r<=rq)return dat[x];
            int m=l+r>>1,res=0;
            if(lq<=m)res+=query(lq,rq,ls[x],l,m);
            if(m<rq)res+=query(lq,rq,rs[x],m+1,r);
            return res;
        }
    }pst;
    int cnt=1,lst=1,nxt[N<<1][A],link[N<<1],len[N<<1],plc[N];
    void extend(int c,int id){
        int p=lst,np=lst=++cnt;pst.modify(id,rt[np]);plc[id]=np;
        len[np]=len[p]+1;
        for(;p&&!nxt[p][c];p=link[p])nxt[p][c]=np;
        if(!p){link[np]=1;return;}
        int q=nxt[p][c];
        if(len[q]==len[p]+1){link[np]=q;return;}
        int nq=++cnt;
        memcpy(nxt[nq],nxt[q],sizeof nxt[q]),link[nq]=link[q];
        len[nq]=len[p]+1,link[np]=link[q]=nq;
        for(;p&&nxt[p][c]==q;p=link[p])nxt[p][c]=nq;
    }
    vec<int> G[N<<1];
    void dfs(int x){
        for(auto y:G[x])dfs(y),rt[x]=pst.merge(rt[x],rt[y]);
    }
    void build(string s){
        n=s.size();
        rep(i,1,cnt)memset(nxt[i],0,sizeof nxt[i]),vec<int>().swap(G[i]),rt[i]=0;cnt=lst=1;
        rep(i,1,pst.cnt)pst.dat[i]=pst.ls[i]=pst.rs[i]=0;pst.cnt=0;
        int it=0;for(auto c:s)extend(c-'a',++it);
        rep(i,2,cnt)G[link[i]].pub(i);
        dfs(1);
    }
}using namespace SAM;

习题

P3804 【模板】后缀自动机(SAM)

出现次数等价于 \(\mathrm{endpos}\) 集合大小,最长的话只需要对每个状态用 \(len\) 乘上集合大小更新即可。

考虑怎么求集合大小,我们之前已经提到过实际上就是 \(link\) 树上所有子节点的集合大小加上可能的前缀位置。考虑后者如何处理,我们只需将每次 \(extend\) 操作一开始新建的那个状态标记初始大小为 \(1\) 即可,最后深搜一遍累加子节点大小即可。

P4070 [SDOI2016] 生成魔咒

动态维护本质不同子串个数。由于每个子串只会出现在唯一一个状态里,因此我们只需要每次累加所有状态包含的个数即可。

但显然不能每次枚举所有状态,我们注意到每次除了新建的那个状态外,其余状态里串数总和不变(因为分裂状态不会凭空多出串),因此只需要每次给答案累计上新状态的 \(len(x)-len(link(x))\) 即可。

P3975 [TJOI2015] 弦论

不妨建出 SAM,然后每次从小到大枚举转移字符,看其能走到的子串个数与 \(k\) 的关系,超过就把 \(k\) 减去对应个数,否则就递归进去。

考虑如何处理每个状态的子串个数。若 \(t=0\),即为当前状态能走出的路径数总和,记搜一下即可;否则就是当前节点所有能走出的路径终止状态 \(\mathrm{endpos}\) 集合大小的总和,也是记搜一下即可。

SP1811 LCS - Longest Common Substring

对一个串建 SAM,然后用另一个串在上面跑,每次能走就走,不能走就跳 \(link\),动态维护全过程最大值即可。

整个过程中在第一个串上的匹配状态形如一个双指针滑动窗口,故而算法是 \(O(n)\) 的。前进一步匹配长度加一,跳一次 \(link\) 匹配长度更新为新跳到的状态的 \(len\),如果跳到初始状态就从下一个位置重新开始匹配。

SP1812 LCS2 - Longest Common Substring II

沿用上一题的做法,对第一个串建 SAM,然后将之后所有串放上面跑,对每个节点维护所有串中匹配到这个点的最大长度。

注意每一个串跑完之后,每个状态对于当前串跑出的结果应该对 \(link\) 树上所有子节点取 \(\max\) 再对自身 \(len\) 取 \(\min\),否则会影响到最终答案统计。

P4112 [HEOI2015] 最短不公共子串

第一问,建出 \(B\) 的 SAM,然后枚举 \(A\) 中起点找首个不被接受的长度即可。第二问将第一问的做法迁移到序列自动机上即可。

第三问,依次枚举 \(A\) 中字符,标记当前 \(B\) SAM 上可以走到的状态及其最短匹配长度,每次枚举所有状态更新信息与答案。第四问将第三问的做法迁移到序列自动机上即可。

CF700E Cool Slogans

首先每个串的答案均可由其任意前缀的答案直接更新得到,故而其只需考虑其前一个串为其后缀的情况。显然这个后缀尽可能长是不劣的。

也就是说我们要对一个串找到其最长的一个后缀,满足其在当前串中完整出现了两次,也就是在非后缀位置上额外出现过至少一次。假如判定 \(x\) 的后缀 \(y\),则只需判定 \(\mathrm{endpos}(y)\cap[pos(x)-len(x)+len(y),pos(x)-1]\ne \varnothing\) 即可。后缀越短 \(\mathrm{endpos}\) 集合越大,答案具有单调性。

但是枚举 \(n^2\) 个子串显然并不现实。

首先注意到,同一个 \(\mathrm{endpos}\) 等价类内部是无法互相转移的,假如 \(A\) 串内 \(B\) 串出现了两次,那 \(B\) 串的 \(\mathrm{endpos}\) 集合显然不可能与 \(A\) 串的完全相同。

继续注意,注意到一个等价类内所有串对另一个等价类的最长串的合法情况是一致的,不可能出现部分合法部分不合法,否则那个最长串可以向左延伸相应超出的部分,与最长要求冲突。

综上,我们只需要考虑所有 \(\mathrm{endpos}\) 等价类的最长串转移即可。求每个状态的 \(\mathrm{endpos}\) 集合可以在 \(link\) 树上主席树合并得到。

记 \(f(x)\) 表示到状态 \(x\) 的答案,则 \(f(x)\in[f(link(x)),f(link(x))+1]\),因为 \(f\) 关于深度是有单调性的,假如 \(f(x)>f(link(x))+1\) 则说明存在一个 \(link(x)\) 的祖先 \(f\) 比 \(f(link(x))\) 还大,矛盾。所以我们只需要存储 \(f\) 与 \(link(x)\) 相同的最浅祖先 \(g(link(x))\),每次尝试从 \(g(link(x))\) 更新即可。

于是我们做到了 \(O(n\log n)\)。

QOJ7345 Circular Shift

给定一个串 \(S\),统计其子串 \(T\) 个数,满足循环左移一位后仍然为 \(S\) 的子串。

考虑关心 \(R=T[2:|T|]\),考虑统计每个本质不同的 \(R\) 所有出现位置左右两侧同时出现了多少对字符。对于一个 \(\mathrm{endpos}\) 等价类,其右侧出现过的字符是一定的,可以枚举该字符 \(c\),考虑如何统计 \(cR\) 出现于几个长度,对于小于 \(len\) 的长度任意选一个 \(\mathrm{endpos}\) 往前区间查询 \(c\) 数量即可,恰为 \(len\) 的枚举 \(link\) 树上儿子求就行。最后再统计一下单字符个数。

于是我们做到了 \(O(n|\Sigma|\log n)\)。

P6640 [BJOI2020] 封印

考虑单次匹配的话就是建出 \(S\) 的 SAM 然后把 \(T\) 放上面跑一遍双指针就行。

考虑每次查询 \(T\) 都是不变的,我们不妨先把 \(T\) 跑完一遍然后处理出 SAM 上每个状态能匹配上的最大 \(T\) 长度。

考虑如何处理每次询问。区间左端点显然影响很大。不妨二分答案 \(m\),那么只要求存在终止在 \([l+m-1,r]\) 的前缀的匹配长度超过 \(m\) 即可。可以线段树处理。注意一个前缀的后缀匹配长度应当是所在状态 \(link\) 树上到根的匹配长度最大值。

CF235C Cyclical Quest

问题可以写成,\(S\) 有多少个长为 \(|x|\) 的与 \(x+x\) 的公共子串。建出 \(S\) 的 SAM 对每个状态跑出最大匹配然后枚举状态更新答案即可,不同位置子串不同所以要搞出 \(\mathrm{endpos}\) 大小。

但不能每次询问枚举所有状态,考虑每次尽量经过包含长为 \(|x|\) 的状态,每次 \(len(link(i))\ge |x|\) 就跳,由于单调性所以不会影响答案。然后我们每次只枚举所有经过过的点更新答案即可,记得标记一下每个状态只算一次。

P4248 [AHOI2013] 差异

求两两后缀 LCP 长度之和。对反串建出 SAM,则 \(link\) 树上两个串的 LCA 的最大长度即为二者 LCP 长度,注意对二串原长取 \(\min\) 处理 LCA 为其中一点的情况。

回到本题,所有后缀即为反串的所有前缀,前缀长度即为所在状态 \(len\) 故无需取 \(\min\),枚举 LCA 计算即可。

P4094 [HEOI2016/TJOI2016] 字符串

考虑 \(s[a:b]\) 怎么处理,左边界显然还是很不好处理。考虑二分答案 \(m\),于是要求 \(s[c:c+m-1]\) 是 \(s[a:b]\) 的子串。建出 SAM 之后我们倍增找到 \(s[c:c+m-1]\) 所在状态,看它的 \(\mathrm{endpos}\) 中是否包含 \([a+m-1,b]\) 中的某点即可。

P7180 [BalticOI 2004] Repeats (Day2)

首先会一个 \(O(n^2)\) 做法,枚举 \(l\),然后枚举 \(x\),计算后缀 \(x\) 和后缀 \(x+l\) 的 LCP 长度 \(len\),用 \(\lfloor\frac{len+l}{l}\rfloor\) 更新答案。

换一个思路,我们考虑枚举 LCP,然后枚举这个串所有相邻两个出现位置 \(x,x+l\),显然只需要最小化这个 \(l\) 即可。考虑 SAM,一个等价类中显然只需要取最长串作为 LCP,我们考虑快速维护相邻两项的最小间距。

考虑主席树合并,我们维护区间内部最小间距、区间最左侧点和区间最右侧点即可。输出方案的话再维护一个区间最小间距产生位置即可。

CF1037H Security

每次输出 \(s[l:r]\) 内字典序大于 \(x\) 的最小串。

假如说每次查询 \(s\) 全集的话,我们建出 \(s\) 的 SAM,每次尽量往最小的可行方向转移:若前缀同 \(x\) 要求最小方向存在超过 \(x\) 的串,否则直接往最小了操作;

实现上可以考虑先尽可能往 \(x\) 方向走,直到无路可走回退,再直到首个有路可走且超过了 \(x\) 的位置走一步,然后结束进程。

延续这个思路的话考虑需要对每个状态维护是否存在串完全位于 \([l,r]\) 内。注意到每次查询串长是一定的,因此只维护 \(\mathrm{endpos}\) 即可。

P4770 [NOI2018] 你的名字

可以写作 \(T\) 的每个状态内的子串数减去其中为 \(S[l:r]\) 子串的状态数。

考虑如果是全集怎么做。状态 \(i\) 内所有串均可表示为 \(S[:x]\) 的一段连续长度的后缀,\(x\in \mathrm{endpos}(i)\)。我们预处理出 \(T\) 每个前缀的最长的为 \(S\) 子串的后缀即可,是经典 SAM 双指针。

考虑 \(S[l:r]\) 限制,能不能走变成了下一个状态是否存在相应长度的完全位于 \([l:r]\) 的串,由于长度一定所以可以直接转化为 \(\mathrm{endpos}\) 区间查询,注意这里的长度应当为下一个状态的最短长度,走完之后再查验最大匹配值,于是就做完了。

posted @ 2026-03-21 17:03  LastKismet  阅读(44)  评论(0)    收藏  举报