字符串

本文字符串下标从 11 开始,无歧义时默认 nn 为当前所述字符串长度。

自动机

自动机(automaton) 是一种对信号序列进行判定的数学模型,可以模拟人类的思考方式,对给定的一串信号给出真或假的判定。

确定有限状态自动机(DFA) 是自动机的一种,在 OI 中较常用,其中信号序列一般使用字符串。

形式化地说,它由五部分组成:

  • 字符集(Σ\Sigma,为信号序列所含信号组成的集合。
  • 状态集合(QQ,为所有状态构成的集合。DFA 有许多不同含义的状态,可以视作有向图中点。QQ 可以视作点集。
  • 起始状态(sssQs\in Q,为一个特殊的状态,是所有转移的起点。
  • 接受状态集合(FFFQF\subseteq Q,为一些特殊的状态,是判定的依据。
  • 转移函数(δ\deltaδ(u,c)\delta(u,c) 表示 DFA 在读取到字符 cc,且当前在状态 uu 的情况下,DFA 的状态转移到 δ(u,c)\delta(u,c)。如果 uu 没有 cc 的转移,则约定 δ(u,v)null\delta(u,v)\gets\mathrm{null}nullF\mathrm{null}\notin F 是一个特殊的状态,其不能转移到任何其他状态,不是可接受状态。

若将 DFA 视作有向图,则状态集合可视作点集,转移函数可以视作有向图的边。

设 DFA A=(Σ,Q,s,F,δ)A=(\Sigma,Q,s,F,\delta) 判定信号序列 SS,我们有 AA 工作流程:

  • 设当前状态 uuuu 初始时为 ss
  • 对于 SS 中的每一个字符 cc,执行 uδ(u,c)u\gets\delta(u,c),即通过转移函数,转移到新状态;
  • A(S)=[uF]A(S)=[u\in F] 表示是否接受 SS

Trie

考虑构建一个自动机,使得其只接受字符串 ss。很显然,我们可以对 ss 的所有前缀建立状态,将该前缀称为其状态的对应串,然后在所有相邻前缀的状态间建立转移即可。类似的,若使其只接受字符串集合 SS 中的字符串,可以依次建立状态。上述自动机即为 Trie 树。

形式化地说,构建一颗 Trie 树需要以下步骤:

  • 对于 SS 中每个字符串 ss,枚举其前缀 s(1,i)s(1,i),即遍历 i=1si=1\dots |s|
  • 设状态 uu 的对应串为 s(1,i)s(1,i),状态 vv 的对应串为 s(1,i1)s(1,i-1)。如果不存在状态对应 s(1,i)s(1,i),新建一个状态;
  • 构建转移 δ(v,s(i))u\delta(v,s(i))\gets u
1
2
3
4
5
         he* -> her*
/
(s) -> h -> hi -> his*
\
i -> it*

上图为 S={he,her,his,it}S=\{\texttt{he},\texttt{her},\texttt{his},\texttt{it}\} 时的 Trie 树。其中 (s) 为起始状态,每个字符串都代表一个状态,->\/ 代表状态间的转移,带 * 的是接受状态。

KMP 与 AC 自动机

自动机结构

我们希望借助自动机完成下述任务:给定字符串 s,ts,t,找出所有等于 ttss 的子串。

我们知道 ss 的子串相当于 ss 的一个前缀的一个后缀,所以我们考虑建立一个自动机使得其只接受后缀为 tt 的字符串,然后依次判定 ss 的每个前缀(输入串)。

类似 Trie,我们对 tt 的所有前缀建立状态,将该前缀称为其状态的对应串,并让每个状态表示输入串的某个后缀等于该状态的对应串。

设状态 uu 的深度 dep(u)dep(u) 为其对应串长度,定义邻转移为 uuvv 的转移,使得 v=δ(u,c)dep(v)=dep(u)+1v=\delta(u,c)\land dep(v)=dep(u)+1。那么显然可以建立若干邻转移,其他默认转移到初始状态 ssss 对应串为 \varnothing)。如下图中,所有 -> 均为邻转移。

1
(s) -> a -> ap -> app -> appl -> appl -> apple

但由于一个输入串有若干后缀,可能满足多个状态。可以发现较短后缀一定是较长后缀的后缀,即符合状态 uu 的输入串一定符合对应串是“uu 的对应串的后缀”的状态。

所以我们修改定义,令每个状态表示输入串的某个后缀等于该状态的对应串,且不存在更长的后缀等于其他状态对应串。也就是说,输入串只满足最长可匹配后缀的状态。

自然的,对于状态 uu,若状态 vv 是对应串最长的状态,使得 vv 的对应串是 uu 的对应串的真后缀,则称 uu 的失配链接(fail 指针)指向 vv。通过不断地跳失配链接,我们可以遍历所有对应串是“uu 的对应串的后缀”的状态。值得注意的是,失配链接并非转移,其表达的是一种“继承”关系。若 δ(u,c)=s\delta(u,c)=s,我们可以 δ(u,c)δ(v,c)\delta(u,c)\gets\delta(v,c),即继承 vv 的转移。故这些继承来的转移也可以视作跳若干次失配链接后邻转移。

1
2
3
                           fail
<===============================
(s) -> t -> to -> tom -> toma -> tomat -> tomato

如上图,tomato 的失配链接指向 to

构建过程

  • 建立初始状态 ss
  • 遍历 i=1ti=1\dots |t|
    • s(1,i)s(1,i) 的状态为 uus(1,i1)s(1,i-1) 的状态为 vv,状态 xx 的失配链接为 f(x)f(x)
    • f(u)δ(f(v),s(i))f(u)\gets\delta(f(v),s(i))
    • δ(v,s(i))u\delta(v,s(i))\gets u
    • δ(u,)δ(f(u),)\delta(u,*)\gets\delta(f(u),*)

复杂度分析

不难发现判定一个字符串 ss 的时间复杂度为均摊 O(s)O(|s|),但构建 KMP 自动机的时间复杂度为 O(Σt)O(|\Sigma||t|),因为需要拷贝转移。故实际实现中不会拷贝继承来的转移,构建时间复杂度就降到了 O(t)O(|t|),判定字符串时直接跳失配链接,不难证明判定时间复杂度仍为 O(s)O(|s|)

综上 KMP 算法时间复杂度为 O(s+t)O(|s|+|t|)

推广

上述 DFA 即为 KMP 自动机,观察其形态可以发现其邻转移形成一条链,其他继承来的转移为“返祖边”。结合 Trie 的思想,我们就可以造出 AC 自动机。但这次我们就需要拷贝继承来的转移,(为什么?)。

如果你理解了 KMP 自动机,那么 AC 自动机的构建方式就十分显然了。

后缀数组(SA)

定义 sufis[i,n]suf_i\gets s[i,n]rkirk_isufisuf_i 在所有后缀中的字典序排名,saisa_i 为字典序第 ii 小的后缀。rk,sark,sa 互逆。

例如 s=aababs=\texttt{aabab} 时,aabab<ab<abab<b<bab\texttt{aabab}<\texttt{ab}<\texttt{abab}<\texttt{b}<\texttt{bab},所以 rk=[1,3,5,2,4],sa=[1,4,2,5,3]rk=[1,3,5,2,4],sa=[1,4,2,5,3]

后缀排序指求上述数组的过程。

O(nlog2n)O(n\log^2n) 做法

考虑朴素倍增。不妨定义 rki,jrk'_{i,j} 表示将 s[,+2j1]s[*,*+2^j-1] 按字典序排序后 s[i,i+2j1]s[i,i+2^j-1] 的排名,sai,jsa'_{i,j} 同理。

倍增分为 logn\log n 轮,第 jj 轮得到 rki,j,sai,jrk'_{i,j},sa'_{i,j}

具体地,在第 jj 轮中,按 (rk,j1,rk+2j11,j1)\left(rk'_{*,j-1},rk'_{*+2^{j-1}-1,j-1}\right) 排序,得到 sai,jsa'_{i,j} 并推出 rki,jrk'_{i,j},注意需要去重。

最终 rkirki,logn,saisai,lognrk_i\gets rk'_{i,\log n},sa_i\gets sa'_{i,\log n},即倍增覆盖长度大于 nn,使 sufisuf_i 等价于 s[i,i+2j1]s[i,i+2^j-1] 后结束。

O(nlogn)O(n\log n) 做法

考虑到两个关键字的值域都为 O(n)O(n),可以使用基数排序,复杂度即可优化到 O(nlogn)O(n\log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>

using std::cin;
typedef long long ll;
constexpr int N=2e6+114;
int n;
std::string s;
int sa[N],rk[N],id[N],ork[N],buc[N];

inline void build()
{
int m=1<<7,k=0;
for(int i=1;i<=n;i++)buc[rk[i]=s[i]]++;
for(int i=1;i<=m;i++)buc[i]+=buc[i-1];
for(int i=n;i;i--)sa[buc[rk[i]]--]=i;
for(int j=1;;m=k,k=0,j<<=1)
{
for(int i=n-j+1;i<=n;i++)id[++k]=i;
for(int i=1;i<=n;i++)if(sa[i]>j)id[++k]=sa[i]-j;
std::fill(buc+1,buc+m*2+1,0);
std::copy(rk+1,rk+n*2+1,ork+1);
k=0;
for(int i=1;i<=n;i++)buc[rk[i]]++;
for(int i=1;i<=m;i++)buc[i]+=buc[i-1];
for(int i=n;i;i--)sa[buc[rk[id[i]]]--]=id[i];
for(int i=1;i<=n;i++)rk[sa[i]]=
(ork[sa[i-1]]==ork[sa[i]]&&ork[sa[i-1]+j]==ork[sa[i]+j])?k:++k;
if(k==n)break;
}
}

int main()
{
std::ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>s;
n=s.size();
s=' '+s;
build();
for(int i=1;i<=n;i++)printf("%d ",sa[i]);
return 0;
}

注意 rkrk' 严格满足排名的定义(即小于本身的不同元素的数量),可以维护 rki,jrk'_{i,j} 的值域 mm,当 m=nm=n 时直接退出。

同时对第二关键字的排序是不必要的,对于 i[n2j+1,n]i\in[n-2^j+1,n]rki+2j,j1=0rk'_{i+2^j,j-1}=0,直接将它们放置在 sai,jsa'_{i,j} 的开头,其他的按照 rki+2j,j1rk'_{i+2^j,j-1} 顺序即可。

htht 数组

lcp(i,j)\operatorname{lcp}(i,j)sufi,sufjsuf_i,suf_j 的最长公共前缀,htilcp(sai1,sai),ht10ht_i\gets|\operatorname{lcp}(sa_{i-1},sa_i)|,ht_1\gets 0

结论 1rkirkjrkkrk_i\le rk_j\le rk_k,则 lcp(i,j),lcp(j,k)lcp(i,k)|\operatorname{lcp}(i,j)|,|\operatorname{lcp}(j,k)|\ge\operatorname{lcp}(i,k)

结合图片容易理解,lcp\operatorname{lcp} 实际上是一个取区间 min\min,也就是说:

结论 2rki<rkjrk_i<rk_j,则 lcp(i,j)=mink=rk(i)+1rk(j)htk|\operatorname{lcp}(i,j)|=\min\limits_{\mathclap{k=rk(i)+1}}^{rk(j)}ht_k

大区间的最小值自然不大于其子区间的最小值。

htht 数组所需的一个重要引理

ht(rki)ht(rki1)1ht(rk_i)\ge ht(rk_{i-1})-1

证明

i1i-1 的前驱 psa(rki11)p\gets sa(rk_{i-1}-1),根据定义 ht(rki1)=lcp(sa(rki11),sa(rki1))=lcp(p,i1)ht(rk_{i-1})=|\operatorname{lcp}(sa(rk_{i-1}-1),sa(rk_{i-1}))|=|\operatorname{lcp}(p,i-1)|

假设 ht(rki1)>0ht(rk_{i-1})>0,则显然

lcp(p+1,i)=lcp(p+1,i1+1)=lcp(sa(rki11)+1,sa(rki1)+1)=lcp(sa(rki11),sa(rki1))1=ht(rki1)1\begin{aligned} |\operatorname{lcp}(p+1,i)|&=|\operatorname{lcp}(p+1,i-1+1)|\\ &=|\operatorname{lcp}(sa(rk_{i-1}-1)+1,sa(rk_{i-1})+1)|\\ &=|\operatorname{lcp}(sa(rk_{i-1}-1),sa(rk_{i-1}))|-1\\ &=ht(rk_{i-1})-1 \end{aligned}

即同时删去第一个字符,lcp\operatorname{lcp}11

同时因为 ht(rki1)>0,rkp<rki1ht(rk_{i-1})>0,rk_p<rk_{i-1},不难发现 rkp+1<rkirk_{p+1}<rk_i。根据结论 1,

lcp(sa(rki1),i)lcp(p+1,i)ht(rki)ht(rki1)1\begin{aligned} |\operatorname{lcp}(sa(rk_i-1),i)|&\ge|\operatorname{lcp}(p+1,i)|\\ ht(rk_i)&\ge ht(rk_{i-1})-1\\ \end{aligned}

\square

以上内容可以结合下图理解。

1
2
3
4
5
6
7
8
9
10
11
s=aabbaba
p=sa(rk(i)-1)

ind rk suf p.s.
7 1 a p+1
1 2 aabbaba sa(rk_i-1)
5 3 aba i
2 4 abbaba
6 5 ba p
4 6 baba i-1
3 7 bbaba

这样,我们就有了均摊 O(n)O(n)htht 数组的优美做法。

1
2
3
4
5
6
for(int i=1,k=0;i<=n;i++)
{
if(k)k--;
while(s[i+k]==s[sa[rk[i]-1]+k])k++;
ht[rk[i]]=k;
}

参考