介绍字符串中的哈希算法。
假设有 \(n\) 个长度为 \(L\)
直接比较两个长度为 \(L\) 的字符串是否相等的时间复杂度是 \(O(L)\) 的。因此需要枚举 \(O(n^2)\) 对字符串进行比较,时间复杂度为 \(O(n^2 L)\)。如果我们把每个字符串都用一个哈希函数映射成一个整数。问题就变成了查找一个序列中的众数,时间复杂度变为了 \(O(nL)\)。
字符串哈希函数
注:本小节代码仅为演示,请不要实际运行。代码模板在下小节的代码实现中介绍。
一个设计良好的的字符串哈希函数可以让我们先用 \(O(L)\) 的时间复杂度预处理,之后每次获取这个字符串的一个子串的哈希值都只要 \(O(1)\) 的时间。这里重点介绍 BKDRHash。
BKDRHash 的基本思想就是把一个字符串当作一个 \(k\)
代码如下:
const int HASH_K = 131;
const int HASH_M = 1e9 + 7;
int BKDRHash(char* str)
{
int ans = 0;
for (int i = 0; str[i]; ++i) {
ans = (ans * HASH_K + str[i]) % HASH_M;
}
return ans;
}
例如,处理字符串 abac
。
第一个循环,ans
为 \((97)\)。
第二个循环,ans
为 \(((97) * 19 + 98)\)。
第三个循环,ans
为 \((((97)*19+98) * 19 + 97)\)。
第四个循环,ans
为 \(((((97)*19+98) * 19 + 97) * 19 + 99)\)。
其中,模运算是为了保证运算过程中数的范围,防止指数爆炸。且 HASH_K
和 HASH_M
最好取成质数,这样可以减少哈希冲突。
现在我们考虑子串的哈希,假设字符串 \(s\) 的下标从 \(1\) 开始,长度为 \(n\),我们得到 \(s[1..i]\) 的 BKDRHash 值 ha[i]
。令哈希函数为 \(\text{hash}\),则有定义
\[ha[i] := \text{hash}(s[1..i]) \]
计算代码如下:
const int HASH_K = 131;
const int HASH_M = 1e9 + 7;
p[0] = 1;
ha[0] = 0;
for (int i = 1; i <= n; ++i) {
p[i] = p[i - 1] * HASH_K;
ha[i] = (ha[i - 1] * HASH_K + s[i]) % HASH_M;
}
现在询问 \(s[x..y]\) 的 BKDRHash 可以得到(加的括号仅是为了方便展示后面得到的关系)
\[ha[y] = s[1]k^{y-1} + s[2] k^{y-2} + \cdots + s[x-1]k^{y-x+1} + (s[x] k^{y-x} + \cdots + s[y]), \quad y \geqslant 1 \tag{1} \]
又注意到
\[ha[x-1] = s[1] k^{x-2} + s[2] k^{x-3} + \cdots + s[x-1], \quad x \geqslant 2 \tag{2} \]
而我们要求的 \(s[x..y]\)
\[\text{hash}(s[x..y]) = s[x] k^{y-x} + \cdots + s[y] \tag{3} \]
可以发现得到如下关系式
\[\text{hash}(s[x..y])=ha[y]-ha[x-1]k^{y-x+1} \tag{4} \]
因此我们预处理出 ha
数组和 \(k\) 的幂次 p
数组,之后每次询问 \(s[x..y]\) 的哈希值,只要 \(O(1)\)
哈希函数代码实现
每次迭代后取模
如果我们按照我们上面推导的公式表述的式子做代码实现,会带来一个问题:指数爆炸。即 \(k\) 的幂次可能很高,以致超出了 C 语言的数据表示范围。因此我们需要每迭代一次,就对结果取一次模,就像上节的代码展示的那样,但这需要重新计算关系式 \((4)\)。
每一步的迭代表示为
\[ha[i] = (ha[i-1] * k + s[i]) \% P \tag{5} \]
注意到上式参与计算的数都大于等于 \(0\),因此计算机上的 \(\%\) 与数学上的 \(\bmod\) 一致,此时 对任意 \(a,b \geqslant 0\)
\[(a \% P * k) \% P = (a * k) \% P \tag{6} \]
\[(a + b) \% P = (a \% P + b \% P) \% P \tag{7} \]
上述性质可参见相关的数学教材即可,或者自己尝试证明也行,并不困难。由式 \((6)\) 和式 \((7)\)
\[\begin{align*} \text{hash}(s[l..r]) & = (⋯((0*k+s[l]) \%P * k+s[l+1]) \%P * k + ⋯) \%P * p+s[r]) \%P \\ & = (s[l] * k^{l-r} + s[l+1] * k^{l-r-1} + s[r]) \% P \end{align*} \tag{8} \]
可将 \(ha[r] := \text{hash}(s[1..r])\) 和 \(ha[l-1]\) 看作上式的特例,因此我们得到了 \(ha[r]\) 和 \(ha[l-1]\) 的结果,此时你应该会发现这些结果与 \((1)-(4)\)
\[\text{hash}(s[l..r]) = (ha[r]-ha[l-1]*k^{r-l+1}) \% P \tag{9} \]
但是并不正确,因为式 \((9)\) 中的 \(ha[r]\) 和 \(ha[l-1]\) 是由式 \((5)\) 迭代产生的,两者相减可能为负,而被除数为负数,除数为正数时,产生的余数会不一致(数学上与 C 语言上的模运算,见链接)。我们只需将式 \((9)\)
\[\text{hash}(s[l..r]) = ((ha[r]-ha[l-1]*k^{r-l+1}) \% P + P) \% P\tag{10} \]
式 \((10)\) 就是最终得到的关系式,利用该关系式,仍然可在 \(O(1)\)
数据溢出与数据表示范围
在我们循环过程中,我们计算如下语句
ha[i] = (ha[i - 1] * HASH_K + s[i]) % HASH_M;
会出现的一种情况是,在计算 ha[i - 1] * HASH_K + s[i]
过程中,数据过大导致数据溢出,相当于对计算结果做了一次模运算。如果你构造哈希函数时就用的是数据的自然溢出来作为模运算,那计算过程中导致的溢出并不影响最终结果;但若不是如此,很可能导致哈希值计算不正确。
因此一般要开数据范围在 long long
,且 HASH_K
和 HASH_M
值要保证不发生溢出。
举一个例子,取 HASH_K
为 131
,取 HASH_M
为 1e9+7
,由于每次迭代后都取模,因此保证了 ha[i - 1]
不超过 1e9+7
,即使之后每次运算都尽量取到最大,也能保证计算过程 ha[i - 1] * HASH_K + s[i]
未溢出(都在 long long
的数据范围内)。
双哈希减小冲突
在计算一个字符串的哈希值的过程中,用两个不同的 \(k\) 和两个不同的模数 \(m\)
即每一步迭代产生两个哈希
\[ha_1[i] = (ha_1[i-1] * k_1 + s[i]) \% P_1 \]
\[ha_2[i] = (ha_2[i-1] * k_2 + s[i]) \% P_2 \]
此时将二元组作为哈希的结果,即
\[\text{hash}(s[1..i]) = <ha_1[i], ha_2[i]> \]
给定一个子串,计算其哈希,就是对该子串按式 \((10)\)