常见字符串算法简记

包含 KMP,Manacher,Z algorithm(exKMP),Suffix Array 的简单记录。例题碰到一道补一道。

下面的 SA 仅指 Suffix Array。

当然写的很烂,欢迎当玩笑看。

你说得对,但是哈希不是万能的。

0.前言

0.1 记一忘三二

本文所写的字符串算法都基于一个基本思想:充分利用已知信息的性质加速求出所求信息的过程。这是老生常谈的。因此在这些算法的复杂度分析中主要运用了均摊的思想。

这些算法或多或少都是在刻画字符串的前后缀,换句话说,子串。面对不同的需求,这些算法利用前后缀 \(O(n)\) 的状态数去刻画子串 \(O(n^2)\) 的状态数。Manacher 实际上也是在刻画同一位置相同的前后缀。

当回首审视这些学过的算法时,似乎简洁而相似的形式化表达就是其独特的魅力所在吧。

0.2 前置

若无特殊说明,字符串下标均从 \(1\) 开始

为行文方便,使用了以下记号:

\(|s|\):表示字符串 \(s\) 的长度;

\(s[l:r]\):表示字符串 \(s\)\(l\)\(r\) 位置的字符顺次连接形成的子串。特别地,记 \(P_i\)\(s[1:i]\)\(S_i\)\(s[i:|s|]\)。为避免混淆,不使用大写字母 \(S\) 表示独立意义的字符串。

\(AB\)\(A+B\):表示字符串 \(A\)\(B\) 顺次连接的字符串。\(A^n\) 表示将 \(A\) 拼接 \(n\) 次形成的字符串。

\(\operatorname{lcp}(s,t)\):表示字符串 \(s,t\)最长公共前缀,longest common prefix。

真子串、真前缀、真后缀中的“真”均表示排除原串

1.KMP 算法

1.1 简介

KMP 算法的主要用途是在 \(O(|s|+|t|)\) 的时间内解决两个字符串匹配的问题。实际上,KMP 算法刻画了原串的前缀 与 所有前缀的后缀 的匹配关系。换而言之,KMP 刻画了所有前缀 \(P_1\cdots P_{|s|}\) 的前后缀的匹配关系。

1.2 原理

KMP 算法基于前缀函数 \(\pi\),即 nxt 数组。对于一个字符串 \(s\),有定义:\(\pi(i)=\max\{j|j\in[1,i)\land s[1:j]=s[j-i+1,i]\}\)。即表示以 \(i\) 结尾的前缀 \(P_i\)最长公共真前后缀。公共即相等的意思。

更多有关公共前后缀的内容写进下面的 Border 理论里了。

考虑求 \(\pi\) 的过程。假如已知 \(1\)\(i-1\)\(\pi\) 值,试求 \(\pi(i)\)。由于有 \(s[i-\pi(i-1):i-1]=s[1:\pi(i-1)]\),尝试匹配下一位,如果有 \(s_{\pi(i-1)+1}=s_i\)(匹配成功),则有 \(\pi(i)=\pi(i-1)+1\)。证明考虑反证法,分别证明 \(\pi(i)\) 的上界和下界。

如果 \(s_{\pi(i-1)+1}=s_i\) 不满足,为了加速过程考虑利用更前面的 \(\pi\)。具体地,由 \(\pi\) 的定义可得:若令 \(k=\pi(i)\),有 \(s[1:\pi(k)]=s[k-\pi(k)+1,k]=s[i-k+1:i-k+\pi(k)]=s[i-\pi(k):i-1]\)

由此得出:\(s[1:\pi(\pi(i-1))]=s[i-\pi(\pi(i-1)):i-1]\)。和之前的 \(s[1:\pi(i-1)]=s[i-\pi(i-1):i-1]\) 对比一下,可以发现这是一个 \(\pi\) 复合的过程,换句话说,border 的 border 是 border。此时如果仍不匹配,继续让 \(\pi\) 自我复合。

