『模块学习』SAM

引入

  daduoli最近对自己的名字很感兴趣,于是他开始研究自己的名字。知周所众,搞清楚一个字符串的最好方法就是把他的所有子串列出来(误),所以daduoli开始尝试列举他名字中所有的子串。
  列了好一会,daduoli发现子串太多了,于是尝试把它们拼在一起。拼了好一会儿,他拼出来一个奇怪的东西。此时shui_dream路过:“你咋拼了个SAM?”
  (以上故事情节纯属虚构,如有雷同,那就雷同)

算法原理

\(trie\)

  事情还要从daduoli拼的过程说起。
  daduoli打算先拿他的姓试试水(知周所众,daduoli姓daduo),他考虑将姓的所有的后缀放到一个 \(trie\) 树上,它看起来是酱紫的:
  
  发现这个 \(trie\) 有许多非常好的性质:

性质1

 有一个源点,若干个终止点。边代表在目前的字符串后加上的字母。从源点到任意一个节点的任意路径可以形成一个字符串

性质2

  从源点到任意节点的任意路径形成的字符串均为原串子串。从源点到任意节点的任意路径不能形成的字符串均不为原串子串(简单来说,这个图可以表示,且仅可以表示出原串的所有子串)

性质3

  从源点到任意终止节点的任意路径形成的字符串均为原串后缀

性质4

  从源点出发的任意两条不同路径形成的字符串不相同

  虽然这个 \(trie\) 能完美满足daduoli的要求,但daduoli并不满意,应为这棵树的节点数实在是太多了,最坏情况会是 \(O(n^2)\) 的,如果daduoli名字在长个十倍的话,建出来的 \(trie\) 对于密恐的daduoli来说就太恐怖了。
  突然,daduoli发现了这个 \(trie\) 是可以压缩的,他尝试压缩掉了一个点:
  
  daduoli发现即使trie变成了一个DAG,他仍然能在上面找到所有的子串,于是daduoli开始思考如何把原trie压缩成一个点数尽可能少的DAG。
  就在daduoli苦思冥想时,突然受到仙人托梦,醒了之后立刻就理解了一切()
  接下来让我们看看daduoli理解了什么:
  (daduoli觉得自己的名字真不好拼,于是下两个小节的所有图片将描述字符串"\(aababa\)"。)

\(endpos\)

定义

  对于原串的一个子串 \(p\)\(endpos(p)\) 的值为\(p\)在原串中出现的所有位置的右端点集合。举个栗子,对于串"daduo"而言,\(endpos(\)"\(d\)"\()=\{1,3\}\)

结论1

\[\color{Red}结论1:如果两个子串的 endpos 相同,则其中子串一个必然为另一个的后缀 \]

  设两个子串中较短的串为 \(t\) , 较长的串为 \(s\) ,用反证法假设两个子串的后\(|t|\)个字符中至少有一个不相同,但是因为两串的 \(endpos\) 相同,所以这个假设恒为假。QED.

结论2

\[\color{Red}结论2:对于任意两个子串 t 和 p(|t|<|p|) ,[endpos(p) \in endpos(t)] xor [endpos(p) \cap endpos(t) = \varnothing] = 1 \]

  结论1的逆命题,分类讨论 \(t\) 为或不为 \(p\) 的后缀即可。

定义:\(endpos\) 等价类

  \(endpos\) 相同的两个子串归为一个 \(endpos\) 等价类。

结论3

\[\color{Red}结论3:一个 endpos 等价类中的串长度连续 \]

  由结论2可知,长度是连续的。并且任意的两个 \(endpos\) 等价类不会包含同一个子串。

  后面的推论会比较重要

结论4

\[\color{Red}结论4:endpos 等价类个数的级别为 O(n) \]

  对于一个 \(endpos\) 等价类(后文简称类),根据结论3,存在一个长度最长的串 \(p\) ,在 \(p\) 前添加一个字符使得字符串仍为原串的子串,得到的串 \(t\) 必然不属于 \(p\) 所在的类,并且由结论2我们可以知道该串的 \(endpos(t)\) 必然是 \(endpos(p)\) 的子集。
  发现在 \(p\) 前加的字符如果不同,设形成的子串分别为 \(t_1\)\(t_2\) ,那么由结论2可知\(endpos(t_1) \cap endpos(t_2) =\varnothing\)
  由上面的两个小结论,我们就可以把“在 \(p\) 前添加一个字符使得字符串仍为原串的子串”这个操作看做对 \(endpos(p)\) 进行分割,割成若干 \(endpos(t_x)\) 。这个操作像极了daduoli天天钻研的data structure:线段树。不难发现线段树这样二分的分割所产生的子集个数是最多的,但节点数也才堪堪 \(2n-1\) ,所以类个数数量级 为 \(O(n)\) ,使用时开二倍空间即可。

