忘光了,所以复习【STR】

字符串

本文字符串下标从 1 开始。

S[l,r] 表示字符串 Slr 的部分。

速通

哈希

没什么可说的,我也不喜欢用。

trie

顾名思义,就是一个像字典一样的树。

基础

不多说了。

01trie

在一些题目中,可以用 trie 维护 01 串。

最长异或路径

给定一棵 n 个点的带权树,求异或和最大的简单路径。

0n105,0w<231

solution

考虑树上两点路径的异或和可以转化为根到两点的异或和异或起来。

于是转化为求寻找两点使两点的权值的异或和最大。

用 trie 维护:不断插入一个数,查询这个数与已插入的数的最大异或和是多少(只要尽量保证前缀是 1)。

自动机

定义

自动机是一个对信号序列进行判定的数学模型。

比如说,你在初始状态,可以往几条路走,通过这几条路可以走到其他状态。

一个确定有限状态自动机(DFA,deterministic finite automation)由以下五部分构成:

(另外有个东西叫做 NFA,以后可能会提到)

  • 字符集(),该自动机只能输入这些字符。
  • 状态集合(Q),顾名思义。
  • 起始状态(st),同样顾名思义,stQ
  • 接受状态集合(F),FQ,或终止状态集合。
  • 转移函数(δ(x,y)),x 是一个状态,y 是字符串,意思就是从 x 开始,走一个字符串 y

以上的字符串均为广义的。

想要更快的理解,可以把 DFA 看做一个有向图,但是 DFA 只是一个数学模型。

另外不难发现 trie 也是自动机,我称其为广义前缀自动机。下面就拿 01trie 举个例子。

v51RDP.png

  • :0/1。
  • Q:每个节点。
  • st:如图。
  • F:如图黄点。
  • δδ(st,101)

边可以看做状态转移。

序列自动机

对于字符串 S 的一个子串 sδ(st,s) 表示 sS 中第一次出现时末位。

转移(u 是位置,c 是个字符):δ(u,c)=min(i|i>u,si=c)

可以通过记录下一个字符出现位置来实现。

给你两个由小写英文字母组成的串 AB,求:

  • A 的一个最短的子串,它不是 B 的子序列。
  • A 的一个最短的子序列,它不是 B 的子序列。

n2000

solution

第一个相对简单。直接枚举起点跑就行。

fi,j 表示在 A 中到第 i 位,在 B 中到第 j 位的答案。

那么 fi,j=min{fδ(i,c),δ(j,c)+1}

KMP

pi 表示前 i 位最长相同真前后缀长度。

对于字符串 abbaabb

  1. p1=0a
  2. p2=0ab
  3. p3=0abb
  4. p4=1abba
  5. p5=1abbaa
  6. p6=2abbaab
  7. p7=3abbaabb

处理出 pi 有什么用呢?

模式串在匹配文本串的时候,如果是以下这个状态:

v5HMNR.png

发现到第五位时失配了,我们接下来肯定是想让它从蓝色这个状态继续匹配。

可以发现,跳到 pi 一定不劣。即比如在第五位失配了,就跳到 p4=1 位,如果跳到这里发现可以匹配下去,由于前后缀相同,所以可以继续匹配下去。如果不能匹配下去,那就继续往前跳。

由于每次只往后移动一格,往前跳的次数一定小于等于往后走的次数,复杂度 O(n)

怎么求 p 呢。

v5bZGt.png

假设当前位是 ipi1=j。如果 pi>pj,那么 pi=pj+1Sj+1=Si

否则,贪心地令 j=pj,即上图绿框,可以发现如果此时 Sj+1=Si 还是可以下去且最优。

最后,不难发现 kmp 的过程与求 p 的过程类似,代码:

int j=0;
for(int i=2;i<=m;i++){
    while(j&&b[i]!=b[j+1])j=pre[j];
    j+=(b[i]==b[j+1]);pre[i]=j;
}
j=0;
for(int i=1;i<=n;i++){
    while(j&&a[i]!=b[j+1])j=pre[j];
    j+=(a[i]==b[j+1]);
    if(j==m)write(i-m+1,'\n'),j=pre[j];
}

KMP自动机

用 KMP 建出的自动机,转移:

