后缀自动机SAM

后缀自动机

推荐学习博客:

后缀自动机(SAM)学习笔记(极其推荐,有图过程更清晰)

oi-wiki 后缀自动机 (SAM)(十分严谨的语言和证明)

后缀自动机学习笔记(应用篇)(配套习题,各种应用)

本文参考了上述文章

  • 确定有限状态自动机(DFA):由字符集,状态集合,初始状态,接受状态集合,转移函数。

接受状态集合属于状态集合,也就是说状态中有一些是接受状态。

总的来说,在自动机上,从初始状态开始,输入一个字符集里的字符,就能通过转移函数到达另一个状态,其中到达过所有状态都属于状态集合

如果最终到达一个状态,这个状态是属于接受状态集合,那么输入的所有字符构成的字符串就被自动机接受了。

  • 后缀自动机:接受状态集合是一个串所有后缀的 DFA, 并且 sam 的节点数最小。

其中,它的状态集合包含了这个字符串的所有子串,每个状态是一个\(\text{endpos}\)等价类,记录一个或多个子串。

定理(太菜不会证)和概念

约定:字符下标从1开始。

  1. 初始节点为空集,下标视为 0,且 \(\text{len}\) 为 0。

  2. \(\text{endpos}\) 代表结束位置的集合。对于一个字符串 \(s\), 它的子串 \(t\), \(\text{endpos}(t)\) 就是 \(t\) 在所有 \(s\) 出现过的地方的结尾的位置的集合。

    例如:\(s = aabaab,t = aab,\text{endpos}(t) = \{3,6\}\)

  3. 字符串 \(u, v(|u| < |v|)\)\(\text{endpos}\) 相同,那么就有 \(u\)\(v\) 的后缀。

  4. 对于字符串 \(u, v(|u| < |v|)\) 属于文本串 \(s\),就有 \(\text{endpos}(v) \subseteq\text{endpos}(u)\)\(\text{endpos}(v) \cap \text{endpos}(u) = \varnothing\)

    这说明两个串 \(\text{endpos}\) 的关系是 包含或者不交

    大概是因为出现相同的位置时,必有一个串是另一个串的子串(同时也是另一个串后缀),既然是后缀,\(\text{endpos}\)就是包含关系, 小的串 \(\text{endpos}\)更大。

  5. \(\text{endpos}\) 等价类 : 所有 \(\text{endpos}\)相同的子串的集合。

    在这个类中,串的长度是连续的,可以用\([\text{minlen},\text{maxlen}]\)表示。

  6. 后缀连接(\(\text{link}\)):对于任意状态\(u\)(\(\text{endpos}\)的等价类), 有\(v = \text{link}(u)\), 满足\(\text{endpos}(u) \subsetneq \text{endpos}(v)\), 那么就有 \(\text{maxlen}(v) + 1 = \text{minlen}(u)\)

    \(\text{link}\) 连接了不同 \(\text{endpos}\)等价类 的关系。

    特别的,将初始状态\(\text{link}\) 设为 \(-1\),仅仅是方便实现。

  7. 将所有 \(u\)\(\text{link}(u)\) 连接,就会构成一个树形结构,就称其为\(\text{parent}\) 树。

  8. 从一个状态 \(u\) 通过后缀连接跳到初始状态,那么这个状态所表示的字符串的所有后缀都会被遍历,也就是长度在\([0, \text{mexlen}(u)]\)的后缀都会被遍历到。

  9. sam最多 \(2n - 1\) 个点, 最多 \(3n - 4\) 条边。

开始构造sam:

通过增量法构造。

假设已经造好了关于字符串\(S\)的前 \(i\) 个字符的sam,现在加入第 \(i + 1\) 个字符。

令:

\(last\)\(S_{1 ...i}\) 所在的状态。

\(\text{len}(u)\) 为状态 \(u\)\(\text{endpos}\) 等价类中最长子串的长度(\(\text{maxlen}\))

