字符串小结(持续更新)

只是给忘记模板时的我看的

AC自动机

大概流程

首先对所有模式串建出 \(Trie\) 树,并标记。

\(fail\) 的定义:设 \(i\) 节点所代表的字符串为 \(S\),则 \(fail_i\) 表示 \(S\) 的所有后缀里面,在 \(Trie\) 树中出现过的最长的那个串所代表的节点。

然后通过 \(\texttt{bfs}\) 求出 \(fail\),代码如下:

void getfail()
{
	queue<int>q;
	for(int i=0;i<26;i++)
		if(t[0].ch[i])
			q.push(t[0].ch[i]);
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		for(int i=0;i<26;i++)
		{
			if(t[u].ch[i])
			{
				t[t[u].ch[i]].fail=t[t[u].fail].ch[i];
				q.push(t[u].ch[i]);
			}
			else t[u].ch[i]=t[t[u].fail].ch[i];//tag1
		}
	}
}

其中为什么 \(tag_1\) 处可以将 \(u\) 的儿子直接指向 \(fail_u\) 的儿子 \(v\)

首先实际的操作应该是新建一个虚拟节点 \(new\),使 \(new\)\(u\) 的儿子,且 \(fail_{new}=v\)

又由于 \(new\) 本身是新建的节点,没有任何儿子,所以 \(new\) 的儿子全都是要靠新建虚拟节点构成。

所以 \(new\) 的子树其实和 \(v\) 的子树是一模一样的。

那我们不妨用同一棵子树表示他们,也就是说让 \(u\) 的儿子指向 \(v\) 而不是新建节点。

然后由于 \(new\) 树的 \(fail\) 全部都是指向 \(v\) 树的,所以合并到一起不会对 \(fail\) 产生影响。

那么 \(\operatorname{getfail}()\) 之后原来的 \(Trie\) 树就会变成一个 DAG 了。

实际应用

一、模式串与文本串匹配上的应用

原理

首先通过递归 \(fail\),就可以遍历某个串的所有在模式串中出现过的后缀。

同样,如果建立 \(fail\) 树(\(fail_i\to i\),就可以通过遍历某一个点 \(u\) 的子树(设 \(u\) 所代表的串为 \(s\)),遍历所有以 \(s\) 为后缀的串。(也就是往 \(s\) 的前面加字符)

其次,对于原 \(Trie\) 树中的某一个节点 \(u\)(设其代表的串为 \(s\)),可以遍历统计 \(u\) 子树内的所有点,遍历所有以 \(s\) 为前缀的串。(也就是往 \(u\) 后面加字符)

那么综合上面两个操作,对于某个串 \(t\),我们可以求出所有满足 \(t\)\(s\) 的子串的 \(s\) 串的信息。

时间复杂度为 \(O(n)\)(遍历一遍 \(Trie\) 树+一遍 \(fail\) 树)。

所以对于解决模式串类的问题,AC 自动机的本质就是对于每一种字符串,除了记录在它后面加字符能到达的出现过的串(\(Trie\) 树),还记录了在它前面加字符能到达的出现过的串(\(fail\) 树)。

那么对于 \(s\) 串的子串信息,我们可以对 \(s\) 的前缀跳 \(fail\) 链。而对于 \(t\) 串的扩展串信息(\(t\) 是某个串的子串),我们可以在 \(fail\) 树中遍历 \(t\) 树的子树,再在 \(Trie\) 树中遍历 遍历到的点 的子树。

例题

1.请你分别求出每个模式串 \(T_i\) 在文本串 \(S\) 中出现的次数。

可以直接按我们刚刚的做法来做(跳 \(S\) 前缀的 \(fail\) 链),但是会 T 飞。

考虑优化,把根到 \(S\) 路径上的节点都标记(设为 \(size=1\)),然后建立 \(fail\) 树(\(fail_i \to i\)),设 \(size_i\)\(i\) 这个节点所代表的字符串在 \(S\) 中出现的次数。

那么在 \(fail\) 树中,\(i\) 的子树中的所有有效节点都能为 \(size_i\) 贡献 \(1\)。所以把每一个有效节点 \(size\) 的初始值都设为 \(1\) 然后在 \(fail\) 树上从下往上统计 \(size\)

#include<bits/stdc++.h>

#define N 200010
#define ll long long

using namespace std;

struct Trie
{
	int ch[26],fail;
	ll size;
}t[N];

int n,node,id[N];
int cnt,head[N],nxt[N],to[N];

void adde(int u,int v)
{
	to[++cnt]=v;
	nxt[cnt]=head[u];
	head[u]=cnt;
}

int insert(string s)
{
	int u=0,len=s.size();
	for(int i=0;i<len;i++)
	{
		int v=s[i]-'a';
		if(!t[u].ch[v]) t[u].ch[v]=++node;
		u=t[u].ch[v];
	}
	return u;
}

void dfsTrie(string s)
{
	int u=0,len=s.size();
	for(int i=0;i<len;i++)
	{
		int v=s[i]-'a';
		u=t[u].ch[v];//这里可能没有u->v这个转移然后回到根,但也是对的。因为这代表在Trie树中没有出现任何一个s[1...i]的后缀(注意这里的转移时geifail后的)
		t[u].size++;
	}
}

void getfail()
{
	queue<int>q;
	for(int i=0;i<26;i++)
		if(t[0].ch[i])
			q.push(t[0].ch[i]);
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		for(int i=0;i<26;i++)
		{
			if(t[u].ch[i])
			{
				t[t[u].ch[i]].fail=t[t[u].fail].ch[i];
				q.push(t[u].ch[i]);
			}
			else t[u].ch[i]=t[t[u].fail].ch[i];
		}
	}
	for(int i=1;i<=node;i++)
		adde(t[i].fail,i);
}

