字符串之字符串哈希
前言
Hash 函数有助于解决很多问题,如果我们想有效地解决比较字符串的问题,最朴素的办法是直接比较两个字符串,这样做的时间复杂度是 O ( min ( n 1 , n 2 ) ) O(\min(n_1,n_2)) O(min(n1,n2)),字符串哈希的想法在于,我们将每个字符串转换为一个整数,然后比较它们而不是字符串。
多项式哈希
哈希函数简单来说就是一个函数 f f f,它将输入映射到另外一个空间,以便于我们的操作。
哈希函数最重要的性质可以概括为下面两条:
- 如果两个对象相等,则它们的哈希值相等
- 如果两个哈希值相等,则对象很可能相等。
Hash 函数值一样时原字符串却不一样的现象我们成为哈希碰撞。
当选择 Hash 函数时,你需要确保碰撞概率尽可能低
对于一个长度为 l l l的字符串 s s s来说,我们可以这样定义多项式 Hash 函数:
h a s h ( s ) = ∑ i = 1 l s [ i ] × p l − i ( m o d m ) hash(s) = \sum_{i=1}^l s[i] \times p^{l-i}(\bmod m) hash(s)=i=1∑ls[i]×pl−i(modm)
更进一步,考虑序列 { a 0 , a 1 , … , a n − 1 } \{a_0,a_1,\dots,a_{n-1}\} {a0,a1,…,an−1}, 在这个序列从左到右的多项式散列下,我们可以得到多项式 Hash 函数:
h a s h ( a , p , m ) = ( a 0 + a 1 ⋅ p + a 2 ⋅ p 2 + ⋯ + a n − 1 ⋅ p n − 1 ) m o d m hash(a,p,m) = (a_0 + a_1 \cdot p + a_2 \cdot p^2 + \cdots + a_{n-1} \cdot p^{n-1}) \bmod m hash(a,p,m)=(a0+a1⋅p+a2⋅p2+⋯+an−1⋅pn−1)modm
这里, b b b和 m m m分别是基和哈希模块。
其中 max ( a i ) < p < m , g c d ( p , m ) = 1 \max(a_i) < p < m, gcd(p, m) = 1 max(ai)<p<m,gcd(p,m)=1
O(1)比较时间
为了比较给定序列 { a 0 , a 1 , … , a n − 1 } \{a_0,a_1,\dots,a_{n-1}\} {a0,a1,…,an−1}的片段,我们需要计算原始序列的每个前缀上的多项式散列。
将前缀上的多项式散列定义为:
p r e f ( k , a , p , m ) = ( a 0 + a 1 ⋅ p + a 2 ⋅ p 2 + ⋯ + a k − 1 ⋅ p k − 1 ) m o d m pref(k,a,p,m) = (a_0 + a_1 \cdot p + a_2 \cdot p^2 + \cdots + a_{k-1} \cdot p^{k-1}) \bmod m pref(k,a,p,m)=(a0+a1⋅p+a2⋅p2+⋯+ak−1⋅pk−1)modm
我们将 p r e f ( k , a , p , m ) pref(k,a,p,m) pref(k,a,p,m)简要表示为 p r e f ( k ) pref(k) pref(k)。
一般形式:
p r e f ( n ) = ( a 0 + a 1 ⋅ p + a 2 ⋅ p 2 + ⋯ + a n − 1 ⋅ p n − 1 ) pref(n) = (a_0 + a_1 \cdot p + a_2 \cdot p^2 + \cdots + a_{n-1} \cdot p^{n-1}) pref(n)=(a0+a1⋅p+a2⋅p2+⋯+an−1⋅pn−1)
每个前缀上的多项式散列可以在 O ( n ) O(n) O(n)时间内计算,使用递推关系:
p r e f ( k + 1 ) = p r e f ( k ) + a k ⋅ p k pref(k+1) = pref(k) + a_k \cdot p^k pref(k+1)=pref(k)+ak⋅pk
现在假设我们需要比较两个分别以 a i a_i ai和 a j a_j aj开头且长度为 l e n len len的子字符串
a i , a i + 1 , ⋯ , a i + l e n − 1 A N D a j , a j + 1 , ⋯ , a j + l e n − 1 a_i,a_{i+1},\cdots,a_{i+len-1} \quad AND \quad a_j,a_{j+1},\cdots,a_{j+len-1} ai,ai+1,⋯,ai+len−1ANDaj,aj+1,⋯,aj+len−1
考虑 p r e f ( i + l e n ) − p r e f ( i ) pref(i+len)-pref(i) pref(i+len)−pref(i)和 p r e f ( j + l e n ) − p r e f ( j ) pref(j+len)-pref(j) pref(j+len)−pref(j)可以得到:
p r e f ( i + l e n ) − p r e f ( i ) = a i ⋅ p i + a i + 1 ⋅ p i + 1 + ⋯ + a i + l e n − 1 ⋅ p i + l e n − 1 pref(i+len) - pref(i) = a_i \cdot p^i + a_{i+1} \cdot p^{i+1} + \cdots + a_{i+len-1} \cdot p^{i+len-1} pref(i+len)−pref(i)=ai⋅pi+ai+1⋅pi+1+⋯+ai+len−1⋅pi+len−1
p r e f ( j + l e n ) − p r e f ( j ) = a j ⋅ p j + a j + 1 ⋅ p j + 1 + ⋯ + a j + l e n − 1 ⋅ p j + l e n − 1 pref(j+len) - pref(j) = a_j \cdot p^j + a_{j+1} \cdot p^{j+1} + \cdots + a_{j+len-1} \cdot p^{j+len-1} pref(j+len)−pref(j)=aj⋅pj+aj+1⋅pj+1+⋯+aj+len−1⋅pj+len−1
接着我们对两个式子进行简单转换,将第一个方程乘以 p j p^j pj,将第二个方程乘以 p i p^i pi。我们得到:
p j ⋅ ( p r e f ( i + l e n ) − p r e f ( i ) ) = p i + j ⋅ ( a i + a i + 1 ⋅ p + ⋯ + a i + l e n − 1 ⋅ p l e n − 1 ) p^j \cdot (pref(i+len) - pref(i)) = p^{i+j} \cdot(a_i + a_{i+1} \cdot p + \cdots + a_{i+len-1} \cdot p^{len-1}) pj⋅(pref(i+len)−pref(i))=pi+j⋅(ai+ai+1⋅p+⋯+ai+len−1⋅plen−1)
p i ⋅ ( p r e f ( j + l e n ) − p r e f ( j ) ) = p i + j ⋅ ( a j + a j + 1 ⋅ p + ⋯ + a j + l e n − 1 ⋅ p l e n − 1 ) p^i \cdot (pref(j+len) - pref(j)) = p^{i+j} \cdot(a_j + a_{j+1} \cdot p + \cdots + a_{j+len-1} \cdot p^{len-1}) pi⋅(pref(j+len)−pref(j))=pi+j⋅(aj+aj+1⋅p+⋯+aj+len−1⋅plen−1)
可以发现,等式右侧括号中的多项式 Hash 正是我们期望的序列
因此,为了确定所需的序列段是否重合,我们需要检查以下等式
p j ⋅ ( p r e f ( i + l e n ) − p r e f ( i ) ) = p i ⋅ ( p r e f ( j + l e n ) − p r e f ( j ) ) p^j \cdot (pref(i+len) - pref(i)) = p^i \cdot (pref(j+len) - pref(j)) pj⋅(pref(i+len)−pref(i))=pi⋅(pref(j+len)−pref(j))
这样比较的时间复杂度是 O ( 1 ) O(1) O(1),最后加上 m m m可以得到:
p j ⋅ ( p r e f ( i + l e n ) − p r e f ( i ) ) m o d m = p i ⋅ ( p r e f ( j + l e n ) − p r e f ( j ) ) m o d m p^j \cdot (pref(i+len) - pref(i)) \bmod m = p^i \cdot (pref(j+len) - pref(j)) \bmod m pj⋅(pref(i+len)−pref(i))modm=pi⋅(pref(j+len)−pref(j))modm
我们也可以在等式两边同时乘以 p − i − j p^{-i-j} p−i−j, 最终得到:
p − i ⋅ ( p r e f ( i + l e n ) − p r e f ( i ) ) m o d m = p − j ⋅ ( p r e f ( j + l e n ) − p r e f ( j ) ) m o d m p^{-i} \cdot (pref(i+len) - pref(i)) \bmod m = p^{-j} \cdot (pref(j+len) - pref(j)) \bmod m p−i⋅(pref(i+len)−pref(i))modm=p−j⋅(pref(j+len)−pref(j))modm
{% note info %}
对于另外一种哈希多项式,这里就不在赘述了,方法是相同的。
h a s h ( a , p , m ) = ( a 0 ⋅ p n − 1 + a 1 ⋅ p n − 2 + ⋯ + a n − 2 ⋅ p + a n − 1 ) m o d m hash(a,p,m) = (a_0 \cdot p^{n-1} + a_1 \cdot p^{n-2} + \cdots + a_{n-2} \cdot p + a_{n-1} ) \bmod m hash(a,p,m)=(a0⋅pn−1+a1⋅pn−2+⋯+an−2⋅p+an−1)modm
(
p
r
e
f
(
i
+
l
e
n
)
−
p
r
e
f
(
i
)
⋅
p
l
e
n
)
m
o
d
m
=
(
p
r
e
f
(
j
+
l
e
n
)
−
p
r
e
f
(
j
)
⋅
p
l
e
n
)
m
o
d
m
(pref(i+len) - pref(i) \cdot p^{len}) \bmod m = (pref(j+len) - pref(j) \cdot p^{len}) \bmod m
(pref(i+len)−pref(i)⋅plen)modm=(pref(j+len)−pref(j)⋅plen)modm
{% endnote %}
计算子串的哈希值
在上面,我们定义了 Hash 函数,单次计算一个字符串的哈希值复杂度是 O ( n ) O(n) O(n), 如果需要多次询问一个字符串的子串的哈希值,每次重新计算效率非常低下。
考虑序列 { a i , a i + 1 , … , a j } \{a_i,a_{i+1},\dots,a_{j}\} {ai,ai+1,…,aj}
按照定义我们可以得到
h a s h ( s [ i ⋯ j ] ) = a i + a i + 1 ⋅ p + ⋯ + a j ⋅ p j − i hash(s[i \cdots j]) = a_i + a_{i+1} \cdot p + \cdots + a_{j} \cdot p^{j-i} hash(s[i⋯j])=ai+ai+1⋅p+⋯+aj⋅pj−i
考虑
h a s h ( s [ 0 ⋯ i − 1 ] ) = a 0 + a 1 ⋅ p + ⋯ + a i − 1 ⋅ p i − 1 hash(s[0 \cdots i-1]) = a_0 + a_1 \cdot p + \cdots + a_{i-1} \cdot p^{i-1} hash(s[0⋯i−1])=a0+a1⋅p+⋯+ai−1⋅pi−1
h a s h ( s [ 0 ⋯ j ] ) = a 0 + a 1 ⋅ p + ⋯ + a j ⋅ p j hash(s[0 \cdots j]) = a_0 + a_1 \cdot p + \cdots + a_{j} \cdot p^{j} hash(s[0⋯j])=a0+a1⋅p+⋯+aj⋅pj
根据前缀和思想,我们可以得到
h a s h ( s [ i ⋯ j ] ) = p − i ⋅ ( h a s h ( s [ 0 ⋯ j ] ) − h a s h ( s [ 0 ⋯ i − 1 ] ) ) hash(s[i \cdots j]) = p^{-i} \cdot (hash(s[0 \cdots j]) - hash(s[0 \cdots i-1])) hash(s[i⋯j])=p−i⋅(hash(s[0⋯j])−hash(s[0⋯i−1]))
{% note info %}
对于多项式哈希的另外一种形式
h a s h ( a , p , m ) = ( a 0 ⋅ p n − 1 + a 1 ⋅ p n − 2 + ⋯ + a n − 2 ⋅ p + a n − 1 ) m o d m hash(a,p,m) = (a_0 \cdot p^{n-1} + a_1 \cdot p^{n-2} + \cdots + a_{n-2} \cdot p + a_{n-1} ) \bmod m hash(a,p,m)=(a0⋅pn−1+a1⋅pn−2+⋯+an−2⋅p+an−1)modm
按照同样的方法,我们也可以得到字串的哈希值:
h a s h ( s [ i ⋯ j ] ) = h a s h ( s [ 0 ⋯ j ] ) − h a s h ( s [ 0 ⋯ i − 1 ] ) ⋅ p j − i + 1 hash(s[i \cdots j]) = hash(s[0 \cdots j]) - hash(s[0 \cdots i-1]) \cdot p^{j-i+1} hash(s[i⋯j])=hash(s[0⋯j])−hash(s[0⋯i−1])⋅pj−i+1
{% endnote %}
Hash 实现
M = int(1e9 + 7)
B = 233
def get_hash(s):
res = 0
for char in s:
res = (res * B + ord(char)) % M
return res
def cmp(s, t):
return get_hash(s) == get_hash(t)
实际上,我们不可能在每次比较字符串时都计算一遍 Hash,这样的效率是低下的。
就像我们之前讨论的, O ( 1 ) O(1) O(1)时间复杂度进行查询
h a s h ( s [ i ⋯ j ] ) = h a s h ( s [ 0 ⋯ j ] ) − h a s h ( s [ 0 ⋯ i − 1 ] ) ⋅ p j − i + 1 hash(s[i \cdots j]) = hash(s[0 \cdots j]) - hash(s[0 \cdots i-1]) \cdot p^{j-i+1} hash(s[i⋯j])=hash(s[0⋯j])−hash(s[0⋯i−1])⋅pj−i+1
B = 233
M = 1e9 + 7
h, p = [0] * (n + 1), [0] * (n + 1)
p[0] = 1
def get_pref(s:str):
for i in range(len(s)):
h[i + 1] = (h[i] * B + ord(s[i])) % M
p[i + 1] = (p[i] * B) % M
def find_sub(i:int, j:int) -> int:
return (h[j] - h[i - 1] * p[j - i + 1]) % M;
最小化碰撞概率
对生日问题进行推广: 假设有 n 个人,每一个人都随机地从 N 个特定的数中选择出来一个数(N 可能是 365 或者其他的大于 0 的整数)。p(n)表示有两个人选择了同样的数字,这个概率有多大?
p ≈ 1 − exp ( − n 2 2 N ) p \approx 1 - \exp(-\frac{n^2}{2N}) p≈1−exp(−2Nn2)
结论:在字符串哈希中,值域需要小到能够快速比较( 1 0 9 10^9 109、 1 0 18 10^{18} 1018都是可以快速比较的)。同时,为了降低哈希冲突率,值域也不能太小。
Hash 应用
字符串匹配问题
核心思想:求出模式串的哈希值后,求出文本串每个长度为模式串长度的子串的哈希值,分别与模式串的哈希值比较即可。
最长回文子串
核心思想:二分答案,判断是否可行时枚举回文中心(对称轴),哈希判断两侧是否相等。需要分别预处理正着和倒着的哈希值。
最长公共子字符串
问题:给定 m m m个总长不超 n n n的非空字符串,查找所有字符串的最长公共子字符串,如果有多个,任意输出其中一个。
很显然如果存在长度为
k
k
k的最长公共子字符串,那么
k
−
1
k-1
k−1的公共子字符串也必定存在。因此我们可以二分最长公共子字符串的长度。假设现在的长度为
k
k
k,check(k)
的逻辑为我们将所有所有字符串的长度为
k
k
k的子串分别进行哈希,将哈希值放入
n
n
n个哈希表中存储。之后求交集即可。
练习
- A Needle in the Haystack - SPOJ
- Double Profiles - Codeforces
- Password - Codeforces
- SUB_PROB - SPOJ
- INSQ15_A
- SPOJ - Ada and Spring Cleaning
- GYM - Text Editor
- 12012 - Detection of Extraterrestrial
- Codeforces - Games on a CD
- UVA 11855 - Buzzwords
- Codeforces - Santa Claus and a Palindrome
- Codeforces - String Compression
- Codeforces - Palindromic Characteristics
- SPOJ - Test
- Codeforces - Palindrome Degree
- Codeforces - Deletion of Repeats