初识SAM——后缀自动机

2022年01月20日,第七天

后缀自动机(SAM)

后缀自动机 是一个能解决许多字符串相关问题的有力数据结构。

以下的字符串问题都可以在线性的时间复杂度内通过 S A M SAM SAM 解决。

  • 在另一个字符串中搜索一个字符串的所有出现位置。
  • 计算给定的字符串中有多少个不同的子串。

直观上,字符串的 S A M SAM SAM 可以理解为给定字符串的所有子串的压缩形式。

一些保证线性的性质:

  • 对于一个长度为 n n n 的字符串 s s s ,它的 S A M SAM SAM 中的 状态数 不会超过 2 n − 1 2n - 1 2n1 (假设 n ≥ 2 n \ge 2 n2)。
  • 对于一个长度为 n n n 的字符串 s s s ,它的 S A M SAM SAM 中的 转移数 不会超过 3 n − 4 3n-4 3n4 (假设 n ≥ 3 n\ge 3 n3)。
1. SAM 的定义

字符串 s s s S A M SAM SAM 是一个接受 s s s 的所有的最小 D F A DFA DFA (确定性有限自动机或确定性有限状态自动机)。

换句话说:

  • S A M SAM SAM 是一张有向无环图。结点被称作 状态 ,边被称作状态间的 转移
  • 图存在一个原点 t 0 t_0 t0 ,称作 初始状态 ,其它各结点均可从 t 0 t_0 t0 出发到达。
  • 每个 转移 都标有一些字母。从一个结点出发的所有转移均 不同
  • 存在一个或多个 终止状态 。如果我们从初始状态 t 0 t_0 t0 出发,最终转移到了一个终止状态,则路径上的所有转移连接在起来一定是字符串 s s s 的一个后缀。 s s s 的每个后缀均可用一条从 t 0 t_0 t0 到某个终止状态的路径构成。
  • 在所有满足上述条件的自动机中, S A M SAM SAM 的结点数是最少的。
2. 子串的性质

S A M SAM SAM 最简单、也最重要的性质是,它包含关于字符串 s s s 的所有子串的信息。任意从初始状态 t 0 t_0 t0 开始的路径,如果我们将转移路径上的标号写下来,都会形成 s s s 的一个 子串 。反之每个 s s s 的子串对应从 t 0 t_0 t0 开始的某条路径。

为了简化表达,我们称子串 对应 一条路径(从 t 0 t_0 t0 开始、由一些标号构成这个子串)。反过来,我们说任意一条路径 对应 它的标号构成的字符串。

到达某个状态的路径可能不止一条,因此我们说一个状态对应一些字符串的集合,这个集合的每个元素对应这些路径。

3. 构造 SAM(线性时间内构造)