后缀自动机

定义:\(parent \enspace tree\)

  daduoli发现可以把等价类之间的分割表示成一棵树:
  
  这样形成类与类之间父子关系的一棵树,就叫 \(parent \enspace tree\)

结论5

\[\color{Red}结论5:一个类中,最短的子串长度等于其父亲最长子串+1 \]

  其实证明早就在结论4第二个小结论的推导中便已给出。
  这个结论结合结论2,我们就只需在 \(parent \enspace tree\) 的节点保存该类的最长子串( \(longest(p)\) )即可,其他子串都可以通过一步步去掉前缀直到父亲的最长子串长度\(+1\) ,这样的 \(parent \enspace tree\) 长这样:
  

定义:后缀自动机

  后文简称后缀自动机为 \(SAM\)
  在daduoli坚持不懈地拼字符串后,\(SAM\) 终于是给他拼出来了。
  \(SAM\) 的节点是 \(parent \enspace tree\) 中的节点,\(parent \enspace tree\) 的根节点(即空串所在的类)是 \(SAM\) 的源点,而终止节点则是整个原串所在类的节点以及该节点在 \(parent \enspace tree\) 上的所有祖先。
  虽然daduoli把 \(SAM\) 的雏形给搞出来了,但仙人并没有告诉他怎么连边,也没有告诉他到底怎么用。正当daduoli感到疑惑时,shui_dream过来了,这才发生了开头的一幕。
  (接下来是shui_dream的主场!)
  shui_dream大手一挥,往daduoli的 \(SAM\) 上连上好几条边,daduoli定睛一看,恍然大悟:
  
  不难看出,沿着原 \(parent \enspace tree\) 的边走,就是在串的前面加字符,而沿着非 \(parent \enspace tree\) 的边走,则相当于在边的后面加字符。
  发现 \(SAM\) 完美满足本节开头所提到 \(trie\) 的性质。前三个性质显然满足,对于最后一个,因为类是分割形成的,所以不同的类中不会包含同一个串,因此到任意两个不同节点所形成的字符串均不重复。接下来说明 \(SAM\) 存在。
  \(SAM\) 的存在性取决于边的正确性,因此下面论证边的正确性。显然沿着 \(parent \enspace tree\) 的边走是没有任何问题的,问题就是非 \(parent \enspace tree\) 的边是否正确。对于一个类中的所有子串,在其后添加一个字符,他们一定在另一个相同的类中,所以非 \(parent \enspace tree\) 的边也是正确的。从而 \(SAM\) 存在。
  更严谨的证明将会在后文提到,这里只是进行并不那么严谨的解释。

结论6

\[\color{Red}结论6:SAM 的边数为 O(n) \]

  又臭又长,直到有这个东西就行了,先不整了

  反正写代码不考证明

构造

基本形态

  daduoli非常想要juan学习 \(SAM\) ,于是死皮赖脸地让shui_dream教他构造(说是要帮shui_dream代打舟游)。没有办法,shui_dream只能再次大手一挥,画了一张描述 \(SAM\) 形态的图片:

  

  daduoli根本看不懂,于是shui_dream解释道:下面整齐的一行表示各个前缀所属的节点。一个前缀显然是所属类中长度最长的,因为它前面不能再加字符了。相邻的两个前缀显然一定会连上非树边,但是不相邻的点之间也会有边,所以这些节点放在最下面,方便观察。

  上方零散的点是不包含任意前缀的类,一些边连下来,一些边连上去,内部还有一些边。这就是 \(SAM\) 的基本形态(结构)。

Code

  我相信一定有人会直接跳到这看我丑陋的马蜂的(说的就是你Cust10)

  “\(SAM\) 的构造是在线的,相当于把原串拆成一个个字符,通过不断把字符塞进 \(SAM\) 进行构造。”shui_dream这样说到。

  突然,天上掉下来一个大大的代码框和链接,想必是daduoli认真学习的样子感动了仙人吧()link

点击查看代码
#include<bits/stdc++.h>
using namespace std;

const int N = 2e6+10;														//两倍空间 

struct Blck{int to, next;}e[N];
int head[N], ecnt;
void addE(int u, int v) {e[++ecnt].to = v, e[ecnt].next = head[u], head[u] = ecnt;}


