ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

理性理解 SAM の笔记

2022-07-05 18:01:10  阅读:226  来源: 互联网

标签:状态 par SAM 后缀 text 笔记 endpos 理性


之前学过 SAM 但是过不久就发现“忘记”了,究其故里就是没有真正理解 SAM 原理。

故特此记录,大部分知识均来源于:

确定性有限状态自动机(DFA)

为了方便还是需要引入 确定有限状态自动机(DFA),顺便规范符号。

一个 DFA 由一下部分组成:

  • 字符集 \(\sum\):该 DFA 只能输入这些字符。
  • 状态集合 \(Q\):如果把 DFA 看作一张有向图,那么 \(Q\) 就是节点集合。
  • 起始状态 \(\text{sta}\):有 \(\text{sta}\in Q\),是一个特殊的状态。
  • 接受状态 \(F\):有 \(F\subseteq Q\),是一种特殊的状态集合。
  • 转移函数 \(\delta\):表示有向图中的边,是一个接受两个参数(状态 与 字符)返回一个值(到达状态)的函数。

DFA 的作用就是识别字符串,原理也很简单直接。

如果从 \(\text{sta}\) 开始,将一个字符串在 DFA 中不断转移,最终能到达一个接受状态,那么称这个 DFA 接受这个字符串,反之称为 不接受

如果一个状态 \(v\) 没有字符 \(c\) 的转移,那么我们令 \(\delta(v,c)=\text{null}\),而 null 不属于接受状态集合,且无法转移到任何一个接受状态的状态。

一个熟悉的例子就是最基本的 trie 树,它的起始状态就是树根,接受状态就是每次插入遍历到的最后一个节点的集合。

后缀自动机(SAM)

SAM 本质上就是一个 DFA,字符串 \(s\) 的 SAM 作为一个 DFA,接受且只接受所有 \(s\) 的后缀。

一个朴素的想法就是把每个后缀拿出来建立一棵 trie 树,但它的状态数为 \(O(n^2)\),不妨称之为朴素 SAM

一个 DFA 往往有很多与之完全等价的 DFA,但状态集合 \(Q\) 以及转移个数(无向图的点数和边数)可能天差地别。

作为 OI 题目的处理工具,肯定是越少越好,所以有了 最简 SAM 的构造。

终点集合(\(\text{endpos}\))

对于一个字符串 \(t\),如果它在 \(s\) 中出现的位置为 \(\{[l_1,r_1),[l_2,r_2),\cdots,[l_n,r_n)\}\),那么称 \(s\) 中的 \(\text{endpos}(t)=\{r_1,r_2,\cdots,r_n\}\)。

注意这里字符串下标从 \(0\) 开始,但和朴素的从 \(1\) 开始的 \([l_1,r_1]\) 得到的 \(\text{endpos}\) 集合相等,这样定义是为了方便后续阐述。

引入 \(\text{endpos}\) 集合的原因是,SAM 的压缩原理就是把所有 \(\text{endpos}\) 相等的状态全部称为等价状态

原因很简单,设 \(\text{rev}(v)\) 表示从状态 \(v\) 开始能识别的字符串集合,即把 \(v\) 看作 \(\text{sta}\)。

而 \(\text{rev}(v_1)=\text{rev}(v_2)\) 的充要条件就是 \(\text{endpos}(v_1)=\text{endpos}(v_2)\),因为 \(\forall c,\text{endpos}(\delta(v,c))=\{r_i|r_i\in\text{endpos}(v),s(r_i)=c\}\)。

同时这样的 SAM 也显然是 最简 的,因为所有状态的 \(\text{rev}\) 不同,没法进一步压缩。

\(\text{endpos}\) 集合相等的字符串有一个显然的性质,设 \(\min(v)\) 表示状态 \(v\) 对应的最短字符串,\(\max(v)\) 则表示最长,

那么状态 \(v\) 对应的所有字符串,恰好包含长度为 \([|\min(v)|,|\max(v)|]\) 的字符串各一个,且较短的是较长的后缀。不做过多解释。

