浅谈字符串

浅谈字符串

1 前言

由于笔者字符串水平不高,可能会出现事实性的错误。

你说这篇博客会讲什么?我想大概会讲字符串技术巡礼中的前置知识(

0 记号与约定

  • 字符集记作 Σ
  • 一般使用 S,T 来表示一个字符串。
  • 字符串 S 的长度(即包含的字符数量)记作 |S|
  • 空串为长度为 0 的唯一字符串,记作 ε
  • 字符串 S 的第 i 个字符记作 Si,字符串下标默认从 1 开始。
  • 字符串 S 的一个子串 S[l,r] 为由 Sl,Sl+1,,Sr1,Sr 组成的字符串。若 l>rS[l,r]=ε
  • S[1,i] 是字符串 S 的一个前缀,S[i,n] 是字符串 S 的一个后缀,其中 n=|S|
  • 对字符串 S 的一个子串/前缀/后缀 S,如果 SS,则称 SS 的真子串/真前缀/真后缀。
  • ST 的前缀,记作 ST,特别的,如果 ST 的真前缀,记作 ST
  • 对字符串 S,TS+T 定义为 ST 的拼接。
  • Rev(S) 定义为 S 的翻转,有 Rev(S)i=S|S|i+1
  • 我们称字符串 S 为回文串,当且仅当 S=Rev(S),即 Si=S|S|i+1
  • LCP(S,T) 定义为 max{iimin(|S|,|T|)S[1,i]=T[1,i]}
  • LCS(S,T) 定义为 max{iimin(|S|,|T|)S[|S|i+1,|S|]=T[|T|i+1,|T|]}
  • 对于两个字符串 S,T,我们称 S 字典序小于 T,记作 S<T,当且仅当 ST 或存在 i 满足 S[1,i1]=T[1,i1]Si<Ti

其实就是抄的 ix35 的博客

1 字符串 Hash

Hash 的思想是将不方便存储/比较的东西(比如字符串)通过 Hash 函数 f(s) 转换为便于存储/比较的东西(比如整数)

一般来说,我们使用多项式 Hash:f(S)=(i=1|S|f(Si)b|S|i)modm,其中 b 称作底数,m 称作模数,对于单个字符 c,我们可以简单地将 f(c) 定义为 c 的 ASCII 码。我们一般将 m 设为质数,b 可以随便选择。

显然有 S=Tf(S)=f(T),它的逆否命题 f(S)f(T)ST 也是成立的。但注意,若 f(S)=f(T)S 有可能不等于 T,我们称这种情况为哈希碰撞。

定理 1.1:对于两个随机字符串 S,T,它们哈希碰撞(即 f(S)=f(T)ST)的概率为 max(|S|,|T|)1m

证明不会,可以去看 oi-wiki。

定理 1.2:对于 n 个随机字符串,它们发生哈希碰撞的概率为 1i=0n1(1im)

证明还是不会/oh/oh/oh。

一般我们会选择 m=109+7m=998244353,但在 n=106 的情况下,错误率是极高的。所以我们一般会使用一种叫做双哈希的技巧,通过选取两个模数 m1,m2 分别对 S 进行 Hash,可以使值域扩大到 m1m2,减小错误率。我一般选的是 m1=109+7,m2=998244353,此时值域扩大到了约 1018,错误率下降到了约 107

1/1 查询子串 Hash 值

我们可以将多项式 Hash 看成 将字符串 S 当作 b 进制数转化为 10 进制,于是理所当然的,通过维护 S 的前缀的 Hash 值 hi=f(S[1,i]),我们可以使用 hrhl1brl+1 来得到 f(S[l,r]),其中 brl+1 可以简单地预处理出来。

S 的前缀的 Hash 值可以简单预处理,有 hi=hi1b+f(Si),边界条件为 h0=0

1/2 字符串匹配

给定两个字符串 S,T,你需要在 S 中找到所有位置 i,使 S[i,i+|T|1]=T

朴素做法是枚举 i,然后暴力判断,时间复杂度 O(|S||T|)

我们在后面会学到许多能在很优的复杂度内做字符串匹配的算法,但哈希也可以很简单地实现它。

我们先预处理出 S 的前缀 Hash 值和 T 的 Hash 值,枚举 i,我们可以取出 S[i,i+|T|1] 子串的 Hash 值,直接和 T 的 Hash 值比较即可。时间复杂度 O(|S|+|T|)


还有很多本来应使用一些专属算法但能用 Hash 乱搞弄过去的题,下文讲这些题时将提到 Hash 做法。

2 自动机

自动机分确定有限状态自动机(DFA)和非确定有限状态自动机(NFA)。OI 中 DFA 要更为常见,所以本节只介绍 DFA。

你可以将 DFA 简单地看成一张带权有向图,其中顶点被称作状态,而有向边被称作转移,边权表示该转移接受的字符。状态中有一个起始状态 s 和一组接受状态集合 F,每次我们可以丢进去一个字符串,从起始状态 s 开始,我们依次查看字符串中的每个字符,根据字符找到对应的转移,并转移到对应的状态。如果最后的状态在 F 中,我们称此 DFA 接受该字符串,反之亦然。

形式化的,DFA 由以下部分构成:

  • 字符集 Σ,包含此自动机允许的字符。
  • 状态集 V,即 DFA 中的状态集合,如果把它看成带权有向图,V 就是图上的顶点。
  • 起始状态 sV
  • 接受状态集 FV
  • 转移函数 δ(x,c),接受一个状态 xV 和一个字符 cΣ,输出为状态 x 通过字符 c 对应的转移得到的状态,如果把自动机看成带权有向图,δ 就描述了该图的边集。特别的,如果状态 x 没有字符 c 对应的转移,我们就令 δ(x,c)=nullδ(null,c)=null,且 nullF

对于字符串 S,我们从 s 开始,通过转移函数一个字符一个字符地转移,最后如果状态属于 F,我们就称该自动机接受 S

3 字典树 / Trie

Trie 是一个树状自动机,它能够接受一系列字符串的所有前缀。

首先考虑只有一个字符串 S 的情况,我们可以直接让 s=1δ(i,Si)=i+1,同时让 F{i1i|S|+1}。那么从 1 开始,每匹配 Si 的一位,我们都会跳到编号加一的状态,这个过程不断扩展 S 的前缀,于是每个状态都唯一对应着 S 的一个前缀。

对于多个字符串,我们可以依次按上面的流程建出一条从根挂下来的链,如果有两个字符串 S,T 在某一位相等,我们可以让 S,T 共用当前状态。

偷一张 oi-wiki 的图:

trie1

这个 Trie 接受字符串 aa,aba,ba,caaa,cab,cba,cc 及其所有前缀。对应的路径为:

aa1a2b5aba1a2b6a11ba1b3a7caaa1c4a8a12a15cab1c4a8b13cba1c4b9a14cc1c4c10

可以发现,如果按每一位从小到大的顺序深度优先遍历这棵 Trie,得到的字符串也是字典序从小到大依次递增的。

如果令 Σ={0,1},我们就得到了 01 Trie,本文不会讨论其及其相关应用。

Trie 是许多 多串构造出来的自动机 的基础,而它本身也有许多应用。

4 KMP 与 Border

Hash 做字符串匹配自然是一个很优的算法,但它毕竟是有错误率,且有时会点名被卡的算法,我们想要一个较优时间复杂度的确定性算法。

先来看下暴力匹配时怎么被卡到 O(|S||T|) 的:

(图源 KMP 算法教程

观察这个过程,我们实际上可以利用 i 处的匹配信息来跳过无用的段:

(图源 KMP 算法教程

假设我们正在尝试从 S 的第 i 位开始匹配,如果失配在 T 的第 j 位,那么我们事实上可以利用前 j1 位的信息。

上图中,我们从 S1 开始匹配,在 T6 失配,那么我们可以发现,从 S2,S3 开始必然失配,因为 S2=T2T1,S3=T3T1

S4=T4=T1,S5=T5=T2,所以由我们已知的信息,似乎从 S4 开始匹配有可能能够匹配完。所以我们可以直接让 iS1 移到 S4,而且可以直接比对 S6T3,省去了大把的字符比较次数。

描述一下上面的过程:由于 T[1,2]=T[4,5],T[1,3]T[3,5],所以我们可以直接将 iS1 移到 S4

我们引入 Border 的概念:对于一个字符串 S,如果 S 的一个真子串 T 既是 S 的前缀又是 S 的后缀,我们就认为 TS 的一个 Border。

举个例子:abcab 的 Border 只有 ab

假设我们现在匹配到了 SiTj,失配。那么我们就需要找到最大的 p 使得 T[1,p]=T[jp,j1],将 iSi 退回到 SipT1 重新匹配,又因为我们已经知道了 S[ip,i1]=T[jp,j1]=T[1,p],所以我们不用回退 i,直接将 j 跳到 p+1,让 SiTj 继续比较即可。由 Border 的定义,p 就是 T[1,j1] 的最长 Border 的长度。

注意一个特例:如果 j=1,我们无法得到任何信息,只能让 i 前进一位,重新开始比较。

当我们找到一个 T 后,可以当成是在第 |T|+1 位失配进行处理。

这个过程是 O(|S|+|T|) 的,我不会证。于是我们只需要快速求出 T 的前缀最长 Border 长度即可,不妨把 T[1,i] 的最长 Border 长度记作 pi

p1=0,假设我们现在已经求出了 p1pi1,对于 pi,我们有两种情况:

  • Ti=Tpi1+1:那么由于 T[1,pi1]=T[ipi1,i1],自然有 T[1,pi1+1]=T[ipi1,i],显然我们不可能有更大的解(否则 pi1 也会有更大的解),所以有 pi=pi1+1
  • TiTpi1+1:有 pipi1,我们需要满足 T[1,pi]=T[ipi+1,i],把此条件拆成 T[1,pi1]=T[ipi+1,i1]Tpi=Ti,因为 T[1,pi1]=T[ipi1,i1]pipi1,于是有 T[ipi+1,i1]=T[pi1pi+2,pi1]=T[1,pi1],因为 pipi1,所以 T[1,pi1] 实际上是 T[1,pi1] 的一个 Border,我们想让 pi 最大,于是可选的最大值即为 ppi1,我们判断此时的 Tpi 是否等于 Ti,如果等于就退出,否则重复以上流程。

复杂度是 O(|T|) 的,还是不会证/ng。

于是我们就做到了 O(|S|+|T|) 的字符串匹配。

code

5 AC 自动机 / Trie 图

考虑以自动机的形式理解 KMP:

我们需要实现一个自动机,使得其接受所有以给定串 T 结尾的串。

首先我们挂出一条 T 的前缀链,其上每个状态的含义为“到这个状态时,当前串以该状态对应前缀结尾”。

于是对于当前状态,如果 Si 能够让当前状态到达下一个状态,我们可以直接转移;否则,我们需要考虑回到之前的状态。

形式化的,假设当前需要处理的字符是 c,当前状态对应的前缀为 T[1,i]

  • c=Ti+1,直接 T[1,i]T[1,i+1] 转移即可。
  • cTi+1,此时我们需要找到上一个状态 T[1,j] 满足 T[1,j]=T[ij+1,i](即是 T[1,i] 的后缀的最长的 T 的前缀),可以发现这就是 pi,跳到上一个状态,重复此过程即可。

于是我们只需要求出 pi,在自动机中,我们不妨称其为“失配链接”。

假设我们已经求出了 T[1,1]T[1,i1] 的失配链接,现在需要求出 T[1,i] 的失配链接。考虑前一个状态 T[1,i1],我们可以考虑在 T[1,i1] 的失配链接 pi1 后添加 Ti,如果 Tpi1+1=Ti,那么可以直接让 T[1,i] 的失配链接指向 pi1+1,否则重复这个过程,不断找到当前状态的失配链接,看能不能接上 Ti 即可。

在匹配 S 的过程中,我们不断按自动机上的边走,如果走到了接受状态 T[1,|T|]=T,我们将其当作在 |T|+1 处失配,按失配逻辑跳回之前的状态继续匹配即可。

构建自动机时,我们只需要记录到下一个状态的转移和失配链接即可。

code

(虽然理解方式是自动机,但写出来其实和原来的几乎一样)


AC 自动机和 KMP 类似,不同的是,现在有多个模式串 T1,T2,,Tm(我们使用 Ti,j 表示 Ti 的第 j 个字符)和一个母串 S,我们需要将每个 TiS 中进行匹配。

显然可以直接对每个 Ti 暴力匹配一遍,时间复杂度 O(m|S|+|Ti|),不优。

参考上文 KMP 在自动机意义下的理解,我们考虑将其中的一条前缀链扩展成一棵 Trie。

失配链接也不局限于单个串 Ti,我们只需找到最长的那个状态满足对应的串是当前状态对应的串的后缀即可。

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