struct Node {
	int ch[26];
	int len, fa;
}a[N];
int pre = 1, cnt = 1;
long long val[N];
void add(int x) {
	int p = pre, np = pre = ++cnt; val[cnt] = 1;							//新建节点 
	a[np].len = a[p].len+1;													//新建节点
	for (; p && !a[p].ch[x]; p = a[p].fa) a[p].ch[x] = np;					//遍历旧串后缀连边 
	if (!p) a[np].fa = 1;													//如果产生的都是新子串,说明是新字符,祖先连空串 
	else {
		int q = a[p].ch[x];													//要连边的子串 
		if (a[q].len == a[p].len+1) a[np].fa = q;							//如果满足条件,直接连q即可 
		else {
			int nq = ++cnt; a[nq] = a[q];									//建新节点,复制出边与len 
			a[nq].len = a[p].len+1;											//把len(nq)设为len(p)+1
			a[q].fa = a[np].fa = nq;										//q与np的fa都为nq 
			for (; p && a[p].ch[x] == q; p = a[p].fa) a[p].ch[x] = nq;		//循环连边 
		}
	}
}

long long ans;
void dfs(int x) {
	for (int i = head[x]; i; i = e[i].next) {
		int y = e[i].to;
		dfs(y);
		val[x] += val[y];													//儿子必然包含自己,因此次数加上儿子 
	}
	if (val[x] != 1) ans = max(ans, val[x]*a[x].len);						//len本身就是类中最大len的,直接乘即可 
}

char str[N];
int len;

int main() {
	scanf("%s", str+1);
	len = strlen(str+1);
	for (int i = 1; i <= len; ++i) add(str[i]-'a');							//构建SAM 
	for (int i = 2; i <= cnt; ++i) addE(a[i].fa, i);						//邻接表储存变成真·DAG 
	dfs(1);																	//跑DAG枚举节点 
	printf("%lld", ans);
	return 0;
}

  这种题就没必要放评测记录了吧……

  即使有仙人所写的精美注释,daduoli看见这玩意一脸懵逼,(我也是),于是旁边一脸轻松的shui_dream开始给daduoli一段段地讲解。

case1

int p = pre, np = pre = ++cnt;
a[np].len = a[p].len+1;

  在这段程序段中,\(pre\) 表示未加入字符 \(x\) 之前,整个串所属的节点编号,\(cnt\) 则表示整个 \(SAM\) 的节点总数。

  加入了字符 \(x\) 后,显然整个串的长度就变长了,因此需要新建一个节点 \(np\) ,并且 \(len(np) = len(p)+1\)

  注意到在加入字符 \(x\) 之后 \(endpos\) 发生变化的串只有可能是新串的后缀,所以接下来就需要遍历每一个旧串的后缀,看看加上字符 \(x\) 之后 \(endpos\) 有没有变化。

for (; p && !a[p].ch[x]; p = a[p].fa) a[p].ch[x] = np;

  这个语句其实就是上述操作,去遍历每一个旧串后缀,然后尝试加上字符 \(x\)p = a[p].fa 这个在 \(parent \enspace tree\) 跳父亲的操作,就是在不断寻找更短的前缀。而如果这个节点有 \(x\) 的出边,就说明之前有这个子串,不能把它丢到新的节点,这就是!a[p].ch[x]的意义。至于p只是为了防止自由奔放地跳出根节点。

if (!p) a[np].fa = 1;

  如果说最后 \(!p\) ,就说明新加的字符没有产生任何一个旧串中已有的子串。长度为 \(0\) 的串 \(+x\) 都不是旧串中的子串,说明 \(x\) 是全新的字符,它的父亲就应该是空串。

case2

int q = a[p].ch[x];
if (a[np].len == a[p].len+1) a[np].fa = q;

  \(x\)不是新字符,那么某个旧串后缀就会有一个 \(x\) 的出边。在第二节后缀自动机定义处有提到到达同一节点的字符串 \(endpos\) 都相同,因此如果它们的最长串之间的长度差为 \(1\) ,说明它们之间只差了一个字符 \(x\) ,直接将 \(q\) 作为 \(np\) 的父亲就好。另一种更严谨的解释是,因为 \(longest(p)+x\) 是新串的后缀,所以如果 \(len(q) = len(p)+1\) ,那么 \(longest(q)\) 就会是新串的后缀,由结论3,自然而然的,小于等于 \(q\) 的子串都是新串的后缀,因为它们一定会是 \(longest(q)\) 的祖先,所属的类中一定包含新串的结尾点,即它们是新串的后缀,所有小于等于 \(q\) 的类会加上新串的结尾点,即 \(p\) 的父亲为 \(q\)