void dfsFail(int u)
{
	for(int i=head[u];i;i=nxt[i])
	{
		int v=to[i];
		dfsFail(v);
		t[u].size+=t[v].size;
	}
}

int main()
{
	scanf("%d",&n);
	for(int i=1;i<=n;i++)
	{
		string str;
		cin>>str;
		id[i]=insert(str);
	}
	getfail();
	string s;
	cin>>s;
	dfsTrie(s);
	dfsFail(0);
	for(int i=1;i<=n;i++)
		printf("%lld\n",t[id[i]].size);
	return 0;
}
/*
3
abc
cde
de
abcde
*/

2.https://blog.csdn.net/ez_lcw/article/details/99613063

后缀自动机(SAM)

大概流程

(以下的 “节点” 均表示后缀自动机中的节点)

(定义对于两个字符串 \(A,B\) 的运算 \(A+B\) 表示 \(A\)\(B\) 顺次拼接起来的串)

(下面请注意 \(S(i)\)\(S[i]\) 的区别,其中后者表示字符串 \(S\) 的第 \(i\) 位,而前者在下文中会有定义)

\(\operatorname{Endpos}\) 集合

我们把 \(S\) 的一个子串在 \(S\) 中每一次出现的结束位置的集合定义为 \(\operatorname{Endpos}\) 集合。

然后我们考虑我们要构建的后缀自动机长什么样:我们将 \(\operatorname{Endpos}\) 集合完全相同的子串合并到同一个节点。

我们发现,对于越短的子串,其 \(\operatorname{Endpos}\) 集合往往越大。更具体地,如果 \(t\) 是某一个子串 \(T\) 的后缀,则 \(|\operatorname{Endpos}(t)|\geq |\operatorname{Endpos}(T)|\)。当且仅当取等号时,\(t\)\(T\) 会被压缩到同一个节点中。

而对于某一个子串 \(T\) 来说,肯定有一个分界长度 \(len\),使得每一个长度 \(\geq len\)\(T\) 的后缀的 \(\operatorname{Endpos}\) 都和 \(\operatorname{Endpos}(T)\) 相同(所以这些后缀和 \(T\) 在同一个节点),且每一个长度 \(<len\)\(T\) 的后缀的 \(\operatorname{Endpos}\) 大小都比 \(\operatorname{Endpos}(T)\) 大(所以这些后缀和 \(T\) 不在同一个节点,而且这些后缀可能在不同的节点)。

所以每个节点 \(u\) 中存储的一定是一堆长度连续的子串,且短的串是长的串的后缀。不妨把这些串的集合称为 \(S(u)\),设其中最长的串为 \(\operatorname{longest}(u)\),最短的串为 \(\operatorname{shortest}(u)\)

我们在具体实现时会用一个 \(len\) 数组记录每个节点中最长的子串的长度(即 \(\operatorname{longest}(u)\) 的长度),为什么不用记最短的长度,下文会讲。

