Skip to content

字符串哈希 学习笔记

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

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

Hash 的思想

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

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

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

在字符串哈希中,值域需要小到能够快速比较(1091018 都是可以快速比较的

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

性质

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

  1. 在 Hash 函数值不一样的时候,两个字符串一定不一样;

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

    我们将 Hash 函数值一样但原字符串不一样的现象称为哈希碰撞。

解释

我们需要关注的是什么?

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

通常我们采用的是多项式 Hash 的方法,对于一个长度为 l 的字符串 s 来说,我们可以这样定义多项式 Hash 函数:f(s)=i=1ls[i]×bli(modM)。例如,对于字符串 xyz,其哈希函数值为 xb2+yb+z

特别要说明的是,也有很多人使用的是另一种 Hash 函数的定义,即 f(s)=i=1ls[i]×bi1(modM),这种定义下,同样的字符串 xyz 的哈希值就变为了 x+yb+zb2 了。

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

由于前者的 Hash 定义计算更简便、使用人数更多、且可以类比为一个 b 进制数来帮助理解,所以本文下面所将要讨论的都是使用 f(s)=i=1ls[i]×bli(modM) 来定义的 Hash 函数。

还有,有时为了方便和扩大模数,我们在 C++ 中我们会使用 unsigned long long 来定义 Hash 函数的结果。由于 C++ 的特性,我们相当于把模数 M 定为 264,也是一个不错的选择。

准确率会在后面讨论。

Hash 的错误率分析

Hash 冲突

Hash 冲突是指两个不同的字符串映射到相同的 Hash 值。

我们设 Hash 的取值空间(所有可能出现的字符串的数量)为 d,计算次数(要计算的字符串数量)为 n

则 Hash 冲突的概率为:

p(n,d)=d!dn(dn)!1exp(n(n1)2d)

证明

当 Hash 中每个值生成概率相同时,Hash 不冲突的概率为:

p(n,d)=1(11d)(12d)(1n1d)

化简得到:

p(n,d)=ddd1dd2ddn+1d=d(d1)(d2)(dn+1)dn=d!dn(dn)!

则 Hash 冲突的概率为:

p(n,d)=1d!dn(dn)!

这个公式还是太复杂了,我们进一步化简。

根据泰勒公式:

exp(x)=k=0xkk!=1+x+x22+x36+x424+

x 为一个极小值时,exp(x) 趋近于 1+x

将它带入 Hash 不冲突的原始公式:

p(n,d)1exp(1d)exp(2d)exp(n1d)

化简:

p(n,d)exp(1d2dn1d)=exp(n(n1)2d)

则 Hash 冲突的概率为:

p(n,d)1exp(n(n1)2d)

卡大模数 Hash

注意到这个公式:

p(n,d)1exp(n(n1)2d)

为了卡掉 Hash,我们要满足一下条件:

  1. d 要大于模数。
  2. 1p(d,n) 要尽量小。

举个例子:

若字符集为 大小写字母和数字,模数为 109+7 时:

log62109+76

p(106,626)0.9

所以对于这个范围,我们随机生成 106 个长度为 6 的字符串,它们 Hash 值相同的概率高达 90%

卡自然溢出 Hash

这种 Hash 由于模数太大,用上面的方法卡不了,所以我们需要另一种方法。

首先,这种 Hash 是形如 f(s)=i=1ls[i]×bli,我们根据 b 来分类讨论。

b 为偶数

此时 f(s)=s1bl+s2bl1++slb(modM),其中 M264

容易发现若 l64sibl0(modM)

所以我们只要构造形如:

aaa...a

baa...a

且长度大于 64 的字符串就能冲突。

b 为奇数

定义 !si 为把 si 中所有字符反转。

例:

si=abaab

!si=babba

即把 a 变成 b,把 b 变成 a

再定义 hashisi 的 Hash 值,!hashi!si 的 Hash 值。

不断构造 si=si1+!si1

s12!s12 就是我们要的两个字符串。

推导

hashi=hashi1base2i2+!hashi1!hashi=!hashi1base2i2+hashi1

尝试相减:

hashi!hashi= hashi1base2i2+!hashi1(!hashi1base2i2+hashi1)= (hashi1!hashi1)(base2i21)

发现出现了 2i,但是原式太复杂,尝试换元:

设:

fi=hashi!hashigi=base2i21

根据原式得:

fi=fi1gi=f1g1g2gi1

因为 base2i2 一定是奇数,所以 gi 一定是偶数。

所以:

2i1|fi

但这样太大了,i164 才能卡掉,继续化简:

gi=base2i21=(base2i31)(base2i3+1)

gigi1c (c0(mod2)) 的形式。

所以 2|s14|s2...,即

2i|gi2122232i1|fi2i(i1)/2|fi

即当 i=12 时就可以使 264|hashi!hashi 达到要求。

多值 Hash

看了上面这么多的卡法,当然也有解决办法。

多值 Hash,就是有多个 Hash 函数,每个 Hash 函数的模数不一样,这样就能解决 Hash 冲突的问题。

判断时只要有其中一个的 Hash 值不同,就认为两个字符串不同,若 Hash 值都相同,则认为两个字符串相同。

一般来说,双值 Hash 就够用了。

多次询问子串哈希

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

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

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

现在,我们想要用类似前缀和的方式快速求出 f(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])=fr(s)fl1(s)×brl+1 成立(可以手动代入验证一下因此我们用这个式子就可以快速得到子串的哈希值。其中 brl+1 可以 O(n) 的预处理出来然后 O(1) 的回答每次询问(当然也可以快速幂 O(logn) 的回答每次询问

Hash 的应用

字符串匹配

求出模式串的哈希值后,求出文本串每个长度为模式串长度的子串的哈希值,分别与模式串的哈希值比较即可。

允许 k 次失配的字符串匹配

问题:给定长为 n 的源串 s,以及长度为 m 的模式串 p,要求查找源串中有多少子串与模式串匹配。ss 匹配,当且仅当 ss 长度相同,且最多有 k 个位置字符不同。其中 1n,m1060k5

这道题无法使用 KMP 解决,但是可以通过哈希 + 二分来解决。

枚举所有可能匹配的子串,假设现在枚举的子串为 s,通过哈希 + 二分可以快速找到 sp 第一个不同的位置。之后将 sp 在这个失配位置及之前的部分删除掉,继续查找下一个失配位置。这样的过程最多发生 k 次。

总的时间复杂度为 O(m+knlog2m)

最长回文子串

二分答案,判断是否可行时枚举回文中心(对称轴哈希判断两侧是否相等。需要分别预处理正着和倒着的哈希值。时间复杂度 O(nlogn)

这个问题可以使用 manacher 算法 在 O(n) 的时间内解决。

通过哈希同样可以 O(n) 解决这个问题,具体方法就是记 Ri 表示以 i 作为结尾的最长回文的长度,那么答案就是 maxi=1nRi。考虑到 RiRi1+2,因此我们只需要暴力从 Ri1+2 开始递减,直到找到第一个回文即可。记变量 z 表示当前枚举的 Ri,初始时为 0,则 z 在每次 i 增大的时候都会增大 2,之后每次暴力循环都会减少 1,故暴力循环最多发生 2n 次,总的时间复杂度为 O(n)

最长公共子字符串

问题:给定 m 个总长不超过 n 的非空字符串,查找所有字符串的最长公共子字符串,如果有多个,任意输出其中一个。其中 1m,n106

很显然如果存在长度为 k 的最长公共子字符串,那么 k1 的公共子字符串也必定存在。因此我们可以二分最长公共子字符串的长度。假设现在的长度为 kcheck(k) 的逻辑为我们将所有所有字符串的长度为 k 的子串分别进行哈希,将哈希值放入 n 个哈希表中存储。之后求交集即可。

时间复杂度为 O(m+nlogn)

确定字符串中不同子字符串的数量

问题:给定长为 n 的字符串,仅由小写英文字母组成,查找该字符串中不同子串的数量。

为了解决这个问题,我们遍历了所有长度为 l=1,,n 的子串。对于每个长度为 l,我们将其 Hash 值乘以相同的 b 的幂次方,并存入一个数组中。数组中不同元素的数量等于字符串中长度不同的子串的数量,并此数字将添加到最终答案中。

为了方便起见,我们将使用 h[i] 作为 Hash 的前缀字符,并定义 h[0]=0

例题

CF1200E Compress Words

给你若干个字符串,答案串初始为空。第 i 步将第 i 个字符串加到答案串的后面,但是尽量地去掉重复部分(即去掉一个最长的、是原答案串的后缀、也是第 i 个串的前缀的字符串求最后得到的字符串。

字符串个数不超过 105,总长不超过 106

题解

每次需要求最长的、是原答案串的后缀、也是第 i 个串的前缀的字符串。枚举这个串的长度,哈希比较即可。