case3

  请深呼吸。

  处理完这个,那么就只剩下了最后一种情况:\(len(q) > len(p)+1\) , 因为 \(p\) 可以通过一个字符 \(x\) 到达 \(q\) ,所以有 \(len(q) \geq len(p)+1\) 。既然不相等,那么就是严格大于。

  那么尝试解决这最后的情况。这种情况说明了存在比 \(longest(p)+x\) 更长的子串属于 \(q\) ,并且这个子串并不是新串的后缀,因为去掉该子串去掉最后一个字符并不是旧串的后缀,并且因为其长度大于 \(longest(p)\) ,在跳父亲的时候就会先被跳到,而不是在现在才出现。

  那这个子串到底是什么东西呢?我们分析case2是提到过,属于 \(q\) 的长度小于等于 \(len(p)+1\) 的串都是新串的后缀,但大于 \(len(p)+1\) 的则不是。这就说明了一个问题,到达 \(q\) 的所有子串不全属于一个类,而是两个类,一个包含新串结尾,另一个不包含。我们考虑转走其中的一类,这样 \(endpos(q)\) 才能符合定义。

  原后缀自动机中显然不存在这样一个节点包含新串结尾,因此我们新建一个节点 \(nq\) ,让 \(endpos\) 带新串结尾的那一部分转到这里。

  对于一个新的节点,我们需要考虑它的非树边怎么连,以及其在 \(parent \enspace tree\) 上的 \(fa\),其自身的最长字串长度 \(len\)

  对于 \(len\) ,长度大于 \(len(p)+1\) 的串都不可能是新串的后缀。并且 \(p\) 是能通过字符 \(x\) 到达 \(nq\) 的,所以他们之间有一条边, \(len(nq)\) 应设为 \(len(p)+1\)

  接下来考虑出边, \(nq\) 本质上是拆掉了 \(q\)\(endpos\) 不同的一部分子串,因此在 \(nq\) 后加字符显然是与在 \(q\) 后加字符在同一个类中。

  考虑 \(q\)\(fa\) 。其实就是 \(q\) 被拆分了,\(len(fa(nq)) < len(nq) < len(q)\) , 相当于新拆分出来的 \(nq\) 插到了旧的 \(nq\)\(q\) 之间,于是令 \(fa(nq)\) 等于旧自动机的 \(fa(q)\) ,然后让 \(fa(q)\) 等于 \(nq\) 像链表一样插入就好。

  考虑 \(np\)\(fa\) ,显然是最先被跳到的 \(q\)\(nq\)\(q\) 是不行的,它的 \(endpos\) 不含新串结尾。所以应该为 \(nq\)

  以上的所有步骤程序实现如下:

int nq = ++cnt; a[nq] = a[q];
a[np].len = a[p].len+1;
a[q].fa = a[np].fa = nq;
for (; p && a[p].ch[x] == q; p = a[p].fa) a[p].ch[x] = nq;

  前面三行是建新节点与 \(fa\) 赋值,而最后一行则是一个类似case1的循环,目的是让不能连到 \(q\) 的部分连到 \(nq\),即处理 \(nq\) 的入边。

Ending

  最后的最后,学会了 \(SAM\) 的daduoli把他的名字塞进了 \(SAM\) 里,可喜可贺,可喜可贺。(daduoli:我只是想划水)

  感谢daduoli与shui_dream的友情出演()

应用

判断子串

  利用第二节最开始所说的性质2即可,在 \(SAM\) 上跑,会跑出去就说明没有这个子串,否则就是有。

不同子串个数

  \(dp\) 即可,转移方程为 \(f_i = \sum\limits_{u, v}(f_j+1)\) ,最后答案为 \(f_1\)

字典序第 \(i\) 大子串

  这是一个典link

Description

  给定字符串 \(s\) , 以及整数 \(opt,k\) 表示要求第 \(k\) 小,以及 \(opt\) 表示 \(opt = 0\) 时不同位置的相同子串算作一个, \(opt = 1\) 时不同位置相同子串算多个。

Solution

  先对 \(s\) 构造出 \(SAM\) ,然后对自动机每个点计算 \(sum[i]\) 表示有 \(sum[i]\) 个子串经过 \(i\) 号点。

  然后 \(siz[i]\) 表示 \(i\) 所代表的 \(endpos\) 等价类大小, 相当于 \(i\) 所对应字符串集合的出现次数。

  对于 \(opt = 1\)\(sum\) 会等于子树内的所有 \(siz\) 和,按节点 \(len\) 大小桶排,\(O(n)\) 就可以算出 \(sum\) 数组;对于 \(opt = 0\)\(sum\) 会等于子树内节点数,把 \(siz\) 赋值为 \(1\) ,然后如前一种情况操作即可。

  最后输出答案则从空串开始跑,每到一个节点就与它的 \(sum\) 比较,大于就减掉,小于就说明答案在这个节点里。输出边的字母,然后跑进去。一直循环直到跑不动,这是已输出的就是答案。