δ(x,c)={x+1Sx+1=c0i=0S1cδ(px,c)i>0Sx+1c

[HNOI2008]GT考试

求有多少个 N 位数字文本串满足:没有一个子串为给定模式串。

模式串长度为 M,对 K 取模。

N109,M20,K1000

solution

dp,设 fi,j 表示文本串中匹配到第 i 位,模式串中匹配到第 j 位的方案数。

那么:

fi,j=ck,δ(k,c)=jfi1,k=kfi1,kc[δ(k,c)=j]

设矩阵 g 有: gk,j=c[δ(k,c)=j],就可以把转移看做向量乘上矩阵。

又发现 gi 无关,于是矩阵快速幂。

失配树

border

任意长度相同前后缀。

失配树

考虑一个问题:

【模板】失配树

给定 ST 次询问 p,q,求 p 前缀和 q 前缀的最长公共 border。

首先,根据 KMP 中 pi 的定义,从一个点开始不断往上跳 pi,跳到的就是它的所有 border。

而仔细思考后发现一个点向它的 pi 连边,连出来的就是一个树形结构,我们称其为失配树。

而两段前缀的最长公共 border 转化为了失配树上的 LCA。

[NOI2014] 动物园

求出对于 S 每个前缀的不相交 border 个数。

对于一个前缀 S[1,i] 求不相交 border 可以转化为长度 i2,所以我们就可以在失配树上往上跳,跳到符合条件,深度就是答案。

可以不用显式建树。

Z 函数

zi 表示一个字符串 ss[i,n] 的 LCP,特别的,z1=0

对于字符串 aaabaab

  1. z1=0
  2. z2=2aaa
  3. z3=1aaa
  4. z4=0
  5. z5=2aaabaa
  6. z6=1aaabaa
  7. z7=0

Z 函数其实也很好求:

从某个位置 i 开始,与整串前缀相同的前缀(称为 Z-box)为 s[i,i+zi1]

我们维护当前 i+zi1 的最大值 r,以及其对应的 i ,令其为 l

假设我们已经求出 1i1 的 Z 函数,接下来要求 zi

根据定义,有 s[i,r]=s[il,rl],所以,zimin(zil,ri+1)

如果还有机会继续拓展(ri+1zil),那就右移 r,否则就 G 了。

l=1,r=0;
for(int i=2;i<=m;i++){
    if(i<r)z[i]=min(z[i-l+1],r-i+1);
    while(i+z[i]<=m&&b[i+z[i]]==b[z[i]+1])++z[i];
    if(i+z[i]-1>=r)r=i+z[i]-1,l=i;
}

匹配另一个串

拼起来再做一遍即可。

但是注意拼起来时中间放一个随便什么引荐字符,避免匹配到后面的串。

[NOIP2020] 字符串匹配

给字符串 S,求 S=(AB)iC 的方案数,设 F(S) 表示 S 中出现奇数次的字符数量,有 F(A)F(C)。定义乘法为前后拼接。

solution

枚举循环节长度:

如图为长度为 i1 的循环节,不难发现有颜色的段是完全相同的,而且不能再往后延伸一段。

可以得出,最大循环节段数为 ti=zii1+1。注意:为了最后留至少一个字符给 C,所以如果循环节把整串排满了,要 1

prei 表示 S[1,i] 中出现奇数次的字符数量,sufi 表示 S[i,n] 中出现奇数次的字符数量。

对循环节段数 k 奇偶分类讨论:

  • k1(mod2)

    不难发现,段数为奇数时,F(C) 保持不变(两个不同的奇数 k 对应的串 C 只差偶数段循环节,正好抵消了)。

    所以,我们只要找到 S[1,j](j<i) 使得 F(A)=prejF(C)=sufi(算 k=1 时的 F(C))。

    这部分的贡献为:满足条件的 j 的个数 × 满足条件的 k 的个数。

  • k0(mod2)

    段数为偶数时 F(C) 也保持不变(原因同上),且等于 suf1

    所以,我们只要找到 S[1,j](j<i) 使得 F(A)=prejF(C)=suf1

    这部分的贡献为:满足条件的 j 的个数 × 满足条件的 k 的个数。

然后发现做完了。

🐴拉🚗

【模板】manacher 算法

S 中最长回文串长度。

先把字符串变成 s1|s2|s3|s4|s5 这个样子,这样一定有一个中心。

模仿 Z 函数,设 manacheri 表示以 i 为中心最长回文串半边长(包括中间)。

我们维护当前 i+manacheri1 的最大值 r,以及其对应的 i ,令其为 mid

可以考虑把 imid 为中心翻折,得到 manacherimin(manacher2midi,ri+1)

然后跟 Z 函数类似地右移 r 即可。

int mid=0,r=0;
for(int i=1;i<n;i++){
    if(i<=r)manacher[i]=min(manacher[(mid<<1)-i],r-i+1);
    while(s[i-manacher[i]]==s[i+manacher[i]])++manacher[i];
    if(manacher[i]+i-1>=r)r=manacher[i]+i-1,mid=i;
}

AC 自动机

多模式串 si 配单文本串 S

先将模式串 si 都放到 trie 里面。

faili 表示 trie 上的一个点 i 的最长能在 trie 上查询的真后缀的对应节点。

vTtfkn.png

  1. fail1=0:最长真后缀为
  2. fail2=1:最长真后缀为 a
  3. fail3=5:最长真后缀为 b
  4. fail4=9:最长真后缀为 bb
  5. fail5=0:最长真后缀为
  6. fail6=1:最长真后缀为 a
  7. fail7=2:最长真后缀为 aa
  8. fail8=3:最长真后缀为 aab
  9. fail9=5:最长真后缀为 b

构建方法:设当前求 i 点的 fail,那就从 i 在 trie 上的父亲 fai 开始,往上跳 fail 直到可以往下走为止。

具体的,设当前在点 u

  • trieu,c 存在,那么 failtrieu,c=triefailu,c
  • 否则,令 trieu,c=triefailu,c
inline void init(){
    L=1,R=0;
    for(int i=0;i<26;i++)
        if(trie[0][i])q[++R]=trie[0][i];
    while(L<=R){
        int u=q[L++],t=fail[u];
        for(int i=0;i<26;i++){
            int v=trie[u][i];
            if(v)fail[v]=trie[t][i],q[++R]=v;
            else trie[u][i]=trie[t][i];
        }
    }
}

第二个操作可以使不断往上跳 fail 的过程一步到位。

匹配方法:如果文本串在树上匹配到了一个串,那么一定能匹配到跳 fail 能跳到的所有点。

【模板】AC 自动机(简单版)

给定 n 个模式串 si 和一个文本串 t,求有多少个不同的模式串在文本串里出现过。

1|t|1061|si|106

如果一个串出现过了,那么它的所有 fail 都出现过,所以我们只要在节点上打个 tag,如果访问过了,就不继续。

复杂度 O(|t|+26|si|)

fail 树

不难发现,AC 自动机的 fail 和 KMP 的 p 一样可以连成一棵树。

【模板】AC 自动机(加强版)

N 个由小写字母组成的模式串 si 以及一个文本串 T。你需要找出哪些模式串在文本串 T 中出现的次数最多。

N150,|si|70,|T|106

这题暴力可过,但是同样可以在 fail 树上树形 dp。

【模板】AC 自动机(二次加强版) 要树形 dp 才能过。

复杂度 O(|t|+26|si|)

非速通

SAM

一个字符串 S 的 SAM 是一个接受 S 的所有后缀的最小 DFA。

从初始状态出发,转移到了一个终止状态,则路径上所有转移连起来一定是 S 的一个后缀。

S 的每个子串均可用一条从初始状态到某个状态的路径构成。

画 SAM 工具

结束位置(endpos)

对于 S 的一个非空子串 s,记 endpos(s)S 中所有 s 的结束位置集合,令 endpos(st)={x|xN,x|S|}(注意包含 0)。

对于字符串 abababbbendpos(ab)={2,4,6}

可能存在两个 S 的非空子串 s1,s2,满足 endpos(s1)=endpos(s2),这样所有 S 的非空子串可以依据 endpos 分为若干个等价类。

SAM 中的每个状态对应一个或多个 endpos 相同的子串。

所以 SAM 中的状态数等于所有子串的等价类的个数,再加上初始状态。

endpos 我们可以得到一些重要结论:

  • 字符串 S 的两个不同非空子串的 endpos 相同,当且仅当其中一个每一次出现都是以另一个的真后缀的形式存在的。

    证明:如果 endpos 相同,所以较短者必为较长者的真后缀,所以在较长者每一次出现时,较短者必出现。

  • 对于字符串 S 的两个非空子串 s1,s2|s1||s2|),有 endpos(s1)endpos(s2),或 endpos(s1)endpos(s2)=。取决于 s1 是否为 s2 的后缀。

    证明:如果 endpos(s1)endpos(s2),那么在一个位置定有 s1s2 的后缀的形式出现,那么在每次 s2 出现时 s1 必以其后缀形式出现,即 endpos(s1)endpos(s2)

  • 一个 endpos 等价类中的所有子串长度恰好覆盖一个区间。

    证明:

    令等价类中长度最小的字符串为 sl,最大的为 sr,那么 slsr 的后缀。

    对于 sr 一个后缀 s(|s||sl|),由第二个结论:endpos(sl)endpos(s),endpos(s)endpos(sr)

    又有 endpos(sl)=endpos(sr),所以 endpos(sl)=endpos(sr)=endpos(s)

    即满足条件的 s 均会出现在等价类中,所以恰好覆盖一个区间。

