字符串 hash
一 字符串哈希
1.1 性质
- 哈希值不同,字符串一定不同。
- 哈希值相同,字符串不一定相同。(但大概率相同,并且我们希望它相同)
1.2 模数
根据一些数论知识,模数取质数是好的。
一个例子是 \(ax + b \bmod p\) 以 \(gcd(a, p)\) 为间隔分布,可以说明模数取合数是坏的。
模数需要保证乘起来不超过 i64 ,通常需要常备两个模数。我的模数是 \(1E9\) + \(123, 403\) 。第二个数是 WXU 第一个 ACM 实验室的编号。
1.3 底数
显然底数需要大于字符集,通常要大于 256 即 ascii 码范围。随机取就行。
1.4 双重哈希
数据可能存在一些生日攻击(生日攻击可能很难理解,但原理基于生日悖论,是个容易理解且有意思的问题),可能需要双重哈希。
为了方便好写,哈希的时候开 i64 。
下面是一个模板:
const int MAXN = 2E5+5;
const int HA = 2;
const int BB[HA] = {1234, 5678};
const int PP[HA] = {int(1E9) + 123, int(1E9) + 403};
i64 pw[HA][MAXN], ipw[HA][MAXN];
i64 hdw[HA][MAXN], hup[HA][MAXN];
i64 ksm(i64 a, i64 n, int P) {
i64 res = 1;
for (;n;n>>=1, a=a*a%P) if (n&1)
res=res*a%P;
return res;
}
void init_hash() {
for (int h = 0; h < HA; h++) {
pw[h][0] = 1;
for (int i = 1; i < MAXN; i++) {
pw[h][i] = pw[h][i - 1] * BB[h] % PP[h];
}
ipw[h][MAXN - 1] = ksm(pw[h][MAXN - 1], PP[h] - 2, PP[h]);
for (int i = MAXN - 1; i; --i) {
ipw[h][i - 1] = ipw[h][i] * BB[h] % PP[h];
}
assert(ipw[h][0] = 1);
}
}
void make_hash(std::string s) { // s[0] == ' '
int n = s.size();
for (int h = 0; h < HA; h++) {
for (int i = 1; i <= n; i++) {
hdw[h][i] = (hdw[h][i - 1] * BB[h] % PP[h] + s[i]) % PP[h];
hup[h][i] = (hup[h][i - 1] + s[i] * pw[h][i - 1] % PP[h]) % PP[h];
}
}
}
std::array<int, 2> get_hdw(int l, int r) {
assert(l <= r);
std::array<int, 2> res;
for (int h = 0; h < HA; h++) {
res[h] = (hdw[h][r] - hdw[h][l - 1] * pw[h][r - l + 1] % PP[h] + PP[h]) % PP[h];
}
return res;
}
std::array<int, 2> get_hup(int l, int r) {
assert(l <= r);
std::array<int, 2> res;
for (int h = 0; h < HA; h++) {
res[h] = (hup[h][r] - hup[h][l - 1] + PP[h]) % PP[h] * ipw[h][l - 1] % PP[h];
}
return res;
}
二 字符串 hash 公式
对一个字符串 \(s\) ,不妨把字符权值视为它的 \(ascii\) 码,第 \(i\) 个字符权值令为 \(c_i\) 。考虑一个前缀的 hash 的表现形式: \(ha(x) = \sum_{i = 1}^{x} c_i b^{x - i}\) 。
模数: 一般选 \(10^{9} \sim 2 \times 10^{9}\) 级的数。
系数: 一般随机成 \(> 256\) 的数,但不超过模数。
2.1 降幂 hash
降幂 hash 是更常用的 hash ,较为简单。
2.2 升幂 hash
升幂哈希需要处理一次 \(O(n + \log n)\) 的逆元。
2.3 二维 hash
二维 hash 是在一维上的扩展:
将每一维(行) hash ,于是可以 \(O(1)\) 得到某个子段的 hash 值。每个矩阵的 hash 值被看成一个竖直的 序列 ,再对这个 序列 hash 。
例题:
三 hash 的常见用法
3.1 字符串匹配
平凡模式串匹配:给一个文本串 \(text\) 和一个模式串 \(pattern\) ,询问 \(pattren\) 在 \(text\) 中出现过多少次。
伪代码:
function solve(text, pattern) :
cnt = 0
0 ha1 = get_hash(text)
ha2 = get_hash(pattern)
for i : 1 to n - m + 1 :
if ha1.subhash(i, i + m - 1) == ha2 :
cnt += 1
return : cnt
参考列题:http://oj.daimayuan.top/course/22/problem/908
3.2 \(O(1)\) 判断回文
给定一个长度为 \(n\) 的字符串 \(s\) ,如何判断 \(s_{l, r}\) 是否为回文的?
一个简单的想法是,正向 hash 一次,反向 hash 一次,如果 \(s_{l, r}\) 的正反哈希值相等,则回文。但这个方法不能推广。
另一个想法是,下降幂哈希一次,上升幂哈希一次,若子串哈希值相等,则回文。
证明:
例题: https://atcoder.jp/contests/abc070/tasks/abc070_a
const int MAXN = 2E5+5;
const int HA = 2;
const int BB[HA] = {1234, 5678};
const int PP[HA] = {int(1E9) + 123, int(1E9) + 403};
i64 pw[HA][MAXN], ipw[HA][MAXN];
i64 hdw[HA][MAXN], hup[HA][MAXN];
i64 ksm(i64 a, i64 n, int P) {
i64 res = 1;
for (;n;n>>=1, a=a*a%P) if (n&1)
res=res*a%P;
return res;
}
void init_hash() {
for (int h = 0; h < HA; h++) {
pw[h][0] = 1;
for (int i = 1; i < MAXN; i++) {
pw[h][i] = pw[h][i - 1] * BB[h] % PP[h];
}
ipw[h][MAXN - 1] = ksm(pw[h][MAXN - 1], PP[h] - 2, PP[h]);
for (int i = MAXN - 1; i; --i) {
ipw[h][i - 1] = ipw[h][i] * BB[h] % PP[h];
}
assert(ipw[h][0] = 1);
}
}
void make_hash(std::string s) { // s[0] == ' '
int n = s.size();
for (int h = 0; h < HA; h++) {
for (int i = 1; i <= n; i++) {
hdw[h][i] = (hdw[h][i - 1] * BB[h] % PP[h] + s[i]) % PP[h];
hup[h][i] = (hup[h][i - 1] + s[i] * pw[h][i - 1] % PP[h]) % PP[h];
}
}
}
std::array<int, 2> get_hdw(int l, int r) {
assert(l <= r);
std::array<int, 2> res;
for (int h = 0; h < HA; h++) {
res[h] = (hdw[h][r] - hdw[h][l - 1] * pw[h][r - l + 1] % PP[h] + PP[h]) % PP[h];
}
return res;
}
std::array<int, 2> get_hup(int l, int r) {
assert(l <= r);
std::array<int, 2> res;
for (int h = 0; h < HA; h++) {
res[h] = (hup[h][r] - hup[h][l - 1] + PP[h]) % PP[h] * ipw[h][l - 1] % PP[h];
}
return res;
}
void solve() {
init_hash();
std::string s; std::cin >> s;
int n = s.size(); s = " " + s;
make_hash(s);
int ok = 1;
for (int h = 0; h < HA; h++) {
ok &= get_hdw(1, n) == get_hup(1, n);
}
std::cout << (ok ? "Yes" : "No") << "\n";
}
3.3 回文信息合并
有时候反向哈希不容易维护,比如:容易维护从根借点开始的链上的正向哈希,不好维护叶结点开始的链上的反向哈希。
一个处理方法是,正向分别以下降幂和上升幂 hash 一次。于是区间信息和链上信息都可合并。
设下降幂 hash 数组为 \(h1\) ,上升幂 hash 数组为 \(h2\) ,假设 \(s_{l, r}\) 是回文的:
这意味着可以轻易在链上或区间上合并下降幂和上升幂的哈希,然后判断是否回文。
3.4 最长回文子串
实际上它的线性做法是 \(manacher\) 。
给一个长度为 \(n\) 的字符串 \(s\) ,从 \(1\) 开始编号。
求有关最长回文子串的问题,一般都会将 \(s_1 s_2 s_3 \cdots s_n\) 处理成 $ @ s_1 @ s_2 @ s_3 @ \cdots @ s_n $ 。这里的 \(@\) 常用 $ 。
好处:
回文串分奇偶讨论,所以回文半径也要分奇偶讨论。做出上述处理后,回文直径严格为二分半径。
如 \(abcba -> @a@b@c@b@a@\) 。二分半径为 \(len(@a@b@)\) ,等于回文直径。
如 \(abccba -> @a@b@c@c@b@a@\) 。二分半径为 \(len(@a@b@c)\) ,等于回文直径。
不难用下降幂和上升幂哈希,枚举中点,求最长的二分半径。
3.5 常数失配匹配
非常固定的模型:给一个长度为 \(n\) 的文本串 \(s\) ,长度为 \(m\) 的模式串 \(t\) 。询问 \(t\) 在 \(s\) 中出现的次数。其中,\(t\) 允许和 \(s\) 有不超过 \(k\) 个字符的失配。\(k\) 次失配指 \(k\) 个在不相等的情况下认为匹配。
非常常见的技巧: 枚举起点,二分哈希值查询 lcp 。
考虑 \(t\) 和一个长度相等的子串 \(p\) ,是否在不超过 \(k\) 次失配的意义下,能够让 \(t = p\) 。
容易想到 hash 后,二分出 \(t\) 和 \(p\) 的 \(lcp\) ,维护 \(t\) 的起点 \(now\) 。
如果 \(now + lcp - 1 \leq m\) ,则 \(now = lcp + 2\) ,然后继续二分,并计数一次 \(cnt\) 。若 \(cnt > k\) 可以退出以优化常数。
\(cnt \leq k\) 则在不超过 \(k\) 次失配的意义下 \(t = p\) 。事件复杂度 \(O(\log m)\) 。
于是枚举 \(s\) 的长度与 \(t\) 相同的子串,并进行 \(check\) 算法。时间复杂度为 \(O(n \log m)\) 。
四 字符串 hash 的 string 判断优化
4.1 用 string 的哈希值做 map 键值
直接使用 \(string\) 作为 map 键值,每次访问是 \(O(length(string))\) 的。访问一组 \(n\) 个 \(string\) 的总复杂度是 \(O(C)\) 。\(\sum len(string) \leq C\) 。
用哈希值替代 string ,map 访问一组 \(n\) 个 \(string\) 的总复杂度是 \(O(n)\) 。
4.2 本质不同子串数量
求长度为 \(n\) 的字符串 \(s\) 的不同字符串数量。注意两个不同位置的相同字符串本质不同,但相同。
不难想到一个暴力,伪代码如下:
function (s) :
mp={}
for len in range(1, n) :
for i in range(1, n - len + 1) :
mp[s[i:i+len-1]] = 1
return : len(mp)
这个算法的问题在于访问 string 是 \(O(len(string))\) 的。
优化是:字典可以使用散列表。string 可以换成哈希值。