\(\text{link}(u)\)\(u\) 的后缀连接,满足 \(\text{link}(u)\)\(\text{len}\)\(u\)\(\text{minlen} - 1\)

  1. 新建点 \(cur\) 表示 \(S_{1...i+1}\) 在这个状态的等价类里,显然\(\text{len}(cur) \gets \text{len}(last)+ 1\)

  2. 由于状态集合是所有的子串,需要考虑加入新增那些子串,不难发现,这些串都是由 \(S_{j...i}\) + \(c_{i + 1}\)\((j\leq i)\) 构成的 ,所以需要遍历\(S_{1...i}\) 的所有后缀。

    \(p\)\(last\) 开始,每次跳\(\text{link}(u)\),如果转移函数\(\text{next}_p(c_{i+1})\) 的值是空,就代表 \(s_{j...(i + 1)}\) 第一次出现,\(p\) 就能通过转移函数转移到 \(cur\) .

    \(\begin{array}{ll} 1 & p \gets last \\ 2 & \textbf{while } p \neq -1 \textbf{ and } \text{next}_p(c_{i+1}) \text{ is } null \textbf{ do} \\ 3 & \qquad \text{next}_p(c_{i+1}) \gets cur \\ 4 & \qquad p \gets \text{link}(p) \\ 5 & \textbf{end} \end{array}\)

  3. 如果最后 \(p\) 的值为 \(-1\) 就代表 \(c_{i + 1}\) 当前第一次出现在字符串里,直接令 \(\text{link}(cur) = 0\).

    \(\begin{array}{ll} 1 & \textbf{if } p = {-1} \textbf{ then} \\ 2 & \qquad \text{link}(cur) = start \\ 3 & \textbf{end} \\ \end{array}\)

  4. \(p\) 的值不为 \(-1\)

    先令 \(q\)\(\text{next}_p(c_{i + 1})\).

    由于 \(q\) 状态的等价类中的子串一定是 \(S_{1...i}\) 的后缀 + \(c_{i+ 1}\) 构成的串。

    这个状态中的串原本已经出现在\(1...i\)位中,又会在第 \(i + 1\) 位出现,那么 \(\text{endpos}(cur) \subsetneq \text{endpos}(q)\).

    又因为 \(p\) 是第一个 \(\text{next}(c_{i + 1})\) 不为空的,所以 \(q\) 中的子串是长度最大的能匹配\(i + 1\) 位置的子串。

    具体地,只有在 \(q\) 中长度为 \(\text{len}(p) + 1\) 的串的后缀,才会新增第\(i + 1\)位。

    • 当满足 \(\text{len}(q) = \text{len}(p) + 1\) 时,刚好 \(q\) 中所有的串都能新增 \(i + 1\) 这个位置, 就将$ \text{link}(cur) \gets q$。

    • \(\text{len}(q) > \text{len}(p) + 1\), 这时 \(q\) 中长度在 \((\text{len}(p) + 1, \text{len}(q)]\) 就不会更新 \(i + 1\) 这个位置。

      所以就将 \(q\) 拆成 两个一样的点,只不过一个是最大串长度是 \(\text{len}(p) + 1\),另一个保持不变,令新增的状态为 clone.

      那么原先 \(q\) 中最长串的后缀的转移函数就会改变,通过跳\(\text{parent}\)树遍历所有转移到 \(q\) 的状态并修改, 当不是 \(q\) 是直接退出,这时再上面的状态都不会是 \(q\)

      最后将

      \(\text{link}(cur) \gets clone\)

      \(\text{link}(q) \gets clone\)

    \(\begin{array}{ll} 1 & \textbf{if } p \neq -1 \textbf{ then}\\ 2 & \qquad q \gets \text{next}_p(c_{i + 1})\\ 3 & \qquad \textbf{if } \text{len}(q) = \text{len}(p) + 1 \textbf{ then} \\ 4 & \qquad\qquad \text{link}(cur) \gets q \\ 5 & \qquad \textbf{else} \\ 6 & \qquad\qquad \text{link}(clone) \gets \text{link}(q) \\ 7 & \qquad\qquad \text{len}(clone) \gets \text{len}(p) + 1 \\ 8 & \qquad\qquad \text{next}_{clone} \gets \text{next}_{q} \\ 9 & \qquad\qquad \textbf{while } p \neq -1 \textbf{ and } \text{next}_p(c_{i + 1}) = q \textbf{ do} \\ 10 &\qquad\qquad\qquad \text{next}_p(c_{i + 1}) = clone \\ 11 &\qquad\qquad\qquad p \gets \text{link}(p) \\ 12 &\qquad\qquad\textbf{end} \\ 13 &\qquad\text{link}(cur) \gets clone \\ 14 &\qquad\text{link}(q) \gets clone \\ 15 &\qquad\textbf{end} \\ 16 &\textbf{end} \end{array}\)

  5. 最后 \(last \gets cur\)

