算法 - 字符串算法 - 字符串哈希算法介绍
介绍字符串中的哈希算法。
假设有 \(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}\),则有定义
计算代码如下:
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 可以得到(加的括号仅是为了方便展示后面得到的关系)
又注意到
而我们要求的 \(s[x..y]\) 的哈希值为
可以发现得到如下关系式
因此我们预处理出 ha
数组和 \(k\) 的幂次 p
数组,之后每次询问 \(s[x..y]\) 的哈希值,只要 \(O(1)\) 的时间。
哈希函数代码实现
每次迭代后取模
如果我们按照我们上面推导的公式表述的式子做代码实现,会带来一个问题:指数爆炸。即 \(k\) 的幂次可能很高,以致超出了 C 语言的数据表示范围。因此我们需要每迭代一次,就对结果取一次模,就像上节的代码展示的那样,但这需要重新计算关系式 \((4)\)。
每一步的迭代表示为
注意到上式参与计算的数都大于等于 \(0\),因此计算机上的 \(\%\) 与数学上的 \(\bmod\) 一致,此时 对任意 \(a,b \geqslant 0\) 有性质
上述性质可参见相关的数学教材即可,或者自己尝试证明也行,并不困难。由式 \((6)\) 和式 \((7)\) 可将我们要计算哈希值的表达式进行化简
可将 \(ha[r] := \text{hash}(s[1..r])\) 和 \(ha[l-1]\) 看作上式的特例,因此我们得到了 \(ha[r]\) 和 \(ha[l-1]\) 的结果,此时你应该会发现这些结果与 \((1)-(4)\) 非常相似,你也许会认为有下式成立
但是并不正确,因为式 \((9)\) 中的 \(ha[r]\) 和 \(ha[l-1]\) 是由式 \((5)\) 迭代产生的,两者相减可能为负,而被除数为负数,除数为正数时,产生的余数会不一致(数学上与 C 语言上的模运算,见链接)。我们只需将式 \((9)\) 调整为
式 \((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\) 分别运算,将这两个结果用一个二元组表示,作为哈希的结果。
即每一步迭代产生两个哈希
此时将二元组作为哈希的结果,即
给定一个子串,计算其哈希,就是对该子串按式 \((10)\) 分别做两次哈希,然后将这两个哈希值组合为二元组的结果。