Code

点击查看代码
#include<bits/stdc++.h>
using namespace std;

const int N = 2e6+10;

struct Node {
	int ch[26];
	int len, fa;
}a[N];
int pre = 1, cnt = 1;
long long siz[N];
void add(int x) {
	int p = pre, np = pre = ++cnt;
	a[np].len = a[p].len+1; siz[np] = 1;
	for (; p && !a[p].ch[x]; p = a[p].fa) a[p].ch[x] = np;
	if (!p) a[np].fa = 1;
	else {
		int q = a[p].ch[x];
		if (a[q].len == a[p].len+1) a[np].fa = q;
		else {
			int nq = ++cnt; a[nq] = a[q];
			a[nq].len = a[p].len+1;
			a[q].fa = a[np].fa = nq;
			for (; p && a[p].ch[x] == q; p = a[p].fa) a[p].ch[x] = nq;
		}
	}
}

long long sum[N], tax[N], id[N];
void dfs(int x, int k) {
	if (k <= siz[x]) return ;
	k -= siz[x];
	for (int i = 0; i <= 26; ++i) {
		int y = a[x].ch[i]; if (!y) continue ;
		if (k > sum[y]) {k -= sum[y]; continue ;}
		cout << (char)(i+'a');
		dfs(y, k); return ;
	}
}

char str[N];
int len;

int main() {
	int t, k;
	scanf("%s%d%d", str+1, &t, &k);
	len = strlen(str+1);
	for (int i = 1; i <= len; ++i) add(str[i]-'a');
	
	for (int i = 1; i <= cnt; ++i) ++tax[a[i].len];
	for (int i = 1; i <= cnt; ++i) tax[i] += tax[i-1];
	for (int i = 1; i <= cnt; ++i) id[tax[a[i].len]--] = i;
	for (int i = cnt; i >= 1; --i) siz[a[id[i]].fa] += siz[id[i]];
	for (int i = 1; i <= cnt; ++i) !t ? (sum[i] = siz[i] = 1) : sum[i] = siz[i];
	siz[1] = sum[1] = 0;
	
	for (int i = cnt; i >= 1; --i)
		for (int j = 0; j < 26; ++j)
			if (a[id[i]].ch[j]) sum[id[i]] += sum[a[id[i]].ch[j]];
//	for (int i = 1; i <= cnt; ++i) printf("%lld ", sum[i]);
	
	if (sum[1] < k) printf("-1");
	else dfs(1, k);
	return 0;
}

最长公共子串

广义 \(SAM\)

  话说那daduoli把名字塞进 \(SAM\) 之后,一时名震sm,引得各路英雄豪杰前来相会。在这些英雄豪杰中,有一个叫MuelsyseU,他带着一大串他最喜欢的术力口歌曲名,请求daduoli使用 \(SAM\) 在黑鸦的歌单里查找。

  daduoli显然不会啊,于是又恭(si)恭(pi)敬(lai)敬(lian)地把shui_dream请来。shui_dream表示出强烈的兴趣,于是开始着手解决这个问题。

算法

方法1:加分隔符

  shui_dream很快开始了他的思考,然而就在这时北文走了过来,看了两眼,表示:“你这歌没星号啊,那在每首歌间加星号跑普通 \(SAM\) 不好吗……”

  最怕气氛突然安静。

  那么这就是第一个方法:加分隔符。但是这种方法有时候会有一定的限制,因此还是很有必要其他的广义 \(SAM\) 方法。

方法2:\(trie+SAM\)

  咕咕咕

  最后的最后,学习一下高德纳:感谢你阅读本文,希望本文对你有用。

附录:概念表

概念 含义
\(endpos(p)\) 子串 \(p\) 在原串中出现的位置的右端点集合
\(endpos\) 等价类、类 所有 \(endpos\) 值相等的子串所构成的集合
\(longest(p)\) 在等价类 \(p\) 中长度最长的子串及
\(len(p)\) \(longest(p)\) 的长度
\(fa(p)\) \(p\)\(parent \enspace tree\) 中的父节点
属于 \(p\) 在等价类 \(p\)
旧/新串 增加字符 \(x\) 前/后的串
posted @ 2023-09-03 12:33  Black_Crow  阅读(21)  评论(0编辑  收藏  举报