字符串哈希:滚动哈希与子串比较
介绍多项式滚动哈希、前缀哈希、子串哈希的 O(1) 计算,以及哈希冲突的处理方式。
字符串哈希的思路是把字符串映射为整数,用整数比较代替一部分字符串比较。预处理完成后,可以在 O(1) 时间内取得任意子串的哈希值,常用于重复子串、字符串匹配和二分答案。
哈希函数存在冲突:不同字符串可能得到相同的哈希值。因此字符串哈希是一种概率算法,哈希相等不能严格证明字符串相等。对正确性要求很高的场景,应使用双哈希或在哈希相等后再次比较原字符串。
一、多项式滚动哈希
给定字符串 s,选择一个基数 base,令 code(s[i]) 为字符编码,定义前缀哈希:
prefix[0] = 0
prefix[i + 1] = prefix[i] * base + code(s[i])
使用模数 mod 时,以上计算需要在每一步取模。也可以使用 uint64_t 的自然溢出,让运算结果按 2^64 取模。无符号整数的溢出是 C++ 标准定义的行为,但仍然存在哈希冲突。
二、O(1) 计算子串哈希
设 prefix[i] 表示前 i 个字符的哈希值,power[i] 表示 base^i。对于半开区间 [left, right):
hash(left, right) =
prefix[right] - prefix[left] * power[right - left]
减法和乘法都在选定的模数下进行。使用 uint64_t 自然溢出时,减法和乘法也会自动按 2^64 计算。
2.1 C++17 实现
#include <cstdint>
#include <string_view>
#include <vector>
class RollingHash {
public:
using Value = std::uint64_t;
explicit RollingHash(std::string_view text,
Value base = 911382323)
: prefix_(text.size() + 1), power_(text.size() + 1, 1) {
for (std::size_t i = 0; i < text.size(); ++i) {
const Value code = static_cast<unsigned char>(text[i]) + 1;
prefix_[i + 1] = prefix_[i] * base + code;
power_[i + 1] = power_[i] * base;
}
}
// 返回 text[left:right] 的哈希值,区间为半开区间。
Value Hash(std::size_t left, std::size_t right) const {
return prefix_[right] -
prefix_[left] * power_[right - left];
}
bool Equal(std::size_t left1, std::size_t right1,
std::size_t left2, std::size_t right2) const {
if (right1 - left1 != right2 - left2) {
return false;
}
return Hash(left1, right1) == Hash(left2, right2);
}
private:
std::vector<Value> prefix_;
std::vector<Value> power_;
};
std::string_view 按字节访问字符串。如果输入包含 UTF-8 中文,代码比较的是 UTF-8 字节序列,而不是 Unicode 码点;只要所有字符串使用相同的 UTF-8 编码,哈希比较仍然是一致的。
预处理时间复杂度为 O(n),空间复杂度为 O(n),每次子串哈希查询为 O(1)。
三、如何降低哈希冲突概率
常见做法包括:
- 选择合适的基数,不要使用过小的固定基数。
- 使用两个不同模数计算两个哈希值。
- 使用两个不同基数计算两个自然溢出哈希值。
- 哈希相等时,再对候选字符串进行原文比较。
“模数越大,冲突概率一定越低”并不是严格结论,实际概率还取决于哈希函数、输入分布和攻击方式。对于可能受到构造输入攻击的场景,不能只依赖一个固定哈希函数。
四、应用场景
字符串哈希可以用于:
- 判断两个等长子串是否相等。
- 二分查找最长重复子串。
- 统计重复的固定长度子串。
- 将字符串匹配问题转换为整数集合查找。
相关题目:187. 重复的 DNA 序列、1044. 最长重复子串。
五、总结
- 滚动哈希使用前缀哈希和幂数组在
O(1)时间内计算子串哈希。 - 哈希相等不代表字符串一定相等,哈希冲突必须纳入设计考虑。
- 双哈希或哈希后验证原文可以进一步降低错误概率。
- 使用半开区间可以统一处理子串长度和边界。