[笔记]字符串哈希

定义

把一个字符串映射到一个整数的函数称作哈希函数,映射到的这个整数就是这个字符串的哈希值。

需要注意的一点是,哈希是将大空间上的东西(字符串有无穷多个)映射到了小空间(一定范围内的整数),所以注定了它一定会存在冲突,即若干个不同的字符串映射到了相同的哈希值,我们将这种冲突称作“哈希碰撞”。也就是说,不同哈希值的两个字符串一定不同,但相同哈希值的两个字符串也可能不同。

不过在大部分情况下,哈希碰撞发生概率很小。所以我们可以放心地用哈希来表示一个唯一的字符串,进而可以通过哈希值来比较两个字符串是否相等(这也是哈希最重要的性质)。

减少哈希碰撞概率的方法后面会提到。

多项式哈希函数

哈希函数有很多种,比较常用的是多项式类型(下面默认字符串下标从\(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\)是一个较大的正整数(一般选取素数,表示值域)。

子串哈希值快速计算

Libre OJ #103.子串查找

给定\(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_tableunordered_map都可以自定义哈希函数,这样更加优雅而且也可以跑得飞快,具体见https://www.cnblogs.com/week-end/articles/17652672.htmlhttps://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.

posted @ 2024-07-29 16:37  Sinktank  阅读(194)  评论(0编辑  收藏  举报
★CLICK FOR MORE INFO★ TOP-BOTTOM-THEME
Enable/Disable Transition
Copyright © 2023 ~ 2024 Sinktank - 1328312655@qq.com
Illustration from 稲葉曇『リレイアウター/Relayouter/中继输出者』,by ぬくぬくにぎりめし.