哈希和哈希表
哈希算法是通过一个哈希函数 \(\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.【模板】字符串哈希
思路:
模板题。读入所有字符串后,对所有字符串进行字符串哈希,然后将哈希值存储。最后看有多少个不同的哈希值即可。因为有自然溢出,可以减少哈希冲突。
代码:
#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.子串查找
思路:
应用前缀和的思想 \(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
思路:
先预处理出以 \(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
思路:
一开始没有想明白复杂度为什么是对的。
暴力枚举分段点,写一个 \(\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
思路:
先从简单的地方思考。
首先 \(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.可怕的诗
思路:
有点像前面做过的一道题,但是这个询问次数有点吓人,每次做一遍肯定超时。(是一道卡常题)
首先关于循环节我们需要知道:循环节是区间字符串长度的约数;如果 \(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
思路:
首先第一想法是枚举 \(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
思路:
在这里讨论哈希的做法。
看到数据范围 \(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.门票
思路:
这道题目有很多种做法,可以用 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;
}
标签:Hash,ull,int,寻迹,哈希,字符串,operatorname
From: https://www.cnblogs.com/Cybersites/p/18391667