【笔记】字符串哈希
对于两个字符串 \(s,t\),如果要判断其是否相等,则需要分别比较每一个字符。而使用哈希函数将字符串映射可以更方便地完成判断。
有哈希函数 \(f(s)\),我们希望:
- 当 \(f(s)\neq f(t)\) 时,\(s\neq t\)。
- 当 \(f(s)=f(t)\) 时,\(s\) 可能等于 \(t\),也可能不相等。我们希望他们相等,而不相等的情况称为哈希冲突。
我们要做的就是构造一个哈希函数,使得哈希冲突最好没有。这样我们就能通过比较两个字符串的哈希值准确判断相等。
通常我们采用多项式哈希(进制哈希)的方式。我们定义一个基底 \(base\)(通常是质数,推荐 \(131\)、\(233\)、\(13331\)),然后把原字符串转为一个 \(base\) 进制数,再对其取模一个大数(需要与 \(base\) 互质,因此常用一些大质数,如 \(998244353\)、\(10^9+7\)、\(1222827239\)、\(212370440130137957\),也可以用 unsigned long long 自然溢出的方法,相当于取模 \(2^{64}\) 以此扩大模数),最终得到哈希值。
const int base=233;
const int p=998244353;
int hash(string s){
int res=0;
for(int i=0;i<s.size();i++){
res=(1ll*res*base+s[i])%p;
}
return res;
}
单个模数的哈希容易产生冲突,我们可以对多个数取模得到多个哈希值,然后分别比较是否相等,这样值域就扩大到了两模数的乘积。一般来说两个模数就已足够(目前暂无卡确定模数的双哈希的方法)。
子串哈希
多次询问一个字符串子串的哈希,每次计算哈希值复杂度与暴力没有区别。
于是我们可以对字符串预处理出每个前缀的哈希,用类似前缀和的思想求出子串哈希。
根据进制哈希的计算方法,我们有 \(s\) 长度为 \(i\) 的前缀子串的哈希 \(f_i(s)=s[1]\cdot base^{i-1}+s[2]\cdot base^{i-2}+\cdots+s[i-1]\cdot base+s[i]\)。那么 \(s[l..r]\) 的哈希为 \(f(s[l..r])=f_r(s)-f_{l-1}(s)\times b^{r-l+1}\)。预处理 \(b^{r-l+1}\) 即可在 \(O(1)\) 求得。
Luogu P4503 [CTSC2014] 企鹅 QQ
想一下怎么枚举。首先你不可能枚举两两字符串对,去比较他们是不是只差一个。这样肯定是不对的。
然后你就得换一个维度考虑,去枚举那个不相同的字符。
假设这个字符出现在第 \(i\) 位,那么可以构成相似字符串对的两个串,必须在删掉第 \(i\) 位之后完全相同。
这时候我们就可以用子串哈希去求一下每个串删掉第 \(i\) 位后的哈希值。具体来说就是求出 \(s[1..i-1]\) 和 \(s[i+1..L]\) 的哈希值,把前面串的值 \(\times b^{L-i}\) 再加上后面串的值,拼起来就是整个的哈希值。求出来以后把他们放一块排序,看看有多少组相同串。对于一组总共有 \(k\) 个相同串的组,其对答案的贡献为 \(k\times(k-1)\div2\)。
这样时间复杂度是 \(O(LN\log N)\),可以通过。
为了防止被卡写了双哈希。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=3e4+10;
const int base=233;
const int MOD1=998244353;
const int MOD2=1e9+7;
int n,L,S,ans;
int pob1[N],pob2[N];
string s[N];
struct Node{
int hs1,hs2;
}hs[N][210],a[N];
int calc1(int pos,int l,int r){
if(l>r) return 0;
return (hs[pos][r].hs1-hs[pos][l-1].hs1*pob1[r-l+1]%MOD1+MOD1)%MOD1;
}
int calc2(int pos,int l,int r){
if(l>r) return 0;
return (hs[pos][r].hs2-hs[pos][l-1].hs2*pob2[r-l+1]%MOD2+MOD2)%MOD2;
}
bool cmp(Node x,Node y){
if(x.hs1==y.hs1) return x.hs2<y.hs2;
else return x.hs1<y.hs1;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n>>L>>S;
pob1[0]=pob2[0]=1;
for(int i=1;i<=n;i++){
s[i]=" ";
for(int j=1;j<=L;j++){
char c;
cin>>c;
s[i]+=c;
hs[i][j].hs1=(hs[i][j-1].hs1*base+s[i][j])%MOD1;
hs[i][j].hs2=(hs[i][j-1].hs2*base+s[i][j])%MOD2;
}
}
for(int i=1;i<=L;i++){
pob1[i]=(pob1[i-1]*base)%MOD1;
pob2[i]=(pob2[i-1]*base)%MOD2;
}
for(int i=1;i<=L;i++){
for(int j=1;j<=n;j++){
a[j].hs1=(calc1(j,1,i-1)*pob1[L-i]%MOD1+calc1(j,i+1,L))%MOD1;
a[j].hs2=(calc2(j,1,i-1)*pob2[L-i]%MOD2+calc2(j,i+1,L))%MOD2;
}
sort(a+1,a+1+n,cmp);
int cnt=0,l=1,r=1;
while(l<=n&&r<=n){
while(r<=n&&a[r].hs1==a[l].hs1&&a[r].hs2==a[l].hs2) r++;
cnt=r-l;
ans+=cnt*(cnt-1)/2;
l=r;
}
}
cout<<ans;
return 0;
}
Luogu P4824 [USACO15FEB] Censoring S
维护一个栈,把 \(S\) 从前往后依次入栈,每次检查栈顶是否是 \(T\),是就删除。最后把留在栈中的字符从底到顶输出。用字符串哈希快速判断栈顶是不是 \(T\),且删除栈顶若干个元素后,因为递推前面元素的哈希不受影响,所以可行。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e6+10;
const int B=233;
const int MOD1=998244353;
const int MOD2=1e9+7;
int n,m;
string s,t;
int hsT1,hsT2;
int powb1[N],powb2[N];
struct Node{
int hsS1,hsS2;
int pos;
}st[N];
int top;
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>s>>t;
n=s.size(),m=t.size();
s=' '+s,t=' '+t;
powb1[0]=powb2[0]=1;
for(int i=1;i<N;i++){
powb1[i]=powb1[i-1]*B%MOD1;
powb2[i]=powb2[i-1]*B%MOD2;
}
for(int i=1;i<=m;i++){
hsT1=(hsT1*B%MOD1+t[i])%MOD1;
hsT2=(hsT2*B%MOD2+t[i])%MOD2;
}
for(int i=1;i<=n;i++){
top++;
st[top].hsS1=(st[top-1].hsS1*B%MOD1+s[i])%MOD1;
st[top].hsS2=(st[top-1].hsS2*B%MOD2+s[i])%MOD2;
st[top].pos=i;
while(top>=m){
int topS1=(st[top].hsS1-st[top-m].hsS1*powb1[m]%MOD1+MOD1)%MOD1;
int topS2=(st[top].hsS2-st[top-m].hsS2*powb2[m]%MOD2+MOD2)%MOD2;
if(topS1==hsT1&&topS2==hsT2) top-=m;
else break;
}
}
for(int i=1;i<=top;i++) cout<<s[st[i].pos];
return 0;
}
UVA11019 Matrix Matcher
二维哈希。
模仿二维前缀和的形式,我们给行和列附上两个不同的 \(base\),在这个矩阵第 \(i\) 行第 \(j\) 列的元素会乘上 \(base_1^{n-i}\times base_2^{m-j}\)。
然后在 \(A\) 矩阵里枚举一下即可。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1010;
const int M=110;
const int B1=131;
const int B2=2113;
const int MOD=920121431;
int T,n,m,p,q;
char A[N][N],B[M][M];
int powb1[N],powb2[N];
int hA[N][N],hB[M][M];
void getHash(){
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
hA[i][j]=(((hA[i-1][j]*B1%MOD+hA[i][j-1]*B2%MOD)%MOD-hA[i-1][j-1]*B1%MOD*B2%MOD+MOD)%MOD+A[i][j])%MOD;
}
}
for(int i=1;i<=p;i++){
for(int j=1;j<=q;j++){
hB[i][j]=(((hB[i-1][j]*B1%MOD+hB[i][j-1]*B2%MOD)%MOD-hB[i-1][j-1]*B1%MOD*B2%MOD+MOD)%MOD+B[i][j])%MOD;
}
}
}
int calc1(int x,int y,int a,int b){
return (((hA[x][y]-hA[x][b]*powb2[q]%MOD+MOD)%MOD-hA[a][y]*powb1[p]%MOD+MOD)%MOD+hA[a][b]*powb2[q]%MOD*powb1[p]%MOD)%MOD;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>T;
powb1[0]=powb2[0]=1;
for(int i=1;i<N;i++){
powb1[i]=powb1[i-1]*B1%MOD;
powb2[i]=powb2[i-1]*B2%MOD;
}
while(T--){
memset(A,0,sizeof(A));
memset(B,0,sizeof(B));
memset(hA,0,sizeof(hA));
memset(hB,0,sizeof(hB));
cin>>n>>m;
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++) cin>>A[i][j];
}
cin>>p>>q;
for(int i=1;i<=p;i++){
for(int j=1;j<=q;j++) cin>>B[i][j];
}
getHash();
int cnt=0;
for(int i=p;i<=n;i++){
for(int j=q;j<=m;j++){
if(calc1(i,j,i-p,j-q)==hB[p][q]) cnt++;
}
}
cout<<cnt<<'\n';
}
return 0;
}
UVA11475 Extend to Palindrome
相当于找到 \(S\) 最长的回文后缀,这部分就不用动,然后把剩下的前缀翻转复制到最后得到 \(S^*\)。
容易发现判断一个字符串是否回文,只需要求出该串和反转串的哈希值,然后判断一正一反两个哈希值是否相等。
然后枚举后缀串即可。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+10;
const int B=233;
const int MOD1=998244353;
const int MOD2=1e9+7;
int n;
string s;
int powb1[N],powb2[N];
int hpre1[N],hsuf1[N];
int hpre2[N],hsuf2[N];
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
powb1[0]=powb2[0]=1;
for(int i=1;i<N;i++){
powb1[i]=powb1[i-1]*B%MOD1;
powb2[i]=powb2[i-1]*B%MOD2;
}
while(cin>>s){
memset(hpre1,0,sizeof(hpre1));
memset(hpre2,0,sizeof(hpre2));
memset(hsuf1,0,sizeof(hsuf1));
memset(hsuf2,0,sizeof(hsuf2));
n=s.size();
s=' '+s;
for(int i=1;i<=n;i++){
hpre1[i]=(hpre1[i-1]*B+s[i])%MOD1;
hpre2[i]=(hpre2[i-1]*B+s[i])%MOD2;
}
for(int i=n;i>=1;i--){
hsuf1[i]=(hsuf1[i+1]*B+s[i])%MOD1;
hsuf2[i]=(hsuf2[i+1]*B+s[i])%MOD2;
}
int m=0;
for(int i=0;i<n;i++){
int pre1=(hpre1[n]-hpre1[i]*powb1[n-i]%MOD1+MOD1)%MOD1;
int pre2=(hpre2[n]-hpre2[i]*powb2[n-i]%MOD2+MOD2)%MOD2;
if(pre1==hsuf1[i+1]&&pre2==hsuf2[i+1]){
m=i;
break;
}
}
for(int i=1;i<=n;i++) cout<<s[i];
for(int i=m;i>=1;i--) cout<<s[i];
cout<<'\n';
}
return 0;
}
Codeforces 1200E Compress Words
同时维护合并串与当前串的哈希,枚举 \(i\) 找到最长公共前后缀,然后从当前串的 \(i+1\) 位开始把哈希值拼到合并串的后面,同时输出。
写 CF 一定要用双哈希。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e6+10;
const int B=233;
const int MOD1=998244353;
const int MOD2=1e9+7;
int n,m,len;
int now1[N],now2[N];
int powb1[N],powb2[N];
string s;
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
powb1[0]=powb2[0]=1;
for(int i=1;i<N;i++){
powb1[i]=powb1[i-1]*B%MOD1;
powb2[i]=powb2[i-1]*B%MOD2;
}
for(int i=1;i<=n;i++){
cin>>s;
m=s.size();
s=' '+s;
if(i==1){
for(int i=1;i<=m;i++){
len++;
now1[len]=(now1[i-1]*B+s[i])%MOD1;
now2[len]=(now2[i-1]*B+s[i])%MOD2;
cout<<s[i];
}
continue;
}
int p=0,hs1=0,hs2=0;
for(int i=1;i<=m;i++){
if(i>len) break;
hs1=(hs1*B%MOD1+s[i])%MOD1;
hs2=(hs2*B%MOD2+s[i])%MOD2;
int suf1=(now1[len]-now1[len-i]*powb1[i]%MOD1+MOD1)%MOD1;
int suf2=(now2[len]-now2[len-i]*powb2[i]%MOD2+MOD2)%MOD2;
if(hs1==suf1&&hs2==suf2) p=i;
}
for(int i=p+1;i<=m;i++){
len++;
now1[len]=(now1[len-1]*B+s[i])%MOD1;
now2[len]=(now2[len-1]*B+s[i])%MOD2;
cout<<s[i];
}
}
return 0;
}
字符串哈希还能用来解决一些其他经典算法处理的问题。
Luogu P3805 【模板】Manacher
\(O(n\log n)\) 做法:枚举回文串中心点,二分回文串长度。
\(O(n)\) 做法:其实我们并不关心以每个位置为中心的最长回文串是多长,因此只需要去判断当前最大串长能否在这个位置被更新,直接从上一次的串长往两侧扩展,这样就能做到均摊 \(O(n)\)。
奇数长度和偶数长度的串要分别处理。
然后这个题数据范围比较大,也没有特意卡哈希,所以用单模数 + 自然溢出这个常数较小的实现更好。
#include<bits/stdc++.h>
#define ull unsigned long long
using namespace std;
const int N=1.1e7+10;
const int B=131;
int n,ans=1;
string s;
ull hsl[N],hsr[N],pob[N];
bool check(int l,int r){
if(l>r) return 0;
ull hs1=hsl[r]-hsl[l-1]*pob[r-l+1];
ull hs2=hsr[l]-hsr[r+1]*pob[r-l+1];
return hs1==hs2;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>s;
n=s.size();
s=' '+s;
pob[0]=1;
for(int i=1;i<=n;i++) pob[i]=pob[i-1]*B;
for(int i=1;i<=n;i++) hsl[i]=hsl[i-1]*B+s[i];
for(int i=n;i>=1;i--) hsr[i]=hsr[i+1]*B+s[i];
for(int i=1;i<=n;i++){
int len=(ans+1)/2;//奇数串长
while(len<i&&len<n-i+1&&check(i-len,i+len)) len++;
len--,ans=max(ans,2*len+1);
len=ans/2;//偶数串长,此时 i 表示中点左边的位置
while(len-1<i&&len<n-i+1&&check(i-len+1,i+len)) len++;
len--,ans=max(ans,2*len);
}
cout<<ans<<'\n';
return 0;
}
变形:Luogu P3501 [POI 2010] ANT-Antisymmetry
就是换皮回文串,在反转基础上增加了取反。这么改动只会造成一个问题,就是奇数长度的串全都不合法了,因为中间点被取反后必不相等。
用 Manacher 把匹配相等改成不相等就好了。用字符串哈希,这里需要求每个位置做回文中心的最长回文串长度,必须用 \(O(n\log n)\) 做法。
统计答案就是,每个位置作回文中心的所有回文串数量之和,也就是最长回文串长之和。
#include<bits/stdc++.h>
#define int long long
#define ull unsigned long long
using namespace std;
const int N=5e5+10;
const int B=233;
int n,ans;
string s;
ull powb[N];
ull pre[N],suf[N];
bool check(int l,int r){
ull hs1=pre[r]-pre[l-1]*powb[r-l+1];
ull hs2=suf[l]-suf[r+1]*powb[r-l+1];
return hs1==hs2;
}
int binsch(int k){
int l=0,r=min(k,n-k);
while(l<r){
int mid=(l+r+1)/2;
if(check(k-mid+1,k+mid)) l=mid;
else r=mid-1;
}
return l;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
cin>>s;
s=' '+s;
powb[0]=1;
for(int i=1;i<=n;i++) powb[i]=powb[i-1]*B;
for(int i=1;i<=n;i++) pre[i]=pre[i-1]*B+s[i];
for(int i=n;i>=1;i--) suf[i]=suf[i+1]*B+((s[i]=='1')?'0':'1');
for(int i=1;i<=n;i++) ans+=binsch(i);
cout<<ans;
return 0;
}
Luogu P8306 【模板】字典树 / Trie
哈希套哈希,先用字符串哈希求出每个串的所有前缀,然后套哈希表计数,记录每个串是多少个字符串的前缀,查询时查询哈希表即可。
此题可以用 unordered_map 卡过,更高效的办法是使用 pb_ds 库或者手写哈希。
#include<bits/stdc++.h>
#define ull unsigned long long
using namespace std;
const int B=233;
int T,n,m;
string s;
unordered_map<ull,int> mp;
void getHash(){
int len=s.size()-1;
ull hashS=0;
for(int i=1;i<=len;i++){
hashS=hashS*B+s[i];
mp[hashS]++;
}
}
int query(){
int len=s.size()-1;
ull hashS=0;
for(int i=1;i<=len;i++) hashS=hashS*B+s[i];
return mp[hashS];
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>T;
while(T--){
cin>>n>>m;
mp.clear();
for(int i=1;i<=n;i++){
cin>>s;
s=' '+s;
getHash();
}
for(int i=1;i<=m;i++){
cin>>s;
s=' '+s;
cout<<query()<<'\n';
}
}
return 0;
}
变形:Atcoder ABC377G Edit to Match
把谜语人题面转化一下:对于每个字符串,要么将其删空,要么删成此前某个字符串的某个前缀,并加字母让其变成那个串。
所以依然是要维护前缀信息的,考虑 Trie 树,不过我们也用哈希套哈希来处理。
我们在前缀哈希值的哈希表里存,删成这个前缀最少需要加多少个字母才能变成一个完整的串。
那么一个串的代价就是,要么是删空,代价是字符串长度,要么枚举一个前缀,如果这个前缀能加字母变成字符串,代价就是删成这个前缀的代价 + 加字母的代价。取一个最小值。
最后更新哈希表,依然枚举前缀,然后用加字母代价即串长 - 前缀长跟哈希表里的值比大小,取小的。
时间复杂度就是 \(\sum\limits_{i=1}^N|S_i|\),哈希表均摊的情况下。
#include<bits/stdc++.h>
#define ull unsigned long long
using namespace std;
const int B=233;
const int INF=1e9;
int n,m;
string s;
unordered_map<ull,int> mp;
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
cin>>s;
m=s.size();
s=' '+s;
int ans=m;
ull hsS=0;
for(int j=1;j<=m;j++){
hsS=hsS*B+s[j];
if(mp.count(hsS)) ans=min(ans,m-j+mp[hsS]);
}
cout<<ans<<'\n';
hsS=0;
for(int j=1;j<=m;j++){
hsS=hsS*B+s[j];
if(!mp.count(hsS)) mp[hsS]=m-j;
else mp[hsS]=min(mp[hsS],m-j);
}
}
return 0;
}
Luogu P3375 【模板】KMP
对于第一问,直接跑字符串哈希然后在 \(s_1\) 中枚举长度为 \(|s_2|\) 的字串。时间复杂度 \(O(|s_1+s_2|)\)。
对于第二问,有一个易得的性质:长度为 \(i+1\) 的前缀的 border,长度至多为长度为 \(i\) 前缀的 border 长度 \(+1\)。
所以我们在枚举前缀时,到达一个新位置直接暴力减小 border 长度直到合法,因为长度至多被增加 \(|s_2|\),所以时间复杂度是均摊 \(O(|s_2|)\)。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e6+10;
const int B=233;
const int MOD1=998244353;
const int MOD2=1e9+7;
int n,m;
string s1,s2;
int powb1[N],powb2[N];
int h11[N],h12[N];
int h21[N],h22[N];
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>s1>>s2;
n=s1.size(),m=s2.size();
s1=' '+s1,s2=' '+s2;
powb1[0]=powb2[0]=1;
for(int i=1;i<N;i++){
powb1[i]=powb1[i-1]*B%MOD1;
powb2[i]=powb2[i-1]*B%MOD2;
}
for(int i=1;i<=n;i++){
h11[i]=(h11[i-1]*B+s1[i])%MOD1;
h12[i]=(h12[i-1]*B+s1[i])%MOD2;
}
for(int i=1;i<=m;i++){
h21[i]=(h21[i-1]*B+s2[i])%MOD1;
h22[i]=(h22[i-1]*B+s2[i])%MOD2;
}
for(int i=m;i<=n;i++){
int subh1=(h11[i]-h11[i-m]*powb1[m]%MOD1+MOD1)%MOD1;
int subh2=(h12[i]-h12[i-m]*powb2[m]%MOD2+MOD2)%MOD2;
if(subh1==h21[m]&&subh2==h22[m]) cout<<i-m+1<<'\n';
}
int len=0;
cout<<"0 ";
for(int i=2;i<=m;i++){
while(len>=0){
int pre1=h21[len+1],suf1=(h21[i]-h21[i-len-1]*powb1[len+1]%MOD1+MOD1)%MOD1;
int pre2=h22[len+1],suf2=(h22[i]-h22[i-len-1]*powb2[len+1]%MOD2+MOD2)%MOD2;
if(pre1==suf1&&pre2==suf2) break;
else len--;
}
len++,cout<<len<<' ';
}
return 0;
}
至此,字符串的全部基础模板都被我们用哈希攻克了。

浙公网安备 33010602011771号