[笔记]字符串哈希
定义
把一个字符串映射到一个整数的函数称作哈希函数,映射到的这个整数就是这个字符串的哈希值。
需要注意的一点是,哈希是将大空间上的东西(字符串有无穷多个)映射到了小空间(一定范围内的整数),所以注定了它一定会存在冲突,即若干个不同的字符串映射到了相同的哈希值,我们将这种冲突称作“哈希碰撞”。也就是说,不同哈希值的两个字符串一定不同,但相同哈希值的两个字符串也可能不同。
不过在大部分情况下,哈希碰撞发生概率很小。所以我们可以放心地用哈希来表示一个唯一的字符串,进而可以通过哈希值来比较两个字符串是否相等(这也是哈希最重要的性质)。
减少哈希碰撞概率的方法后面会提到。
多项式哈希函数
哈希函数有很多种,比较常用的是多项式类型(下面默认字符串下标从\(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.
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· NetPad:一个.NET开源、跨平台的C#编辑器
· PowerShell开发游戏 · 打蜜蜂
· 凌晨三点救火实录:Java内存泄漏的七个神坑,你至少踩过三个!