Parent Tree

如上文所述,对于每一个子串都会有唯一一个 ”分界长度“,而且每一个节点中所有子串的 “分界长度” 都相同,为这个节点中最短的子串的长度。

而如果 \(t\)\(T\) 的一个后缀且没有和 \(T\) 分在一个节点中,那么 \(t\) 肯定也是别的子串的后缀,例如 \(\texttt{ab}\) 在串 \(\texttt{cabzab}\) 中既可以是 \(\texttt{cab}\) 的后缀,也可以是 \(\texttt{zab}\) 的后缀。这样我们看到:长的串 \(T\) 只能 “对应” 唯一的一个短的串 \(t\),而短的串可以 “对应” 多个长的串,如果将 “短的串” 视为 “长的串” 的父亲,这就构成了一棵严格的树形结构。我们称为Parent Tree。

形式化地说,对于一个节点 \(u\),我们找到 \(S(u)\) 中某一个子串 \(T\) 的后缀 \(t\),使得 \(t\) 不在 \(\operatorname{S}(u)\) 中且满足 \(|t|\) 最大(显然 \(t\)\(S(u)\) 中任何一个串的后缀且 \(|t|\) 等于 \(S(u)\) 中任何一个串的 “分界长度“ 减 \(1\)),记 \(u\) 的后缀链接 \(\operatorname{link}(u)\)\(t\) 所属的节点。那么 \(\operatorname{link}\) 所构成的就是这个 Parent Tree。

这时你会发现 \(\operatorname{shortest}(u)\) 的长度其实就是 \(\operatorname{longest}(\operatorname{link}(u))\) 的长度加 \(1\),即 \(len(\operatorname{link}(u))+1\),所以我们无需记录 \(\operatorname{shortest}(u)\) 的长度。

SAM 的转移

对于一个节点 \(u\),在 \(S(u)\) 中的某一个串后面添加一个字符 \(c\) 变成一个新的串,如果这个新的串仍是 \(S\) 的子串(那么由于 \(S(u)\) 中的任意一个串在 \(S\) 的某个位置出现,\(S(u)\) 中的其他串肯定也会在同样位置出现,所以此时 \(S(u)\) 中的所有串添加这个字符 \(c\) 所形成的的新串也都仍是 \(S\) 的子串\({\,}^{(1)}\)),设这个新串所属的节点为 \(p\),那么我们记录转移 \(ch[u][c]\gets p\)

注意对于添加字符 \(c\) 而言,添加 \(c\) 后的新串可能不同,但它们的 \(\operatorname{Endpos}\) 都是相同的,因为新串中的某一个串在某个位置出现,那么将它末尾的 \(c\) 删除后,\(S(u)\) 中的其他串也肯定会在同样位置出现,然后再加上末尾的 \(c\),于是所有的新串也都会在同样的位置出现。这同时说明了 \(ch[u][c]\) 是唯一的\({\,}^{(2)}\)

但注意这些新串所属的等价类 \(S(ch[u][c])\) 不一定只包含这些新串\({\,}^{(3)}\)。同时也说明了有可能有多个 \(ch[u][c]\) 指向同一个点,于是 SAM 实际上是一个 DAG。

算法(实现)

考虑从前往后加入 \(S\) 的每一个字符,假设当前加入的是 \(c=S[x]\)

加入字符 \(c\) 的实际操作是把 \(S[1..x]\) 的所有后缀的 \(\operatorname{Endpos}\) 集合都改变了(新增加了元素 \(x\)),考虑这将如何影响后缀树的形态,那么我们先要找到 \(S[1..x]\) 的所有后缀所在的节点。

那我们肯定要先新建一个节点 \(now\) 表示 \(S[1..x]\)\(\operatorname{Endpos}\) 等价类,因为这个等价类之前一直没有出现过。

我们上一次插入 \(S[x-1]\) 的时候肯定也新建了一个节点表示 \(S[1..x-1]\)\(\operatorname{Endpos}\) 等价类,记这个节点为 \(last\)

根据 \((1)\),由于 \(S[1..x]\)\(S[1..x-1]\) 末尾添加字符 \(c\) 后得到的串,那么 \(S(last)+c\) 的所有串都应该属于同一个 \(\operatorname{Endpos}\) 等价类,于是直接 \(ch[last][c]\gets now\)