后缀连接(\(\text{link}\))

对于每种状态 \(v\)(除了 \(\text{sta}\))找到某个最长的字符串所对应的状态 \(t\),满足 \(\text{endpos}(v)\subsetneq \text{endpos}(t)\)。则定义 \(\text{link}(v)=t\)。

一个显然的性质是 \(\text{len}(\min(v))=\text{len}(\max(\text{link}(v)))+1\)。

显然后缀连接能够显式的得到一棵树。

由此也能够引出 SAM 的接收状态集合 \(F\),对应于所有包含 \(r_i=n\) 的状态,也就是 \(\text{link}\) 树上 \(\{n\}\) 自己及其所有祖先构成的集合。

实际应用中 \(F\) 可能不那么重要,因为在 OI 中 \(\text{link}\) 树带来的价值远大于 DFA 本身的功能。

值得一提的是,SAM 的构造是增量的,令每次插入 \(s(i)\) 后的状态为 \(v\),这样得到的所有状态构成 前缀状态集合 \(P\)。

不难发现 \(\text{link}\) 树上所有的叶子节点都 \(\in P\),反之不然,但所有前缀能引出的后缀代表了所有 \(s\) 的子串,所以集合 \(P\) 有其价值。

例如一个状态对应 \(\text{endpos}\) 的大小,就是它在 \(\text{link}\) 树上的子树中,\(\in P\) 的节点个数。

最简 SAM 的状态数与转移数

均为线性,且有简单的证明方法。

对于状态数,相当于求本质不同的 \(\text{endpos}\) 集合个数,而已知这些 \(\text{endpos}\) 集合构成了一棵树。

且每个非叶子节点都有 \(\geq 2\) 个儿子,否则根据定义,这个节点和儿子的 \(\text{endpos}\) 等价,可以合并。

这样每分出一个节点就把集合分成两部分,最劣情况下会分 \(n-1\) 次,即 \(n-1\) 个非叶子节点,以及 \(\leq n\) 个叶子节点。

故总状态数 \(\leq 2n-1\)。

对于转移数,考虑 SAM 的任意一棵生成树,那么 SAM 上的边就能分为树边和非树边。显然树边至多 \(2n-2\) 条。

对于任意一条非树边 \((u,v)\),一定有从 \(\text{sta} \to u\) 的不经过非树边的路径(外向树的根能到达所有节点),

也一定有 \(v\to F\) 的路径,否则 \(v=\text{null}\)。而 \(u\to F\) 代表字符串的一个后缀。

我们称一个后缀 同 将其输入 SAM 后到某个 \(F\) 的路径中经过的 第一条 非树边对应,它显然是唯一的,因为路径唯一。

不难发现上述 \((u,v)\) 就与 \(u\to F\) 代表的后缀相互对应,因为后缀只有 \(n\) 种。

故总转移数 \(\leq 3n-2\)。

这样就证明了最简 SAM 的状态数与转移数都是线性的。

最简 SAM 的构造

大概能够很自然的引出 SAM 的构造方法。

对于 SAM 中的状态,考虑维护 \(\text{lnk}(v),\text{tr}(v,*)\) 以及 \(\text{len}(v)\),前两个分别对应 \(\text{link}\) 和 \(\delta\),最后一个则表示 \(|\max(v)|\)。

\(\text{len}\) 被维护的唯一原因是用于判断 \(\delta\) 转移到的两个状态是否是在最长串上连续的

据此能够判断插入一个字符后某状态的 \(\text{endpos}\) 等价的集合是否变小,更具体的构造方法见OI-Wiki