endpos(x) 等价类中长度最大的为 tx

一个后缀链接 link(x) 中, x 代表一个状态,设 tx 后缀中最长且不属于 endpos(x) 的后缀为 g,那么 link(x) 指向 g 所在的状态。

不难发现,link 也能类似于 fail 变成一棵以 st 为根的树,我们叫它后缀链接树或 parent 树。

一些性质(对于一个字符串 S):

  • 共有 |S| 个叶子结点,代表 S|S| 个前缀所属状态。

    证明:没有点会链接到前缀对应的等价类,非前缀不是属于前缀所在状态,就是能通过 link 。

  • 后缀链接树(SAM)的节点个数最多为 2n1,后面会证。

  • 任意串的后缀全部位于该串所在状态的后缀链接路径上。

  • 一个状态的 endpos 是后缀链接树上子树内所有叶子的 endpos 的并。

    后缀链接树上的边可以看做 endpos 的偏序关系。

线性构造

后缀链接树不够用,建出 SAM 才行。(建 SAM 的算法叫 Blumer 算法)

endpos(x) 等价类中长度最大的长度为 len(x)

假设当前已经完成了 [1,i1] 的,当前加入字符 Si=c

  • [1,i1] 这一前缀所在状态是 last,那我们创建一个新节点 u,表示后缀 [1,i] 所在状态,由定义:len(u)=len(last)+1
  • last 开始跳 link,如果当前状态没有 c 的转移,那就创建一个 c 的转移,指向 u
  • 如果跳到了有 c 的转移的点,设其为 p,设 qδ(p,c),如果 len(q)=len(p)+1,那就令 link(u)=q
  • 否则,构造一个新点 vv 继承 q 出发的转移和 link,并且 len(v)=len(p)+1,然后令 link(u)=link(q)=v
  • 最后,要从 p 继续跳 link,并把路径上原来指向 q 的边指向 v

