字符串哈希

定义

我们定义一个把字符串映射到整数的函数 ff ,这个 ff 称为是 Hash 函数。

我们希望这个函数 ff 可以方便地帮我们判断两个字符串是否相等。

Hash 的思想

Hash 的核心思想在于,将输入映射到一个值域较小、可以方便比较的范围。

这里的「值域较小」在不同情况下意义不同。

哈希表 中,值域需要小到能够接受线性的空间与时间复杂度。

在字符串哈希中,值域需要小到能够快速比较( 10910^9101810^{18} 都是可以快速比较的)。

同时,为了降低哈希冲突率,值域也不能太小。

性质

具体来说,哈希函数最重要的性质可以概括为下面两条:

  1. 在 Hash 函数值不一样的时候,两个字符串一定不一样;
  2. 在 Hash 函数值一样的时候,两个字符串不一定一样(但有大概率一样,且我们当然希望它们总是一样的)。

Hash 函数值一样时原字符串却不一样的现象我们成为哈希碰撞。

解释

我们需要关注的是什么?

时间复杂度和 Hash 的准确率。

通常我们采用的是多项式 Hash 的方法,对于一个长度为 ll 的字符串 ss 来说,我们可以这样定义多项式 Hash 函数: f(s)=i=1ls[i]×bli (mod  M)f(s) = \sum _{i = 1} ^{l} s[i] \times b^{l - i}\ (mod\ \ M) 。例如,对于字符串 xyzxyz ,其哈希函数值为 xb2+yb+zxb^2 + yb + z

特别要说明的是,也有很多人使用的是另一种 Hash 函数的定义,即 f(s)=i=1ls[i]×bi1 (mod  M)f(s) = \sum _{i = 1} ^{l} s[i] \times b^{i - 1}\ (mod\ \ M) ,这种定义下,同样的字符串 xyzxyz 的哈希值就变为了 x+yb+zb2x + yb + zb^2 了。

显然,上面这两种哈希函数的定义函数都是可行的,但二者在之后会讲到的计算子串哈希值时所用的计算式是不同的,因此千万注意 不要弄混了这两种不同的 Hash 方式

由于前者的 Hash 定义计算更简便、使用人数更多、且可以类比为一个 bb 进制数来帮助理解,所以本文下面所将要讨论的都是使用 f(s)=i=1ls[i]×b<!swig1> (mod  M)f(s) = \sum _{i = 1} ^{l} s[i] \times b^NaN\ (mod\ \ M) 来定义的 Hash 函数。

下面讲一下如何选择 MM 和计算哈希碰撞的概率。

这里 MM 需要选择一个素数(至少要比最大的字符要大), bb 可以任意选择。

如果我们用未知数 xx 替代 bb ,那么 f(s)f(s) 实际上是多项式环 ZM[x]\Z_M[x] 上的一个多项式。考虑两个不同的字符串 s,ts,t ,有 f(s)=f(t)f(s) = f(t) 。我们记 h(x)=f(s)f(t)=i=1l(s[i]t[i])xli (mod M)h(x) = f(s) - f(t) = \sum _{i = 1} ^{l} (s[i] - t[i]) x^{l - i}\ (mod\ M) ,其中 l=max(s,t)l = max(|s|, |t|) 。可以发现 h(x)h(x) 是一个 l1l - 1 阶的非零多项式。

如果 ssttx=bx = b 的情况下哈希碰撞,则 bbh(x)h(x) 的一个根。由于 h(x)h(x)ZM\Z_M 是一个域(等价于 MM 是一个素数,这也是为什么 MM 要选择素数的原因)的时候,最多有 l1l - 1 个根,如果我们保证 bb 是从 [0,M)[0, M) 之间均匀随机选取的,那么 f(s)f(s)f(t)f(t) 碰撞的概率可以估计为 l1M\frac{l - 1}{M} 。简单验算一下,可以发现如果两个字符串长度都是 11 的时候,哈希碰撞的概率为 l1M=0\frac{l - 1}{M} = 0 ,此时不可能发生碰撞。