正确性可以由 \(\pi\) 的最大性得出。

所以对于 \(i\),求 \(\pi(i)\) 的过程可以归纳如下:

  1. \(k=\pi(i-1)\)
  2. 如果 \(s_{k+1}=s_i\),则 \(\pi(i)=k+1\),结束算法;
  3. 否则若 \(k\not=0\)\(k\leftarrow \pi(k)\),回到第 2 步;若 \(k=0\),则 \(\pi(k)=0\),结束算法;

1.3 实现

以下是本人常用的模板实现。

inline void KMP(const int &N, const string &s, int *pi) {
	pi[1] = 0;
	for (int i = 2; i <= N; ++i) {
		int j = pi[i - 1];
		while (j && s[j + 1] != s[i]) j = pi[j];
		pi[i] = j + (s[j + 1] == s[i]);
	}
}

注意上面的 \(j\) 指针最多执行 \(|s|\)\(+1\) 操作,所以向左跳 \(\pi\) 最多 \(|s|\) 次。时间复杂度 \(O(|s|)\)

1.4 应用

1.4.1 字符串匹配

KMP 算法可以以 \(O(|s|)\) 的时间复杂度对 \(s\) 进行预处理,以 \(O(|s|+|t|)\)\(t\) 求出在 \(s\) 中的所有出现位置。可以使用 KMP 自动机(见下)加速多个 \(s\) 的过程至 \(O((|t|+\sum |s|)\times |c|)\),适应多串匹配。其中 \(|c|\) 是字符集大小。

利用 \(\pi\) 刻画了原串与所有前缀的匹配关系,将 \(t\) 拼接在 \(s\) 前面得到 \(t+inf+s\),其中 \(inf\) 是不属于原字符集的分隔符。对拼接得到的串跑 KMP,可以得到新串中 \(t\) 的匹配。具体地,原串中每一个 \(\pi(i)=|t|\) 的位置,都是可以匹配 \(t\) 的结束位置。需要注意开始与结束位置都要在原来的 \(s\) 的范围内。

1.4.2 KMP 自动机

KMP 自动机是一种确定有限状态自动机。

状态由一个整数 \(i\) 表示已经输入的字符串 \(t\) 与模式串 \(s\) 的前缀匹配长度。

\(s\) 建立 KMP 自动机,转移函数 \(\delta(i,c)\) 表示从状态 \(i\) 输入字符 \(c\) 的转移过程:

对于 \(s_{i+1}=c\),即匹配成功,可以转移到状态 \(i+1\);对于 \(s_{i+1}\not=c\),由于 \(\delta(\pi(i),c)\) 已知,可以直接利用而省去跳 \(\pi\) 的过程,从而加速多串匹配。

1.5 例题

I. [NOI2014] 动物园

要求前后缀不能重叠,即要求 \(num_i\times 2\leq i\)。可以通过预处理 \(\pi\) 之后利用类似于求 \(\pi\) 的递归复合方式跳 \(num\)

II. CF1721E Prefix Function Queries

注意 KMP 的复杂度基于均摊(见 1.3实现),所以使用 KMP 自动机加速多串匹配。

III. CF1163D Mysterious Code

双模式串匹配,对两个模式串都建 KMP 自动机。对于这种匹配问题定义 \(dp_{i,p,q}\) 表示当前在考虑原串的第 \(i\) 位,已经匹配了 \(s\) 的前 \(p\) 位,\(t\) 的前 \(q\) 位时的最大答案。转移直接枚举当前填的字符在 KMP 自动机上转移。

当然如果有更多模式串,对所有模式串分别建 KMP 自动机复杂度不太能承受。这时可以建 AC 自动机(相当于把 KMP 自动机搬到 Trie 上)达到加速的效果。

2.Border 理论

只有最最简单的内容。

2.1 定义