复杂度证明:

不难发现,一次加点最多加两个,再加上前两个点不可能加点,一共 2n2,但是还有初始状态,所以是 2n1 个。

发现转移数也是 3n+O(1) 的,具体可以看 这里

其它部分的复杂度显然,主要是两次跳 link 的过程。

第一次比较显然,最多创建转移数条新边。

对于第二次:

不难发现,第二次跳 link 的过程跳到第一个不指向 q 的位置就可以停了,再往上跳,就会指向 link(q)

depth(x) 表示后缀链接树上 x 的深度。

  • 引理:若 xy 存在转移边,则 depth(x)+1depth(y)

vqulh8.png

last 表示在构建 last 时的 lastv 在一些时候可能代表 q

在构建 last 时,第一次跳 link 的过程为下面的绿色部分,第二次为紫色部分,令紫色部分最上方的点为 t

构建 u 时,第一次跳 link 的过程为下面的红色部分,第二次为棕色部分,令棕色部分最上方的点为 t

不难发现,构建 last 时第二次跳的距离为 depth(v)depth(t)+1,构建 u 时为 depth(v)depth(t)+1

引理 depth(v)depth(t)+1,换句话说就是 t 最多往一层。

这说明,构建一个点时第二次开始跳的位置的深度,最多是构建上一个点时第二次结束的位置的深度 +1

