Loading

【笔记】字符串哈希

对于两个字符串 \(s,t\),如果要判断其是否相等,则需要分别比较每一个字符。而使用哈希函数将字符串映射可以更方便地完成判断。

有哈希函数 \(f(s)\),我们希望:

  1. \(f(s)\neq f(t)\) 时,\(s\neq t\)
  2. \(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;
}

至此,字符串的全部基础模板都被我们用哈希攻克了。

posted @ 2026-05-04 23:18  Seqfrel  阅读(23)  评论(0)    收藏  举报