在描述线性时间内构造 S A M SAM SAM 的算法之前,我们需要引入几个对理解构造过程非常重要的概念并对其进行简单证明。

  1. 结束位置 e n d p o s endpos endpos

    考虑字符串 s s s 的任意非空子串 t t t ,我们记 e n d p o s ( t ) endpos(t) endpos(t) 为在字符串 s s s t t t 的所有结束位置 (假设对字符串中字符的编号从零开始)。例如,对于字符串 “ a b c b c ” “abcbc” abcbc ,我们有 e n d p o s ( “ b c ” ) = 2 ,   4 endpos(“bc”)=2,\ 4 endpos(bc)=2, 4

    两个子串 t 1 t_1 t1 t 2 t_2 t2 e n d p o s endpos endpos 集合可能相等: e n d p o s ( t 1 ) = e n d p o s ( t 2 ) endpos(t_1)=endpos(t_2) endpos(t1)=endpos(t2) 。这样所有字符串 s s s 的非空子串都可以根据它们的 e n d p o s endpos endpos 集合被分为若干 等价类

    显然, S A M SAM SAM 中的每个状态对应一个或多个 e n d p o s endpos endpos 相同的子串。换句话说, S A M SAM SAM 中的状态数等于所有子串的等价类的个数,再加上初始状态。 S A M SAM SAM 的状态个数等价于 e n d p o s endpos endpos 相同的一个或多个子串所组成的集合的个数 + 1 +1 +1

    e n d p o s endpos endpos 的值我们可以得到一些重要结论:

    引理1:两个非空子串 u u u w w w (假设 ∣ u ∣ ≤ ∣ w ∣ |u|\le|w| uw) 的 e n d p o s endpos endpos 相同,当且仅当字符串 u u u w w w 的后缀。

    引理2:考虑两个非空子串 u u u w w w (假设 ∣ u ∣ ≤ ∣ w ∣ |u|\le|w| uw)。那么要么 e n d p o s ( u ) ∩ e n d p o s ( w ) = ∅ endpos(u)\cap endpos(w)=\varnothing endpos(u)endpos(w)= ,要么 e n d p o s ( w ) ⊆ e n d p o s ( u ) endpos(w)\subseteq endpos(u) endpos(w)endpos(u) ,取决于 u u u 是否为 w w w 的一个后缀:
    { e n d p o s ( w ) ⊆ e n d p o s ( u ) i f   u   i s   a   s u f f i x   o f   w e n d p o s ( w ) ∩ e n d p o s ( u ) = ∅ o t h e r w i s e \begin{cases} \begin{aligned} &endpos(w)\subseteq endpos(u) & if\ u\ is\ a\ suffix\ of\ w\\ &endpos(w)\cap endpos(u)=\varnothing & otherwise \end{aligned} \end{cases} {endpos(w)endpos(u)endpos(w)endpos(u)=if u is a suffix of wotherwise

    引理3:考虑一个 e n d p o s endpos endpos 等价类,将类中的所有子串按长度非递增的顺序排序。每个子串都不会比它前一个子串长,于此同时每个子串也是它前一个子串的后缀。换句话说,对于同一等价类的任意两个子串,较短者为较长者的后缀,且该等价类中的字串长度恰好覆盖整个区间 [ x , y ] [x,y] [x,y]

  2. 后缀链接 l i n k link link

    考虑 S A M SAM SAM 中某个不是 t 0 t_0 t0 的状态 v v v 。我们已经知道,状态 v v v 对应于具有相同 e n d p o s endpos endpos 的等价类。我们如果定义 w w w 为这些字符串中最长的一个,则所有其它的字符串都是 w w w 的后缀。

    我们还知道字符串 w w w 的前几个后缀(按长度降序考虑)全部包含于这个等价类,且所有其它后缀(至少有一个——空后缀)在其它的等价类中。我们记 t t t 为最长的这样的后缀,然后将 v v v 的后缀链接连到 t t t 上。

    换句话说,一个 后缀链接 l i n k ( v ) link(v) link(v) 链接到对应于 w w w 的最长后缀的另一个 e n d p o s endpos endpos 等价类的状态。

    以下我们假定初始状态 t 0 t_0 t0 对应于它自己这个等价类(只包含一个空字符串)。为了方便,我们规定 e n d p o s ( t 0 ) = { − 1 , 0 , ⋯   , ∣ S ∣ − 1 } endpos(t_0)=\{-1,0,\cdots,|S|-1\} endpos(t0)={1,0,,S1}

    引理4:所有后缀链接构成一棵根结点为 t 0 t_0 t0 的树。

    引理5:通过 e n d p o s endpos endpos 集合构造的树(每个子节点的 s u b s e t subset subset 都包含在父节点的 s u b s e t subset subset 中)与通过后缀链接 l i n k link link 构造的数相同。

小结:在学习算法本身前,我们总结一下之前学过的知识,并引入一些辅助记号。

  • s s s 的子串可以根据它们结束的位置 e n d p o s endpos endpos 被划分为多个等价类。

  • S A M SAM SAM 由初始状态 t 0 t_0 t0 和与每一个 e n d p o s endpos endpos 等价类对应的每个状态组成。

  • 对于每一个状态 v v v ,一个或多个子串与之匹配。我们记 l o n g e s t ( v ) longest(v) longest(v) 为其中最长的一个字符串,记 m a x l e n g t h ( v ) maxlength(v) maxlength(v) 为它的长度。类似地,记 s h o r t e s t ( v ) shortest(v) shortest(v) 为最短的子串,它的长度为 m i n l e n g t h ( v ) minlength(v) minlength(v) 。那么对应这个状态的所有字符串都是字符串 l o n g e s t ( v ) longest(v) longest(v) 的不同的后缀,且所有字符串的长度恰好覆盖区间 [ m i n l e n g t h ( v ) ,   m a x l e n g t h ( v ) ] [minlength(v),\ maxlength(v)] [minlength(v), maxlength(v)] 中的每一个整数。

  • 对于任意不是 t 0 t_0 t0 的状态 v v v ,定义后缀链接为连接到对应字符串 l o n g e s t ( v ) longest(v) longest(v) 的长度为 m i n l e n g t h ( v ) − 1 minlength(v)-1 minlength(v)1 的后缀的一条边。从根节点 t 0 t_0 t0 出发的后缀链接可以形成一棵树。这棵树也表示 e n d p o s endpos endpos 集合间的包含关系。

  • 对于 t 0 t_0 t0 以外的状态 v v v ,可用后缀链接 l i n k ( v ) link(v) link(v) 表达 m i n l e n g t h ( v ) minlength(v) minlength(v)
    m i n l e n g t h ( v ) = m a x l e n g t h ( l i n k ( v ) ) + 1 minlength(v) = maxlength(link(v)) + 1 minlength(v)=maxlength(link(v))+1

  • 如果我们从任意状态 v 0 v_0 v0 开始顺着后缀链接遍历,总会到达初始状态 t 0 t_0 t0 。这种情况下我们可以得到一个互不相交的区间 [ m i n l e n g t h ( v i ) , m a x l e n g t h ( v i ) ] [minlength(v_i), maxlength(v_i)] [minlength(vi),maxlength(vi)] 的序列,且它们的并集形成了连续的区间 [ 0 , m a x l e n g t h ( v 0 ) ] [0, maxlength(v_0)] [0,maxlength(v0)]

4. 实现过程

S A M SAM SAM O ( ∣ S ∣ ) O(|S|) O(S) 的构造方法,为了实现 O ( ∣ S ∣ ) O(|S|) O(S) 的构造,对于每个状态肯定不能保存太多数据,例如 s u b s t r ( s t a t e ) substr(state) substr(state) 肯定无法保存下来。

对于状态 s t a t e state state 我们只保存如下数据:

  • m a x l e n [ s t a t e ] maxlen[state] maxlen[state] s t a t e state state 包含的最长子串的长度。
  • m i n l e n [ s t a t e ] minlen[state] minlen[state] s t a t e state state 包含的最短子串的长度。
  • c h [ s t a t e ] [ 1 ⋯ c ] ch[state][1\cdots c] ch[state][1c] s t a t e state state 的转移函数, c c c 为字符集的大小
  • l i n k [ s t a t e ] link[state] link[state] s t a t e state state 的后缀链接

在该方法中,使用 增量法 构造 S A M SAM SAM ,从初始状态开始,每次考虑添加一个字符 S [ 1 ] , S [ 2 ] , ⋯   , S [ N ] S[1],S[2],\cdots,S[N] S[1],S[2],,S[N],依次构造可以识别 S [ 1 ] , S [ 1 ⋯ 2 ] , S [ 1 ⋯ 3 ] , ⋯   , S [ 1 ⋯ N ] S[1],S[1\cdots2],S[1\cdots3],\cdots,S[1\cdots N] S[1],S[12],S[13],,S[1N] S A M SAM SAM

假设已经构造好了 S [ 1 ⋯ i ] S[1\cdots i] S[1i] S A M SAM SAM ,要添加字符 S [ i + 1 ] S[i + 1] S[i+1] ,相当于新增了 i + 1 i + 1 i+1 S [ i + 1 ] S[i+1] S[i+1] 的后缀要识别,分别为: S [ 1 ⋯ i + 1 ] , S [ 2 ⋯ i + 1 ] , ⋯   , S [ i ⋯ i + 1 ] , S [ i + 1 ] S[1\cdots i + 1],S[2\cdots i + 1], \cdots,S[i\cdots i + 1], S[i+ 1] S[1i+1],S[2i+1],,S[ii+1],S[i+1]

这些新增状态分别是从 S [ 1 ⋯ i ] , S [ 2 ⋯ i ] , S [ 3 ⋯ i ] , S [ i ] , _ S[1\cdots i],S[2\cdots i],S[3\cdots i],S[i],\_ S[1i],S[2i],S[3i],S[i],_(空串) 通过字符 S [ i + 1 ] S[i+1] S[i+1] 转移过来的。

假设 S [ 1 ⋯ i ] S[1\cdots i] S[1i] 所在的状态是 u u u ,即: S [ 1 ⋯ i ] ∈ s u b s t r ( u ) S[1\cdots i]\in substr(u) S[1i]substr(u) S [ 1 ⋯ i ] , S [ 2 ⋯ i ] , S [ 3 ⋯ i ] , ⋯   , S [ i ] , _ S[1\cdots i],S[2\cdots i],S[3\cdots i],\cdots,S[i],\_ S[1i],S[2i],S[3i],,S[i],_(空串) 对应的状态恰好是从 u u u 到初始状态 S S S 的由 后缀链接 连接起来路径上的所有状态,不妨称这条路径上所有状态集合是 s u f f i x _ p a t h ( u → S ) suffix\_path(u\rightarrow S) suffix_path(uS) ,显然至少 S [ 1 ⋯ i + 1 ] S[1\cdots i + 1] S[1i+1] 不能被以前的 S A M SAM SAM 识别,所以我们至少需要添加一个状态 z z z z z z 至少包含 S [ 1 ⋯ i + 1 ] S[1\cdots i + 1] S[1i+1]

情况一 :对于 s u f f i x _ p a t h ( u → S ) suffix\_path(u\rightarrow S) suffix_path(uS) 的任意状态 v v v ,都有 c h [ v ] [ S [ i + 1 ] ] = N U L L ch[v][S[i+1]]=NULL ch[v][S[i+1]]=NULL

这时我们只要令 c h [ v ] [ S [ i + 1 ] ] = z ch[v][S[i+1]]=z ch[v][S[i+1]]=z ,并且令 l i n k [ z ] = S link[z]=S link[z]=S (这里的 S S S 表示源点)即可。

情况二 s u f f i x _ p a t h ( u → S ) suffix\_path(u\rightarrow S) suffix_path(uS) 上存在结点 v v v ,满足 c h [ v ] [ S [ i + 1 ] ] ≠ N U L L ch[v][S[i+1]]\not= NULL ch[v][S[i+1]]=NULL

我们可以认为在 s u f f i x _ p a t h ( u → S ) suffix\_path(u\rightarrow S) suffix_path(uS) 遇到的第一个状态 v v v 满足 c h [ v ] [ S [ i + 1 ] ] = x ch[v][S[i+1]]=x ch[v][S[i+1]]=x 。这时讨论 x x x 包含的子串的情况,分为 A , B A,B A,B 两类:

  • A A A 类: x x x 中包含的最长子串就是 v v v 中包含的最长字串接上字符 S [ i + 1 ] S[i+1] S[i+1] ,即 m a x l e n ( v ) + 1 = m a x l e n ( x ) maxlen(v) + 1 = maxlen(x) maxlen(v)+1=maxlen(x) z z z 的后缀链接恰好是 x x x 。若在 s u f f i x _ p a t h ( u → S ) suffix\_path(u\rightarrow S) suffix_path(uS) v v v p r e _ v pre\_v pre_v 转移,则 m a x l e n ( x ) = m a x l e n ( v ) + 1 = m i n l e n ( p r e _ v ) = m i n l e n ( z ) − 1 maxlen(x)=maxlen(v)+1=minlen(pre\_v)=minlen(z)-1 maxlen(x)=maxlen(v)+1=minlen(pre_v)=minlen(z)1

    此时只要增加 l i n k [ z ] = x link[z]=x link[z]=x 即可。既然 x x x 存在,那么 x x x 的后缀一定也存在,本次增量完成。

  • B B B 类: x x x 中包含的最长字串不是 v v v 中包含的最长字串接上字符 S [ i + 1 ] S[i+1] S[i+1] ,即 m a x l e n ( v ) + 1 < m a x l e n ( x ) maxlen(v) + 1 < maxlen(x) maxlen(v)+1<maxlen(x) 。这种情况下增加的字符是 c c c ,状态是 z z z 。在 s u f f i x _ p a t h ( u → S ) suffix\_path(u\rightarrow S) suffix_path(uS) 这条路径上,从 u u u 开始有一部分连续的状态满足 c h [ u ⋯   ] [ c ] = z ch[u\cdots][c]=z ch[u][c]=z ,紧接着有一部分连续的状态 v ⋯ w v\cdots w vw 满足 c h [ v ⋯ w ] [ c ] = x ch[v\cdots w][c]=x ch[vw][c]=x ,并且 m a x s u b ( v ) + c maxsub(v)+c maxsub(v)+c 不等于 m a x s u b ( x ) maxsub(x) maxsub(x)

    此时只要从 x x x 拆分出新的状态 y y y ,并且把原来 x x x 中长度小于等于 m a x s u b ( v ) + c maxsub(v) + c maxsub(v)+c 的子串分给 y y y ,其余子串留给 x x x 。同时令 c h [ v ⋯ w ] [ c ] = y , l i n k [ y ] = l i n k [ x ] , l i n k [ x ] = l i n k [ z ] = y ch[v\cdots w][c]=y,link[y]=link[x],link[x]=link[z]=y ch[vw][c]=y,link[y]=link[x],link[x]=link[z]=y 。也就是 y y y 先继承 x x x l i n k link link ,并且 x , z x,z x,z 前面断开的 s u b s t r i n g s substrings substrings 就存在于 y y y 中了。

    由此产生了以下短小精悍的代码QwQ

const int N = 1e6 + 5;
struct node {
    int len, link, ch[26];
}st[N << 1];
int sz, last;

void sam_init () { sz = last = 1; }   // 为方便操作,这里我们从 1 开始

void sam_extend (int c) {
    int cur = ++ sz, p = last;
    last = cur, st[cur].len = st[p].len + 1;
    for ( ; p && ! st[p].ch[c]; p = st[p].link) st[p].ch[c] = cur;
    if (p == 0) st[cur].link = 1;   // 情况一
    else {
        int q = st[p].ch[c];
        if (st[p].len + 1 == st[q].len) st[cur].link = q;   // 情况二 A类
        else {			// 情况二 B类
            int clone = ++ sz;
            st[clone].len = st[p].len + 1;
            st[clone].link = st[q].link;
            memcpy (st[clone].ch, st[q].ch, sizeof (int)*26);
            for ( ; p && st[p].ch[c] == q; p = st[p].link) st[p].ch[c] = clone;
            st[q].link = st[cur].link = clone;
        }
    }
}
5. SAM 的应用(施工中)
  1. 检查字符串是否出现
  2. 不同子串的个数
  3. 所有不同子串的总长度
  4. 字典序第 k k k 大子串
  5. 最小循环位移
  6. 出现次数
  7. 第一次出现位置
  8. 所有出现的位置
  9. 最短的没有出现的字符串
  10. 两个字符串的最长公共子串
  11. 多个字符串间的最长公共子串
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值