字符串复习

字符串复习

字符串哈希

字符串哈希,是一种对字符串单向加密的算法。即,对于局面内的每个字符串,它们都被分配有一个掩码,使得这个掩码代表且仅代表它本身。这个掩码便叫作字符串的哈希值。

这块的内容比较容易理解,直接放代码了。

注意,必须确保 base 大于字符集大小 \(\Sigma\)。

求字符串的哈希值:

const int N = 1e4 + 5, base = 131, mod = 1e9 + 7, prime = 233317;
int encode(string str) {
	int hash = 0;
	for (auto x : str) {
		hash = (hash * base + x) % mod + prime;
	}
	return hash;
}

求字符串的前缀哈希:

for (int i = 1; i <= n; i++) {
	h[i] = (h[i-1] * base + s[i]) % mod;
	power[i] = power[i-1] * base % mod;
}

求字符串某一段的哈希值:

inline int encode(int l, int r) {
	return (h[r] + mod - h[l-1] * power[r-l+1] % mod) % mod;
}

KMP 算法的再理解

(非特殊注明,均为 1-indexed。)

主旨

当模式串失配时,通过避免匹配模式串中极长的相同部分(这个部分即为最长公共前后缀),减少时间开销。

匹配步骤

举例:考虑模式串 t = "abcab",被匹配串 s = "abcacababcab"。

假定现在已获得模式串 t 的 fail 数组,其中 fail[i] 表示前缀 [1, i] 的最长相同前后缀长度(非本身)。

创建指针 j 指向 t 的待匹配位置的前一位(个人代码习惯),i 指向 s 的待匹配位置。

考虑匹配到如下情形时:

此时绿框中的字符串完全相同。注意到模式串 t 的第 1 位与第 4 位完全相同(即 "a" 是绿框中字符串的最长相同前后缀);因此,若将指针 j 移到第 1 位,则顺应地,模式串 t 的第 1 位与被匹配串 s 的第 4 位能被自动匹配上。

接下来到达这个局面:

发现 t 的第 2 位与 s 的第 5 位仍无法匹配,则再将 j 往前跳,跳到 fail[1] = 0 这个位置。

此时,两指针的位置表示:t 待匹配的位置为 1,s 待匹配的位置为 5。

但是,t[1] 与 s[5] 仍无法匹配。所以,不管如何往前移动 j,都无法产生合法匹配。因此需要往前移动 i。

此时 t[1] == s[6],能够匹配。继续重复之前的步骤。

失配数组的求法

这个比较容易。

维护两个指针,i 指向目前扫到的位置,j 指向候选 border 位置的前一位(个人习惯)。

往前移动 i 的时候,考虑加入新位置的字符会造成什么影响。如果能跟 j+1 匹配,那么 j 前移;否则把 j 跳到 fail[j] 的位置,这恰好保证了 t[1,fail[j]] 这个子串也是当前的合法 border。

模板

#include <bits/stdc++.h>
#define int long long
#define inf 1e18
#define debug cout << '!';
#define filein(x) freopen(#x".in", "r", stdin);
#define fileout(x) freopen(#x".out", "w", stdout);
#define file(x) filein(x) fileout(x)
// #define Fast_IO
using namespace std;
#ifdef Fast_IO
inline int read() {
	int x = 0, f = 1; char c = getchar();
	while (c < '0' or c > '9') { if (c == '-') f = -1; c = getchar(); }
	while (c >= '0' and c <= '9') { x = x * 10 + c - '0'; c = getchar(); }
	return x * f;
}
void write(int x) {
	if (x < 0) putchar('-'), x = -x;
	if (x > 9) write(x / 10);
	putchar(x % 10 + '0'); return;
}
#endif
const int N = 1e6 + 5;
string s, t;
int n, m, fail[N];
signed main() {
	cin.tie(0) -> sync_with_stdio(0);
	cin >> s >> t; n = s.length(), m = t.length(), s = ' ' + s, t = ' ' + t;
	for (int i = 2, j = 0; i <= m; i++) {
		while (t[i] != t[j + 1] and j > 0) {
			// cout << "Cur j = " << j << " (Cur i = " << i << ")" << '\n';
			j = fail[j];
		}
		if (t[i] == t[j + 1]) {
			j++;
		}
		fail[i] = j;
	}
	for (int i = 1, j = 0; i <= n; i++) {
		while (s[i] != t[j + 1] and j > 0) {
			j = fail[j];
		}
		if (s[i] == t[j + 1]) {
			j++;
		}
		if (j == m) {
			cout << i - m + 1 << '\n';
			j = fail[j];
		}
	}
	for (int i = 1; i <= m; i++) {
		cout << fail[i] << ' ';
	}
	return 0;
}

例题

P4391 [BalticOI 2009] Radio Transmission 无线传输

十分经典的问题,答案为 \(n - fail_n\)。可以考虑将字符串依次错位匹配,可以得到 \(fail_n\) 之前的部分为字符串的最小周期。

P9606 [CERC2019] ABB

题意转化为寻找原串中最长的回文后缀,记其长度为 \(L\),则答案为 \(n - L\)。

回文串有一个性质:它的反串与自身相同。

考虑充分利用这个性质,把原串反转再与原串拼接(注意必须有字符集外的分隔符),让回文串作为最长 border。把前缀函数算出来,判断即可。

CF432D Prefixes and Suffixes

先考虑求给定字符串的每个前缀在串中的出现次数怎么做。可以 DP。设 \(dp_i\) 表示跳到 \(i\) 的方案数,从后往前扫,每次把前缀本身加入贡献。前缀 \(i\) 每次出现时,其后缀必然挂着一个 \(fail_i\),因此用 \(dp_{fail_i} \overset + \leftarrow dp_i\) 继承贡献。

