[笔记]字符串哈希
定义
把一个字符串映射到一个整数的函数称作哈希函数,映射到的这个整数就是这个字符串的哈希值。
需要注意的一点是,哈希是将大空间上的东西(字符串有无穷多个)映射到了小空间(一定范围内的整数),所以注定了它一定会存在冲突,即若干个不同的字符串映射到了相同的哈希值,我们将这种冲突称作“哈希碰撞”。也就是说,不同哈希值的两个字符串一定不同,但相同哈希值的两个字符串也可能不同。
不过在大部分情况下,哈希碰撞发生概率很小。所以我们可以放心地用哈希来表示一个唯一的字符串,进而可以通过哈希值来比较两个字符串是否相等(这也是哈希最重要的性质)。
减少哈希碰撞概率的方法后面会提到。
多项式哈希函数
哈希函数有很多种,比较常用的是多项式类型(下面默认字符串下标从\(1\)开始):
\(f(s)=\sum\limits_{i=1}^{|s|}idx(s[i])*b^{n-i}\mod P\)
- 其中的\(idx(c)\)表示的是\(c\)这个字符的顺序,比如\(idx('a')=0,idx('z')=25\)。
需要特别注意的是如果用c-'a'
这样的逻辑来计算\(idx()\)可能会求出负数,因为一些题目的字符串构成可能还有大写字母、数字等。因此你可以取模后再\(+P\)再取模一次避免负数,否则哈希值可能出现错误。但更好的办法就是直接用字符的ASCII码作为\(idx\),避免了多次取模带来的效率损失。 - \(b\)是任意整数(一般取质数,推荐\(131,13331,233\),据说这样碰撞概率会小)。
- \(P\)是一个较大的正整数(一般选取素数,表示值域)。
子串哈希值快速计算
给定\(2\)个字符串\(A,B\),求\(B\)在\(A\)中的出现次数。
\(1\le |A|,|B|\le 10^6\)。
我们知道哈希值可以用于比较字符串是否相等,然而在这道题中如果我们暴力的计算\(A\)每个长度为\(|B|\)的子串哈希值,复杂度就是\(O(n^2)\),完全就是暴力嘛。
实际上,在我们计算\(A\)的哈希值过程中,可以换用递推的方式,用\(d[i]\)表示\(A\)的前\(i\)位的哈希值,则有:
\(d[i]=\begin{cases}
0&i=0\\
d[i-1]\times b+idx(s[i])&i>0
\end{cases}\)
那么\(s[l\sim r]\)的哈希值就是\(d[r]-d[l-1]\times b^{r-l+1}\),可以带入验证理解一下。
点击查看代码
#include<bits/stdc++.h>
#define int long long
#define N 1000010
#define B 131
#define P 1000000007
using namespace std;
string a,b;
int n,m,d[N],powb[N],ans,fb;
int f(int l,int r){//计算a[l~r]的hash值
return ((d[r]-d[l-1]*powb[r-l+1]%P)%P+P)%P;
}
signed main(){
cin>>a>>b;
n=a.size(),m=b.size();
a=' '+a,b=' '+b;
powb[0]=1;
for(int i=1;i<=n;i++){
d[i]=(d[i-1]*B%P+a[i])%P;
powb[i]=powb[i-1]*B%P;
}
for(int i=1;i<=m;i++){
fb=(fb*B%P+b[i])%P;
}//因为b不用求子串hash,所以就不开数组了
for(int i=1;i<=n-m+1;i++){
if(f(i,i+m-1)==fb) ans++;
}
cout<<ans<<"\n";
return 0;
}
哈希碰撞
我们试着计算一下哈希碰撞的概率:
假设值域为\(P\),有\(n\)个字符串,那么第\(i\)个字符串不碰撞的概率就是\(\frac{P-i+1}{P}\)。
相乘得到\(\prod\limits_{i=0}^{n-1}\frac{P-i}{P}\),这是\(n\)个字符串互不碰撞的概率。
通过计算,可以发现在\(P=10^9+7,n=10^6\)时概率约是\(6*10^{-218}\),也就是说几乎一定会发生碰撞。这个结论与生日悖论很相像(一个\(50\)人的班里,至少\(2\)人生日相同的概率大约是\(97\%\))。
当我们把值域\(P\)调至\(10^{18}+9\),不碰撞的概率达到了\(0.9999995\),此时碰撞几乎不可能发生,这与上面的结果是截然不同的。所以你可以尝试用unsigned long long
自然溢出来达到取模的效果,代码实现比较简单,不用单独写取模而且不用特判负数取模,效率也较高。
还有一种方法——双哈希,即使用两个不同的模数,比如\(10^9+7\)和\(10^9+9\)(都是质数)。这样值域就扩大到了两个模数的乘积,效果相同。
当然,对于要比较的字符串较少的情况没有必要使用双哈希(\(n\)在\(10^3\)以内使用模数为\(10^9+7\)的单哈希,均可以达到\(>99.95\%\)的正确率)。
不过,有些狠毒的出题人可能会特意去卡你的单哈希模数(包括unsigned long long
自然溢出,根据你选取底数\(b\)的奇偶性有不同的卡法),比如\(998244353,10^9+7\)这些常用的模数,遇到这种情况可以用一个比较生僻的素数,或者换用双哈希(目前还不存在卡确定模数的双哈希的方法)。所以如果是刷题可以用单哈希,错了可以再改;但打比赛的话还是建议开双哈希,因为可能会特意卡。
例题
P3763 [TJOI2017] DNA
多测,每次给定字符串\(A,B\),请计算与\(B\)匹配的\(A\)的子串个数。
定义“\(S\)与\(T\)匹配”,当且仅当\(|S|=|T|\),且它们不相同的字符个数\(\le 3\)。
这是一个有一定容错的字符匹配问题。仍然可以用哈希解决。
我们先枚举\(A\)的长度为\(|B|\)的子串,设它为\(T\)。
现在我们要判断\(T\)和\(B\)是否匹配。
由于计算子串哈希值是\(O(1)\)的,我们可以倍增计算\(T\)和\(B\)第\(1\)个失配位置(即从最右边开始往左跳,直到再跳一下\(a[l\sim r]\)就和\(b[1\sim r-l+1]\)相等了),然后从上一次的位置往后\(1\)位开始,用相同的方式再找第\(2\)个失配位置,再找第\(3\)个。然后,如果第\(4\)个失配位置存在,则说明不匹配,否则匹配。
这个做法同样可以推广至允许最多\(k\)个位置失配。时间复杂度为\(O(m+kn\log m)\),其中有\(O(n+m)\)是初始化hash;每次倍增是\(O(\log m)\)的,每个子串倍增\(k\)次,一共\(n-m+1\)个子串。
点击查看代码
#include<bits/stdc++.h>
#define int long long
#define K 3
#define N 1000010
#define B 131
#define P 1000000007
using namespace std;
string a,b;
int t,n,m,powb[N],ans;
int da[N],db[N],pow2[20];
void init(int d[],string a,int n){
d[0]=0;
for(int i=1;i<=n;i++) d[i]=(d[i-1]*B%P+a[i])%P;
}
int f(int d[],int l,int r){//计算a[l~r]的hash值
return ((d[r]-d[l-1]*powb[r-l+1]%P)%P+P)%P;
}//两个哈希值相减可能出现负数,需要特殊处理一下
bool solve(int l){
int tl=l,tmp=l+m,r;
for(int cnt=1;cnt<=K+1;cnt++){//为什么要找k+1次,是因为第k+1次的结果决定合不合法
r=tmp;
for(int i=19;i>=0;i--)
if(r-pow2[i]>=l&&f(da,l,r-pow2[i])!=f(db,l-tl+1,r-pow2[i]-tl+1))
r-=pow2[i];
if(r==tmp) return 1;
l=r+1;
}
return 0;
}
signed main(){
pow2[0]=1,powb[0]=1;
for(int i=1;i<20;i++) pow2[i]=pow2[i-1]*2;
for(int i=1;i<N;i++) powb[i]=powb[i-1]*B%P;
cin>>t;
while(t--){
ans=0;
cin>>a>>b;
n=a.size(),m=b.size();
a=' '+a,b=' '+b;
init(da,a,n);
init(db,b,m);
for(int i=1;i<=n-m+1;i++)
ans+=solve(i);
cout<<ans<<"\n";
}
return 0;
}
P3805 【模板】manacher
给定一个字符串\(A\),请计算它的最长回文子串的长度。
\(|A|\le 1.1*10^7\)
这道题其实是想让我们用Manacher算法做,Manacher也是一种字符串算法,是专为解决这种回文子串计数问题设计的,时间复杂度为\(O(n)\),这几天会再写一个Manacher的笔记。而哈希同样可以做到\(O(n)\)的时间复杂度,虽然在常数方面略逊一筹,但面对\(1.1*10^7\)量级的数据,仍然能保持空间和时间的优秀性能。
我们可以想到枚举对称点(如果是偶数长度的回文串,则对称点是中间偏左的那一个位置),然后通过二分找到这个对称点形成的最长回文串,判断对称点左右是否相等,可以正反建立\(2\)个哈希表。
时间复杂度\(O(n\log n)\),尽管数据很***钻,但还是能AC(为什么会被当作屏蔽词啊喂!!)。
其实我们很容易发现不用每次重新二分,只需要在前面计算的答案的基础上看看能不能继续增加对称半径(长度为\(n\)的字符串,对称半径为\(\lceil \frac{n}{2}\rceil\)),如果能增加就更新答案。时间复杂度\(O(n)\)。
奇数长度和偶数长度的回文串需要单独处理,具体见代码。
代码使用单模数哈希,ull
自然溢出,这样常数小点。
点击查看代码
#include<bits/stdc++.h>
#define ull unsigned long long
#define N 11000010
#define B 131
using namespace std;
string s;
int n;
ull ds[N],dr[N],powb[N];
inline void init(ull d[],string a,int n){
d[0]=0;
for(int i=1;i<=n;i++) d[i]=d[i-1]*B+a[i];
}
inline ull f(ull d[],int l,int r){//计算a[l~r]的hash值
return d[r]-d[l-1]*powb[r-l+1];
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
powb[0]=1;
for(int i=1;i<N;i++) powb[i]=powb[i-1]*B;
cin>>s;
n=s.size(),s=' '+s;
init(ds,s,n);
int ans=1;
for(int i=1;i<=n;i++)
dr[i]=dr[i-1]*B+s[n-i+1];
for(int i=1;i<=n;i++){
int len=((ans+1)>>1);//对称半径
while(len<i&&len<n-i+1&&f(ds,i-len,i)==f(dr,n-i+1-len,n-i+1))//奇
len++,ans=(len<<1)-1;//如果len+1也是回文,则扩大len
len=ans>>1;//对称半径,因为是偶数所以向下取整
while(len<i&&len<n-i&&f(ds,i-len,i)==f(dr,n-i-len,n-i))//偶
len++,ans=(len<<1);//和上面同理,注意的边界与上面不同
}
cout<<ans<<"\n";
return 0;
}
代码的判断回文串其实不是很简洁,其实只需要判断反转过来是否相同就行。
原题多大样例,因此附上一些小样例便于调试:
abbbaabaabba
9
abacddc
4
abbadefed
5
LCS2 - Longest Common Substring II
给定若干个字符串,求它们的最长公共子串的长度。
每个字符串长度不超过\(10^5\),字符串个数\(\le 10\)。
如果长度为\(k\)时存在答案,那么\(k-1\)也存在。所以我们可以二分枚举长度\(k\),然后用字符串哈希去枚举每个字符串长度为\(k\)的子串,找到公共部分即可。
时间复杂度为\(O(K\log n)\),其中\(K\)为所有字符串的总长,\(n\)为单个字符串的长度。
注:此题需要手写哈希表(数值哈希,和字符串哈希有些不同,可以自行搜索“哈希表”,并不难)来代替
map
,否则基本卡不过,unordered_map
甚至gp_hash_table
也不行,对于此题来说,哈希表\(2*10^5\)左右的质数模数最为合适(模数大了用不着,而且开大数组需要额外费时间;模数小了冲突严重,时间开销更大。所以哈希表的模数选择\([n,3n]\)的质数较合适,可根据情况调整)。亲测手写哈希表可以跑到170ms,平均运行时间仅有
gp_hash_table
的\(\frac{1}{7}\)。upd 2024/10/02:刚知道
gp_hash_table
和unordered_map
都可以自定义哈希函数,这样更加优雅而且也可以跑得飞快,具体见https://www.cnblogs.com/week-end/articles/17652672.html和https://codeforces.com/blog/entry/62393。
点击查看代码
#pragma GCC optimize("Ofast,unroll-loops")//Ofast,加不加差不多
#include<bits/stdc++.h>
#define N 12
#define M 100010
#define B 131
#define ull unsigned long long
using namespace std;
int n,m[N];
ull d[N][M],powb[M];
string s[N];
void init(ull d[],string a,int n){//初始化字符串哈希
d[0]=0;
for(int i=1;i<=n;i++) d[i]=d[i-1]*B+a[i];
}
inline ull f(ull d[],int l,int r){//查询a[l~r]的哈希值
return d[r]-d[l-1]*powb[r-l+1];
}
struct hsh{//手写哈希表(拉链法)
static const int P=200003;
int w[P],ne[P],head[P],cnt;
void insert(int x){//插入
int k=(x%P+P)%P;
w[++cnt]=x;
ne[cnt]=head[k];
head[k]=cnt;
}
bool count(int x){//查询是否在表中
int k=(x%P+P)%P;
for(int i=head[k];i;i=ne[i]){
if(w[i]==x) return 1;
}
return 0;
}
void clear(){cnt=0;memset(head,0,sizeof head);}
int size(){return cnt;}
}se[N];
bool check(int x){//询问是否存在长度为x的公共子串
int minpos=-1,minsiz=INT_MAX;
for(int i=1;i<=n;i++){
se[i].clear();
for(int j=1;j<=m[i]-x+1;j++){
ull tf=f(d[i],j,j+x-1);
se[i].insert(tf);
}
if((int)se[i].size()<minsiz)
minpos=i,minsiz=se[i].size();
}//↓为了提升效率,用哈希表数据最少的字符串与其他字符串比较
for(int i=1;i<=se[minpos].size();i++){
bool flag(1);
for(int j=1;j<=n;j++){
if(!se[j].count(se[minpos].w[i])){
flag=0;
break;
}
}
if(flag) return 1;
}
return 0;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
powb[0]=1;
for(int i=1;i<M;i++) powb[i]=powb[i-1]*B;
int l=0,r=INT_MAX;
while(cin>>s[++n]){
m[n]=s[n].size();
s[n]=' '+s[n];
r=min(r,m[n]);
init(d[n],s[n],m[n]);
}
n--;
while(l<r){
int mid=(l+r+1)>>1;
if(check(mid)) l=mid;
else r=mid-1;
}
cout<<l<<"\n";
return 0;
}
UVA11475 Extend to Palindrome
多测,每次给定字符串\(S\),请你输出一个字符串\(S^*\),保证:
- \(S\)是\(S^*\)的前缀。
- \(S^*\)是一个回文串。
- \(|S^*|\)要尽可能小。
保证\(|S|\le 10^5\)。
很显然我们要找出\(S\)的一个最长回文后缀,后缀前的内容记为\(S'\),那么答案就是\(S+\text{reverse}(S')\)。找最长回文后缀可以用字符串哈希,枚举\(i\),记录\(da\)表示从\(a[i\sim n]\)的哈希值,\(db\)表示\(a[n\sim i]\)的哈希值,两者相等的位置的最小值就是后缀的开始位置。
处理单个字符串时间复杂度\(O(n)\),空间复杂度\(O(1)\),可以不开数组,边计算边更新答案。
点击查看代码
#include<bits/stdc++.h>
#define B 131
#define ull unsigned long long
#define N 1000010
using namespace std;
string s;
int n;
ull da,db,powb[N];
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr),cout.tie(nullptr);
powb[0]=1;
for(int i=1;i<N;i++) powb[i]=powb[i-1]*B;
while(cin>>s){
n=s.size(),s=' '+s;
da=db=0;
int pos;
for(int i=n;i>=1;i--){
da=da+s[i]*powb[n-i];
db=db*B+s[i];
if(da==db) pos=i;
}
for(int i=1;i<=n;i++) cout<<s[i];
for(int i=pos-1;i>=1;i--) cout<<s[i];
cout<<"\n";
}
return 0;
}
双倍经验:SP4103 EPALIN - Extend to Palindrome
CF1200E Compress Words ~ 洛谷
给定\(n\)个字符串,请按下面的规则,从左往右依次合并\(n\)个字符串,成为\(1\)个字符串:
- 将\(A,B\)合并,就是找到最大的\(i\),使得\(A\)的长为\(i\)的后缀和\(B\)的长为\(i\)的前缀相等,删除\(A\)的这个后缀,并将\(B\)连接到它的后面。
注意每次应该将第\(i\)个字符串与\(1\sim (i-1)\)合并后的结果进行新的一轮合并,而非输入字符串之间合并。
\(n\le 10^5\),字符串总长\(\le 10^6\)。
每合并一个字符串,就用字符串哈希计算出与之前合并好的字符串的公共部分。然后将删除前缀后的\(s[i]\)连接到之前的字符串上,更新当前结果的哈希值即可。根据哈希函数的计算规则,仅需修改刚插入的字符串的哈希值。
需要注意的一点是,CF上的哈希题务必要用双模数哈希,否则赛时基本上会被hack。
点击查看代码
#include<bits/stdc++.h>
#define N 1000010
#define P 1000000007
#define P2 1000000009
#define B 131
#define B2 233
#define ll long long
using namespace std;
int t,n,siz;
ll d[N],d2[N],td[N],td2[N],powb[N],powb2[N];
void init(ll d[],string a,int n){
d[0]=0;
for(int i=1;i<=n;i++) d[i]=(d[i-1]*B%P+a[i])%P;
}
void init2(ll d2[],string a,int n){
d2[0]=0;
for(int i=1;i<=n;i++) d2[i]=(d2[i-1]*B2%P2+a[i])%P2;
}
inline ll f(ll d[],int l,int r){//查询a[l~r]的哈希值
return ((d[r]-d[l-1]*powb[r-l+1]%P)%P+P)%P;
}
inline ll f2(ll d2[],int l,int r){//查询a[l~r]的哈希值
return ((d2[r]-d2[l-1]*powb2[r-l+1]%P2)%P2+P2)%P2;
}
string s;
int main(){
powb[0]=powb2[0]=1;
for(int i=1;i<N;i++) powb[i]=powb[i-1]*B%P;
for(int i=1;i<N;i++) powb2[i]=powb2[i-1]*B2%P2;
cin>>t;
while(t--){
cin>>s;
n=s.size(),s=' '+s;
init(td,s,n);
init2(td2,s,n);
int lim=min(n,siz),i;
for(i=lim;i>=1;i--){
if(f(d,siz-i+1,siz)==f(td,1,i)&&f2(d2,siz-i+1,siz)==f2(td2,1,i)) break;
}
//s[i+1~n]是不重合、需要添加的部分
for(i++;i<=n;i++){
cout<<s[i];
siz++;
d[siz]=(d[siz-1]*B%P+s[i])%P;
d2[siz]=(d2[siz-1]*B2%P2+s[i])%P2;
}
}
return 0;
}
Fin.