接着,令 \(p=last\),然后让 \(p\) 沿着 \(\operatorname{link}\) 往上跳,并且一直记录 \(ch[p][c]\gets now\),直到满足已经存在转移 \(ch[p][c]\) 了(此时证明 \(S[1..x-1]\) 中出现过 \(S[1..x]\) 的后缀)。

\(p\) 一直往上跳的过程实际上相当于从长到短枚举 \(S[1..x-1]\) 后缀中的每一种 \(\operatorname{Endpos}\) 定价类,也就相当于把 \(S[1..x-1]\) 的所有后缀都枚举一遍,而判断是否已经存在转移 \(ch[p][c]\) 也就相当于把 \(S[1..x]\) 的每一个后缀都枚举了一遍(因为满足一个串 \(T\)\(S[1..x]\) 的后缀的必要条件是 \(T\) 去掉最后一位后是 \(S[1..x-1]\) 的后缀),并判断它们是否在 \(S[1..x-1]\) 中出现过。

所以如果跳到某个 \(p\) 仍然不存在转移 \(ch[p][c]\),即 \(S(p)+c\)(显然这是 \(S[1..x]\) 的一段长度连续的后缀)没有在 \(S[1..x-1]\) 中出现过,那么 \(S(p)+c\)\(\operatorname{Endpos}\) 集合和 \(S[1..x]\) 的是一样的,即 \(S(p)+c\) 包含于 \(S(now)\),于是我们直接令 \(ch[p][c]\gets now\),再继续往上跳即可。

接下来我们分情况讨论:

  • 如果就这么顺着 Parent Tree 跳一直跳到了根节点还要往上,此时证明 \(S[1..x]\) 的任何一个后缀都没有在 \(S[1..x-1]\) 中出现过,那么我们直接让 \(\operatorname{link}(now)=rt\) 即可。

  • 否则,如果我们在跳的过程中找到了一个 \(p\) 使得已经存在转移 \(ch[p][c]\) 了,我们就先设 \(q=ch[p][c]\)

    但注意此时仅满足 \(S(p)+c\) 包含于 \(S(q)\),所以并不一定是 \(S(q)\) 中所有串的 \(\operatorname{Endpos}\) 集合都改变了,即 \(S(q)\) 里面不一定全是 \(S[1..x]\) 的后缀。

    可以发现 \(S(q)\) 中所有 \(\operatorname{longest}(p)+c\) 的后缀(即 \(S(q)\) 中所有长度小于等于 \(len(p)+1\) 的串)都是 \(S[1..x]\) 的后缀(尽管这些串中可能有长度短的一部分并不属于 \(S(p)+c\),但他们仍然是 \(S[1..x]\) 的后缀,我们一起考虑),它们的 \(\operatorname{Endpos}\) 集合都改变了。

    同时 \(S(q)\) 中所有长度大于 \(len(p)+1\) 的串都一定不是 \(S[1..x]\) 的后缀(因为这个 \(p\) 使我们最先找到的,即 \(\operatorname{longest}(p)+c\) 应该是 \(S[1..x]\)\(S[1..x-1]\) 中出现的最长的后缀),它们的 \(\operatorname{Endpos}\) 集合都没有改变。

    然后我们再分情况讨论:

    • \(len(q)=len(p)+1\),我们直接令 \(\operatorname{link}(now)\gets q\) 即可,上面已经证明了这样的 \(q\) 一定是最长的。

    • \(len(q)\neq len(p)+1\),此时 \(\operatorname{longest}(q)\) 不是 \(S[1..x]\) 的后缀,而且 \(\operatorname{longest}(q)\) 会比 \(\operatorname{longest}(p)\) 长一截。

      那么此时 \(S(q)\) 中长度大于 \(len(p)+1\) 和长度小于等于 \(len(p)+1\) 的两部分串的 \(\operatorname{Endpos}\) 集合已经不同了,需要分离。

      于是我们新建一个点 \(nq\),表示 \(S(q)\) 中长度小于等于 \(len(p)+1\) 的那一部分串的 \(\operatorname{Endpos}\) 等价类。这样就在 \(q\)\(f=\operatorname{link(q)}\) 之间新插入了一个点,所以 \(\operatorname{link}(q)\gets nq\)\(\operatorname{link}(nq)\gets f\)。同时更新 \(len(nq)\gets len(p)+1\)。也要更新 \(ch[nq]\gets ch[q]\)(更新 \(ch[nq]\gets ch[q]\) 的原因上面 \((1)\) 处有提到)。

      同时要让 \(\operatorname{link}(now)\gets nq\),上面同样也已经证明了这样找到的 \(nq\) 一定是最长的。

      最后,我们就要更新我们还要继续让 \(p\) 沿着 \(\operatorname{link}\) 往上跳,如果 \(ch[p][c]=q\),那么更新 \(ch[p][c]\gets nq\)(这里这么更新的证明比较显然,略去),否则停止上跳退出。

    然后就结束了吗?\(q\)\(nq\))在 Parent Tree 上的祖先(即 \(p\) 在 Parent Tree 上的祖先往 \(c\) 的转移)的 \(\operatorname{Endpos}\) 集合都有改变,它们不需要更新吗?事实上由于这些点所包含的所有串的 \(\operatorname{Endpos}\) 集合都同样增加了一个元素 \(x\)(而且由于增加的元素为 \(x\),所以这些点的转移不可能有更新),于是经过若干推导可知 SAM 的结构并没有改变,所以我们无需更新。

