字符串哈希
在信息学竞赛中,经常需要处理和比较字符串。例如,判断两个字符串是否相等,或者在一个字符串中查找另一个字符串。直接进行字符串操作(如逐字符比较)的时间开销与字符串长度成正比,当字符串很长或数量很多时,这种方法可能会非常慢。
字符串哈希是一种将任意长度的字符串通过一个哈希函数映射成一个固定长度的整数(或整数对)的技术,这个整数被称为字符串的哈希值。
它的核心思想是:
- 如果两个字符串相等,那么它们的哈希值一定相等。
- 如果两个字符串不相等,那么它们的哈希值有极大的概率不相等。
通过这种映射,可以将复杂的、较慢的字符串操作转化为简单的、极快的整数操作,从而大大提高算法的效率。
哈希冲突:理论上,两个不同的字符串可能会被映射到同一个哈希值,这种情况被称为“哈希冲突”。但在精心设计的哈希函数下,对于一般的数据,冲突的概率极低,但如果测试数据也精心构造过那么还是不能完全保证不冲突。
最常用且效果最好的字符串哈希方法之一是多项式滚动哈希。
它的思想是,将一个字符串 \(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|)\) 的时间内计算出来。
例题: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;
}
习题: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' > 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;
}

浙公网安备 33010602011771号