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


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

    题目链接:luogu P3804

    题目大意

    给你一个字符串,求出它出现次数超过 1 的子串乘它长度的最大值。

    思路

    SAM

    前文

    有一些题目,它要你用 DAG 表示一个字符串的所有子串,要怎么搞。
    容易想到可以用 Trie,把每个后缀都插进去。

    但是它建图的时间和建的点数是 (n^2) 的,(n) 跑到 (10^4) 或更大的时候就爆了。

    那要怎么搞呢?
    我们考虑将一些节点合并在一起,以减小用的点数,然后通过一些东西,让构造它的时间也短。

    于是,就有了这个叫做后缀自动机(SAM)的东西。

    一些定义&结论

    一个子串,它可能出现在原来串中的几个位置。
    我们把这些位置的右端点组成一个集合,就是这个集合的 ( ext{endpos})
    比如原来的串是 abcabbc,那 ( ext{endpos}(ab) ={2,5})

    接着要证明三个东西:

    1. 如果两个子串的 ( ext{endpos}) 相同,那其中一个必然是另一个的后缀。

    这个其实挺显然的。
    你设长的长度是 (x),短的长度是 (y)
    如果不是后缀,那它们在后面 (y) 个字符应该有不同。
    但是他们的 ( ext{endpos}) 相同,那就说明在后面 (y) 个字符应该相同。
    所以就矛盾了,所以就一定短的是长的的后缀。

    1. 设有两个子串 (a,b)(a) 的长度大于等于 (b) 的长度。那么要么 ( ext{endpos}(a)in ext{endpos}(b)),要么 ( ext{endpos}(a) ext{endpos}(b)=emptyset)

    这个我们分两种情况,(b)(a) 后缀和 (b) 不是 (a) 后缀。
    如果是后缀,那它们如果长的长度是 (x),短的长度是 (y),那它们后 (y) 个字符就是相同的,那其中一个又只有 (y) 个字符,那它的 ( ext{endpos}) 必然会包含另一个的 ( ext{endpos})
    如果不是后缀,那它们后 (y) 个字符是有不相同的地方的,那它们的 ( ext{endpos}) 必然是一个有了另一个就一定没有。
    (其实就是第一个证明的东西的逆命题)

    1. 我们把 ( ext{endpos}) 相同的子串归为一个 ( ext{endpos}) 等价类。对于每个这样的等价类,我们把里面的子串按长度从大到小排个序,你会发现每个子串的长度是上个子串的长度 (-1)(即长度连续),且后面是前面的后缀。

    很容易看出,它一个等价类要覆盖就是覆盖一个右端点固定,左端点是一个连续的没有间隔的区间中的数。
    而且从我们第二个证明的东西可以看出不同的等价类不会包含同一个子串。

    1. ( ext{endpos}) 等价类的个数级别为 (O(n))

    对于一个等价类,我们找到长度最大的,根据我们第三个证明的定理,我们在它前面任意加一个字符,得到的新串都不是这个类的。
    那我们考虑其实就是在原来的集合中进行分割,得到新的集合。
    那新的集合也会分,那总的集合个数按线段树的分发是最多的,但是不超过 (2*n) 个。

    所以级别个数是 (O(n))

    诶,你就会发现啊这个不断分集合的形式,其实可以弄出一个树。
    点表示和,那连儿子就是它可以拆出的集合。

    而这个树就叫做 parent tree。

    我们的后缀自动机用的节点就是 parent tree 上的点,只是边不同。
    因为这样点数就是 (O(n)) 级别,而且我们后面也会发现它的边数也是 (O(n)) 级别的。

    1. 一个类中有最长的子串也有最短的,对于 (a) 这个类,最长的的长度是 (len_a),最短的是 (minlen_a),那在 parent tree 上有一些类之间有父子关系,设 (a) 的父亲是 (fa_i),那么 (len_{fa_a}+1=minlen_a)

    这个其实从第四个结论就可以看出。就是在这个类的最长字符串前加一个子串,那这个新的字符串所属的类就是它儿子的,而且是这个儿子的类中最短的那个。

    那我们其实就只用保存 (len_i)(minlen_i) 我们可以推出来。

    那我们会发现,沿着 parent tree 走就是在字符串前面加字符,而沿着后缀自动机走就是在字符串后面加字符。

    1. 后缀自动机边数是 (O(n)) 级别

    我们可以先构出一个后缀树,把其他边舍去,然后对于每个终止节点,我们把它们的后缀根据后缀自动机到它的唯一路径跑。

    那如果可以跑,就直接处理下一个,如果跑不了了,就连上要跑的边,沿着它跑。
    那你这样搞可能会把后面要跑的子串给跑了,那我们就不用跑了。
    那就其实相当于我们跑一个每加上的边都会让一个后缀可以跑。

    那就会加上不超过 (n) 条边,那加上原来的 (n-1),边的数量级还是 (O(n))

    构造

    首先,我们要知道它的构造是在线的,也就是说你可以随时把一个点放到你现在放进去的字符串的后面形成新的字符串,然后得到这个新字符串的后缀自动机。

    接着,我们来根据代码,讲讲它构造的过程:
    在这里插入图片描述
    这个是把新形成的串的 ( ext{endpos}) 对于的点弄出来。
    (因为它新加入了一个字符,有最新的长度 (n) 在它的 ( ext{endpos}) 里,它整个新的字符串就是在一个新的 ( ext{endpos}) 等价类中)

    那它的最长长度就是之前的字符串所在的 ( ext{endpos}) 的最长长度(就是这个之前的字符串)加一(加上你新放进去的字符串)。
    那现在的字符串所在的 ( ext{endpos}) 自然就是新的这个点了。

    在这里插入图片描述
    它的意思就是不断地找 (p) 对于的串的后缀,直到找到一个后缀它后面加 (c) 这个字符在字符串中出现过。
    那就相当于把新的,没有在后缀自动机上的子串搞出来。

    那之前出现过就可以退出是因为后面的后缀一定是现在这个串的子串,它加上字符 (c) 也还是这个串加 (c) 的子串,那就还是出现过,所以就不用搞了。

    现在我们求出了 (len) 和后缀自动机上的边,接着就剩 parent tree 上的了。

    在这里插入图片描述
    那如果 p 一直跳到了没有,就说明这个字符没有出现过在之前的串中,那它的祖先出了节点 (1)(它代表空串),都没有别的,那 (1) 就是它父亲。

    在这里插入图片描述
    那它其实就是找到了第一个后缀加这个子串有在原来串中出现过的子串,那它如果满足上面的条件,又有什么性质呢?
    (p) 集合中最长的子串加 (c) 形成了 (q) 形成的子串是新串后缀。到达了 (q) 的所有串都是新串后缀,而它们的 ( ext{endpos}) 相比之前就都多了 (n),而这时所有到 (q) 的所有串 ( ext{endpos}) 原来一样,都加上一个 (n) 之后还是一样,就还满足后缀自动机性质。
    (q) 是我们找到的第一个跟 (np) 不同而且有后缀关系的点,那 (fa_{np}) 就是 (p) 了。

    在这里插入图片描述
    那刚刚说的是满足条件的,那如果不满足条件呢?
    我们容易想到 (len_qgeq len_p+1),因为 (p) 可以到 (q),但是你又说 (len_q eq len_p+1),那就只有 (len_q>len_p+1) 了。

    那这又说明了什么吗?
    说明还有至少一个比 (p) 这一类中最长的子串后面加 (c) 字符得到的字符串还要长的串是属于 (q) 的。
    但这个更加长的串就不是新串的后缀了(不然它就会被跳到),所以你就发现问题了。
    属于 (q) 的串中,长度 (leq len_p+1) 的是新串后缀,但是 (>len_p+1) 的却不是,那到 (p) 个这个节点的字符串就不同属于一个类,就无法定义 (q)( ext{endpos}) 了。

    那怎么办呢?
    它说分成了两类,是新串后缀和不是的,那我们考虑把 (q) 拆成两个点,分别表示是新串后缀的和不是新串后缀的,然后再维护各项值,就可以了呗。

    那接着我们来看如何维护值。
    我们考虑把是新串后缀的转移出来,转到 (nq) 上((nq) 新建一个节点来弄),那它们的 ( ext{endpos}) 相比没转移出来的就多了个 (n)

    我们先考虑 (len) 值。
    这个很好想,那要转移出来的是新串后缀,那新串后缀又满足 (len_q= len_p+1),那 (len_{nq}=len_p+1)

    接着我们考虑连边,连后缀自动机上的。
    我们考虑直接用 (q) 的边,而且这样是可以的。
    因为我们拆点是因为 ( ext{endpos}) 一样,但在后面加同样的字符,得到的字符串还是在同一个类的。
    (因为它们在旧串中是属于同一个类,而且类中不是新串后缀,不然的话就会先跳到它停下,那它就不会受到新加入的字符的影响,就还是在同一个类中)

    最后我们考虑 (fa_i),也就是 parent tree 上的边。
    原来的 (q) 被拆成了 (q)(nq),而且 (len_{fa_{nq}}<len_{nq}<len_q)
    而且在旧串中 (q)(nq) 是相同的,那 (fa_{nq}=fa_{q})(旧串中)
    那其实就类似于 (nq) 插入到 (q)(fa_q(fa_{nq})) 的父子关系中。
    那就让 (fa_{nq} = fa_q),再让 (fa_q=nq)。(类似链表的感觉)

    接着我们还要考虑 (np)(fa)(因为我们一开始就是因为求 (fa_{np}) 求不了才拆点的)
    那它要么是 (q),要是 (nq)(q) 不行,因为其 ( ext{endpos}) 没有 (n)( ext{endpos}(np)) 中有 (n),所以只能是 (nq)

    接着呢,我们从 (p) 不断的找 (c) 字符对于的边连向 (q) 点,然后 (p) 不断跳父亲(像前面一样),知道跳到不是连向 (q) 点的。
    那这个说明什么呢?就说明这些点对应的 ( ext{endpos}) 都是有 (n) 的,那就不能连向 (q),而是要连向 (nq)

    那不是就可以退出是因为此时连向的就是 (q) 的祖先,那 (q) 父亲是 (nq),那也就是说连向祖先的时候 ( ext{endpos}) 就都有 (n) 这个位置了,就不会再出现错误了。

    关于复杂度

    其实是 (O(n)) 的。
    因为两个循环均摊下来其实是 (O(n)) 的。

    第一个循环,其实就是在加边,那边的个数是 (O(n)) 级别的,那它所有执行的次数也是 (O(n)) 级别的。
    第二个循环,也就是第三种情况里面的那个,它就是在看一个带你的深度要跳 parent tree 要跳多少次才能到根。(las) 深度每插入一次最多加 (2)(新放入的两个点),而跳 (fa) 的这个循环跳多少次就减少多少层的深度,所以总体来讲还是 (O(n)) 级别的执行次数。

    SAM 与 SA

    SAM 和 SA 都能处理一些相同的问题,SAM 有 SA 不能处理的,而且其复杂度是 (O(n)),后缀数组是 (O(nlogn))
    (虽然 SAM 常数大,但还是比 SA 快)

    但是 SA 有着 (O(1)) 求两个后缀的 LCP 的神仙操作。(加 ST 表)

    一些例题&常见用法(给出链接&题解&代码)

    判断子串

    给你两个字符串,问你给出的第二个字符串是否是第一个字符串的子串。
    ——>点我跳转<——

    直接拿一个建,然后拿另外一个跑。
    如果跑到最后跑到的不是 ( ext{NULL}) 就说明是它的子串,否则就不是子串。

    不同子串数

    给你一个字符串,问你它有多少个不同的子串。
    ——>点我跳转<——

    我们考虑可以直接用每个 (len_i-len_{fa_i}) 相加。
    也可以选择 DP,转移出从 (i) 出发的不同子串个数(含空串或不含空串),转移用后缀自动机的边。
    然后记得如果是含空串输出的是 (f_1-1),不含空串就是输出 (f-1)

    K小子串 / 弦论

    给你一个字符串,要你求字典序第 k 小的子串。
    (相同的子串可能算一个,也可能算多个,数据以读入 0/1 来判断)
    ——>点我跳转<——

    我们考虑按照题目分两种情况来做。
    相同算一个我们就用上一题做法做出的 (f_i) 来搞,用一种类似平衡树找第 k 大的方式。
    按字典序从小到大枚举下一个字符,然后能用就用,减去这个当前位置对于串的个数(因为多个算一个所以这里是减一),不然就 k 减去走那边的个数。
    k 减到 0 就退出。

    不同我们就求 (size_i) 表示 (i) 对于的子串个数,然后通过 (fa) 边来转移。
    (size_i) 的初始化新建的点是 (1),复制的点是 (0)
    然后 (size\_s_i) 表示 (i) 出发的子串个数,然后像求 (f_i) 一样转移。
    (其实就是 (f_i) 最后的加一变成这里最后的加子串个数,或者说相同其实就是所有的 (size_i) 都是 (1),然后像这样跑)
    然后又用平衡树找第 k 小的方式来搞。

    最长公共子串 / LCS - Longest Common Substring

    给你两个字符串,求它们的最长公共子串。
    ——>点我跳转<——

    你考虑用一个子串去构 SAM,然后拿另一个上去跑。
    能跑就继续匹配下去,然后长度加一。
    不能匹配就跳 (fa) 边,跳到可以匹配位置,此时的匹配长度就是你跳到可以跳的位置 (now)(len) 值加一(加一是你新加上的字符),即 (len_{now}+1)

    然后中途一直维护,求出长度的最大值就可以了。

    LCS2 - Longest Common Substring II

    给你多个字符串,要你求它们的最长公共子串。
    ——>点我跳转<——

    这道题其实就是上一道题的升级版。
    你考虑还是用一个子串去构 SAM,然后那剩下的去跑。
    然后由于它有的话,它 fa 连向的点也会有。(不过要判断一下长度是否超过 fa 点的最长串大小,如果超时就取最长串)

    那就用逆拓扑序 DP 一下取 (max) 就好了。

    然后每一次到与每个串的匹配长度取最小值,然后再每个串的这个值取最大值就是答案了。

    子串差异 / 差异

    给你一个字符串,要你求一个式子:
    在这里插入图片描述
    (Ti 是字符串从第 i 个字符开始的后缀,len(a) 是字符串 a 的长度,lcp(a,b) 是字符串 a,b 的最长公共前缀)
    ——>点我跳转<——

    这题主要是看如何快速求后缀两两间 LCP 的和。
    翻转子串变成要快速求前缀两两之间最长后缀长度的和。

    然后考虑两个前缀求最长后缀长度就是跳 fa 边,跳到 parent tree 上它们的 LCA。

    就考虑枚举 LCA 的一个直属儿子,然后 DP 预处理 parent tree 某个点为根的串个数,然后就搞搞。

    这道题

    没啥好说的,直接拿字符串构一个 SAM,然后 DP 求出每个子串的出现次数。
    然后枚举 SAM 上的点 (i),如果它对应的子串出现次数大于 (1),就拿去算值和最大值比较。((len_i) 就是长度)

    最后输出比较出的最大值就可以了。

    代码(这道题的)

    #include<cstdio>
    #include<cstring>
    #include<iostream>
    #define ll long long
    
    using namespace std;
    
    struct node {
    	int len, fa;
    	ll size;
    	int son[26];
    	node() {
    		len = fa = 0;
    		size = 0ll;
    		memset(son, 0, sizeof(son));
    	}
    }d[2000001];
    char s[1000001];
    int n, tot, lst;
    ll ans;
    
    void SAM_build(int now) {
    	int p = lst;
    	int np = ++tot;
    	lst = np;
    	d[np].size = 1;
    	d[np].len = d[p].len + 1;
    	for (; p && !d[p].son[now]; p = d[p].fa)
    		d[p].son[now] = np;
    	
    	if (!p) d[np].fa = 1;
    		else {
    			int q = d[p].son[now];
    			if (d[q].len == d[p].len + 1) d[np].fa = q;
    				else {
    					int nq = ++tot;
    					d[nq] = d[q];
    					d[nq].size = 0;
    					d[nq].len = d[p].len + 1;
    					d[q].fa = nq;
    					d[np].fa = nq;
    					for (; p && d[p].son[now] == q; p = d[p].fa)
    						d[p].son[now] = nq; 
    				}
    		}
    }
    
    int tmp[1000001], tp[2000001];
    
    void get_tp() {
    	for (int i = 0; i <= n; i++)
    		tmp[i] = 0;
    	for (int i = 1; i <= tot; i++)
    		tmp[d[i].len]++;
    	for (int i = 1; i <= n; i++)
    		tmp[i] += tmp[i - 1];
    	for (int i = 1; i <= tot; i++)
    		tp[tmp[d[i].len]--] = i;
    }
    
    void DP() {
    	for (int i = tot; i >= 1; i--) {
    		int now = tp[i];
    		d[d[now].fa].size += d[now].size;
    	}
    	for (int i = 1; i <= tot; i++)
    		if (d[i].size > 1)
    			ans = max(ans, d[i].size * d[i].len); 
    }
    
    int main() {
    	scanf("%s", s + 1);
    	n = strlen(s + 1);
    	
    	tot = lst = 1;
    	for (int i = 1; i <= n; i++)
    		SAM_build(s[i] - 'a');
    	
    	get_tp();
    	DP();
    	
    	printf("%lld", ans);
    	
    	return 0;
    }
    
  • 相关阅读:
    PHP图像处理之画图
    PHP中的日期和时间
    windows socket网络编程基础知识
    socket编程(Linux)
    变量作用域
    JavaScript中的this
    基于jQuery的2048小游戏设计(网页版)
    I/O流
    并发名词解释
    synchronized 实现原理
  • 原文地址:https://www.cnblogs.com/Sakura-TJH/p/luogu_P3804.html
Copyright © 2020-2023  润新知