Hash 的分析与改进

错误率

若进行 nn 次比较,每次错误率 1M\frac{1}{M} ,那么总错误率是 1(11M)n1 - (1 - \frac{1}{M})^n 。在随机数据下,若 M=109+7M = 10^9 + 7n=109n = 10^9 ,错误率约为 11000\frac{1}{1000} ,并不是能够完全忽略不计的。

所以,进行字符串哈希时,经常会对两个大质数分别取模,这样的话哈希函数的值域就能扩大到两者之积,错误率就非常小了。

多次询问子串哈希

单次计算一个字符串的哈希值复杂度是 O(n)O(n) ,其中 nn 为串长,与暴力匹配没有区别,如果需要多次询问一个字符串的子串的哈希值,每次重新计算效率非常低下。

一般采取的方法是对整个字符串先预处理出每个前缀的哈希值,将哈希值看成一个 bb 进制的数对 MM 取模的结果,这样的话每次就能快速求出子串的哈希了:

fi(s)f_i(s) 表示 f(s[1..i])f(s[1..i]) ,即原串长度为 ii 的前缀的哈希值,那么按照定义有 fi(s)=s[1]bi1+s[2]bi2++s[i1]b+s[i]f_i(s) = s[1] \cdot b^{i - 1} + s[2] \cdot b^{i - 2} + \dots + s[i - 1] \cdot b + s[i]

现在,我们想要用类似前缀和的方式快速求出 f(s[l..r])f(s[l..r]) ,按照定义有字符串 s[l..r]s[l..r] 的哈希值为 f(s[l..r])=s[l]brl+s[l+1]brl1++s[r1]b+s[r]f(s[l..r]) = s[l] \cdot b^{r - l} + s[l + 1] \cdot b^{r - l - 1} + \dots + s[r - 1] \cdot b + s[r]

对比观察上述两个式子,我们发现 f(s[l..r])=fr(s)fl1(s)×brl+1f(s[l..r]) = f_r(s) - f_{l - 1}(s) \times b^{r - l + 1} 成立(可以手动代入验证一下),因此我们用这个式子就可以快速得到子串的哈希值。其中 brl+1b^{r - l + 1} 可以 O(n)O(n) 的预处理出来然后 O(1)O(1) 地回答每次询问(当然也可以快速幂 O(log n)O(log\ n) 的回答每次询问)。

模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const int L = 2e6 + 5;
const int HASH_CNT = 2;

int hashBase[HASH_CNT] = {29, 31}; // {13331, 23333}
int hashMod[HASH_CNT] = {int(1e9 + 9), 998244353}; // {1e9 + 7, 1e9 + 9}

struct StringWithHash {
char s[L];
int ls;
int hsh[HASH_CNT][L];
int pwMod[HASH_CNT][L];

void init() { // 初始化
ls = 0;
for (int i = 0; i < HASH_CNT; ++i) {
hsh[i][0] = 0;
pwMod[i][0] = 1;
}
}

StringWithHash() { init(); }

void extend(char c) {
s[++ls] = c; // 记录字符数和每一个字符
for (int i = 0; i < HASH_CNT; ++i) { // 双哈希的预处理
pwMod[i][ls] =
1ll * pwMod[i][ls - 1] * hashBase[i] % hashMod[i]; // 得到b^ls
hsh[i][ls] = (1ll * hsh[i][ls - 1] * hashBase[i] + c) % hashMod[i];
}
}

vector<int> getHash(int l, int r) { // 得到哈希值
vector<int> res(HASH_CNT, 0);
for (int i = 0; i < HASH_CNT; ++i) {
int t =
(hsh[i][r] - 1ll * hsh[i][l - 1] * pwMod[i][r - l + 1]) % hashMod[i];
t = (t + hashMod[i]) % hashMod[i];
res[i] = t;
}
return res;
}
};
作者

Kanoon

发布于

2023-01-08

更新于

2023-01-08

许可协议

评论