不难发现深度最多 +|S|,构建而跳一直是跳到深度更低的,所以势能分析一下,复杂度 O(|S|)


瓶颈在于复制。

朴素的构造是 O(|||S|)(每次复制时 memcpy)或 O(|S|log||)(使用 std::map)的。

当然也可以开一个 std::vector,在每次加转移时把字符压进去。这样可以均摊 O(n) 复制。

但是由于常数问题,||=26 时第一种最快。

代码:

inline void add(int c){
    int p=last,u=++cnt;last=u;
    clear(u);len[u]=len[p]+1;
    while(p&&!son[p][c])son[p][c]=u,p=link[p];
    if(!p){link[u]=1;return;}
    int q=son[p][c];
    if(len[q]==len[p]+1){link[u]=q;return;}
    int v=++cnt;copy(v,q);len[v]=len[p]+1;link[u]=link[q]=v;
    while(p&&son[p][c]==q)son[p][c]=v,p=link[p];
}

本文字符串下标从 1 开始。

S[l,r] 表示字符串 Slr 的部分。

应用

检查字符串是否出现

【模板】AC 自动机(简单版)

给定 n 个模式串 si 和一个文本串 t,求有多少个不同的模式串在文本串里出现过。

1|t|1061|si|106

直接根据建出来的 SAM 转移即可。


计算字符串出现次数

显然,一个字符串 sS 中的出现次数为 i=1|S|[s is a suffix of S[1,i]]

而如果 s 是一个 S[1,i] 的后缀,那么在后缀链接树上 s 对应的节点定是 S[1,i] 对应的叶子结点的祖先。

于是树形 dp 即可。

【模板】AC 自动机(二次加强版)

有 N 个由小写字母组成的模式串 si 以及一个文本串 S,求每个模式串在文本串中的出现次数。

1n2×105|si|2×105|S|2×106

上面已经讲了,但是被卡空间了。【模板】后缀自动机 (SAM) 也类似。


本质不同的子串个数

一个子串就是一条从 st 开始的路径,而 SAM 又是一张 DAG,所以转换为求 DAG 上本质不同路径条数,可以拓扑排序+DP,转移方程 du 表示点 u 的出度:

fu=du+v,vδ(u)fv

但是有更优美的做法,每个状态对应的 endpos 等价类中的元素,与从 st 开始、以该状态结尾的路径构成双射。

所以只需求出每个点的等价类大小,即对于点 xlen(x)len(link(x))

不同子串个数

给一个的字符串,求不同的子串的个数。

直接来即可。


[SDOI2016]生成魔咒

共进行 n 次操作,每次在数组 S 的末尾加入一个数 xi。每次操作求出,S 的不同子串个数。

n105,xi109

由于 xi 比较大,用 std::map 即可。每次末尾加一个数,注意一下即可。

本质不同子串总长度

拓扑排序的方法同样行得通,转移方程(fu 是上一题的):

gu=fu+v,vδ(u)gv

第二种同样行得通,因为 endpos 等价类恰好形成一个区间,所以等差数列求和即可。

求字典序第 k 大子串

求出 fi 之后,每一层减下去即可。

[TJOI2015]弦论

给定的长度为 n 的字符串,求出它的第 k 小子串是什么。

t0 则表示不同位置的相同子串算作一个,t1 则表示不同位置的相同子串算作多个。

1n5×1050t11k109

t=1fi 还需改动一下。

求第一次出现的位置

维护 firstpos(i) 表示该状态对应的 endpos 等价类的第一次出现的末尾位置。

当加入新点时 firstpos(u)=len(u)

复制一个点时 firstpos(v)=firstpos(q)

第一次出现位置即为查询的字符串对应状态的 firstpos 减去查询的字符串的长度再 +1

求每一次出现的位置

每一次出现位置对应这后缀链接树上子树内所有叶子节点的位置。

暴力就可以了。

posted @   TOBapNw-cjn  阅读(35)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
点击右上角即可分享
微信分享提示