字符串哈希
字符串Hash
考虑这样一个问题:给定 \(n\) 个字符串 \(s\) ,总共有 \(m\) 组询问,每次询问给定一个字符串 \(t\) 问是否在之前 \(n\) 个串中出现。如果是暴力匹配,算法的时间复杂度为 \(O(nm|t|)\) ,我们可以考虑用字符串 \(Hash\) 来优化时间复杂度。
符号约定
\(pre(s,i)\) 为字符串 \(s\) 长度为 \(i\) 的前缀
\(suf(s,i)\) 为字符串 \(s\) 长度为 \(i\) 的后缀
\(s[l,r]\) 为字符串 \(s\) 从第 \(l\) 个位置开始,第 \(r\) 个位置结束的子串
\(|s|\) 为字符串 \(s\) 的长度
规定下标从 \(1\) 开始
定义
建立一个映射,使得字符串可以对应到一个整数函数 \(f\) 上,则 \(f\) 为该字符串的 \(Hash\) 函数。可以通过比较两个字符串的 \(Hash\) 函数判断两个字符串是否相等。
Hash的性质
\(Hash\) 函数最重要的有两条性质:
- 当两个字符串的 \(Hash\) 值不一样时,两个字符串一定不一样
- 当两个字符串的 \(Hash\) 值一样时,两个字符串不一定一样
两个字符串 \(Hash\) 值相同却不一样的现象称为哈希冲突 ,\(Hash\) 函数值之所以冲突,需要从它实现的原理来看。
Hash的实现
在 XCPC 之中字符串哈希常用的实现形式为进制哈希。
其中较为常见的一种构造方式是对于长度为 \(len\) 、下标从 \(1\) 开始的字符串 \(s\) ,其哈希函数 \(f(s)=\sum_{i=1}^{len}s[i]\times base^{len-1}( \bmod\;M)\)
其中 \(s[i]\) 为第 \(i\) 个位置的字符的ASCii码, \(base\) 为选定的进制, \(M\) 为选定的模数
本文接下来所介绍内容均以该式为基础分别介绍三种哈希的实现方式
Hash模数与进制数的选择
由于存在哈希冲突,因此进制与模数并不是随意选取的,例如 “YZT” 与 "GOD" 在 \(3883\) 进制,模 \(4173\) 的意义下哈希值相同!
因此为了减少哈希冲突的可能性,我们所选择的模数 \(M\) 与进制数 \(base\) 都应当为质数以减小冲突概率。但是总会有良(du)心(liu)出题人会去卡一些常用模数,因此我们还可以使用双模哈希。
哈希的使用
我们得到一个字符串对应的哈希函数后,难道我们只能用他们来比较两个整串是否相同吗?显然是错误的,哈希的功能相当之强大!
先看哈希函数最基础的构建
\(f(pre(s,1))=s[1]\)
\(f(pre(s,2))=f(pre(s,1))\times base+s[2]=s[1]\times base^1+s[2]\)
\(f(pre(s,3))=f(pre(s,2))\times base+s[3]=s[1]\times base^2+s[2]\times base^1+s[3]\)
考虑 \(pre(s,i)\) 的哈希函数:
\(f(pre(s,i))=s[1]*base^{i-1}+s[2]*base^{i-2}+...+s[i]*base^{0}\)
利用前缀和的思想,我们可以得到
\(f(s[l,r])=s[l]*base^{r-l}+...+s[r-1]*base^1+s[r]*base^{0}\)
不难得出下式成立:
\(f(s[l,r])=f(pre(s,r))-f(pre(s,l-1))\times base^{r-l+1}\)
其中我们可以对 \(base\) 的幂次做时间复杂度为 \(O(n)\) 的预处理,便可以在已知两字符串哈希函数的情况下以 \(O(1)\) 的时间复杂度判断两串是否相等。
代码实现
自然溢出
自然溢出为单模哈希的一种特殊实现形式,主要是利用了 unsigned long long
数据类型的特性,会自动对 \(2^{64}\) 取模,优点是代码实现简单且运行速度最快,缺点是冲突几率高
点击查看代码
struct Hash
{
using ull = unsigned long long;
const int base = 31;
vector<ull> p, h;
int n;
string s;
void init(string str)
{
n = str.size();
s = "$" + str; //使得下标从1开始
p.resize(n + 10), h.resize(n + 10);
p[0] = 1, h[0] = 0;
for (int i = 1; i <= n; i++)
p[i] = p[i - 1] * base;
for (int i = 1; i <= n; i++)
h[i] = h[i - 1] * base + s[i];
}
ull get(int l, int r)
{
return h[r] - h[l - 1] * p[r - l + 1];
}
};
单模哈希
单模哈希可以解决大部分哈希问题,优点是代码实现较为简单且冲突几率较低,缺点是会被刻意卡掉
点击查看代码
struct Hash
{
using i64 = long long;
const int base = 31;
const int mod = 1e9 + 7;
vector<i64> p, h;
int n;
string s;
void init(string str)
{
n = str.size();
s = "$" + str; //使得下标从1开始
p.resize(n + 10), h.resize(n + 10);
p[0] = 1, h[0] = 0;
for (int i = 1; i <= n; i++)
p[i] = p[i - 1] * base % mod;
for (int i = 1; i <= n; i++)
h[i] = (h[i - 1] * base + s[i]) % mod;
}
i64 get(int l, int r)
{
return ((h[r] - h[l - 1] * p[r - l + 1]) % mod + mod) % mod;
}
};
双模哈希
在单模哈希被卡掉以后可以尝试使用,优点是不易被卡,缺点是码量较大且运行速度慢
点击查看代码
struct Hash
{
using i64 = long long;
const int base1 = 31, base2 = 29;
const int mod1 = 1e9 + 7, mod2 = 1e9 + 9;
vector<array<i64, 2>> p, h;
int n;
string s;
void init(string str)
{
n = str.size();
s = "$" + str; //使得下标从1开始
p.resize(n + 10), h.resize(n + 10);
p[0][0] = p[0][1] = 1, h[0][0] = h[0][1] = 0;
for (int i = 1; i <= n; i++)
{
p[i][0] = p[i - 1][0] * base1 % mod1;
p[i][1] = p[i - 1][1] * base2 % mod2;
}
for (int i = 1; i <= n; i++)
{
h[i][0] = (h[i - 1][0] * base1 + s[i]) % mod1;
h[i][1] = (h[i - 1][1] * base2 + s[i]) % mod2;
}
}
pair<i64, i64> get(int l, int r)
{
return {((h[r][0] - h[l - 1][0] * p[r - l + 1][0]) % mod1 + mod1) % mod1, ((h[r][1] - h[l - 1][1] * p[r - l + 1][1]) % mod2 + mod2) % mod2};
}
};
ps:对于 \(p\) 数组的处理只需进行一次即可,若需对多个字符串构建哈希函数请避免多次预处理
习题
-
题意
求 \(n\) 个字符串中有多少个不同的字符串
做法
扔进std::set
里可以将所有字符串排序后,处理每一个字符串的哈希函数,然后相邻的两个字符串两两比较,时间复杂度 \(O(\sum|s|+n\log n)\)
-
求多串的最长公共前缀
题意
给定一个串 \(s\) 和一个串 \(t\) ,包含 \(q\) 次询问,每次询问给定一个数字 \(x\) ,询问 \(suf(s,x)\) 与 \(t\) 的最长公共前缀
做法
预处理出 \(s\) 和 \(t\) 的哈希函数,对于每一次询问,二分长度检查是否相同即可,时间复杂度 \(O(n+q\log n)\)
-
求回文子串个数
题意
给定串 \(s\) ,求满足 \(s[l,r]=rev(s[l,r])\) 的 \((l,r)\) 对数 (rev(s)表示串 s 翻转后得到的串)
做法
二分答案,枚举每个位置作为回文中心(对称轴),哈希判断两侧是否相等。需要分别预处理正着和倒着的哈希值,时间复杂度 \(O(n\log n)\) 。