SKULL

一言(ヒトコト)

【寻迹#3】 哈希与哈希表

哈希和哈希表

哈希算法是通过一个哈希函数 \(\operatorname H\) ,将一种数据(包括字符串、较大的数等)转化为能够用变量表示或是直接就可作为数组下标的数,通过哈希函数转化得到的数值我们称之为哈希值。通过哈希值可以实现快速查找和匹配。哈希算法具体应用有:字符串 \(\operatorname {Hash}\) 、哈希表。

一、字符串 \(\operatorname{Hash}\)

1.字符串匹配问题

寻找长度为 \(n\) 的主串 \(S\) 中的匹配串 \(T\) (长度为 \(m\) )出现的位置或次数的问题属于字符串匹配问题。朴素的想法是枚举所有起始的位置,再直接检查是否匹配。字符串 \(\operatorname {Hash}\) 的原理是直接比较长度为 \(m\) 的主串 \(S\) 的字串的哈希值与 \(T\) 的哈希值是否相等。

2.具体流程

如果我们用 \(O(m)\) 的时间直接计算长度为 \(m\) 的字符串的哈希值,总的时间复杂度并没有改观。所以需要用到滚动哈希的优化技巧。

所以我们选取两个互质常数 \(b\)\(h\)\(b<h\) ),假设字符串 \(C=c_1c_2...c_m\) ,那么我们定义哈希函数 \(\operatorname H(C)=(c_1b^{m-1}+c_2b^{m-2}+...+c_mb^{0}) \bmod h\) 。仔细观察这个式子发现很像 \(b\) 进制下的某数字的展开。只不过这里将字符 \(c_i\) 看作为一个数字。

这一个过程是递推计算的,设 \(\operatorname H(C,k)\) 为前 \(k\) 个字符构成的字符串的哈希值,则: \(\operatorname H(C,k+1)=\operatorname H(C,k)\times b+c_{k+1}\)

举个例子:现有字符串 \(C=acda\) (为方便处理我们可以令 \(a\) 表示 \(1\)\(b\) 表示 \(2\) ,以此类推),则:

\(\operatorname H(C,1)=1\)

\(\operatorname H(C,2)=H(C,1)\times b+c_2=1\times b+3\)

$\operatorname H(C,3)=H(C,2)\times b+c_3=1\times b^2+3\times b+4 $

$\operatorname H(C,4)=H(C,3)\times b+c_4=1\times b^3+3\times b^2+4\times b +1 $

通常题目要求的是判断主串的一段字符与另一个匹配串是否匹配,即判断字符串 \(C=c_1c_2...c_m\) 从位置 \(k+1\) 开始的长度为 \(n\) 的字串 \(C'=c_{k+1}c_{k+2}...c_{k+n}\) 与另一匹配串 \(S=s_1s_2...s_m\) 的哈希值是否相等,则等价于判断 \(\operatorname H(C')=\operatorname H(C,k+n)-\operatorname H(C,k)\times b^n\) 是否与 \(\operatorname H(S)\) 相等。

所以只需要预处理 \(b^n\) 既可以在 \(O(1)\) 的复杂度内得到任意字符串的子串哈希值,从而完成字符串匹配,那么上述字符串匹配问题的算法复杂度就为 \(O(n+m)\)

借助上面的例子,假设 \(S=cd\) ,那么当 \(k=1,n=2\) 时,

