\(\text{后缀自动机 (SAM) 学习笔记}\)
一、定义
字符串 \(s\) 的 SAM 是一个接受 \(s\) 的所有后缀的最小 DFA (确定性有限自动机或确定性有限状态自动机),也就是说:
- SAM 是一张有向无环图。它的结点是图中的状态,边是状态之间的转移。
- SAM 有源点 \(t_0\),且其它各结点均可从 \(t_0\) 出发到达。
- SAM 中每个转移都标有一个字母,且从一个结点出发的所有转移都是不同的。
- SAM 存在一些终止状态。特殊地,到达一个终止状态时从 \(t_0\) 到该状态的路径连接起来是字符串 \(s\) 的后缀。反之,\(s\) 的每个后缀同样可从一条由 \(t_0\) 到某个终止状态的路径构成。
- 满足这样条件的自动机有多个,而 SAM 的结点数是最少的。
让我们举一个例子来描述一个对于字符串 \(\texttt{abcbc}\) 的 SAM:
需要注意的是,SAM 的结点个数和边数都是 \(O(n)\) 的。具体地,一个 SAM 最多会有 \(2\times n-1\) 个结点和 \(3\times n-4\) 条转移边。
二、\(\operatorname{endpos}\) 等价类及其性质
这一部分的内容,似乎和 SAM 没有直接关系,但却是 SAM 中很重要的一部分。我们需要证明关于它的一些性质,并得出一些结论,这是我们构建 SAM 的基础。
1. 定义
对于字符串 \(s\) 的一个子串,它在原串中会出现若干次。一个子串 \(p\) 在 \(s\) 中出现的右端点位置的集合,就称为 \(\operatorname{endpos}(p)\)。对于上文中串 \(\texttt{abcbc}\),对于字符串 \(\texttt{bc}\),其 \(\operatorname{endpos}\) 集合为 \(\{2,4\}\)。需要说明的是,每一个 \(\operatorname{endpos}\) 等价类对应着 SAM 上的一个结点。根据定义我们显然可以这样做,这样 SAM 中一个状态就会对应这个 \(\operatorname{endpos}\) 等价类中所有的字符串。
2. 性质及其证明
- 若串 \(s_1,s_2\) 满足 \(\operatorname{endpos}(s_1)=\operatorname{endpos}(s_2)\),且 \(s_1\neq s_2\),则 \(\operatorname{len}(s_1)\neq \operatorname{len}(s_2)\)。
证明:若存在 \(s_1,s_2\) 满足 \(\operatorname{endpos}(s_1)=\operatorname{endpos}(s_2)=R,s_1\neq s_2,\operatorname{len}(s_1)=\operatorname{len}(s_2)=l\),则对于任意 \(pos\subset R\),由 \(s_1\neq s_2\) 有 \(s[pos-l+1,pos]\neq s[pos-l+1,pos]\),显然矛盾。
- 对于 \(s\) 的任意后缀 \(t_s\),有 \(\operatorname{endpos}(s)\subset \operatorname{endpos}(t_s)\)。
证明:由 \(\operatorname{endpos}\) 的定义知道每个 \(s\) 能匹配到的右端点 \(t_s\) 必能匹配。
- 若两个不同的串 \(s_1,s_2\) 满足 \(\operatorname{endpos}(s_1)=\operatorname{endpos}(s_2)=R\),则对于 \(\operatorname{len}(s_1)\le l \le \operatorname{len}(s_2)\),一定存在 \(s_3\) 满足 \(\operatorname{len}(s_3)=l\) 且 \(\operatorname{endpos}(s_3)=R\)。
证明:由 1,\(\operatorname{len}(s_1)\neq \operatorname{len}(s_2)\),不妨设 \(\operatorname{len}(s_1)<\operatorname{len}(s_2)\)。令 \(s_3=s_2[l_2-l+1,l_2]\),由 2,\(R\subset \operatorname{endpos}(s_3),\operatorname{endpos}(s_3)\subset R\)。因此 \(\operatorname{endpos}(s_3)=R\)。
- \(\operatorname{endpos}\) 集合相等的字符串的长度必然是连续的。
证明:设其中两个不同串为 \(s_1,s_2\),由 1,可不妨设 \(\operatorname{len}(s_1)<\operatorname{len}(s_2)\)。由 3,必然有 \(\operatorname{len}(s_1)\le l \le \operatorname{len}(s_2)\) 满足 \(\operatorname{endpos}=R\)。
- 对于两个 \(\operatorname{endpos}\) 集合 \(R_a,R_b\),要么 \(R_a\subseteq R_b\),要么 \(R_a\cap R_b=\varnothing\)。
证明:设 \(R_a\cap R_b=r\neq \varnothing\),那么设从 \(t_0\) 到 \(R_a,R_b\) 结点路径表示的字符串的集合为 \(S_a,S_b\)。记 \(\max_s,\) 表示集合 \(S_a\) 中最长的串的长度,\(\min_a\) 同理,则由 4,\([\min_a,\max_a]\cap [\min_b,\max_b]=\varnothing\)。不妨设 \(\max_b<\min_a\),则对于任意 \(s_a\in S_a\),有 \(\operatorname{len}(a)>\operatorname{len}(b)\)。又因为 \(R_a\cap R_b=r\neq \varnothing\),由 2,\(R_a\subset R_b\)。考虑 \(R_a=R_b\) 的情形,则 \(R_a\subseteq R_b\)。
三、 parent 树及 SAM 的复杂度
根据上面的性质,任意两个 \(\operatorname{endpos}\) 集合或是不相交,或是其中一个是另一个的子集。那么对于任意一个不为初始状态的状态 \(a\),一定恰好存在一个状态 \(b\) 满足 \(\operatorname{endpos}(a)\subseteq \operatorname{endpos}(b)\) 且 \(\max_b=\max_a-1\)。这种关系可以抽象成一个树形结构,记非根状态 \(x\) 在 parent 树上的父亲为 \(\operatorname{link}(x)\)。那么容易发现的性质是一个结点在 parent 树上的子结点至少有两个,否则其 \(\operatorname{endpos}\) 集合应当相同。且子结点代表的 \(\operatorname{endpos}\) 集合互不相交。那么仍然以串 \(\texttt{abcbc}\) 举例,我们可以用绿色的线表示 parent 树上的边。
让我们进一步发现 parent 树上的一些奇妙性质:
- parent 树有 \(\operatorname{len}(s)\) 个儿子。
证明:显然会存在的叶子结点 \(\operatorname{endpos}\) 集合为 \(\{1\},\{2\},\cdots,\{\operatorname{len}(s)\}\)。
- parent 树的状态不会超过 \(O(n)\) 级别。
证明:由于一个点至少有两个子结点,那么新增一个结点必然会删去两个结点,因此最多新增 \(n-1\) 个非叶子结点。于是总的状态级别是 \(O(n)\)。
那么我们已经证明了 SAM 状态数是 \(O(n)\) 的。需要知道的是 SAM 的转移边数同样是 \(O(n)\) 的,不过这个性质没有状态数那么重要,且较难证明,因此略去。
四、SAM 的构造
1. 构造流程
初始情况是只有状态 \(t_0=1\),其 \(\operatorname{len}=0,\operatorname{link}=0\),现在将字符 \(c\) 加入 SAM 中,加入之前 SAM 的最终状态为 \(p\)。
我们创建新的状态 \(np\),令 \(\operatorname{len}(np)=\operatorname{len}(p)+1\)。从 \(p\) 开始跳 \(\operatorname{link}\),若没有 \(c\) 的转移,添加到 \(np\) 为字符 \(c\) 的转移,直到找到一个有该转移的状态,改这个状态为 \(p\)。若没有找到 \(p\),那么其 \(\operatorname{endpos}\) 是一个全新的 \(\operatorname{endpos}\),直接令 \(\operatorname{link}(np)=1\) 即可,否则我们记 \(p\) 关于 \(c\) 的转移为 \(q\)。
若 \(\operatorname{len}(q)=\operatorname{len}(p)+1\),那么显然令 \(\operatorname{link}(np)=q\) 是正确的。考虑 \(\operatorname{len}(q)\neq \operatorname{len}(p)+1\) 的情形:我们将状态 \(q\) 复制至 \(nq\),但将 \(\operatorname{len}(nq)\) 置为 \(\operatorname{len}(p)+1\),并将 \(q,np\) 的 \(\operatorname{link}\) 信息指向 \(nq\)。
最后,我们从 \(p\) 开始跳 \(\operatorname{link}\),若 \(p\) 有 \(c\) 的转移且转移到了 \(q\),将这个转移改到 \(nq\) 即可,直到找不到或是回到源点停止。
需要知道的是,建完这个 SAM 后对应的终止状态就是 \(np\)。
2. 正确性证明
需要证明的部分只有 \(\operatorname{len}(q)\neq \operatorname{len}(p)+1\) 的部分。此时显然 \(\operatorname{len}(q)>\operatorname{len}(p)+1\),也就是状态 \(q\) 在对应长度为 \(\operatorname{len}(p)+1\) 后缀的同时也对应了更长的子串。于是将状态 \(q\) 拆一个状态 \(nq\) 出来,且将其 \(\operatorname{len}\) 设为 \(\operatorname{len}(p)+1\)。这样一来,\(nq\) 继承 \(q\) 的其它信息是理所当然的。同时需要留意的是,要将状态 \(p\) 原有到 \(q\) 的转移改到 \(nq\),于是跳 \(\operatorname{link}\) 的后缀直到找不到转移 \((c,q)\) 为止。
需要知晓的是,这样构建 SAM 的时间复杂度是 \(O(n)\)。这是建立在字符集大小为常数的前提下。否则一般使用 std::map
来存边,此时时间复杂度为 \(O(n\log |\sum|)\) 而空间复杂度为 \(O(n)\)。
这里给出 SAM 的一般实现:
struct SAM {int len, fa;int s[M];
} sam[N];
int tot = 1, lst = 1;
void insert(int c) {int p = lst, np = lst = ++tot;sam[np].len = sam[p].len + 1;for (; p && !sam[p].s[c]; p = sam[p].fa) sam[p].s[c] = np;if (!p) sam[np].fa = 1;else {int q = sam[p].s[c];if (sam[q].len == sam[p].len + 1) sam[np].fa = q;else {int nq = ++tot;sam[nq] = sam[q], sam[nq].len = sam[p].len + 1;sam[q].fa = sam[np].fa = nq;for (; p && sam[p].s[c] == q; p = sam[p].fa) sam[p].s[c] = nq;}}
}
五、SAM 的基础应用
1. 求本质不同子串个数
一般的方法是求每个状态内子串的个数。也就是 \(\sum \operatorname{len}(i)-\operatorname{link}(\operatorname{len}(i))\)。
2. 求第 \(k\) 小子串
考虑到每个子串唯一对应着 SAM 上一条路径,于是转化为求 SAM 上字典序第 \(k\) 小的路径。于是简单 dp 可以处理。
3.求两个字符串的最长公共子串
对于两个字符串 \(s,t\),对于 \(s\) 建出后缀自动机,对 \(t\) 进行匹配处理。我们使用两个变量进行匹配:当前状态 \(p\) 和当前长度 \(l\)。初始时 \(p=t_0,l=0\)。
当 \(p\) 存在字符 \(t_i\) 的转移时,我们转移长度并让 \(l\) 加一即可。若不存在 \(p\) 的转移,需要将 \(p\) 跳 \(\operatorname{link}\) 数组知道满足当前字符的转移。对于时间复杂度,显然每次最多使 \(l\) 加一,或是将 \(l\) 减小一些,调整加减顺序不难得到总的时间复杂度为 \(O(|s|+|t|)\)。
给出代码实现:
int fnd(char *s) {int p = 1, ans = 0, l = strlen(s), res = 0;for (int i = 0; i < l; i++) {int c = s[i] - '0';while (p > 1 && !sam[p].s[c]) {p = sam[p].fa;ans = sam[p].len;}if (sam[p].s[c]) {p = sam[p].s[c];++ans;}res = max(res, ans);}return res;
}
4. 线段树合并维护 \(\operatorname{endpos}\) 集合
考虑在 parent 树上对 \(\operatorname{endpos}\) 集合进行线段树合并,这样通常可以维护出每个点 \(\operatorname{endpos}\) 的一些信息,在一些题目中会用到。
5. SAM 求后缀的最长公共前缀
考虑将字符串反向后插入 SAM 中,这样问题转化为了前缀的最长公共后缀,那么 parent 树上所有父亲串一定是儿子串的前缀。于是找到两个字符串对应的结点,求它们的 LCA 即可。
六、广义 SAM
广义 SAM,就是对多个字符串建出的 SAM。如果暴力将它们连接,往往会出现各种各样的问题,因此需要掌握建立正确的广义 SAM 的方法。
我们先针对这些字符串建出 Trie,在此基础上将 Trie 的每条边建到广义 SAM 中即可。我们通常适用的方法是离线 BFS。下面给出代码实现:
struct Trie {int fa, ch;int s[M];
} tr[N];
int cnt = 1;
void ins(char *s) {int l = strlen(s), p = 1;for (int i = 0; i < l; i++) {int ch = s[i] - '0';if (!tr[p].s[ch]) {tr[p].s[ch] = ++cnt;tr[cnt].fa = p;tr[cnt].ch = ch;}p = tr[p].s[ch];}
}
struct SAM {int len, fa;int s[M];
} sam[N];
int tot = 1;
int insert(int c, int lst) {int p = lst, np = lst = ++tot;sam[np].len = sam[p].len + 1;for (; p && !sam[p].s[c]; p = sam[p].fa) sam[p].s[c] = np;if (!p) sam[np].fa = 1;else {int q = sam[p].s[c];if (sam[q].len == sam[p].len + 1) sam[np].fa = q;else {int nq = ++tot;sam[nq] = sam[q], sam[nq].len = sam[p].len + 1;sam[q].fa = sam[np].fa = nq;for (; p && sam[p].s[c] == q; p = sam[p].fa) sam[p].s[c] = nq;}}return lst;
}queue<int>q;
int pos[N];
void build() {for (int i = 0; i < M; i++)if (tr[1].s[i]) q.push(tr[1].s[i]);pos[1] = 1;while (!q.empty()) {int p = q.front();q.pop();pos[p] = insert(tr[p].ch, pos[tr[p].fa]);for (int i = 0; i < M; i++)if (tr[p].s[i]) q.push(tr[p].s[i]);}
}