定义 \(\operatorname{Border}(s)\) 表示 \(s\)公共真前后缀组成的集合,用 border 显性表示其中一个元素。令 \(\operatorname{Border_{max}}(s)\) 表示 \(s\)最长公共真前后缀,有 \(|\operatorname{Border_{max}}(s)|=\pi(|s|)\)

2.2 Period

定义 \(\operatorname{Period}(s)\) 表示 \(s\)周期串组成的集合,用 period 显性表示其中一个元素。一个字符串 \(t\)\(s\) 的周期当且仅当 \(s\)\(t^{\infty}\) 的子串。

2.3 性质

2.3.1 period 与 border 成对出现

如果 \(s\) 存在长度为 \(i\) 的 border,那么 \(s\) 存在长度为 \(|s|-i\) 的 period。且若 \(s[1:i]=s[|s|-i+1:|s|]\),那么 \(s[i+1:|s|]\)\(s[1:|s|-i]\)\(s\) 的 period。

证明如下:令 \(j=|s|-i\),即 period 的长度。

  • \(i\leq \dfrac{n}{2}\),即 border 短于 period,由于有 \(\forall k\in [1,i],s_k=s_{k+j}\),符合 period 的定义。

  • \(i>\dfrac{n}{2}\),即 border 长于 period,可以利用 border 相等的传递性:有 \(s[k:k+j-1]=s[k+j:k+2\times j-1]\),符合 period 的定义。

利用该性质,可以利用 border 求 period,以及有关最小正周期的问题(转化为 \(\operatorname{Border_{max}}\))。

2.3.2 border 的 border 是 border

\(\operatorname{Border}(s)=\operatorname{Border}(\operatorname{Border_{max}}(s)) \cup \{\operatorname{Border_{max}}(s)\}\)。证明在上面的 KMP 中给出。

由此,可以发现 \(s\) 所有前缀的 Border 之间存在树形支配关系。这是失配树的基础。

2.4 应用

对于字符串 \(|s|\),让 \(i\in[1,|s|]\)\(\pi(i)\) 连边,可以得到一棵树,即失配树

由 border 的性质可得:对于 \(v\) 的一个祖先节点 \(u\)\(P_u\)\(P_v\) 的 border。进而 \(P_u\)\(P_v\) 的最长公共 border 是 \(P_{LCA(u,v)}\)

2.5 例题

I. [POI2006] OKR-Periods of Words

要求最长真 period,即求最小正 border。也即是失配树上的最浅非零祖先。

II. UVA455 Periodic Strings

注意这里的周期要求必须整除 \(|s|\)。所以先在支配树上跳祖先(也即之前的跳 \(\pi\) 过程)直到满足条件。

III. [POI2005] SZA-Template

成为答案的一定是 border。定义状态 \(dp_i\) 表示前缀 \(i\) 的答案,\(dp_i\) 只可能取 \(i\)\(dp_{\pi(i)}\)。后者当且仅当 \(\exists j\in[i-\pi(i),i],dp_j=dp_{\pi(i)}\) 时取到,\(j\) 是印章上一次按的末位置。

3.Z Algorithm

3.1 简介

Z 算法,即扩展 KMP 算法。

Z 算法刻画了原串的前缀 与 所有后缀的前缀 的匹配关系。大概这也是为什么它叫扩展 KMP。

3.2 原理

Z 算法基于 z 函数\(z(i)\) 表示 \(S_i\)\(s\)最长公共前缀,即 \(z(i)=|\operatorname{lcp}(s,S_i)|\)。特别地,一般令 \(z(1)=0\)

\(s[i:i+z(i)-1]\)匹配段, z-box。算法过程中记录右端点最大的匹配段 \([l,r]\),初始时 \(l=r=0\)

计算 \(z(i)\) 时:

  • \(i>r\),根据定义暴力匹配;

  • \(i\leq r\),有 \(s[i:r]=s[i-l+1:r-l+1]\)。由此 \(z(i)\) 的下界为 \(\min(z(i-l+1),r-i+1)\),表示从 \(i-l+1\) 开始有 \(z(i-l+1)\) 的长度与 \(s\) 匹配,有 \(r-i+1\) 的长度与 \(S_i\) 匹配。之后暴力匹配即可。

