Appearance
字符串哈希基础
KMP 通过分析模式串的结构来优化匹配。本章介绍另一种思路:把字符串映射为数字,用数字比较代替字符串比较。
这就是字符串哈希。
为什么需要字符串哈希
比较两个长度为 n 的字符串是否相等,需要 O(n) 时间。如果要频繁比较多个子串呢?
javascript
// 朴素方法:每次比较 O(n)
s1 === s2 // O(n)
s1 === s3 // O(n)
s1 === s4 // O(n)如果能把字符串映射为一个数字(哈希值),那么比较就变成了 O(1):
javascript
hash(s1) === hash(s2) // O(1)更妙的是,通过预处理,我们可以 O(1) 计算任意子串的哈希值。这在很多场景中非常有用。
多项式哈希
最常用的字符串哈希方法是多项式哈希:把字符串看作一个 B 进制数。
字符串 "abc" 的哈希值计算:
hash = a × B² + b × B¹ + c × B⁰
其中:
- a, b, c 是字符的数值(如 ASCII 码:a=97, b=98, c=99)
- B 是基数(通常取质数,如 31, 131, 13331)基本实现
javascript
/**
* 计算字符串的哈希值
* @param {string} s - 字符串
* @param {number} B - 基数
* @param {number} MOD - 模数(防止溢出)
* @returns {number}
*/
function stringHash(s, B = 31, MOD = 1e9 + 7) {
let hash = 0;
for (const char of s) {
hash = (hash * B + char.charCodeAt(0)) % MOD;
}
return hash;
}为什么要取模?
- 防止数值溢出(JavaScript 中大数会丢失精度)
- MOD 通常取大质数,如 10⁹+7
计算示例
s = "abc", B = 31, MOD = 10^9+7
hash = ((0 × 31 + 97) × 31 + 98) × 31 + 99
= (97 × 31 + 98) × 31 + 99
= (3007 + 98) × 31 + 99
= 3105 × 31 + 99
= 96255 + 99
= 96354前缀哈希与子串哈希
哈希的真正威力在于:预处理后,可以 O(1) 计算任意子串的哈希值。
前缀哈希数组
定义 prefixHash[i] = hash(s[0..i-1]),即前 i 个字符的哈希值。
递推公式:
prefixHash[0] = 0
prefixHash[i] = prefixHash[i-1] × B + s[i-1]子串哈希的推导
我们要计算 hash(s[l..r])(从 l 到 r 的子串)。
prefixHash[r+1] = s[0]×B^r + s[1]×B^(r-1) + ... + s[l]×B^(r-l) + ... + s[r]×B^0
prefixHash[l] = s[0]×B^(l-1) + s[1]×B^(l-2) + ... + s[l-1]×B^0
prefixHash[l] × B^(r-l+1) = s[0]×B^r + ... + s[l-1]×B^(r-l+1)因此:
hash(s[l..r]) = prefixHash[r+1] - prefixHash[l] × B^(r-l+1)完整实现
javascript
class StringHash {
/**
* @param {string} s - 字符串
* @param {number} B - 基数
* @param {number} MOD - 模数
*/
constructor(s, B = 31, MOD = 1e9 + 7) {
this.B = B;
this.MOD = MOD;
this.n = s.length;
// 前缀哈希
this.prefix = new Array(this.n + 1).fill(0);
// B 的幂次
this.power = new Array(this.n + 1).fill(1);
for (let i = 0; i < this.n; i++) {
this.prefix[i + 1] = (this.prefix[i] * B + s.charCodeAt(i)) % MOD;
this.power[i + 1] = (this.power[i] * B) % MOD;
}
}
/**
* 获取 s[l..r] 的哈希值(0-indexed,闭区间)
*/
getHash(l, r) {
const len = r - l + 1;
let hash = this.prefix[r + 1] - this.prefix[l] * this.power[len] % this.MOD;
// 处理负数(JavaScript 取模可能为负)
return (hash % this.MOD + this.MOD) % this.MOD;
}
/**
* 判断两个子串是否相等
*/
equal(l1, r1, l2, r2) {
return this.getHash(l1, r1) === this.getHash(l2, r2);
}
}使用示例
javascript
const s = "abcabc";
const hash = new StringHash(s);
// 比较 s[0..2] "abc" 和 s[3..5] "abc"
console.log(hash.equal(0, 2, 3, 5)); // true
// 比较 s[0..2] "abc" 和 s[1..3] "bca"
console.log(hash.equal(0, 2, 1, 3)); // false哈希冲突
一个重要问题:不同的字符串可能产生相同的哈希值。
这叫哈希冲突。因为字符串的数量远远大于哈希值的范围(MOD 个),冲突是不可避免的。
处理方法
方法 1:双哈希
使用两组不同的 (B, MOD),两个哈希值都相等才认为字符串相等。
javascript
class DoubleHash {
constructor(s) {
this.hash1 = new StringHash(s, 31, 1e9 + 7);
this.hash2 = new StringHash(s, 37, 1e9 + 9);
}
getHash(l, r) {
return [this.hash1.getHash(l, r), this.hash2.getHash(l, r)];
}
equal(l1, r1, l2, r2) {
const h1 = this.getHash(l1, r1);
const h2 = this.getHash(l2, r2);
return h1[0] === h2[0] && h1[1] === h2[1];
}
}方法 2:哈希相等后逐字符验证
在实际匹配中,哈希相等后再逐字符比较确认。
方法 3:选择好的参数
B 和 MOD 都取大质数可以降低冲突概率。常用组合:
- B = 31, MOD = 10⁹+7
- B = 131, MOD = 10⁹+9
- B = 13331, MOD = 998244353
应用场景
字符串哈希的典型应用:
- 判断子串相等:预处理后 O(1) 比较
- 查找重复子串:配合二分查找
- Rabin-Karp 算法:字符串匹配
- 最长回文子串:配合二分
常见错误
- 溢出问题:每一步都要取模
- 负数处理:减法后可能为负,需要
(hash % MOD + MOD) % MOD - 参数选择:B 和 MOD 都应取质数,且 MOD 要足够大
本章小结
字符串哈希的核心思想:
- 把字符串映射为数字(多项式哈希)
- 预处理前缀哈希和幂次数组
- O(1) 计算任意子串的哈希值
- 注意哈希冲突,可用双哈希或验证解决
下一章,我们将学习基于哈希的字符串匹配算法——Rabin-Karp。