这样构造的时间复杂度是 \(O(n)\) 的。

具体代码

struct state {
	int len, link;
	map<char, int> next;
};
const int MAXLEN = 1000010;
state st[MAXLEN * 2];
int sz, last;

void sam_init() {
	st[0].len = 0;
	st[0].link = -1;
	sz ++;
	last = 0; 
}
void sam_extend(char c) {
	int cur = sz ++;
	st[cur].len = st[last].len + 1;
	int p = last;
	while(p != -1 && !st[p].next.count(c)) {
		st[p].next[c] = cur;
		p = st[p].link;
	}
	if (p == -1) {
		st[cur].link = 0;
	} else {
		int q = st[p].next[c];
		if (st[p].len + 1 == st[q].len) {
			st[cur].link = q;
		} else {
			int clone = sz ++;
			st[clone].len = st[p].len + 1;
			st[clone].next = st[q].next;
			st[clone].link = st[q].link;
			while(p != -1 && st[p].next[c] == q) {
				st[p].next[c] = clone;
				p = st[p].link;
			}
			st[q].link = st[cur].link = clone;
		}
	}
	last = cur;
}
int main() {
	...
   sam_init();
   for(int i = 1; i <= n; i++)
   		sam_extend(s[i]);
   ...
}

题目

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

所有出现次数不为 \(1\) 的子串的出现次数乘上该子串长度的最大值。

只用在接受状态的点的出现次数设为 \(1\),相当于\(\text{endpos}\)集的大小,构造出\(\text{parent}\)树,对于所有\(u\), \(cnt_{\text{link}(u)} += cnt_{u}\), \(\text{endpos}\)集的大小是\(\text{parent}\)树上子树的\(\text{endpos}\)集大小和。

这样就能求出所有状态\(\text{endpos}\)集大小,也就是这个等价类中所有子串的出现次数,称之为\(\text{cnt}(u)\)

其中,状态\(u\)的最长的子串长度为 \(\text{len}(u)\)

所以

\[ans = \max\limits_{\text{cnt}(u) > 1} \text{cnt}(u) \times \text{len}(u) \]

void sam_extend(char c) {
	int cur = sz ++;
	st[cur].len = st[last].len + 1;
	cnt[cur] = 1;
   ...
}

void dfs(int u) {
	for(auto v : e[u]) {
		dfs(v);
		cnt[u] += cnt[v];
	}
	if (cnt[u] > 1) ans = max(ans, 1ll * cnt[u] * st[u].len);
}

int main() {
	...
   for(int i = 1; i < sz; i ++) 
		e[st[i].link].emplace_back(i);
   dfs(0);
   ...
}

P4070 [SDOI2016]生成魔咒

本质不同的子串的大小,

\[sum = \sum\limits_{u \neq start}\text{len}(u) - \text{len}(\text{link(u)}) \]

就是求所有状态的等价类大小。

由于新增的子串都在 \(cur\)\(start\) 的路径上,而根据构造过程,只有 \(cur\) 这个状态包含新的不同的子串。