3.3 实现

inline void EXKMP(const int &N, const string &s, int *z) {
	for (int i = 2, l = 0, r = 0; i <= N; ++i) {
		if (i <= r) z[i] = min(z[i - l + 1], r - i + 1);
		while (i + z[i] <= N && s[i + z[i]] == s[z[i] + 1]) ++z[i];
		if (i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
	}
}

\(z(i-l+1)<r-i+1\),即上面的 \(\min\) 取到 \(z(i-l+1)\) 时,\(z_i\) 不会继续匹配,否则有 \(s_{i+z(i-l+1)+1}=s_{i-l+1+z(i-l+1)+1}=s_{z(i-l+1)+1}\),与 \(z(i-l+1)\) 的最大性矛盾。因此当且仅当上面的 \(\min\) 取到 \(r-i+1\) 时会使 \(z(i)\) 匹配,而这个操作一定会使 \(r\) 增加。所以总复杂度 \(O(|s|)\)

3.4 例题

I. CF526D Om Nom and Necklace

枚举 \(AB\) 的长度,若 \(P_i\) 满足条件,\(AB\)\(P_i\) 的 period,可以用 KMP 检验。后面剩下的 \(A\) 即要求 \(A\)\(AB\) 的前缀,Z 算法+差分统计。

4.Manacher

4.1 简介

Manacher 算法刻画了所有回文子串。换句话说,刻画了同一位置前缀的后缀与后缀的前缀的匹配关系。也即 \(P_i\) 的后缀与 \(S_i\) 的前缀的匹配关系。Manacher 和 Z 算法的最大差异在于 Manacher 刻画的是同一位置的后缀与前缀的关系,而 Z 算法刻画了单一后缀与所有前缀的关系。这也使得它们的思想以及实现极其相似。

4.2 原理

\(p_i\) 表示以 \(i\) 号位置为对称中心的最长回文半径。即 \(p_i=\max\{j|j\in[1,i]\land s[i-j+1:i]=s[i,i+j-1]\}\)

上面也提到了,Manacher 的思想与 Z 算法极其相似。算法过程中记录右端点最大的回文串 \((d,r)\) 表示回文中心与右端点。

计算 \(p_i\) 时:

  • \(i>r\),根据定义暴力匹配;

  • \(i\leq r\),考察与 \(i\) 对称的位置 \(j=2d-i\)。由对称性我们可以设 \(p_i=p_j\)。但是和 Z 算法一样要考虑超出已知的回文串的情况,即取 \(p_i=\min(p_{2d -i}, r - i + 1)\)。之后仍然暴力匹配。

4.3 实现

注意 \(p\) 只能刻画长度为奇数的回文串。所以需要在相邻两个字符之间加上分隔符处理偶数长度回文。

算是写的丑的。

inline int Manacher() {
	int tot = -1;
	s[++tot] = '?';
	for (int i = 0; i < len; ++i) s[++tot] = '@', s[++tot] = t[i];
	s[++tot] = '@', s[++tot] = '!';
	int ret = 0;
	for (int i = 1, r = 0, d = 0; i <= tot; ++i) {
		p[i] = i > r ? 1 : min(p[2 * d - i], r - i + 1);
		while (s[i - p[i]] == s[i + p[i]]) ++p[i];
		if (i + p[i] - 1 > r) d = i, r = i + p[i] - 1;
		ret = max(ret, p[i] - 1);
	}
	return ret;
}

时间复杂度类比 Z 算法。

4.4 例题

I. [THUPC2018] 绿绿和串串

如果位置 \(i\) 可以成为答案,要求存在以 \(i\) 为中心 \(n\) 为右端点的回文串。另外这个过程是可以嵌套的,即若 \(2i-1\) 可以成为答案,且 \([1,2i-1]\) 是回文,\(i\) 号位置也可以成为答案。

II. [国家集训队] 最长双回文串

可以用 Manacher 求出以 \(i\) 号位开头或结尾的最长回文子串,然后进行拼接。

5.后缀数组 Suffix Array

5.1 简介

后缀数组通过刻画所有后缀的相对关系描绘所有子串的关系。

5.2 后缀排序

后缀数组基于后缀排序。即对 \(s\) 的所有后缀 \(S_i\) 按字典序排序。

定义 \(rk_i\) 表示 \(S_i\) 在所有后缀中的排名。

定义 \(sa_i\) 表示排名为 \(i\) 的后缀的起始位置,是 \(rk\) 的逆:\(rk_{sa_i}=sa_{rk_i}=i\)

为了简化下面的叙述,假设超出 \(|s|\) 范围的字符均为极小值。

倍增法是常用的排序算法。假设已知所有长度为 \(\frac{w}{2}\) 子串的排名(即所有后缀的 \(\frac{w}{2}\) 前缀),即所有 \(s[i:i+\frac{w}{2}-1]\) 的排名,可以用这些信息合并得到长度为 \(w\) 的子串排名。具体来说,由于有 \(s[i:i+w-1]=s[i:i+\frac{w}{2}-1]+s[i+\frac{w}{2}:i+w-1]\)\(s[i:i+w-1]\)排名可以用右边两个子串的排名所组成的二元组 \((rk_i,rk_{i+\frac{w}{2}-1})\) 来表示

所以算法流程如下:

  1. 对所有 \(rk_i\) 赋初值,即 \(s_i\) 的相对顺序,表示长度为 \(1\) 的子串排名。
  2. 若已知长度为 \(\frac{w}{2}\) 的子串排名,通过对上面的二元组排序得到新的 \(rk\)
  3. 若此时所有排名互不相同(即所有后缀长度为 \(w\) 的前缀已经互不相同,后面的字符不会影响比较),结束算法。回到第 2 步。

5.3 实现

有一些实现上的细节:

  1. 由于 \(rk\) 的值域最大到 \(n\),可以用计数排序使整个算法做到线性对数时间复杂度。
  2. 为了代码实现方便以及解决常数问题,第二关键字的排序可以直接在排序前考虑(请确保了解计数排序原理):
  • \(i+\frac{w}{2}-1>n\),长度上小于 \(\frac{w}{2}\),即后面存在空,排序时一定会被排到前面,因此优先考虑这些串。
  • \(i+\frac{w}{2}-1\leq n\),直接在上面的串考虑完后按第二关键字的 \(rk\) 顺序加入即可(也就是说,遍历 \(sa_i\),若 \(sa_i>\frac{w}{2}\),加入 \(sa_i-\frac{w}{2}\))。因为若两个子串不同,按照 \(rk\) 顺序已经将第二关键字排好了序,所以保证了正确性。

换句话说,其实就是利用已知信息直接对第二关键字排序(第一个情况是第二个情况的特殊形态,第二关键字为空),而无需借助桶数组。

  1. 由于上面只考虑了第一关键字的计数排序,在对 \(rk\) 二元组排序时,需要记录原 \(rk\) 比较。详见下面的代码及注释。
namespace SA {
	int ork[MAXN*2+5], rk[MAXN+5], sa[MAXN+5], ht[MAXN+5], buc[MAXN+5], id[MAXN+5];
	inline void Sort(int n, int lim) {
		//桶排序
		fill(buc, buc + 1 + lim, 0); 
		for (int i = 1; i <= n; ++i) ++buc[rk[id[i]]];
		for (int i = 1; i <= lim; ++i) buc[i] += buc[i - 1];
		for (int i = n; i; --i) sa[buc[rk[id[i]]]--] = id[i]; 
	}
	inline void build_sa(int n, const string &s) {
		int lim = 1 << 7;//rk 的值域
		for (int i = 1; i <= n; ++i) rk[i] = s[i], id[i] = i;
		Sort(n, lim);
		for (int w = 1; ; w <<= 1) {
			int tot = 0;
			//按第二关键字顺序加入
			for (int i = n - w + 1; i <= n; ++i) id[++tot] = i;
			for (int i = 1; i <= n; ++i) if (sa[i] > w) id[++tot] = sa[i] - w;
			for (int i = 1; i <= n; ++i) ork[i] = rk[i];
			Sort(n, lim);
			auto cmp = [&](int x, int y) { return ork[x] == ork[y] && ork[x + w] == ork[y + w]; };
			tot = 0;	
			for (int i = 1; i <= n; ++i) rk[sa[i]] = cmp(sa[i - 1], sa[i]) ? tot : ++tot;//若 w 子串相同排名相同,否则增加
			if (tot == n) return;//互不相同时结束算法
			lim = tot;
		}
	}
}; using namespace SA;

时间复杂度 \(O(|s|\log|s|)\)

5.4 height 数组

简记 \(\operatorname{lcp}(S_i,S_j)\)\(\operatorname{LCP}(i,j)\)。定义 \(ht_i\) (即 height)表示 \(|\operatorname{LCP}(sa_i,sa_{i-1})|\)

height 数组刻画了排名相邻的后缀的 前缀匹配关系。进而可以利用后缀排序的性质研究更多子串匹配关系

存在简单的线性求 \(ht\) 方法。有性质 \(ht_{rk_i}\ge ht_{rk_{i-1}}-1\)。令 \(j=sa_{rk_{i-1}}\),若 \(ht_{rk_{i-1}}>1\), 有 \(s_j=s_i\)\(S_j\) 排在 \(S_i\) 前面且排名在 \(S_j\)\(S_i\) 之间的后缀与 \(S_i\) 之间的 LCP 长度不会减少,故而 \(ht_{rk_i}\ge ht_{rk_{i-1}}-1\)

实现比较简单:

inline void build_ht(int n, const string &s) {
	for (int i = 1, k = 0; i <= n; ++i) {
		if (k) k--;
		while (max(i, sa[rk[i] - 1]) + k <= n && s[i + k] == s[sa[rk[i] - 1] + k]) ++k;
		ht[rk[i]] = k;
	}
}

\(k\) 最多向右移动 \(2n\) 次,时间复杂度 \(O(|s|)\)

5.5 应用

5.5.1 求任意后缀之间 LCP

有性质 \(\operatorname{LCP}(i,j)=\min\limits_{k=\min(rk_i,rk_j)+1}^{\max(rk_i,rk_j)}ht_k\)

感性理解一下:由于对后缀进行了排序,后缀在字典序排名中相差越远前缀差别越大

可以用 st 表预处理做到 \(O(|s|\log|s|)\) 预处理,\(O(1)\) 单次查询。注意特判 \(i=j\)

5.5.2 求本质不同子串数

由于通过后缀的前缀可以刻画所有子串,去掉后缀之间的公共前缀(即子串) 即可不重不漏地统计。答案即 \(\dbinom{n}{2}-\sum ht_i\)

5.6 例题

I. [AHOI2013] 差异

求两个后缀的 LCP 之和,考虑对每个 \(ht_i\) 分别统计贡献,也即求 \(ht_i\) 作为最小值的区间。可以用单调栈解决。

II. [TJOI2017] DNA

先枚举起始位置,可以每次找到失配的第一个位置,可以用二分+Hash 求 LCP,也可以用 SA 求。

III. [SDOI2008] Sandy 的卡片

求多个串的最长公共子串,可以将其顺次拼接,在中间加上分隔符。需要找到 \([l,r]\) 后缀使得这些后缀包含所有原来的字符串的部分。可以用双指针解决。

posted @ 2025-07-10 19:00  _Communist  阅读(24)  评论(0)    收藏  举报