\(\operatorname H(C')=H(C,1+2)-H(C,1)\times b^2=3\times b+4\)

\(\operatorname H(S)=3\times b+4=H(C')\) ,所以就可以认为 \(C'\)\(S\) 是匹配的。

可以将 \(b\) 一个较大的定质数,这样在使用 $\operatorname{unsigned} \operatorname{long} \operatorname{long} $ 类型计算时可以自然溢出,一定程度上减少了哈希冲突的产生。(自己声明一个 \(MOD\) 用来取余也可以)

二、哈希表

1.定义

哈希表是一种高效的数据结构。它的优点是查找的算法时间复杂度是常数,缺点是会消耗较多的内存。但是以空间换时间,还是一种常用的策略。所以哈希表还是有着极其不错的应用价值。

2.实现

先从一个具体的例子来感受哈希表的实现方法。假设有一个含 \(n\) 个数的数列,我们只需要开一个数组,依次将数组里的数存储到 \(a_1,a_2,...,a_n\) 中即可。但是这样做的缺点是当我们需要查找某一元素时,就需要遍历全部数组或者二分查找。所以查找复杂度为 \(O(1)\) 的算法就是对于每一个 \(key\) 值,直接令 \(A_{key}=key\) ,下标就是对应的数值。但是这样还有一个问题就是如果数据特别大,数据规模很广,对空间的开销又过于大了。于是我们想到字符串 \(\operatorname{Hash}\) 中有一个操作就是自然溢出,换句话说就是取模。所以我们可以设计一个函数 \(\operatorname H(key)=key \mod 13\) 再令 \(A_{\operatorname H(key)}=key\)

可是这样依旧不够完美,因为取余不能保证结果唯一,就比如 \(1\)\(40\) 取余 \(13\) 的结果都为 \(1\) 。但这个问题也好解决,只需要像链表一样,将取余后结果相同的数据链接在同一下标处即可。

3.哈希函数的构造

大致了解了哈希表的存储方式以后,最后的问题就在于如何构造哈希函数。哈希函数没有硬性的规定,只要尽可能减少哈希冲突,就是好的函数,下面给出几种例子。

(1)余数法

就是上文提及到的。选择一个适当正整数 \(b\) 构造 \(\operatorname{H}(x)=x\bmod b\) 。一般地说,如果 \(b\) 的约数越多,那么冲突的几率就越大。

(2)乘积取整法

选择一个实数 \(A\) ,(\(A\in(0,1)\))作为乘数(最好是无理数,例如 \(\dfrac{\sqrt{5}-1}{2}\) 的实际效果就很好),将目标值 \(k\)\(A\) ,得到一个 \((0,k)\) 间的实数,然后取其小数部分,乘以哈希表的大小 \(M\) 再向下取整,即得 \(k\) 在哈希表的位置。可以表达为 $\operatorname{H}(x)= \left\lfloor\ M(x\times A \bmod 1) \right\rfloor $

(3)基数转化法

就是进制转化。将 \(k\) 看作 \(n\) 进制下的一个数然后将 \(k\) 重新转化为十进制数,再用取余。一般选用大于 \(10\) 的数作为转换的基数( \(n>10\) ),且最好满足 \(\gcd(n,10)=1\)

三、题单

1.【模板】字符串哈希

image

思路:

模板题。读入所有字符串后,对所有字符串进行字符串哈希,然后将哈希值存储。最后看有多少个不同的哈希值即可。因为有自然溢出,可以减少哈希冲突。

代码:

#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
#define N 10050
#define M 1550
int n,l[N],b=131,cnt;
char ch[N][M];
ull Hash[N];
bool cmp (ull a,ull b) { return a>b; }
int main()
{
	cin>>n;
	for(int i=1;i<=n;i++) { scanf("%s",ch[i]);l[i]=strlen(ch[i]); }
	for(int i=1;i<=n;i++)
		for(int j=1;j<=l[i];j++)
			Hash[i]=Hash[i]*b+(ch[i][j]-'A'+1);
	sort(Hash+1,Hash+1+n,cmp);
	for(int i=1;i<=n;i++) if(Hash[i]!=Hash[i+1]) cnt++; 
	cout<<cnt<<endl;
	return 0;
}

2.子串查找

image

思路:

应用前缀和的思想 \(O(n)\) 求出以 \(i\) 为终点的字符串的哈希值,再求出目标字符串的哈希值。枚举每一个起点, \(O(1)\) 判断以 \(i\) 为起点长度为目标字符串长度的子串是否与目标字符串匹配。

代码:

#include<bits/stdc++.h>
using namespace std;
#define N 1000050
typedef unsigned long long ull;
char ch[N],s[N];
int n,m,cnt;
int b=131;
ull power[N],Hash[N],sum;
int main()
{
	scanf("%s",ch+1);
	scanf("%s",s+1);
	n=strlen(ch+1);m=strlen(s+1);
	power[0]=1;Hash[0]=0;sum=0;
	for(int i=1;i<=n;i++) power[i]=power[i-1]*b;
	for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*b+(ull)(ch[i]-'A'+1);
	for(int i=1;i<=m;i++) sum=sum*b+(ull)(s[i]-'A'+1);
	for(int i=0;i<=n-m;i++)
		if(sum==Hash[i+m]-Hash[i]*power[m]) cnt++; 
	cout<<cnt<<endl;
	return 0;
} 

3.Seek the name,seek the fame

image

思路:

先预处理出以 \(i\) 为终点的字符串的哈希值。然后枚举每一个位置 \(i\) ,判断开头前 \(i\) 个字母与最后 \(i\) 个字母构成的字符串的哈希值是否相等即可。时间复杂度 \(O(n)\)

代码:

#include<bits/stdc++.h>
using namespace std;
#define N 400050
typedef unsigned long long ull;
ull power[N],Hash[N];
int b=131,n,cnt,ans[N];
string ch,s;
int main()
{
	while(cin>>s)
	{
		cnt=0;power[1]=b;
		ch=' '+s;
		memset(ans,0,sizeof(ans));
		memset(Hush,0,sizeof(Hash));
		n=s.length();
		for(int i=2;i<=n;i++) power[i]=power[i-1]*b;
		for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*b+(ull)(ch[i]-'A'+1);
		for(int i=1;i<=n;i++)
			if(Hash[i]==(Hash[n]-Hash[n-i]*power[i])) cout<<i<<" ";
		puts("");
	}
	return 0;
} 

4.power string

image

思路:

一开始没有想明白复杂度为什么是对的。

暴力枚举分段点,写一个 \(\operatorname{check}\) 函数检验分段点是否合理,因为题目中说分段数尽可能大,所以找到第一个分段点可以直接结束。

检验时算出第一段的哈希值,后面几段依次比对看看是否均相同,不相同就直接返回继续枚举。

可能原因:估计复杂度是 \(O(n\sqrt{n})\) ,但是很可能跑不满。所以就冲过去了?

代码:

#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
#define N 1000050
char ch[N];
int n,b=131,ans;
ull power[N],Hash[N],sum;
inline void init() { power[0]=1;for(int i=1;i<=n;i++) power[i]=power[i-1]*b; }
int check(int x)
{
	Hash[0]=0;
	for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*b+(ull)(ch[i]-'A'+1);
	sum=Hash[x];
	for(int i=x;i<=n-x;i+=x)
		if(Hash[i+x]-Hash[i]*power[x]!=sum) return 0;
	return 1;
}
int main()
{
	while(1)
	{
		scanf("%s",ch+1);
		n=strlen(ch+1);
		init();
		if(n==1&&ch[1]=='.') break;
		int book=0;
		for(int i=1;i<=n;i++)
			if(!(n%i))
				if(check(i)) { ans=n/i;book=1;break; }
		if(book==0) cout<<1<<endl;
		else cout<<ans<<endl;
	}
	return 0;
}  

5.Three Friends

image

思路:

先从简单的地方思考。

首先 \(n\) 一定是奇数,并且如果最后有解,那么将字符串分成两半,一定至少有一半就是正确答案(字符串 \(S\) )。

所以我们大致有了一个思路就是将字符串拆成两半,对左边一半枚举每一个位置的字符被删除后的情况。做完后再对右边进行相同的操作,每找到一个可以被删除的字符 \(cnt\) 就统计一下答案,最后如果 \(cnt=1\) 就说明答案唯一, \(cnt=0\) 说明无解,否则有多组解。

这样我们的时间复杂度是 \(O(n)\) ,面对 \(n\leq2\times 10^6\) 这种数据范围,我们需要尽可能快的方法来检验该位置是否可以删除字符。

于是我们想到字符串 \(\operatorname{Hash}\) 是用前缀和处理的,如果我们能够直接计算出修改字符后哈希值的变化,那每一次判断时间复杂度就变为 \(O(1)\)

我们已知 \([l,r]\) 内的哈希值可以表示为 \(\operatorname{Cal}(l,r)= Hash[r]-Hash[l-1]\times b^{r-l+1}\) ,如果现在 \([l,r]\)\(x\) 位置的字符被删除那么哈希值该怎么表示呢?

我们知道哈希值的表示可以看作是 \(b\) 进制下的数,那么我们可以将删除后 \([l,r]\) 内的哈希值表示为 \(\operatorname{Modify}(l,r)=\operatorname {Cal}(l,x-1)\times b^{r-x}+\operatorname{Cal}(x+1,r)\)

到此所有问题都解决,代码实现即可。注意部分细节。

代码:

#include<bits/stdc++.h>
using namespace std;
#define N 2000050
typedef unsigned long long ull;
ull power[N],Hash[N],sum1,sum2;
char ch[N];
string a,b,c,d;
int n,p=131,mid,cnt;
inline ull Cal(int l,int r) { return (Hash[r]-Hash[l-1]*power[r-l+1]); }
inline ull modify(int l,int r,int k) { return (Cal(l,k-1)*power[r-k]+Cal(k+1,r)); }
int main()
{
	cin>>n;
	scanf("%s",ch+1);
	if(!(n&1)) { puts("NOT POSSIBLE");return 0; }
	power[0]=1;
	for(int i=1;i<=n;i++) power[i]=power[i-1]*p;
	for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*p+(ull)(ch[i]-'A'+1);
	mid=(1+n)>>1;
	sum1=Cal(mid+1,n);
	for(int i=mid+1;i<=n;i++) a.push_back(ch[i]);
	for(int i=1;i<=mid;i++)
	{
		sum2=modify(1,mid,i);
		if(sum1==sum2)
		{
			cnt++;
			c=a;
			break;
		}
	}
	sum2=Cal(1,mid-1);
	for(int i=1;i<mid;i++) b.push_back(ch[i]);
	for(int i=mid;i<=n;i++)
	{
		sum1=modify(mid,n,i);
		if(sum1==sum2)
		{
			cnt++;
			d=b;
			break;
		}
	}
	if(!cnt) puts("NOT POSSIBLE");
	else if(cnt==1||c==d) 
	{
		if(c.size()) cout<<c<<endl;
		else cout<<d<<endl;
	}
	else puts("NOT UNIQUE");
	return 0;
} 

6.可怕的诗

image

思路:

有点像前面做过的一道题,但是这个询问次数有点吓人,每次做一遍肯定超时。(是一道卡常题)

首先关于循环节我们需要知道:循环节是区间字符串长度的约数;如果 \(n\) 是一个循环节,那么 \(k\times n\) 也一定是一个循环节。

假设 \(\operatorname {Cal}(l,r)\) 表示字符串区间 \([l,r]\) 内的哈希值。考虑找最小的循环节就是找最小的 \(len\) 使得 \(\operatorname{Cal}(l,r-len)=\operatorname{Cal}(l+len,r)\)

所以我们的思路是令 \(len\) 一开始为区间长度,用 \(fac_{len}\) 存储 \(len\) 的最小质因子。

如果当前 \(len\) 是循环节的长度则接着判断 \(len/fac_{len}\) 是否可以,重复执行直到不可以为止。则最后成立的 \(len\) 就是最小循环节长度。

最小质因子可以用线性筛来实现。

代码:

#include<bits/stdc++.h>
using namespace std;
#define N 500050
typedef unsigned long long ull;
ull Hash[N],power[N];
char ch[N];
int n,q,b=131,ans,len,cnt,l,r;
int isPrime[N],Prime[N],fac[N];
inline int read()
{
	int x=0,f=1;char c=getchar();
	while(c<'0'||c>'9') { if(c=='-')f=-1;c=getchar(); }
	while(c>='0'&&c<='9') { x=x*10+c-48;c=getchar(); }
	return x*f;
}
inline void Getprime(int n)
{
	for(int i=2;i<=n;i++) isPrime[i]=1;
	for(int i=2;i<=n;i++)
	{
		if(isPrime[i]) { Prime[++cnt]=i;fac[i]=i; }
		for(int j=1;j<=cnt&&i*Prime[j]<=n;j++)
		{
			isPrime[i*Prime[j]]=0;fac[i*Prime[j]]=Prime[j];
			if(i%Prime[j]==0) break;
		}
	}
}
inline void init()
{
	power[0]=1;
	for(int i=1;i<=n;i++) power[i]=power[i-1]*b;
	for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*b+(ull)(ch[i]-'a'+1);
	Getprime(n);
}
inline int Cal(int l,int r) { return Hash[r]-Hash[l-1]*power[r-l+1]; }
int main()
{
	n=read();
	for(int i=1;i<=n;i++) cin>>ch[i];
	q=read();
	init();
	while(q--)
	{
		l=read();r=read();
		ans=len=r-l+1;
		if(Cal(l,r-1)==Cal(l+1,r)) { puts("1");continue; }
		while(len>1)
		{
			if(Cal(l,r-ans/fac[len])==Cal(l+ans/fac[len],r)) ans/=fac[len];
			len/=fac[len];
		}
		printf("%d\n",ans);
	}
	return 0;
} 

(你猜我为什么用快读?)

7.珍珠项链Beads

image

思路:

首先第一想法是枚举 \(k\) ,然后比较不同 \(k\) 下的段数并找出最优。

接下来我们来看看时间复杂度是否符合题目要求,枚举 \(k\) 时间复杂度为 \(O(n)\) ,对于每一个 \(k\) 共有 \(\left\lfloor\dfrac{n}{k}\right\rfloor\) 个字符串。

所以总时间复杂度为 \(\sum\limits_{i=1}^n {\left\lfloor\dfrac{n}{k}\right\rfloor}\)

原式展开化简可得, \(\sum\limits_{i=1}^n {\left\lfloor\dfrac{n}{k}\right\rfloor}=n\left(1+\dfrac{1}{2}+\dfrac{1}{3}+...+\dfrac{1}{n}\right)\approx n\ln n<n\log_2{n}\)

所以我们可以认为时间复杂度为 \(O(n\log_2{n})\)

数据范围 \(n\leq 2\times 10^5\) ,这个做法显然是符合题目要求的。

代码:

#include<bits/stdc++.h>
using namespace std;
#define N 200050
#define MOD 10000007
typedef unsigned long long ull;
int n,b=19260113;
int f[N],h[MOD];
int cnt,ans,sum;
ull a[N],power[N],Hash_front[N],Hash_back[N];
inline int read()
{
	int x=0,f=1;char ch=getchar();
	while(ch<'0'||ch>'9') { if(ch=='-')f=-1;ch=getchar(); }
	while(ch>='0'&&ch<='9') { x=x*10+ch-48;ch=getchar(); }
	return x*f;
}
int main()
{
	n=read();
	for(int i=1;i<=n;i++) cin>>a[i];
	power[0]=1;
	for(int i=1;i<=n;i++) power[i]=(power[i-1]*(ull)b)%MOD;
	for(int i=1;i<=n;i++) Hash_front[i]=((Hash_front[i-1]*b)+a[i])%MOD;
	for(int i=n;i>=1;i--) Hash_back[i]=((Hash_back[i+1]*b)+a[i])%MOD; 
	for(int k=1;n/k>=ans;k++)
	{
		cnt=0;
		for(int i=k;i<=n;i+=k)
		{
			int p=(Hash_front[i]-Hash_front[i-k]*power[k])%MOD;
			int q=(Hash_back[i-k+1]-Hash_back[i+1]*power[k])%MOD;
			if(h[p]==k) continue;
			cnt++;
			h[p]=k;
			h[q]=k;
		}
		if(cnt>ans)
		{
			ans=cnt;
			sum=0;
		}
		if(cnt==ans) f[++sum]=k;
	}
	cout<<ans<<" "<<sum<<endl;
	for(int i=1;i<=sum;i++) cout<<f[i]<<" ";
	return 0;
}

8.反转字符串Antisymmetry

image

思路:

在这里讨论哈希的做法。

看到数据范围 \(n\leq5\times10^5\) ,想到复杂度可能是 \(O(n\log_2{n})\)

发现反转字符串长度一定是偶数,并且可以理解为异或条件下的回文字符串,即前面有一个 \(0\) 后面对应位置就应当是一个 \(1\)

所以我们可以枚举对称轴的位置,然后用二分去找可以从对称轴向外扩展的长度 \(l\) ,则该反转字符串长度为 \(2l\)

接下来考虑其对答案的贡献,反转字符串的所有字串有相同的对称轴,所以任意长度为 \(2l\) 的反转字符串对答案的贡献是 \(l\) 。所以累加每一个位置做对称轴时对应的 \(l\) 即可。

最后考虑对字符串哈希的处理,正向哈希我们可以正常存储,反向哈希我们就把 \(0\) 看作 \(1\) 即可。

需要注意的细节:

① 对称轴位于 \(1\)\(0\) 这些数字之间,即对称轴所处的位置不是数组的下标,在写数组下标时要谨慎。

② 每一次二分长度时右端点初始值 \(r=\min(i,n-i)\)\(i\) 为当前对称轴所在的位置

代码:

#include<bits/stdc++.h>
using namespace std;
#define N 500050
typedef unsigned long long ull;
typedef long long ll;
int n,b=131;
int l,r,mid,x;
ll ans;
char ch[N];
ull Hash_front[N],Hash_back[N],power[N];
ull Cal1(int l,int r) { return Hash_front[r]-Hash_front[l-1]*power[r-l+1]; }
ull Cal2(int l,int r) { return Hash_back[l]-Hash_back[r+1]*power[r+1-l]; }
bool check(int l,int r) { return Cal1(l,r)==Cal2(l,r); }
int main()
{
	cin>>n;
	scanf("%s",ch+1);
	power[0]=1;
	for(int i=1;i<=n;i++) power[i]=power[i-1]*(ull)b; 
	for(int i=1;i<=n;i++) Hash_front[i]=Hash_front[i-1]*b+(ch[i]=='1');
	for(int i=n;i>=1;i--) Hash_back[i]=Hash_back[i+1]*b+(ch[i]=='0');
	for(int i=1;i<n;i++)
	{
		l=0;r=min(i,n-i);x=0;
		while(l<=r)
		{
			mid=(l+r)>>1;
			if(check(i-mid+1,i+mid)) { l=mid+1;x=mid; }
			else r=mid-1;
		}
		ans+=x;
	}
	cout<<ans<<endl;
	return 0;
} 

9.门票

image

思路:

这道题目有很多种做法,可以用 stl 里的 map 或者 unordered_set (用 map 可能会tle ),

或者直接开布尔数组,因为布尔数组占字节数小,可以开的很大, \(1\times10^9\) 大小完全没问题。

在这里讲一下手写 \(\operatorname{Hash}\) 的做法,正好借这个机会学习一下自定义 \(namespace\) 的写法。

手写哈希表,首先构造哈希函数 \(\operatorname{H}\) ,这里采用的是除余法 $\operatorname{H}(key)=key\mod{ P } $ ,

哈希表的原理就是将哈希值相同的关键字以链表的形式存储,

接下来就比较简单了,将 \(a_0\) 先存入表中,接下来对于每一个 \(a_i\) 先检查是否已经在邻接表里出现过,若出现过则直接找到了重复的项,没有的话就将 \(a_i\) 加入邻接表中,并且更新 \(a_i\)

代码:

#include<bits/stdc++.h>
using namespace std;
#define MOD 1000009
#define N 2000050
typedef long long ll;
ll A,B,C,num=1;
namespace Hash
{
	int head[N],ver[N],Next[N],tot=-1;
	inline void init() { memset(head,-1,sizeof(head)); }
	inline int H(int x) { return x%MOD; }
	inline void ADD(int x,int y)
	{
		ver[++tot]=y;
		Next[tot]=head[x];
		head[x]=tot;
	}
	inline bool check(int x,int y)
	{
		for(int i=head[x];~i;i=Next[i])
			if(ver[i]==y) return true;
		return false;
	}
}
using namespace Hash;
int main()
{
	init();
	cin>>A>>B>>C;
	for(int i=0;i<=2000000;i++)
	{
		if(check(H(num),num)) { cout<<i<<endl;return 0; }
		ADD(H(num),num);
		num=(num*A+num%B)%C;
	}
	puts("-1");
	return 0;
}
posted @ 2024-09-01 20:25  A&K.SKULL  阅读(44)  评论(0编辑  收藏  举报