字符串哈希
在信息学竞赛中,经常需要处理和比较字符串。例如,判断两个字符串是否相等,或者在一个字符串中查找另一个字符串。直接进行字符串操作(如逐字符比较)的时间开销与字符串长度成正比,当字符串很长或数量很多时,这种方法可能会非常慢。
字符串哈希是一种将任意长度的字符串通过一个哈希函数映射成一个固定长度的整数(或整数对)的技术,这个整数被称为字符串的哈希值。
它的核心思想是:
- 如果两个字符串相等,那么它们的哈希值一定相等。
- 如果两个字符串不相等,那么它们的哈希值有极大的概率不相等。
通过这种映射,可以将复杂的、较慢的字符串操作转化为简单的、极快的整数操作,从而大大提高算法的效率。
哈希冲突:理论上,两个不同的字符串可能会被映射到同一个哈希值,这种情况被称为“哈希冲突”。但在精心设计的哈希函数下,对于一般的数据,冲突的概率极低,但如果测试数据也精心构造过那么还是不能完全保证不冲突。
最常用且效果最好的字符串哈希方法之一是多项式滚动哈希。
它的思想是,将一个字符串 \(S = s_1 s_2 \dots s_L\) 看作一个 \(P\) 进制的数,然后计算这个数对一个大模数 \(M\) 取模后的结果,公式为 \(H(S) = (s_1 \cdot P^{L-1} + s_2 \cdot P^{L-2} + \cdots + s_L \cdot P^0) \pmod{M}\)。
- \(s_i\):字符串第 \(i\) 个字符的数值表示,通常直接使用其 ASCII 码。
- \(P\):一个选定的质数底数,为了减少冲突,通常选择一个比字符集大小稍大的质数,例如 131 或 13331。
- \(M\):一个大的质数模数,例如 \(10^9+7\),它的作用是防止计算结果超出整数类型的表示范围。
可以使用秦九韶算法在 \(O(|S|)\) 的时间内完成计算,递推关系为 \(H(S_{1 \dots i}) = H(S_{1 \dots i-1}) \times P + s_i\)。
代码实现非常简洁:
hash_value = 0;
for (char c : S) {
hash_value = (hash_value * P + c) % M;
在 C++ 中,为了追求效率,可以利用 unsigned long long 类型的特性来代替显式的取模操作。
unsigned long long 的表示范围是 \([0, 2^{64}-1]\),当计算结果超出这个范围时,它会自动“溢出”,其效果等价于自动对 \(2^{64}\) 取模,这被称为“自然溢出”。
- 优点:\(2^{64}\) 是一个非常大的数,哈希冲突的概率极低。同时,CPU 进行位运算和加法比取模运算快得多,可以显著提升代码运行速度。
- 缺点:理论上不如双哈希(使用两个不同的模数)之类的方式稳妥,但实践中对于绝大多数竞赛题目都表现优异。
例题:P3370 【模板】字符串哈希
题目要求统计 \(N\) 个字符串中有多少个是不同的,这正是字符串哈希的经典应用场景。可以将每个字符串都转换成一个哈希值,然后问题就变成了统计这 \(N\) 个哈希值中有多少个是不同的。
参考代码
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
// 使用 unsigned long long 作为哈希值类型,利用其自然溢出特性,等价于对 2^64 取模。
typedef unsigned long long ULL;
const int N = 10005; // 字符串数量 N 的最大值
const ULL P = 131; // 哈希函数的质数底数,131 是一个常用的经验值
ULL val[N]; // 存储每个字符串的哈希值
char s[N]; // 用于读入字符串的字符数组缓冲区
/**
* @brief 计算字符串的哈希值
* @param s 输入的C风格字符串
* @return 返回字符串对应的 unsigned long long 哈希值
*/
ULL BKDRHash(char s[N]) {
ULL res = 0;
int len = strlen(s);
// 使用秦九韶算法高效计算多项式哈希
// H(S) = s[0]*P^(len-1) + s[1]*P^(len-2) + ... + s[len-1]
// 等价于 res = (...((s[0]*P + s[1])*P + s[2])...*P + s[len-1])
for (int i = 0; i < len; i++) res = res * P + s[i];
return res;
}
int main()
{
int n;
scanf("%d", &n);
// 1. 计算每个字符串的哈希值
for (int i = 0; i < n; i++) {
scanf("%s", s);
val[i] = BKDRHash(s);
}
// 2. 对所有哈希值进行排序
// 排序后,相同的哈希值会聚集在一起,便于统计
sort(val, val + n);
// 3. 统计不同哈希值的数量
int ans = 0;
for (int i = 0; i < n; i++)
// 如果是第一个元素,或者当前元素与前一个元素不同,则说明遇到了一个新的、不同的值
if (i == 0 || val[i] != val[i - 1]) ans++;
// 4. 输出结果
printf("%d\n", ans);
return 0;
}
习题:P7469 [NOI Online 2021 提高组] 积木小赛
解题思路
题目要求找出两个集合的交集的大小:
- \(S_A\):由 \(s\) 的所有非空子序列构成的集合。
- \(S_B\):由 \(t\) 的所有非空子串构成的集合。
- 计算 \(|S_A \cap S_B|\)。
一个直接的想法是生成这两个集合然后求交集,然而,\(s\) 的子序列数量可能高达 \(2^n-1\),这是无法接受的。相比之下,\(t\) 的子串数量是 \(O(n^2)\),对于 \(n=3000\) 来说,这是可以处理的规模。
因此,一个更可行的策略是遍历 \(t\) 的所有子串,并检查每个子串是否也是 \(s\) 的一个子序列。
如果对于每个枚举出来的子串,都独立地在 \(s\) 中进行一次子序列检查(复杂度 \(O(n)\)),那么总复杂度会达到 \(O(n^3)\),对于 \(n=3000\) 来说太慢了,可以优化这个过程。可以固定子串的起始位置,然后向右逐个字符地扩展子串。例如,当检查完 \(t_{i \dots j-1}\) 后,要检查 \(t_{i \dots j}\),只需要在上一次为 \(t_{j-1}\) 找到匹配的位置之后,继续为新字符 \(t_j\) 寻找匹配即可。这样,对于一个固定的起始位置 \(i\),可以用一个在 \(s\) 上单向移动的指针,在 \(O(n)\) 的时间内找出所有以 \(i\) 开头且同时是 \(s\) 的子序列的子串。由于有 \(n\) 个起始位置,找到所有满足条件的公共字符串的总时间复杂度是 \(O(n^2)\)。
在上述过程中,找到了所有既是 \(s\) 的子序列又是 \(t\) 的子串的字符串。但同一个字符串可能会被找到多次,为了只统计不同的字符串,需要一种去重的方法。字符串哈希在这里非常合适,在字符串匹配的过程中,可以同时跟进计算公共字符串的哈希值,并将这个哈希值存入一个大数组中。
这个去重统计的部分与 P3370 【模板】字符串哈希 相同,最终总的时间复杂度为 \(O(n^2 \log n)\)。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
// 使用类型别名简化代码
typedef unsigned long long ULL;
const int N = 3005;
const ULL P = 131; // 哈希计算的质数底数
// --- 全局变量 ---
char s[N], t[N]; // 输入的两个字符串
ULL val[N * N]; // 存储所有找到的公共字符串的哈希值
int main()
{
int n;
scanf("%d%s%s", &n, s, t);
int cnt = 0; // 记录找到的公共字符串的总数(含重复)
// --- 1. 寻找所有公共字符串 ---
// 外层循环:枚举 t 的所有子串的起始位置 i
for (int i = 0; i < n; i++) {
ULL h = 0; // 当前公共字符串的哈希值
int idx = -1; // 指针,用于在 s 中进行子序列匹配
// 内层循环:从 i 开始向右扩展子串 t[i..j]
for (int j = i; j < n; j++) {
// 对于 t[j] 这个新字符,需要在 s 中找到它的匹配位置
// 必须从上一个字符匹配位置 idx 的后面开始找
idx++;
while (idx < n && s[idx] != t[j]) {
idx++;
}
// 如果在 s 中找不到 t[j] (idx 遍历到了末尾),
// 那么 t[i..j] 及更长的子串都不可能是 s 的子序列,跳出内层循环
if (idx == n) break;
// 如果找到了,说明 t[i..j] 是 s 的一个子序列
// 更新当前公共子串的哈希值
h = h * P + t[j];
// 将这个哈希值存入数组
val[cnt] = h;
cnt++;
}
}
// --- 2. 统计不同哈希值的数量 ---
// 对所有找到的哈希值进行排序
sort(val, val + cnt);
int ans = 0;
// 遍历排序后的哈希数组,统计不同值的个数
for (int i = 0; i < cnt; i++)
// 如果是第一个元素,或者当前元素与前一个元素不同,则为一个新的独立字符串
if (i == 0 || val[i] != val[i - 1]) ans++;
printf("%d\n", ans);
return 0;
}
字符串哈希最强大的功能之一,是在 \(O(|S|)\) 的预处理后,能够用 \(O(1)\) 的时间查询任意子串的哈希值,这使得与子串相关的比较和匹配问题变得极为高效。
其原理类似于前缀和,首先预处理出字符串 \(S\) 所有前缀的哈希值。
设 \(h_i\) 为前缀 \(S_{1 \dots i}\) 的哈希值,\(p_i\) 为 \(P^i\),其中 \(h_i = s_1 P^{i-1} + s_2 P^{i-2} + \cdots + s_i P^0\)。
现在,想求子串 \(S_{l \dots r}\) 的哈希值,其定义为 \(H(S_{l \dots r}) = s_l P^{r-l} + s_{l+1} P^{r-l-1} + \cdots + s_r P^0\)。
观察 \(h_r\) 和 \(h_{l-1}\) 的表达式:
- \(h_r = (s_1 P^{r-1} + \cdots + s_{l-1} P^{r-l+1}) + (s_l P^{r-l} + \cdots + s_r P^0)\)
- \(h_{l-1} = s_1 P^{l-2} + \cdots + s_{l-1} P^0\)
可以发现,\(h_r\) 的后半部分就是想要的 \(H(S_{l \dots r})\),前半部分 \(s_1 P^{r-1} + \cdots + s_{l-1} P^{r-l+1}\) 恰好等于 \(h_{l-1}\) 乘以 \(P^{r-l+1}\)。
\(h_{l-1} \times P^{r-l+1} = (s_1 P^{l-2} + \cdots + s_{l-1} P^0) \times P^{r-l+1} = s_1 P^{r-1} + \cdots + s_{l-1} P^{r-l+1}\)
于是就得到了计算字串哈希值的公式 \(H(S_{l \dots r}) = h_r - h_{l-1} \times P^{r-l+1}\),其中这个减法是在模 \(M\)(或自然溢出)下进行的。
为了在 \(O(1)\) 时间内使用上述公式,需要预处理两个数组:
- \(h_i\):所有前缀的哈希值。
- \(p_i\):\(P\) 的所有幂次。
这两个数组都可以在 \(O(|S|)\) 的时间内计算出来。
例题:P10468 兔子与兔子
将任意长度的字符串映射到有限的整数空间,必然存在哈希冲突(即不同的字符串具有相同的哈希值)。为了极大降低冲突概率,可以采用双哈希策略:使用两组不同的模数和基数分别计算哈希值。只有当两组哈希值都相等时,才认为两个子串相同。
例如选择模数对 \((10^9+7,10^9+9)\),基数选取 \(131,13331\) 等质数。
参考代码
#include <cstdio>
#include <cstring>
// 定义数据规模和哈希常量
const int N = 1e6 + 5;
// 使用双哈希策略来极大降低哈希冲突的概率
// 第一组模数和基数
const int MOD1 = 1e9 + 7;
const int BASE1 = 131;
// 第二组模数和基数
const int MOD2 = 1e9 + 9;
const int BASE2 = 13331;
char s[N];
// h数组存储前缀哈希值:h[i] 表示 s[0...i-1] 的哈希值
// p数组存储基数的幂:p[i] 表示 BASE^i
int h1[N], p1[N], h2[N], p2[N];
// 获取第一组哈希中,子串 [l, r] 的哈希值
// 利用前缀和思想:hash(l, r) = h[r] - h[l-1] * BASE^(len)
// 注意 l, r 是 1-based 索引,对应数组下标需调整
int hash1(int l, int r) {
// 加上 MOD1 再取模是为了防止减法结果为负数
return (h1[r] + MOD1 - 1ll * h1[l - 1] * p1[r - l + 1] % MOD1) % MOD1;
}
// 获取第二组哈希中,子串 [l, r] 的哈希值
int hash2(int l, int r) {
return (h2[r] + MOD2 - 1ll * h2[l - 1] * p2[r - l + 1] % MOD2) % MOD2;
}
int main()
{
scanf("%s", s);
int n = strlen(s);
int m; scanf("%d", &m);
// 初始化:BASE^0 = 1
p1[0] = p2[0] = 1;
h1[0] = h2[0] = 0;
// 预处理字符串的前缀哈希值和基数幂
// 时间复杂度 O(N)
for (int i = 0; i < n; i++) {
// 计算 BASE 的幂
p1[i + 1] = 1ll * p1[i] * BASE1 % MOD1;
p2[i + 1] = 1ll * p2[i] * BASE2 % MOD2;
// 计算前缀哈希:当前哈希 = 前一哈希 * BASE + 当前字符值
// 下标 i+1 对应前 i+1 个字符(s[0]...s[i])
h1[i + 1] = (1ll * h1[i] * BASE1 + s[i]) % MOD1;
h2[i + 1] = (1ll * h2[i] * BASE2 + s[i]) % MOD2;
}
// 处理每一次询问
// 时间复杂度 O(M)
for (int i = 0; i < m; i++) {
int l1, r1, l2, r2;
scanf("%d%d%d%d", &l1, &r1, &l2, &r2);
// 如果两个子串长度不同,显然不相同
if (r1 - l1 != r2 - l2) {
printf("No\n");
continue;
}
// 比较两个子串的双哈希值
// 只有当两组哈希值都相等时,才认为子串相同
if (hash1(l1, r1) == hash1(l2, r2) && hash2(l1, r1) == hash2(l2, r2)) {
printf("Yes\n");
} else {
printf("No\n");
}
}
return 0;
}
例题:P10479 匹配统计
给定两个字符串 \(A\) 和 \(B\),长度分别为 \(N\) 和 \(M\)。需要回答 \(Q\) 个询问,每个询问给定一个整数 \(X\),求 \(A\) 的所有后缀子串中,有多少个与 \(B\) 的匹配长度恰好为 \(X\)。匹配长度是指 \(A\) 的某个后缀 \(A_{i \sim N}\) 与 \(B_{1 \sim M}\) 的最长公共前缀(LCP)的长度。
数据范围:\(N,M,Q \le 2 \times 10^5\)。
由于 \(N,M,Q\) 均达到 \(2 \times 10^5\) 级别,暴力匹配的时间复杂度为 \(O(NM)\),显然无法通过。
通过预处理字符串 \(A\) 和 \(B\) 的前缀哈希值,可以在 \(O(1)\) 的时间内获取任意子串的哈希值。设 \(H(s,l,r)\) 为字符串 \(s\) 在区间 \([l,r]\) 的哈希值,判断两个子串是否相等,只需比较它们的哈希值。
对于 \(A\) 串中的每一个起始位置 \(i \in [1,N]\),要找最大的 \(L\),使得 \(A_{i \sim i+L-1} = B_{1 \sim L}\)。匹配长度 \(L\) 具有单调性:如果长度为 \(L\) 的前缀匹配,那么长度小于 \(L\) 的前缀也一定匹配。因此,可以通过二分查找长度 \(L\),利用哈希 \(O(1)\) 判断是否匹配,这样单个位置的计算复杂度为 \(O(\log (\min(M,N-i+1)))\)。
对 \(A\) 的每一个位置 \(i\) 执行二分查找得到匹配长度 LCP,在计数数组对应位置加一。最后对于每个询问 \(X\),直接输出计数数组中存的值。
预处理哈希的时间复杂度为 \(O(N+M)\),二分统计为 \(O(N \log M)\),询问为 \(O(Q)\),总时间复杂度为 \(O(N \log M + Q)\)。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 200005;
const int BASE = 131; // 哈希基数
const int MOD = 1e9 + 7; // 哈希模数,使用大质数减少冲突
char a[N], b[N];
int p[N], ha[N], hb[N], cnt[N];
/**
* 获取字符串指定区间的哈希值
* @param h 哈希前缀和数组
* @param l 起始位置
* @param r 结束位置
* @return 返回区间 [l, r] 的哈希值
*/
int get(int h[], int l, int r) {
return (h[r] + MOD - 1ll * h[l - 1] * p[r - l + 1] % MOD) % MOD;
}
int main() {
int n, m, q;
// 读入 A 串、B 串及其长度和询问次数
scanf("%d%d%d%s%s", &n, &m, &q, a + 1, b + 1);
// 1. 预处理:计算 BASE 的幂次以及字符串 A 和 B 的前缀哈希值
p[0] = 1;
for (int i = 1; i <= max(n, m); i++) p[i] = 1ll * p[i - 1] * BASE % MOD;
for (int i = 1; i <= n; i++) ha[i] = (1ll * ha[i - 1] * BASE + a[i]) % MOD;
for (int i = 1; i <= m; i++) hb[i] = (1ll * hb[i - 1] * BASE + b[i]) % MOD;
// 2. 核心逻辑:遍历 A 串的每一个位置作为起始点
for (int i = 1; i <= n; i++) {
// 二分查找:确定 A[i...n] 与 B[1...m] 的最长公共前缀长度 (LCP)
int low = 1, high = min(m, n - i + 1), lcp = 0;
while (low <= high) {
int mid = (low + high) >> 1;
// 比较 A 串从 i 开始长度为 mid 的子串与 B 串前缀是否一致
if (get(ha, i, i + mid - 1) == get(hb, 1, mid)) {
lcp = mid; // 匹配成功,尝试更长的长度
low = mid + 1;
} else {
high = mid - 1; // 匹配失败,缩短长度
}
}
// 统计匹配长度恰好为 lcp 的位置个数
cnt[lcp]++;
}
// 3. 处理询问
while (q--) {
int x;
scanf("%d", &x);
printf("%d\n", cnt[x]);
}
return 0;
}
例题:P10469 后缀数组
\(S\) 的所有后缀的总长度在 \(O(n^2)\) 级别,如果直接对这 \(n\) 个后缀进行排序,对于两个字符串的大小比较采取逐字符扫描的方式,最坏情况下时间复杂度将达到 \(O(n^2 \log n)\)。
利用字符串哈希,可以在 \(O(1)\) 的时间内查询任意一个区间子串的哈希值。所以在排序需要比较两个后缀 \(p\) 和 \(q\) 时,就可以使用二分法,每次二分时利用哈希值 \(O(1)\) 地比较 \(S_{p \sim p+m-1}\) 与 \(S_{q \sim q+m-1}\) 这两段是否相等,最终求出 \(S_{p \sim n-1}\) 与 \(S_{q \sim n-1}\) 的最长公共前缀的长度,记为 \(l\)。于是 \(S_{p+l}\) 和 \(S_{q+l}\) 就是这两个后缀第一个不相等的位置,直接比较这两个字符的大小就可以确定 \(S_{p \sim n-1}\) 与 \(S_{q \sim n-1}\) 的大小关系。从而每次比较的总复杂度就是 \(O(\log n)\),整个排序求出 SA 数组的过程的复杂度就是 \(O(n \log^2 n)\)。在排序完成后,对于每对排名相邻的后缀执行与上述相同的二分过程,就可以求出 Height 数组了。
参考代码
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
using ull = unsigned long long;
const int N = 3e5 + 5;
const ull BASE = 131; // 哈希基数
char s[N];
ull h[N], p[N]; // h[i] 存储前缀哈希值,p[i] 存储基数的幂
int n, sa[N], he[N];
// 获取子串 s[l...r] 的哈希值,利用前缀哈希公式在 O(1) 时间内得出
ull calc(int l, int r) {
return h[r + 1] - h[l] * p[r - l + 1];
}
// 利用二分查找 + 字符串哈希求两个后缀 s[i...n-1] 和 s[j...n-1] 的最长公共前缀 (LCP)
// 时间复杂度 O(log n)
int lcp(int i, int j) {
// 快速优化:如果首字符就不同,LCP 必为 0
if (s[i] != s[j]) return 0;
int l = 1, r = min(n - i, n - j), ans = 0;
while (l <= r) {
int mid = l + (r - l) / 2;
// 如果两段长度为 mid 的前缀哈希相等,说明 LCP 长度至少为 mid
if (calc(i, i + mid - 1) == calc(j, j + mid - 1)) {
ans = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
return ans;
}
// 比较两个后缀的字典序大小
// 返回 true 如果后缀 i 的字典序小于后缀 j
bool cmp(int i, int j) {
int len = lcp(i, j); // 首先通过二分+哈希找到最长公共前缀长度
// 如果一个后缀是另一个的前缀,则长度较短的字典序更小
if (len == n - i) return true;
if (len == n - j) return false;
// 否则比较 LCP 之后的第一个不相同字符
return s[i + len] < s[j + len];
}
int main()
{
// 读取输入字符串
scanf("%s", s);
n = strlen(s);
// 预处理:计算基数的幂和字符串的前缀哈希值
p[0] = 1;
h[0] = 0;
for (int i = 0; i < n; i++) {
p[i + 1] = p[i] * BASE;
h[i + 1] = h[i] * BASE + s[i];
}
// 初始化后缀起始位置数组 sa,并使用自定义比较函数排序
// 排序复杂度 O(n log n),单次比较 O(log n),总复杂度 O(n log^2 n)
for (int i = 0; i < n; i++) sa[i] = i;
sort(sa, sa + n, cmp);
// 输出后缀数组 SA
for (int i = 0; i < n; i++) printf("%d ", sa[i]);
printf("\n");
// 计算 Height 数组:he[i] 表示字典序第 i 的后缀与第 i-1 的后缀的 LCP 长度
// 题目规定 Height[1] = 0,即第一个后缀对应的 he 值为 0
he[0] = 0;
for (int i = 1; i < n; i++) he[i] = lcp(sa[i], sa[i - 1]);
// 输出 Height 数组
for (int i = 0; i < n; i++) printf("%d ", he[i]);
printf("\n");
return 0;
}
例题:CF7D Palindrome Degree
这是一个典型的动态规划问题,可以定义 \(f_i\) 表示前缀 \(S_{1 \dots i}\) 的回文度,那么目标就是计算 \(\sum f_i\)。
状态转移方程:
- 如果 \(S_{1 \dots i}\) 不是回文串:\(f_i = 0\)
- 如果 \(S_{1 \dots i}\) 是回文串:\(f_i = f_{\lfloor i/2 \rfloor} + 1\)
可以从 \(i=1\) 到 \(n\) 顺序计算 \(f_i\),这个算法的关键瓶颈在于如何快速判断一个前缀 \(S_{1 \dots i}\) 是否为回文串。如果每次都逐字符比较,总时间复杂度将是 \(O(n^2)\),对于 \(n=5 \cdot 10^6\) 的数据规模来说是无法接受的。
为了优化回文串的判断,可以使用字符串哈希。一个字符串是回文串,当且仅当它的正序表示和逆序表示相同。利用哈希,这个问题就变成了判断字符串的正向哈希值和反向哈希值是否相等。
可以在 \(O(n)\) 的时间内预处理出两种哈希值:
- 正向哈希 \(h\):\(h_i\) 存储前缀 \(S_{1 \dots i}\) 的哈希值。
- 反向哈希 \(h'\):\(h'_i\) 存储后缀 \(S_{i \dots n}\) 的哈希值,但是计算方向是从右到左。
通过这两个预处理过的哈希数组,可以在 \(O(1)\) 的时间内得到任意子串的哈希值,判断前缀 \(S_{1 \dots i}\) 是否为回文串,就是要比较 \(H(S_{1 \dots i})\) 和 \(H(S_{i \dots 1})\) 是否相等。
- \(H(S_{1 \dots i})\) 就是 \(h_i\)。
- \(H(S_{i \dots 1})\) 可以通过反向哈希数组 \(h'\) 提取出来,\(h'_1\) 是整个字符串的反向哈希,\(h'_{i+1}\) 是 \(S_{i+1 \dots n}\) 的反向哈希。通过计算 \(h'_1 - h'_{i+1} \cdot P^i\),就可以得到 \(S_{1 \dots i}\) 的反向哈希值。
参考代码
#include <cstdio>
#include <cstring>
// 使用类型别名简化代码
typedef unsigned long long ULL;
const int N = 5000005; // 字符串最大长度
const ULL P = 131; // 哈希计算的质数底数
// --- 全局变量 ---
char s[N]; // 输入字符串 (1-indexed)
int dp[N]; // dp[i] 存储前缀 s[1..i] 的回文度
ULL val1[N]; // val1[i] 存储前缀 s[1..i] 的正向哈希值
ULL val2[N]; // val2[i] 存储后缀 s[i..len] 的反向哈希值
ULL base[N]; // base[i] 存储 P^i
int main()
{
scanf("%s", s + 1); // 读入字符串,存储在 s[1] 到 s[len]
int len = strlen(s + 1);
// --- 1. 预处理哈希值 ---
ULL val = 0;
base[0] = 1;
// 计算正向哈希和 P 的幂
for (int i = 1; i <= len; i++) {
val = val * P + s[i];
val1[i] = val;
base[i] = base[i - 1] * P;
}
// 计算反向哈希
val = 0;
for (int i = len; i >= 1; i--) {
val = val * P + s[i];
val2[i] = val;
}
// --- 2. 动态规划求解 ---
int ans = 0;
// dp[0] 默认为 0
for (int i = 1; i <= len; i++) {
// O(1) 判断前缀 s[1..i] 是否为回文串
ULL v1 = val1[i]; // s[1..i] 的正向哈希
// 从 s[1..len] 的反向哈希中提取出 s[1..i] 的反向哈希
ULL v2 = val2[1] - val2[i + 1] * base[i];
if (v1 == v2) { // 如果正反哈希值相等,说明是回文串
// 应用状态转移方程
dp[i] = dp[i / 2] + 1;
} else {
// 如果不是回文串,回文度为 0
dp[i] = 0;
}
// 累加答案
ans += dp[i];
}
printf("%d\n", ans);
return 0;
}
习题:P10474 [ICPC-Beijing 2011] Matrix 矩阵哈希
在给定的 \(M \times N\) 的大矩阵中,判断 \(Q\) 个 \(A \times B\) 的小矩阵是否作为子矩阵出现过。
数据范围:\(M, N \le 1000, \ Q = 1000\)。
解题思路
如果使用朴素的字符串匹配,对于每个询问,比对所有可能的子矩阵位置,时间复杂度将达到 \(O(QMNAB)\),显然会超时。
可以利用字符串哈希的思想,通过预处理,高效计算大矩阵中任意 \(A \times B\) 子矩阵的哈希值。
将矩阵的每一行看作一个字符串,计算每一行的前缀哈希值。在行哈希的基础上,将每一行作为一个“字符”,在垂直方向上再进行一次哈希。可以计算大矩阵中所有 \(A \times B\) 子矩阵的哈希值,存入 set 中以便快速查询。
对于每个询问矩阵,按照相同的哈希规则计算其对应的 \(A \times B\) 整体哈希值,并在集合中查找。
参考代码
#include <cstdio>
#include <set>
using namespace std;
using ull = unsigned long long;
const int N = 1005;
const ull BASE1 = 131; // 行方向哈希基数
const ull BASE2 = 13331; // 列方向哈希基数
char g[N][N], buf[N];
ull p1[N], p2[N], row[N][N], col[N][N];
int main()
{
int m, n, a, b;
scanf("%d%d%d%d", &m, &n, &a, &b);
// 读取主矩阵,下标从1开始
for (int i = 1; i <= m; i++) scanf("%s", g[i] + 1);
// 预处理 BASE1 的幂次
p1[0] = 1;
for (int i = 1; i <= n; i++) p1[i] = p1[i - 1] * BASE1;
// 预处理 BASE2 的幂次
p2[0] = 1;
for (int i = 1; i <= m; i++) p2[i] = p2[i - 1] * BASE2;
// 第一步:计算每一行的前缀哈希值
// row[i][j] 表示第 i 行前 j 个字符的哈希值
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
row[i][j] = row[i][j - 1] * BASE1 + (g[i][j] - '0' + 1);
}
}
// 第二步:计算列方向的前缀哈希
// 需要的是宽度为 b 的行片段在列方向上的累积哈希
for (int j = 1; j <= n - b + 1; j++) {
for (int i = 1; i <= m; i++) {
// val 是第 i 行,从第 j 列开始、长度为 b 的区间的哈希值
ull val = row[i][j + b - 1] - row[i][j - 1] * p1[b];
// col[i][j] 存储的是上述 val 在第 j 列方向上,前 i 行的滚动哈希值
col[i][j] = col[i - 1][j] * BASE2 + val;
}
}
set<ull> vals;
// 第三步:提取所有可能的 A x B 子矩阵的哈希值
for (int i = 1; i <= m - a + 1; i++) {
for (int j = 1; j <= n - b + 1; j++) {
// 计算以 (i, j) 为左上角的 A x B 子矩阵的哈希值
// 利用列前缀哈希数组 col 快速计算高度为 a 的区间哈希
ull h = col[i + a - 1][j] - col[i - 1][j] * p2[a];
vals.insert(h);
}
}
int q; scanf("%d", &q);
while (q--) {
ull h = 0;
// 计算当前询问矩阵的哈希值
for (int i = 0; i < a; i++) {
scanf("%s", buf);
ull r = 0;
// 计算当前行的哈希值
for (int k = 0; k < b; k++) {
r = r * BASE1 + (buf[k] - '0' + 1);
}
// 将行哈希值累积到整体矩阵哈希中(列方向)
h = h * BASE2 + r;
}
// 查询该哈希值是否存在于主矩阵的所有子矩阵哈希集合中
if (vals.find(h) != vals.end()) printf("1\n");
else printf("0\n");
}
return 0;
}
习题:P7114 [NOIP2020] 字符串匹配
解题思路
一个朴素的想法是枚举所有可能的 \(A,B,C\) 和 \(i\),然后进行验证,但复杂度过高,无法接受。
题目的核心结构是 \(S=(AB)^i C\),这表明字符串 \(S\) 的一个前缀是周期性的,可以围绕这个周期性结构来构建算法。
一个直接的思路是枚举分割点,即 \((AB)^i\) 和 \(C\) 的分界线。但对于一个固定的前缀,要判断它被多少种 \((AB)^i\) 的形式表示,也并不直观。
一个更好的思路是枚举循环节 \(AB\) 的长度。
设 \(\text{len}(AB) = L\),可以遍历所有可能的 \(L\)(从 \(2\) 到 \(|S|-1\))。对于每个 \(L\),确定了 \(AB = S_{1 \dots L}\)。然后,需要检查 \(S\) 的前缀中有哪些是 \(AB\) 的重复,即形如 \((AB)^i\),例如 \(S_{1 \dots 2L}, S_{1 \dots 3L}\) 等。
对于每一个满足 \(S_{1 \dots iL} = (AB)^i\) 的前缀,就找到了一个合法的分割:\(C = S_{kL+1 \dots |S|}\)。此时 \(C\) 是固定的,可以计算出 \(F(C)\)。
接下来,任务就变成了:对于这个固定的 \(AB = S_{1 \dots L}\) 和固定的 \(F(C)\),需要计算有多少种 \(A\) 的划分方式满足条件。\(A\) 是 \(AB\) 的任意非空真前缀,即 \(A = S_{1 \dots l_A}\),其中 \(1 \le l_A \lt L\),需要统计满足 \(F(S_{1 \dots l_A}) \le F(C)\) 的 \(l_A\) 的数量。
将这个过程对所有可能的 \(L\) 和 \(i\) 都考虑一遍,累加每次满足条件的数量就是最终答案。
参考代码
#include <cstdio>
#include <cstring>
using namespace std;
// 使用类型别名简化代码
using ull = unsigned long long;
using ll = long long;
const int N = (1 << 20) + 5; // 字符串最大长度
const ull B = 131; // 哈希计算的基数
// --- 全局变量 ---
char s[N]; // 输入字符串 (1-indexed)
int f_pre[N]; // f_pre[i] 存储前缀 s[1..i] 的 F 值
int f_suf[N]; // f_suf[i] 存储后缀 s[i..n] 的 F 值
int cnt[26]; // 字符计数器
int pre[27]; // pre[k] 存储满足 F(A)<=k 的前缀 A 的数量
ull h[N]; // h[i] 存储前缀 s[1..i] 的哈希值
ull b[N]; // b[i] 存储 B^i
void solve() {
scanf("%s", s + 1);
int n = strlen(s + 1);
// --- 1. 预处理 ---
// 预计算哈希值和基数的幂
b[0] = 1;
for (int i = 1; i <= n; i++) {
h[i] = h[i - 1] * B + s[i];
b[i] = b[i - 1] * B;
}
// 预计算所有前缀的 F 值
for (int i = 0; i < 26; i++) cnt[i] = 0;
f_pre[0] = 0;
for (int i = 1; i <= n; i++) {
cnt[s[i] - 'a']++;
if (cnt[s[i] - 'a'] % 2 == 1) f_pre[i] = f_pre[i - 1] + 1;
else f_pre[i] = f_pre[i - 1] - 1;
}
// 预计算所有后缀的 F 值
for (int i = 0; i < 26; i++) cnt[i] = 0;
f_suf[n + 1] = 0;
for (int i = n; i >= 1; i--) {
cnt[s[i] - 'a']++;
if (cnt[s[i] - 'a'] % 2 == 1) f_suf[i] = f_suf[i + 1] + 1;
else f_suf[i] = f_suf[i + 1] - 1;
}
// --- 2. 主算法 ---
ll ans = 0;
for (int i = 0; i <= 26; i++) pre[i] = 0;
// 外层循环:枚举 AB 的长度 l
for (int l = 1; l < n; l++) {
// 在处理 len(AB)=l 之前, pre[] 存储了所有 len(A) < l 的前缀A的信息
if (l > 1) {
// AB = s[1...l]
ull v = h[l]; // AB 的哈希值
// 内层循环:检查所有以 s[1..l] 为循环节的周期性前缀
// i 是 (AB)^k 中第 k 个 AB 的起始位置
for (int i = 1; i <= n - l; i += l) {
int j = i + l - 1; // (AB)^k 的结束位置
// O(1) 用哈希值判断 s[i...j] 是否等于 s[1...l]
if (h[j] - h[i - 1] * b[l] != v) break; // 若非循环节,则停止
// 找到一个合法的 C = s[j+1...n]
int fc = f_suf[j + 1];
// 累加答案:pre[fc] 存储了所有 len(A)<l 且 F(A)<=fc 的 A 的数量
ans += pre[fc];
}
}
// 更新 pre 数组,将当前长度为 l 的前缀 s[1..l] 作为 A 加入统计
// 以便在后续大于 l 的循环中使用
for (int i = f_pre[l]; i <= 26; i++) {
pre[i]++;
}
}
printf("%lld\n", ans);
}
int main()
{
int t; scanf("%d", &t);
for (int i = 1; i <= t; i++) {
solve();
}
return 0;
}
习题:P3763 [TJOI2017] DNA
在 DNA 序列 \(S_0\) 中找出所有与目标序列 \(S\) 长度相等,且通过修改不超过 3 个字符就能变得与 \(S\) 完全相同的连续子串数量。
数据范围:\(S_0\) 和 \(S\) 的长度 \(N,M \le 10^5\);数据组数 \(T \le 10\);允许的不匹配字符数 \(K=3\)。
解题思路
由于允许的不匹配字符数极少(仅 3 个),可以利用这一特性进行快速跳转。
预处理 \(S_0\) 和 \(S\) 的前缀哈希值,可以在 \(O(1)\) 的时间内比较任意两个子串是否完全相同。
对于 \(S_0\) 的每一个可能的起始位置 \(i\),和 \(S_1\) 开始匹配。使用二分查找配合哈希值,找到从当前位置开始的最长公共前缀(LCP)长度。直接将匹配指针向后移动 LCP 位,如果此时匹配未完成(指针未达到 \(S\) 的末尾),说明遇到了一个不同的字符,消耗一次修改机会,跳过这个不匹配的字符,继续二分。如果在需要使用第四次修改机会之前指针能够越过 \(S\) 的末尾,则该起始位置有效。
共有 \(O(N)\) 个起始位置,对于每个位置,由于最多只允许 3 次不匹配,因此最多执行 4 次二分查找,单次二分查找复杂度为 \(O(\log M)\),总复杂度为 \(O(TN \log M)\)。
参考代码
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
using ull = unsigned long long;
const int N = 200005;
const ull BASE = 131; // 哈希基数
ull p[N], h0[N], hs[N];
char s0[N], s[N];
/**
* 获取字符串指定区间的哈希值
* @param h 前缀哈希数组
* @param l 起始位置
* @param r 结束位置
* @return 区间的哈希值
*/
ull get(ull h[], int l, int r) {
return h[r] - h[l - 1] * p[r - l + 1];
}
void solve() {
scanf("%s%s", s0, s);
int n = strlen(s0);
int m = strlen(s);
// 1. 预处理:计算 S0 和 S 的前缀哈希值
for (int i = 1; i <= n; i++) h0[i] = h0[i - 1] * BASE + s0[i - 1];
for (int i = 1; i <= m; i++) hs[i] = hs[i - 1] * BASE + s[i - 1];
int ans = 0;
// 2. 遍历 S0 中所有长度为 m 的子串起点
for (int i = 1; i <= n - m + 1; i++) {
int pos0 = i, pos = 1; // pos0 指向 S0,pos 指向 S
int diff = 0; // 已使用的修改机会(允许误差次数)
// 核心:在允许误差内尽可能向后匹配
while (pos <= m) {
// 通过二分查找寻找当前位置开始的最长公共前缀 (LCP)
int l = 1, r = m - pos + 1, lcp = 0;
while (l <= r) {
int mid = (l + r) >> 1;
// 比较 S0 对应的子串哈希与 S 的前缀哈希
if (get(h0, pos0, pos0 + mid - 1) == get(hs, pos, pos + mid - 1)) {
lcp = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
// 指针向后跳过完全匹配的部分
pos0 += lcp;
pos += lcp;
// 如果还没跑完 S 串,说明遇到了一个不匹配的字符
if (pos <= m) {
diff++;
if (diff > 3) break; // 误差超过 3 个,该子串不符合要求
// 消耗一次机会,跳过当前这个不匹配的字符
pos0++;
pos++;
}
}
if (diff <= 3) ans++;
}
printf("%d\n", ans);
}
int main() {
// 预处理 BASE 的幂次数组
p[0] = 1;
for (int i = 1; i < N; i++) p[i] = p[i - 1] * BASE;
int t;
scanf("%d", &t);
while (t--) {
solve();
}
return 0;
}

浙公网安备 33010602011771号