最近打暑假多校,发现有许多字符串算法自己有所遗忘,今天就借着补题在这开一个坑,把那几个基础的字符串算法总结复习一下,顺便写几个模板,供今后使用。这篇博客主要就是总结一下字符串Hash,并提一下例题。
简介
我们学习一个算法,肯定要先知道它要解决的是什么问题。字符串Hash,就是解决 字符串匹配问题的良药,即寻找长度为 n 的主串 S 中的匹配串 T(长度为 m)出现的位置或次数问题。
对于上述问题,朴素的想法是枚举 S 所有起始位置,再直接检查是否匹配,我们可以不使用 O(m) 的直接比较字符串的方法,而是 比较长度为 m 的主串 S 的子串的哈希值与 T 的哈希值是否相等,这就是哈希算法解决这类问题的原理,这个原理称为字符串 Hash。
大多数字符串 Hash 问题可以用 KMP 求解,但如果是从主串中每次选出两个子串判断是否匹配的问题,还是要用字符串 Hash 求解(不必纠结,遇到自然懂)。
实现方法
如果我们用 O(m) 的时间计算长度为 m 的字符串的哈希值,则总的时间复杂度并没有改观,这里就需要一个叫做 滚动哈希的优化技巧。
我们 选取两个合适的互质常数 b 和 h(b<h),假设字符串 C=c 1c 2…c m ,那么我们定义哈希函数: H(C)=(c1bm-1 + c2bm-2 + … + cmb0) mod h。
正常的数字是十进制的,这里 b 是基数,相当于把字符串看作是 b 进制数。
这一过程是 递推计算的,设 H(C,k) 为前 k 个字符构成的字符串的哈希值,则:(以下均不考虑取模的情况)。
H(C,k+1) = H(C,k)*b + ck+1
如字符串 C=“ACDA”(为方便处理,我们令"A"表示 1,"B"表示 2,以此类推),则:
H(C,1) = 1
H(C,2) = 1*b + 3
H(C,3) = 1*b 2 + 3*b + 4
H(C,4) = 1*b 3 + 3*b 2 + 4*b + 1
代码实现如下
H[0]=0;
for(int i=1;i<=4;i++)
H[i]=H[i-1]*b+(C[i]-'A'+1);
通常,题目要求的是判断主串的一段字符与另一个匹配串是否匹配,即判断字符串 C=c1c2…cm 从位置 k+1 开始的长度为 n 的子串 C’=ck+1ck+2…ck+n 的哈希值与另一匹配串 S=s1s2…sn 的哈希值是否相等,则:
H(C’) = H(C,k+n) - H(C,k)*bn
于是我们只要预处理求得 b^n ,就能在 O(1)时间内得到任意字符串的子串哈希值,从而完成字符串匹配,那么上述字符串匹配问题的算法时间复杂度就为 O(n+m)。
代码实现如下
for(int i=0;i<=m-n;++i)
{
ull hash=hashC[i+n]-hashC[i]*pow[n];
if(hash==hashS)
ans++;
}
如字符串 C=“ACDA”,S=“CD”,当 k=1, n=2 时:
H(C’) = H(C,1+2) - H(C,1)*b2
= (1*b2+3*b+4) - (1*b2)
= 3*b + 4
H(S) = 3*b + 4
因此子串 C’ 与匹配串 S 匹配。
在实现算法时,可以利用 32 位或 64 位无符号整数计算哈希值,此时 h=232 或 h=264,通过自然溢出省去求模运算。(因为无符号整数,大于最大值后会以最大值+1为模数取模)
注1:众所周知,hash算法有时会产生冲突,但是在一般比赛中用字符串Hash产生冲突的概率是很小的,如果发现错了,可以换个基数或模数,或者采用“双哈希”来避免冲突。
注2:我们在预处理 b^n 时,要根据题目来选择预处理方式(虽然一般不会卡这个),如果只有一组输入,匹配串长度固定,那么利用快速幂求即可。如果有多组输入,每组匹配串长度皆不同,递推更好一些。
递推代码
pow[0]=1;
for(int i=1;i<=10002;i++)//预处理base^n
pow[i]=pow[i-1]*base;
样例分析
Oulipo(POJ3461)
题目大意
给出两个串 S 1,S 2(只有大写字母),求 S 1 在 S 2 中出现了多少次。例如 S 1=“ABA”,S 2=“ABABA”,答案为 2。输入 T 组数据,对每组数据输出结果。每组数据保证 strlen(S 1) <= 10 4,strlen(S 2) <= 10 6。
解题思路
将匹配串 S 1 的哈希值求出来,再将母串 S 2 的哈希值求出来,根据 H(C’) = H(C,k+n) - H(C,k)*b n求出与匹配串长度相等的母串子串的哈希值,与匹配串 S 1 的哈希值比较,如果相等,答案+1。
代码实现
/**
快速幂预处理,取模
跑了 813MS
*/
#include <iostream>
#include <stdio.h>
#include <string.h>
#include <cstring>
#include <algorithm>
using namespace std;
const int maxn=1e6+5;
const int maxm=1e4+5;
const int base=29;
const long long mod=1e7+5;
int n;
char W[maxm],T[maxn];
long long quickpow(long long a,int b)//快速幂
{
long long sum=1;
while(b>0)
{
if(b&1)
sum=(sum*a)%mod;
b>>=1;
a=(a*a)%mod;
}
return sum;
}
int main()
{
scanf("%d",&n);
while(n--)
{
scanf("%s",W);
scanf("%s",T);
int lenw=strlen(W);
int lent=strlen(T);
long long hashw=0;//初始hash
long long hasht=0;
int ans=0;//统计个数
for(int i=0;i<lenw;++i)
{
hashw=(hashw*base+1ll*(W[i]-'A'+1))%mod;
hasht=(hasht*base+1ll*(T[i]-'A'+1))%mod;
}
if(hashw==hasht)
ans++;
long long cnt=quickpow((long long)base,lenw-1);//预处理b^n
for(int i=lenw;i<lent;++i)//T向后找子串去和W比较
{
hasht=(hasht-1ll*cnt*(T[i-lenw]-'A'+1))%mod;
hasht=(hasht+mod)%mod;//防止为负数
hasht=(hasht*base+1ll*(T[i]-'A'+1))%mod;
if(hashw==hasht)
ans++;
}
printf("%d\n",ans);
}
return 0;
}
/**
递推预处理,未取模
跑了 235MS
*/
#include <iostream>
#include <stdio.h>
#include <string.h>
#include <cstring>
#include <algorithm>
typedef unsigned long long ull;
using namespace std;
const int maxn=1e6+5;
const int maxm=1e4+5;
const int base=29;
ull pow[maxm];
char W[maxm],T[maxn];
ull hasht[maxn];
int n;
ull hashs;
int ans;
void init()
{
ans=0;
hashs=0;
}
int main()
{
pow[0]=1;
for(int i=1;i<=10002;i++)//预处理base^n
pow[i]=pow[i-1]*base;
scanf("%d",&n);
while(n--)
{
init();//初始化
scanf("%s",&W);
scanf("%s",&T);
int lens=strlen(W);
int lent=strlen(T);
for(int i=1;i<=lens;++i)
hashs=hashs*base+(ull)(W[i-1]-'A'+1);
hasht[0]=(ull)(T[0]-'A'+1);
for(int i=1;i<=lent;++i)
hasht[i]=hasht[i-1]*base+(ull)(T[i-1]-'A'+1);
for(int i=0;i<=lent-lens;++i)
{
ull hash=hasht[i+lens]-hasht[i]*pow[lens];
if(hash==hashs)
ans++;
}
printf("%d\n",ans);
}
return 0;
}
总结
字符串Hash,在比赛中还是经常出现的,因为用map可能会tle或者mle。在一些题目里,是作为某一关键的步骤。所以,多练多用,才能熟能生巧,在比赛中灵活运用。