个人感觉理解上述内容能够很自然的推导出来(

给出一个能看的代码:

void insert(int c) {
    int cur = ++ tot, par = las;
    len[cur] = len[las] + 1;
    las = cur;
	
    while(par != -1 && ! tr[par][c]) tr[par][c] = cur, par = lnk[par];		
    if(par == -1) return lnk[cur] = 0, void();

    int now = tr[par][c];
    if(len[now] == len[par] + 1) return lnk[cur] = now, void();
    int clo = ++ tot;
    len[clo] = len[par] + 1;
    lnk[clo] = lnk[now];
    memcpy(tr[clo], tr[now], sizeof(tr[now]));
    lnk[now] = lnk[cur] = clo;
    while(par != -1 && tr[par][c] == now) tr[par][c] = clo, par = lnk[par];
}

「NOI2018」你的名字

SAM 能干的事情很多,根据最近做到的题举一个大栗子 QwQ

初始给定 \(s\),多次询问,每次给出 \(t,l,r\),求是 \(t\) 的子串但不是 \(s[l,r]\) 的子串的不同字符串个数。

先看 \(l=1,r=|s|\) 的部分分。

根据之前的一个字符串的所有前缀的后缀等价于其所有子串得到下列算法。

可以分别建出 SAM,然后在 \(s\) 上跑 \(t\) 的每个前缀能被匹配的最长后缀长度,即 \(t[\lim(x),x]\) 是 \(s\) 的子串,且 \(\lim(x)\) 最小。

据说是经典套路,不过我反应了一会。

大概实现方法是,在 \(\text{SAM}_s\) 上维护 \(p\) 指针,每次根据前缀移动,同时维护 \(\text{mat}\) 表示匹配的长度。

如果 \(p\) 移动到了 \(\text{null}\) 就不断在 \(\text{SAM}_s\) 上跳 \(\text{link}\),直到到达的节点 \(v\) 使得 \(\delta(v,c)\neq \text{null}\),跳的时候不断令 \(\text{mat}\gets \text{len}(\text{lnk}(v))\)。

如果没有这样的 \(v\) 就令 \(p=\text{sta},\text{mat}=0\)。否则 \(p\gets \delta(v,c),\text{mat}\gets \text{mat}+1\)。

容易解释上述算法的正确性。

时间复杂度的正确是因为:\(\text{mat}\) 每次至多增加 \(1\),每次跳 \(\text{lnk}\) 则一定减少,且 \(\text{mat}\geq 0\)。

之后还有去重,本质相同的子串不能算多次,所以还要维护 \(\text{mnp}(x)\) 表示 \(t[1,x]\) 的 \(t[\text{mnp}(x),x]\) 在之前的前缀中出现过。

因为是之前的,一定要在在线构造的时候就维护,显然就是当时的 \(\text{len}(\text{lnk}(x))\)。

因为此时 \(t[1,x]\) 只出现了一次,\(\text{lnk}(x)\) 的构造也仅局限于之前的前缀,所以务必注意。然后就显然有:

\[ans=\sum_{i=1}^{|t|} i-\max(\text{mnp}(i),\lim(i)) \]

对于 \(l,r\) 没有特殊性质的情况,相当于利用现有 \(\text{SAM}_s\) 要跑出 \(\text{SAM}_{s[l,r]}\) 的效果。

需要用可持久化的线段树合并来显式的维护每个状态的 \(\text{endpos}\) 集合。

然后在利用上述操作确定 \(p\) 后,判断 \(p\) 是否真的合法。

因为原串更长,所有在原串中合法的 \(p\) 可能并不合法,但至少是必要条件,且单调性相同,复杂度也能够同样证明。

具体的,每次找到在 \([l,r]\) 中最大的 \(r_i\)。

观察是否有以 \(r_i\) 为终点的长度为 \(\min(v)\) 的字符串被完全包含于 \([l,r]\) 中,否则还是得跳 \(\text{link}\)。

还有一个 corner case:\(\lim(x)\) 实质上 \(=\min(\text{len}(v),r_i-l+1)\),因为当 \(v=\text{sta}\) 时,必定成功,会导致 \(\text{mat}\) 没被限制。

更多关于 SAM 的应用可以查看 OI-Wiki

标签:状态,par,SAM,后缀,text,笔记,endpos,理性
来源: https://www.cnblogs.com/lpf-666/p/16447486.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有