算法 - 字符串算法 - 字符串哈希算法介绍

介绍字符串中的哈希算法。

假设有 \(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_KHASH_M 最好取成质数,这样可以减少哈希冲突。

现在我们考虑子串的哈希,假设字符串 \(s\) 的下标从 \(1\) 开始,长度为 \(n\),我们得到 \(s[1..i]\)BKDRHashha[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_KHASH_M 值要保证不发生溢出。

举一个例子,取 HASH_K131,取 HASH_M1e9+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)\) 分别做两次哈希,然后将这两个哈希值组合为二元组的结果。

posted on 2022-04-14 23:09  Black_x  阅读(285)  评论(0编辑  收藏  举报