字符串

AC自动机

用于多个模式串(模式串在文本串里面出现),和文本串匹配

对模式串建立trie, 每个点上维护一个fail, 每个节点其实代表一个前缀。若干个模式串形成了若干个前缀字符串集合S。fail[i]表示S中的所有字符串中,能和i这个字符串的后缀完全匹配的最长字符串。 (当然这个字符串不能是i本身), 如果没有,那么是根节点,表示为空

那么fail, fail[fail],fail[fail[fail]]].... 就是i的所有后缀集合(每个后缀都要作为前缀在trie里面出现过)。每次跳fail都会向上一层

那么对于trie[p][c] = u;

int nw = fail[p]; while (!tr[p][c] && p) p = fail[p];

fail[u] = tr[p][c];

我们进行bfs, 那么p的fail已经处理完了,现在要处理u的fail,那么就从长往短不断跳p的后缀,直到遇到一个tr[p][c]不是空的为止

但是实际的写法是:

void build() {
  for (int i = 0; i < 26; i++)
    if (tr[0][i]) q.push(tr[0][i]);
  while (q.size()) {
    int u = q.front();
    q.pop();
    for (int i = 0; i < 26; i++) {
      if (tr[u][i])
        fail[tr[u][i]] = tr[fail[u]][i], q.push(tr[u][i]);
      else
        tr[u][i] = tr[fail[u]][i];
    }
  }

q里面是已经处理好fail的点, 扩展u的子节点。我们没有while循环。因为tr[u][i]表示的就是不断跳fail,直到有人的[i]不是空的那个节点。对于u的子节点,可以直接fail[tr[u][i]] = tr[fail[u]][i]; // tr[fail[u]][i]里面存储的就是一直跳fail以后到达的点。怎么实现的呢?

如果tr[u][i]是空的,就变成tr[fail[u]][i]. tr[fail[u]][i]是不断跳以后的位置,所以tr[u][i]也是

查询:(只适用于每个点访问一次以后可以直接打标记,之后不会再访问。能保证O(n), 否则每次一直跳fail是n^2,需要优化), 这种查询方式只适用于简单版1,2

int query(char *t) {
  int u = 0, res = 0;
  for (int i = 1; t[i]; i++) {
    u = tr[u][t[i] - 'a'];  // 转移
    for (int j = u; j && e[j] != -1; j = fail[j]) {
      res += e[j], e[j] = -1;
    }
  }
  return res;
}

同时这样求出来的也是最靠前的位置,只需要用vector,第一次访问的时候记录答案。然后把这次访问的点都标记为vis,之后不再访问

最后一次出现,所有串都反过来。求出的是结尾的位置,需要+串的长度才行

如果是求出现在其中的串的个数,访问一个点就可以直接vis=1了,所以总复杂度是O(n)

如果是求每个点出现次数,或者出现总次数,就要优化了


几个模式串在文本串第k次出现的位置

原串 # 询问串1询问串2... (#是分隔符,用来防止出现那种原来的串本来没有,但是由于询问串直接拼接导致错误,加一个分割就没事)

然后后缀排序,比如询问串是bac,那么所有以bac开头的后缀是一个连续的区间。rk[询问串i]就是这个询问的排名,用height数组求LCP,二分一下所有以询问串i开头的后缀的范围。然后求一下区间k小值(大小按照第一次出现的位置作为关键字)


https://www.luogu.com.cn/problem/P3796
这道题就没法用vis标记,每次最多跳max(模式串),一共t次,所以是70*t的复杂度


优化之后: 每个点向自己的fail链接一条有向边,这样会构成一个以0为root的树。从下往上topsort,或者维护子树信息,合并上来

可以做到O(tot), tot是模式串总点数

总复杂度: O(tot*w) + O(n), n是文本串长度(在trie树上走,需要O(n), w是字符集大小)


https://www.luogu.com.cn/problem/CF547E

差分,变成前缀里面出现多少次。询问离线。考虑1~i的串对询问串k的贡献。建立出来所有串的AC自动机。每个i串,在AC自动机上面遍历到len个节点,每个节点不断跳fail,所有fail++,最后查询k所在节点的权值。树状数组维护

fail树可以结合各种东西,一些不断跳fail的操作,都可以转化成一条链上操作。


https://www.luogu.com.cn/problem/P2292

AC自动机fail的实质就是找到当前点的所有后缀,枚举所有前缀里面是某个串后缀的所有串。
所以就可以有转移,枚举所有后缀(这些后缀都是合法单词),然后从fi-后缀长度转移
复杂度mts, 考虑优化,发现不太能topsort
发现s很小,可以预处理出来,得到每个点上面所有合法的单词长度构成一个集合
然后20个f,也构成一个集合,&一下,如果是1,说明这个是1
如果s大一点,也可以bitset


https://qoj.ac/contest/1037/problem/5034

具体题解题目那有。以及https://www.cnblogs.com/Harry27182/p/17971173

不能经过某个路径i,假设i结尾的那个节点是k,那么就是所有跳fail能调到k的都不能选。也就是fail树上每个k的子树的所有点。标记这些点不需要建树,可以直接求fail的时候vis[x] |= vis[fail[x]],要按照相邻的顺序

考虑把每个点接受到一个字符到达的点和这个点连边,但是这个是n^2的

发现如果tr[x][i]是空的,就相当于一直跳fail, 然后和fail的那个点连到同一个节点。这些边中,只有一个能松弛那个终点,所以这个边可以公用,第一次松弛以后就直接删除。用可持久化线段树

对于字符集比较大的时候,那些空节点在字典图上的连边没法一一找。 可以用主席树,以字符集为下标。每个点直接继承fail[x]的所有信息,然后对于那些非空节点,直接修改即可


https://www.luogu.com.cn/problem/P2414

不能在别的里面出现,就是fail子树打标记。这道题可以离线dfs,动态用树状数组维护插入

...在这个串出现,就是当前点到根的路径


https://www.luogu.com.cn/problem/P7456

维护最长可以匹配字符串。维护后缀最小值

对于一类维护单调栈,然后查询某个元素对应栈里面的那个位置(比如离他最近的栈的元素eg(本题),或者小于他的点的个数(eg.丹钓栈))可以用并查集

while (top && f[i] <= f[stk[top]])
	fa[stk[top]] = i; top--;
                                

https://www.luogu.com.cn/problem/P3311

数位dp, dfs写法无法滚动数组

后面是否卡上界以及是否前导0可以不用把数组开出来。数组里面只记忆化没有前导0,没有卡上界的。

但是dfs的时候仍然正常传入lim和zer. 如果dfs发现!lim && !zer,才去看是否被记忆化过。否则如果lim或者zer,直接搜到底(复杂度是对的,因为本身卡上界以及前导0的情况就那n种,且有前导0,lim的情况本身就只会被用到1次,不需要记忆化)//!!!!!!一定要注意最后必须没有lim和zer才能更新 记忆化数组

ll dfs(int p, int x, bool lim, bool zer) {
	if (!p) return 1;
	ll v = f[p][x]; //后面的两个维度可以不存。  f[x][y]表示lim = 0 并且zer = 0的时候的情况
	//对于lim = 1 或者 zer = 1 的情况都很少直接爆搜 
	if (~v && !lim && !zer) return v;
	v = 0;
	for (int i = 0; i < 10; i++) {
		if (lim && i > a[p]) break;
		int to = (!zer || i) ? tr[x][i] : x;
		if (vis[to]) continue;
		v = (v + dfs(p - 1, to, lim && (i == a[p]), zer && !i)) % mod;
 	} if (!lim && !zer) f[p][x] = v; //!!!!!!一定要注意最后必须没有lim和zer才能更新 
	return v;
}

https://www.luogu.com.cn/problem/P5840

完整虚树差分技巧(每个点到根的路径并上+1(重复的时候只加一次)),每个点++,在相邻两个的lca--, (1和n之间的lca不用),如果不想要到根的,就在fa[lca(1,n)]上--


https://www.luogu.com.cn/problem/P3715

https://blog.csdn.net/woshi250hua/article/details/7599472

没法两个都建AC自动机(没法转移),那就枚举一整个下一个可用单词,然后把禁忌词汇建立AC自动机dp

可以矩阵优化dp, 如果基础词汇长度只有1,2,那就存[f[i-2,0~tot], f[i-1,0~tot]]一共两百个数,当做一个向量,进行转移

!!!!!!!!!!!!!!构造初始矩阵,或者初始方程的时候,都写 成+=, 不要写成=, 因为可能一个位置不只是被加一次


https://www.luogu.com.cn/problem/P6125

题解 https://blog.sengxian.com/solutions/bzoj-1444

//如果把f[i]定义成经过过i的概率,转移的方程是错的,

//因为如果先经过一次,这个概率就已经算完了,但是可能会出现先到达这个点一次,又到达一次,然后把第二次到达的概率也加上了

//所以要么就枚举每个点i,每次设fi表示从i出发能让i获胜的概率

//要么就用fi表示经过i的期望次数 。 因为终止节点经过一次就结束了,所以它的期望就是它的概率,因为期望等于经过i次的概率*i

//叶子节点(也就是答案节点)只会经过1次,所以就是概率,而其他节点就转换成期望

//!!!!!!!!!!由于我们设置的是期望,所以当没有人会赢的时候,0的期望次数是正无穷,
//也就是f[0] = f[0] + 1 , 对应的就是无解的情况,所以直接return,这样所有人的概率都是0

一定要注意概率为0的特殊情况

后缀数组

https://oi-wiki.org/string/sa/#结合并查集

https://www.cnblogs.com/alex-wei/p/Basic_String_Theory.html

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
inline ll read() {
	ll x = 0, f = 1;
	char ch = getchar();
	while (ch < '0' || ch > '9') {
		if (ch == '-') f = -1;
		ch = getchar();
	}
	while (ch >= '0' && ch <= '9') {
		x = (x << 1) + (x << 3) + (ch ^ 48);
		ch = getchar();
	} return x * f;
}//oldrk不用2倍也行,但最好是(虽然32行有sa[i]+w, 但是执行到这个一定满足了oldrk[sa[i]]==oldrk[sa[i-1]],这个时候一定能保证sa+w-1<=n 
const int N = 1e6 + 10;
int sa[N], rk[N], n, id[N], oldrk[N*2], cnt[N], m, h[N]; char s[N]; //cnt的是值域 
int main() { //这里的rk,如果两个权值相同,rk也相同,但是最后的每个后缀一定rk互不相同。 
	scanf("%s", s + 1); n = strlen(s + 1); m = 150; oldrk[n+1] = 0; 
	//!!!一定要保证ork[n+1]=0,因为可能会用到ork[n+1]和ork[i]的比较,必须要让ork[n+1]是独一无二的 
	for (int i = 1; i <= n; i++) cnt[rk[i] = s[i]]++; //!!!!!!!!!一定要保证rk[i]不能是0,因为为了放置rk[1] = rk[0]导致rk从0开始标 
	for (int i = 1; i <= m; i++) cnt[i] += cnt[i-1];
	for (int i = n; i >= 1; i--) sa[cnt[rk[i]]--] = i; //!!!!!!这里不代表cnt已经清空 
	for (int w = 1, p = 0; ; w <<= 1, m = p, p = 0) { //不要写<n, 因为如果n=1,rk[1] = 97不是1, 直接不写就行 
		for (int i = n; i >= n - w + 1; i--) id[++p] = i; //先按照第二个维度排序,后w个点的第二维是空的,所以在前面 
		for (int i = 1; i <= n; i++) if (sa[i] > w) id[++p] = sa[i] - w;//对1~n-w的第二个维度排序 
		for (int i = 1; i <= m; i++) cnt[i] = 0; p = 0; //!!!!!!!!!注意p = 0,因为下面需要用p标号 
		for (int i = 1; i <= n; i++) oldrk[i] = rk[i];
		for (int i = 1; i <= n; i++) cnt[rk[i]]++; //这里不用id[i],因为反正只是记一下数 
		for (int i = 1; i <= m; i++) cnt[i] += cnt[i-1];
		for (int i = n; i >= 1; i--) sa[cnt[rk[id[i]]]--] = id[i]; 
		//!!!!!!!!!!!!!!!!!这里一定是rk[id[i]] 不是rk[i],因为需要用id[i]按照第二维排序的结果对第一唯一排序 
		for (int i = 1; i <= n; i++) rk[sa[i]] = (oldrk[sa[i]] == oldrk[sa[i-1]] && oldrk[sa[i]+w] == oldrk[sa[i-1]+w]) ? p : ++p;
		if (p == n) break;//说明没有大小关系相同的两个元素 
	}
	for (int i = 1, p = 0; i <= n; i++) { //用这种写法,前提是0,n+1=空的   以及sa[0] = 0 
	//p表示已经确定了长度为p是合法, 开始要先p-- 
		if (p) p--; int j = sa[rk[i]-1]; 
		while(s[i+p] == s[j+p]) p++;
		h[rk[i]] = p;
	}
	return 0;
}

oldrk只需要开到n+1即可,但是记得oldrk[n+1] = 0

因为如果oldrk[sa[i]] == oldrk[sa[i-1]]成立才会继续执行,如果执行这个,说明sa[i]和sa[i-1]都是长度为w的,所以sa[i]+w <= n + 1,所以只需要oldrk <= n

后缀数组的应用

寻找最小的循环移动位置

在字符串中找子串:同一个前缀后缀排序以后连续, 二分即可,详情见: AC自动机后缀数组写法

从字符串首尾取字符最小化字典序: 这一次选择头还是尾,就看正串和反串那个大,这个直接拼到一起后缀数组即可

height数组

h[i]表示排序后第i个和第i-1的最长公共前缀

有性质: h[rk[i]] >= h[rk[i-1]]-1

证明: 由于h[rk[i-1]] 是 sa[rk[i-1]]和 sa[rk[i-1]-1]两个字符串的lcp, 也就是i-1和 sa[rk[i-1]-1]

如果h[rk[i-1]] <= 1,结论一定成立,否则i-1和sa[rk[i-1]-1] 可以分别表示成aAD和aAB 其中a是一个字符,A是非空字符串, B < D, 那么i就是AD。

sa[rk[i]-1]一定< AD, 因为存在AB的后缀,且sa[rk[i]-1]是最靠近AD的

所以AB <= sa[rk[i]-1] < AD 所以sa[rk[i]-1]和i的lcp >= las-1

	for (int i = 1, p = 0; i <= n; i++) { //用这种写法,前提是0,n+1=空的   以及sa[0] = 0 
	//p表示已经确定了长度为p是合法, 开始要先p-- 
		if (p) p--; int j = sa[rk[i]-1]; 
		while(s[i+p] == s[j+p]) p++;
		h[rk[i]] = p;
	}

应用:

两子串最长公共前缀: !!!是rk[i]+1 ~ rk[j]

比较一个字符串的两个子串的大小关系: [a,b]和[c,d]的大小,分类:lcp(a,c)>=min(len1,len2),长度长的大(相等就一样大)。否则就是rk[a]和rk[c]直接比较(因为在前min(len1,len2)位就得到胜负了)


不同子串的数目

按照rk的顺序加入, 每次重复的部分就是当前i和之前加入的字符串集合里面lcp最大的,正好就是h[i](就是和最相邻的那个之间的lcp)

https://www.cnblogs.com/alex-wei/p/Basic_String_Theory.html


出现至少 k 次的子串的最大长度

出现至少 k 次意味着后缀排序后有至少连续 k 个后缀以这个子串作为公共前缀。


是否有某字符串在文本串中至少不重叠地出现了两次 :

可以二分目标串的长度 |s|,将 h 数组划分成若干个连续 LCP 大于等于 |s| 的段,利用 RMQ 对每个段求其中出现的数中最大和最小的下标,若这两个下标的距离满足条件,则一定有长度为 |s| 的字符串不重叠地出现了两次。

注意:排名连续不一定实际连续


结合单调栈 : https://www.cnblogs.com/alex-wei/p/Basic_String_Theory.html

//	stk[++top] = 1; //方法1 
//	for (int i = 1; i <= n; i++) { //!!!!!!!!!是stk[top-1]~stk[top]-1为合法,因为i~j对应的h区间是i+1~j 
//		//!!!!!!!!是按照sa的顺序从小往大,不能按照原顺序,因为就算转个顺序也成立 
//		while (top > 1 && h[stk[top]] >= h[i]) {
//			f -= 1ll * h[stk[top]] * (stk[top] - stk[top-1]); top--;
//		} f += 1ll * h[i] * (i - stk[top]); stk[++top] = i; ans -= 2 * f; 
//		//!!!!!这个-=f要放在后面 ,因为i弄完以后是左端点在1~i-1 
//	} cout << ans;
	stk[++top] = 1; L[1] = R[1] = 1; 
	for (int i = 2; i <= n; i++) {
		while (top && h[stk[top]] >= h[i]) R[stk[top]] = i, top--;
		L[i] = stk[top]; stk[++top] = i;
	} while (top) R[stk[top]] = n + 1, top--; //!!!!!!!一定要记得这一步 
	for (int i = 1; i <= n; i++) ans -= 2ll * (i - L[i]) * (R[i] - i) * h[i]; //从前半和后半分别选择一个
	cout << ans;

P2463 3.4.4 多个串的最长公共子串

双指针:选择排序后的l~r的区间,用cnt数组(int类型,记录次数)记录这些后缀是否在每个串里面出现,只有都出现才合法。


https://www.luogu.com.cn/problem/P1117

找到每个点作为开头,结尾,形如AA的个数: 用f/g数组表示

枚举A的长度len, 每间隔len个划分一个关键点,每个AA经过两个关键点,枚举每一组p, q两个相邻关键点,找到以p,q为开头的后缀的时候的最长公共前缀cr,以及p,q为结尾的是哦胡的最长公共后缀cl。如果cl + cr >= len + 1,那么合法,会贡献出cl+cr-len种不同的覆盖方案。然后就是差分,区间加得到f,g


后缀自动机

推荐文献:

洛谷网校(算法过程,算法流程的时间复杂度,算法性质,算
法应用)

oiwiki 和
https://www.cnblogs.com/alex-wei/p/Common_String_Theory_Theory_automaton_related.html

可以看看性质,应用,例题,

关于节点数,转移数,额外信息的证明可以看oiwiki(也可以参考alex-wei), 但是操作数的证明,看洛谷就行

一些概念:

后缀自动机是用来接受一个字符串的所有字串的结构(后缀的前缀)

时间复杂度O(n+wn) w是字符集大小(如果比较大的时候,可以用map, 或者可持久化线段树,做到nlogw)

空间O(2*n) 或者 2nlogn之类的

!!!一定要二倍空间: 因为每次可能会分裂出来一个节点

常见应用特征???:

每个节点(状态)是一类endpos相同的字串(这些字串满足是某一段前缀的后缀),两个节点的endpos要么包含关系,要么不相交(cmd的博客)

转移: 构成一个DAG, 就是每个状态接受一个字符以后转移到的状态。

len: 每个状态最长的字串的长度

终点节点: 每次刚调用expand的时候新建的那些节点(分别代表原串的n个前缀)

后缀链接(fa) : 每个终点节点出发,跳fa, 所有节点代表的字串的集合,就是这个终点节点所在前缀的所有后缀。

流程

(参考着洛谷网校的讲解或者ppt)

1是根节点, 0是空节点 (fa[1] = 0, 其他的节点fa[i] = 1或者别的正数)

for1~n, 每次expand一个字符c,上一次的终点字符称为las.

  1. 新建一个新的终止节点cur(最后记得把las = cur), len[cur] = len[las] + 1

  2. 从las不断跳fa(相当于遍历i-1前缀的所有后缀)到达节点p(p包含las), 如果发现没有tr[p][c]的转移,那就把tr[p][c] = cur

  3. 否则到达节点np, int q = tr[np][c]:

    分类:

    l. 如果p是0, fa[cur] = 1, return

    1. 如果len[p] + 1 == len[q], 说明q正好就是np+了一个c构成的, 这个时候直接fa[cur] = q; return

    2. 否则就新建建立一个节点nq(这个节点不算终点节点), 把tr[q]的所有转移边复制到tr[nq], fa[nq] = fa[q]; 饭后一直跳q的fa, 如果tr[q][c] == q, tr[q][c] = nq; (如果遇到一个不是的时候,就可以直接break了, 因为根据定义,每个状态存储的字串, 都是连续的,所以连接到他的节点也一定是连续的)

时间复杂度

首先我们定义最后一次添加完以后,的las节点不断跳fa所能到达的节点集合是终止节点(!!!不是终点节点),终止节点对应的字符串都是原字符串的后缀

  1. 节点个数是2n-1: 因为每次最多添加2个节点

  2. 转移边的个数是3n-3, 首先我们定义连续转移是一条转移边p->q, 满足len[p] + 1 == len[q](每个点都会有一个这样的父亲转移到他,因为每次新建一个cur,las->cur一定就是这样的边,如果是nq类节点,也可以用类似的方法分类讨论证明), 连续转移边一定构成一个树所以,连续转移的数量是2n-2的

    对于不连续的转移: p通过c到达q, 那么找到一条根到p的路径构成的字符串u, 一条q到终止节点的字符串v, 那么这个字符串是Sp,q = u+c+v. 由于后缀自动机上面每个节点,每条路径代表的字符串都是独一无二的,所以对于两组p,q, 他们的Spq是不相同的, 原串最多有n个不同的后缀,所以Spq最多n种 总共3n-3

  3. 关于操作流程里面第一步:跳p的fa,如果没有ch的转移,就连接到当前节点。 我们比较加入这个节点前las的父亲len1,和加入以后,las的父亲的len2(也就是cur的父亲)。如果只跳一次,就是len2 = len1 + 1(只跳一次)
    之后,每当多跳一次,len2都会在这个基础上至少-1,所以总增加量是O(n), 每O(1)的时间, 就会让这个值-1,所以是O(n)

  4. 关于,流程操作2.(3) : las不断跳fa,会构成一条链1, cur不断跳fa,也会构成一条链2。las的路径上的每个点都会连接到链2上一段连续的区间,并且越考上的链1上的点,连接到2的点越考上: 因为链1对应的是上一个所有后缀,链2对应的是加一个字符以后的所有后缀,所以需要用上一个+c,也就是转移边链接过来(所以都有边,且根据len的大小关系可以得到顺序)

    类比上一个证明,我们以las的fa的fa的len作为是能函数.由于一个点的len等于所有能转移到他的点的len的最大值+1(???)这种情况下,就是最下面的那个点+1.

    如果只有tr[p][ch] != 0, 那么cur的fa的fa最好也只能被fa[fa[p]]链接,len是fa[fa[p]].len + 1

    而每当向上多跳一步,len[fa[fa[cur]]]都会至少比原来的基础上少1

  5. memcpy的时候时间复杂度是n*26, 如果用可持久化线段树或者map,O(nlogn)复杂度(因为一共3n条转移边)

性质

  1. 每个节点的终点集合at(也就是在哪些位置出现过),等于其 子树 内所有终点节点对应的终点的集合。 但是注意,终点节点不一定都是叶子节点,比如aa(当且仅当一个字符串存在一个前缀是另一个前缀的后缀)

  2. 所有fa的关系构成一棵树(注意,转移边不算).在每个状态对应的字符串前面加字符,就能到达树上的儿子(因为树上一个状态的祖先节点一定是这个状态的后缀),如果在前面删掉字符,就可以到达自己的父节点。 所以对于前缀1~p和前缀q的lca, 假设第p个字符的时候建立的那个点x和加入q的时候的点y, lca(x, y)对应的状态,就是p,q的最长公共后缀。

  3. 这个树也是反串的后缀树(因为每个点往上跳fa就相当于从这个点对应的某个at(原串的位置),往前一个一个加字符,直到开头,对应到反串,就相当于是从某个字符开始,一个一个从后加字符,直到最后)(每到达终点节点就代表走到了末尾), 每条边就是把从p的最长->q的最长字符串,需要依次在前面加入的边。 每个点出发的所有边的首字母一定是不一样的(否则就会出现两个状体接受同一个字符串)

  4. 每个点父亲的len比自己小

  5. 拿一个串T在S上跑,就能找到T的最长的后缀,使得它是S的字串。(本质上是T的每个前缀的这个值都求出来,在上一个前缀的基础上求下一个前缀)

    假设现在来到了T的i前缀,最长后缀长度是x, 所在节点是p. c是T[i+1]. 如果p有c的转移边,那就转移到这个点,并x++.

    否则就p不断跳fa(相当于删掉前面匹配的若干个字符), 直到遇到有c的转移边,并将x最终修改为len[p] + 1

  6. 每个点的len实际上等于所有tr连接到它的点的len的最大值+1

应用

小写字母, 1e6

posted @ 2024-07-04 12:05  Str_ywr  阅读(4)  评论(0编辑  收藏  举报