本题就是求所有 \(fail_i\) 非零的 \(dp\) 值。

P3435 [POI 2006] OKR-Periods of Words

考虑一个性质:从某位置 \(i\) 沿着 \(fail\) 一直往前跳,记跳到的位置 \(j\),则后缀 \([j+1,n]\) 一定是字符串的一个周期。证明方法同例题第一题。

因此对于每个 \(i\),需要跳到最后一个非 \(0\) 的 \(fail\) 值。每次都暴力跳会超时,所以可以直接在 \(fail\) 数组上记忆化。

P3082 [USACO13MAR] Necklace G

此题可用 KMP 和 AC 自动机两种方式理解。

首先容易想到一个错误的贪心:在文本串中找出模式串的所有匹配位置,把匹配位置抽象成线段摊到数轴上,问题转化为在数轴上选出最少数量的点,使得它们点亮所有线段(这是区间刺穿问题,将线段按右端点排序后贪心地加点即可)。但这个是错误的,原因是没有考虑删除字符后,新字符串中产生的新匹配位置。例如 s = "aacc", t = "ac"。

然后看数据范围,想到直接 DP。容易想到一个简单的转化,即删去最少等价于保留最多。设 \(dp_{i, j}\) 表示考虑到文本串的前 \(i\) 个字符,选出的子序列后缀恰匹配到模式串的第 \(j\) 个字符时,能保留的子序列的最大长度。

假设你目前扫到了文本串的第 \(i\) 个字符,要加入第 \(i+1\) 个字符,有两种决策:

  • 不将字符 \(i+1\) 纳入子序列,则 \(dp_{i+1,j} \leftarrow dp_{i,j}\)。

  • 将字符 \(i+1\) 纳入子序列。现在有一个问题,当匹配模式串的第 \(j\) 个字符时,再加入一个字符,能匹配到模式串的什么位置。

    考虑现在是文本串的后缀与模式串的前缀相同,因此模式串前缀的前缀也一定能与文本串的后缀匹配(类似 AC 自动机失配指针的思想)。设 \(g_{j,c}\) 表示匹配到模式串的第 \(j\) 个字符,要加入字符 \(c\),能最远匹配到模式串的字符位置。根据前面的分析,容易推出转移:\(g_{j,c} = \begin{cases}j+1,& t_{j+1}=c \\ g_{fail_j,c},& \rm{otherwise}\end{cases}\),然后基于 \(g\) 转移 \(dp\) 即可。

P5829 【模板】失配树

性质:\(fail\) 的相互依赖关系构成一棵树。证明是显然的,因为它不存在环且节点均连通。这棵树便叫作失配树。因此在求 \(fail\) 的时候把这棵树建出来,求出两个节点在失配树上的 LCA 即可。

P2375 [NOI2014] 动物园

一个暴力的想法是,对于每个 \(i\),在 \(fail\) 上跳到第一个满足 \(j \le \dfrac i2\) 的 \(j\) 即可。

如何优化复杂度呢?考虑让 \(j\) 单调不减。对于一个 \(i\),当他前进为 \(i+1\) 时,可以仿照求 \(fail\) 的方式更新 \(j\) 指针的位置,即遇到能匹配的字符就自增,不能匹配就往回跳。如果 \(j > \dfrac i2\) 就往回跳直到合法即可。

AC 自动机

例题

P3808 AC 自动机(简单版)

注意到“有多少个不同的模式串在文本串里出现过”。因此每次累加节点的 \(cnt\) 时,将它置为 \(-1\),防止重复算贡献。

P3796 AC 自动机(简单版 II)

记录每个字符串被算入贡献的次数,取最大值即可。

P5357 【模板】AC 自动机

需要对模式串去重。然后会超时,考虑将失配树建出来,算一个节点的贡献就相当于求一个子树的和(为什么?),因此预先 DFS 一遍子树求和就好了。

P3966 [TJOI2013] 单词

和上一题一样,但是需要注意拼接文本串时,在每个模式串之间加入字符集外的分隔符,防止意外的匹配。这里推荐用 '{',因为 '{' = 'z' + 1,但注意字符集大小需要开到 \(27\)。

P2292 [HNOI2004] L 语言

把所有字典单词插入 AC 自动机,并在每个节点记录以该节点结尾的后缀中,哪些长度对应字典中的单词,用二进制状态 st 标记。扫描文章时,维护一个滚动二进制状态 S,表示之前哪些位置是“可理解前缀”的结尾。

每读一个字符,S <<= 1,再用当前 AC 节点记录的 st 判断能否从某个可理解位置接上一个字典单词,使当前位置也可理解。若可以,则置 S |= 1,并更新答案 res = i。最终 res 就是该文章最长可理解前缀的位置。

P4052 [JSOI2007] 文本生成器

统计长度为 m 的随机大写字母串中,至少包含一个给定单词的串数。

可读的信息不好维护,但不可读的信息可以在 AC 自动机上直接 DP,考虑正难则反。把所有单词插入 AC 自动机,并在 build 时把 fail 链上的单词结尾标记合并到当前节点,表示走到该节点时,只要某个后缀是单词,该路径就可读(危险)。设 \(dp_{i,u}\) 表示长度为 \(i\)、当前在 AC 自动机节点 \(u\)、且路径从未经过危险节点的串数,也就是不可读文本的数量。转移时只允许走到 \(u\) 和儿子节点都没有结尾标记的状态,累加计数。最后用总串数 \(26^m\) 减去 \(\sum\limits_u dp_{m,u}\),即得到至少包含一个单词的文本数。

posted @ 2026-10-30 09:09  L-Coding  阅读(4)  评论(0)    收藏  举报