这样 SAM 就建好了。

实际应用

咕咕咕……

后缀树

定义

后缀树定义比 SAM 简单很多。对于串 \(S\) 的后缀树,我们先把串 \(S\) 的所有后缀各加入一个终止符后都插入到一棵 Trie 树中,比如对于串 \(\texttt{banana}\),将得到下面这么一棵 Trie 树:(图来自于 EA's blog

在这里插入图片描述

但这样节点数是 \(O(n^2)\) 的,但我们发现这棵 Trie 树上有很多节点只有一个儿子,这样构成了若干条单链,我们可以把这些链进行压缩,变成这样:

在这里插入图片描述

这样压缩后的字典树我们就把它称为后缀树。

这样的后缀树的节点数量是 \(O(n)\) 级别的,因为它只有 \(n\) 个叶子(终止符),而且每个点的儿子个数都大于 \(1\),于是就能用类似虚树的方式证明出这棵树的节点至多只有 \(2n-1\) 个。

再根据等一下会说的结论,这也侧面证明了 SAM 的节点个数至多为 \(2n-1\) 个。

构建

直接构建后缀树有 Ukkonen 算法,但是实际上我们可以用 SAM 来构建。

结论:串 \(S\) 在 SAM 上的 Parent Tree 为串 \(S\) 的反串的后缀树。

假设现在有某个串 \(S'\),我们先定义 \(S'\) 的某个子串在 \(S'\) 中出现的所有位置的左端点集合为 \(\operatorname{leftpos}\) 集合。这个定义和 \(\operatorname{Endpos}\) 类似。

然后你发现后缀树上的一条边就代表着一个 \(\operatorname{leftpos}\) 等价类,因为这条边上的所有点都没有分支,意味着对于这条边上的任意两个长度相差 \(1\)\(A,A+c\)\(A\) 只会出现在 \(A+c\) 中,否则若 \(A\) 还出现在 \(A+c'\) 中那么就会有 \(c'\) 这个分支,就矛盾了。

于是后缀树上的一个点 \(u\) 就能代表它往父亲的那条边的 \(\operatorname{leftpos}\) 等价类,于是可以类似地定义 \(\operatorname{longest}'(u)\) 表示 \(u\) 所代表的 \(\operatorname{leftpos}\) 等价类中的所有串中最长的那个,显然 \(u\) 中的其他串都是 \(\operatorname{longest}'(u)\) 的前缀。

而且对于后缀树上点 \(u\) 的父亲 \(f\),肯定有 \(\operatorname{longest}'(f)\)\(\operatorname{longest}'(u)\) 的所有前缀中和 \(\operatorname{longest}'(u)\) 不属于同一个 \(\operatorname{leftpos}\) 集合的最长的前缀。

发现这和 SAM 的 Parent Tree 类似,于是把 \(S\) 反串,\(\operatorname{leftpos}\) 变为 \(\operatorname{Endpos}\),就可以得到上面的结论了。

posted @ 2022-10-31 09:44  ez_lcw  阅读(21)  评论(0编辑  收藏  举报