只需要每次插入字符时,\(ans += \text{len}(u) - \text{len}(\text{link(u)})\)

P2408 不同子串个数

同上

CF802I Fake News (hard)

本质不同的子串出现次数的平方和。

和模板题很像。

\[ans = \sum\limits_{u \neq start} (\text{len}(u) - \text{len}(\text{link(u)})) \times \text{cnt}(u)^2 \]

P3975 [TJOI2015]弦论

对于一个给定的长度为 \(n\) 的字符串,求出它的第 \(k\) 小子串是什么。

参考平衡树求第 \(k\) 小的思想,如果能算出某一个状态包含有多少个子串,就能通过决定 \(\text{next}\) 走到哪,实际上sam像trie,并且是DAG,可以这么做。

对于本质不同的子串个数,每个状态起始贡献是 \(1\), 也就是空集,在DAG上跑DP就能算出每个状态中,以这个状态的字符串为开头的字符串的个数。

对于可以有相同子串的个数,每个状态有\(\text{endpos}\)集大小\(\text{cnt}\)贡献,同样跑DP。

注意跑过的状态就不要再跑。

然后把比较一下总个数和\(k\) 大小,决定往哪走,每过一个状态,还要减去这个状态单独的贡献的字符串的个数。

void dfs(int u, int p) {
	for(auto v : e[u]) {
		dfs(v, p);
		cnt[u] += cnt[v];
	}
	if (!p) cnt[u] = 1;
}// 这里cnt表示贡献
ll f[MAXN];

void dfs1(int u) {
	if (f[u]) return;
	f[u] = cnt[u];
	for(int i = 0; i < 26; i ++) {
		int v = st[u].next[i];
		if (!v) continue;
		dfs1(v);
		f[u] += f[v];
	}
}

void query(int u, int k) {
	if (k <= cnt[u]) return;
	k -= cnt[u];
	for(int i = 0; i < 26; i ++) {
		int v = st[u].next[i];
		if (!v) continue;
		if (k > f[v]) k -= f[v];
		else {
			cout << char(i + 'a') ;
			query(v, k);
			break;
		}
	}
}

int main() {
	...
   dfs(0);
   cnt[0] = 0;//空集不算
   dfs1(0);
   ...
}

SP7258 SUBLEX - Lexicographical Substring Search

同上,求本质不同的子串中第 \(k\) 小的子串。

SP1811 LCS - Longest Common Substring

求最长公共子串。

字符串\(S\), \(T\)

\(T\)建sam, 用\(S\)中的字符串依次在sam中转移。

\(i\)个字符,当前在\(p\)点,匹配\(len\)位。

如果有\(\text{next}_p(S_i) \neq 0\), 就能匹配,长度++。

如果不能匹配,就通过\(\text{link}\)\(\text{parent}\)树,找到\(p\)状态的字符串的后缀中长度最大且能匹配\(S_i\)的状态,更新长度。

其实就像kmp跳fail链。

现在求出的是 \(S\)中以\(i\)结尾的字符串中最大能匹配\(T\)的长度,不妨叫它\(\text{slen}(i)\)

答案就是

\[ans = \max\limits_{i = 1}^{|S|} \text{slen}(i) \]

时间复杂度好像也是\(O(n)\)的。

int main() {
	...
    int p = 0, len = 0;
	for(int i = 1; i <= n; i ++) {
		int c = s[i] - 'a';
		if (!st[p].next[c]) {
			while(p != 0 && !st[p].next[c]) p = st[p].link;
			len = st[p].len;//跳link的时候才更新
		}
		if (st[p].next[c]) {
			p = st[p].next[c];
			len ++;
		}
		slen[i] = len;
	}
   int ans = 0;
	for(int i = 1; i <= n; i ++)
		ans = max(ans, slen[i]);
	cout << ans;	
	...
}
posted @ 2022-02-18 20:00  qjbqjb  阅读(58)  评论(0)    收藏  举报