初见 | 字符串 | Hash KMP ExKMP
前言
实际上大约高考假期回来就过了 KMP 的板子了罢,但是之后也没怎么做关于字符串的题,现在快一个月过去了我甚至忘了当时学了个啥(
因为发现自己好久好久之前过的字符串 Hash 也没写博客,于是这个博客也就简单一提,把坑填填。
缺省源
#include <iostream>
#include <stdio.h>
#include <math.h>
#include <algorithm>
#include <string.h>
#define Heriko return
#define Deltana 0
#define Romanno 1
#define U unsigned
#define S signed
#define LL long long
#define R register
#define I inline
#define CI const int
#define mst(a, b) memset(a, b, sizeof(a))
#define ON std::ios::sync_with_stdio(false);cin.tie(0)
using namespace std;
template<typename J>
I void fw(J &x)
{//快读略}
template<typename J>
I void fw(J x,bool k)
{//快输略}
那么我们就从最简单的字符串 Hash 入手罢(
字符串 Hash
Hash 的大概思想就是把不便记录或不能作为数组下标的字符串或者很大的数,转化为便于记录,能够作为数组下标的数据。
我们最最好想的便捷的用数字去表示字符串的方法就是用利用 ASCⅡ 码去记录字符串。
但是这样做显然是很拉的:
-
假如我们直接一个一个字符转成数字拼接在一起作为 Hash 值,这串新数字的长度很容易就超出我们能够存储的范围了。如果要用高精度的话,这就完全和我们的初心相悖了(
-
假如我们把所有字母的 ASCⅡ 码加起来作为 Hash 值,那么很容易就出现冲突:\(\tt{qwqqaq}\) 和 \(\tt{qaqqwq}\) 的 Hash 值显然是相同的。
因为我们并不是要去泰拉世界当泰拉博士,而是要当一个能拿分的 OIer,于是我们应当找一些不拉的方法(
稍微简单一点的想法就是对上面那个容易产生冲突的方法 2 进行进一步优化:我们把原字符串每一位的 ASCⅡ 码乘上一个质数,然后再模上一个质数以得到这个字符串的 Hash 值。
可以发现,如果我们取的模数足够的大,乘上的数取得合理,我们的冲突几率就会很小,这样就不会那么拉了。
于是我们利用 unsigned long long
的自然溢出特性来替换掉相对较慢的取模运算,
然后就有如下的代码:
Code
洛谷模板题 P3370:
I U LL hso(char x[])
{
LL lx=strlen(x);
U LL t=0;
for(R LL i=0;i<lx;i++) t=(t*b+(U LL)x[i])+prime;
Heriko t;
}
S main()
{
fr(n);
for(R LL i=1;i<=n;i++)
{
scanf("%s",s);
h[i]=hso(s);
}
sort(h+1,h+1+n);
for(R LL i=1;i<n;i++) if(h[i]!=h[i+1]) ans++;
fw(ans,1);
Heriko Deltana;
}
由于这是三个多月之前的代码,因此可能码风略有区别
一些 Hash 杂谈
这里顺便再提一句,如果出现 Hash 冲突而我们又都要保存的话,我们可以做一个类似存边的数组链式结构进行存边。
实际上熟知 STL 的同学们应该知道有个东西叫做 map
,它能够等效 Hash 的操作,不过它本身基于一种平衡树,于是要比我们手写 Hash 要慢一点,大约多上一个 \(\log\) 的样子(这个我不是很确定,建议想研究一下的去 C++ Reference 查一下)
其实还有一种本身基于 Hash_table 实现,一般是由一个大 vector,vector 元素节点可挂接链表来解决冲突来实现的 unordered_map
,它的优点就是把数据的存储和查找消耗的时间大大降低,几乎可以看成是常数时间;而代价仅仅是消耗比较多的内存。然而在当前可利用内存越来越多的情况下,用空间换时间的做法是值得的。[1]
可惜的是到现在国内 OI 赛制比赛还没有开放使用 C++11。因此 unordered_map
在 C++98 的标准下只能通过头文件 #include <tr1/unordered_map>
和 using namespace tr1;
来引入使用。
至于实际性能,在需要有序性或者对单次查询有时间要求的应用场景下,应使用map,其余情况应使用unordered_map。[1]
不过要注意一点的是各种 map
的实际效率可能会有各种的问题,于是如果真的要用 Hash 的话,还是推荐手写。
KMP
KMP 算法是 D.E.Knuth、J,H,Morris 和 V.R.Pratt 共同提出的,称之为 Knuth-Morria-Pratt 算法,简称 KMP 算法,相比于 BF 消除了冗余回溯,因此整体算法效率有很大提高(
KMP 算法可以解决这样一类问题:给定一个字符串 \(s\) ,我们称为主串,给定字符串 \(t\) ,我们称之为模式串。我们需要用模式串去匹配主串,也就是说,看看模式串有没有在主串中出现过,如果出现过那么在哪里出现?出现过几次?[2]
不难想到我们可以暴力枚举去做,复杂度是 \(O(|s||t|)\)。
u1s1,我家门口的蜗牛跑的都比他快...[4] —— Dfkuaid
我们 BF 的做法是在一次失配之后重新开始,这样实际上很多有用的能够传递的信息都没有被用上,于是我们考虑如何利用之前匹配的信息来提高我们匹配的效率。
于是下面我来说说这个过程:
定义
nex 数组
实际上这个数组在多数地方都被称作 \(next\) 数组,但是我为了和后面代码统一,故改为了 \(nex\)。
首先需要知道 \(nex\) 数组是定义在模式串 \(t\) 上的, \(nex_j\) 定义为 \(t\) 的非前缀子串和 \(t\) 的前缀能够匹配的最长长度,表示为:
实际上 \(k\) 就是枚举的能够满足匹配的长度,我们在这里称 \(k\) 为候选项。
特别的,如果不存在满足条件的 \(k\),那么 \(nex_j=0\)。
kmp 数组
\(kmp\) 数组和 \(nex\) 数组的定义相似,定义 \(kmp_i\) 为 \(s\) 中以 \(i\) 结尾的子串和 \(t\) 的后缀能够匹配的最长的长度。
过程
因为我们考虑尽量的利用之前匹配留下来的可用信息来提高效率,所以我们就先看看都有什么可以使用的信息:
先来看看 BF 算法下的匹配过程:
可以看到,在失配之后在主串上的指针直接跳回了上次匹配开始的下一位,即 \(i+1\)。
但是我们很清楚能够看到其实在第一次在第 \(k\) 位失配的时候,前面 \([i,k-1]\) 都是能够配对的,我们把主串的匹配指针跳回白白浪费了这个信息。
当我们发现主串匹配到一个匹配串中根本没出现过的字符的时候,我们可以直接从 \(k+1\) 位继续匹配,即:
当然如果第 \(k\) 位在匹配串中出现了,我们可以继续往后移动主串指针,直到出现了相同的字符继续匹配或者出现了不存在字符:
于是我们就考虑如何用上面定义的 \(nex\) 数组记录的信息来帮助我们快速的把指针跳到正确的位置。
在使用暴力算法时,每次仅将模式串向后移动一位,而改进算法则是一下移动好几位,跳过那些不可能匹配成功的位置。[4]
实际上我们就是需要求已经匹配成功的子串的 \(nex\),于是我们考虑的问题只剩下一个:如何快速求 \(nex\)。
求 nex
-
引理 1:如果 \(j0\) 是 \(next_i\) 的一个候选项,则小于 \(j0\) 的最大的 \(next_i\) 的候选项是 \(next_{j0}\),换言之,\(next_{j0}+1\) 到 \(j0−1\) 都不是其候选项。
-
引理 2:如果 \(j\) 是 \(next_i\) 的候选项,那么 \(j−1\) 是 \(next_{i−1}\) 的候选项。
我们假设目前 \(next_1\) 到 \(next_{i−1}\) 已经推出来了,我们来推导 \(next_i\) ,我们设 \(j\) 为 \(next_{i−1}\) ,由可以知道,\(j\) 是 \(next_{i−1}\) 的候选项是 \(j+1\) 为 \(next_{i−1}\) 的必要条件, 再根据引理 ,我们只需要尝试 \(j+1,next_{j}+1,next_{next_{j}}+1 \cdots\) ,所以我们可以写出程序:[2]
for(R int i(2),j(0);i<=lnb;++i)
{
while(j>0 and b[i]!=b[j+1]) j=nex[j];
if(b[i]==b[j+1]) ++j;
nex[i]=j;
}
PS:
nex[1]=0
求 kmp
相似的我们可以求得 \(kmp\) 数组:
for(R int i(1),j(0);i<=lna;++i)
{
while(j>0 and (j==lna or a[i]!=b[j+1])) j=nex[j];
if(a[i]==b[j+1]) ++j;
kmp[i]=j;
}
Code
洛谷模板 P3375:
CI MXX=1e6+5;
int lna,lnb,nex[MXX],kmp[MXX];
char a[MXX],b[MXX];
S main()
{
scanf("%s%s",a+1,b+1);
lna=strlen(a+1),lnb=strlen(b+1);
for(R int i(2),j(0);i<=lnb;++i)
{
while(j>0 and b[i]!=b[j+1]) j=nex[j];
if(b[i]==b[j+1]) ++j;
nex[i]=j;
}
for(R int i(1),j(0);i<=lna;++i)
{
while(j>0 and (j==lna or a[i]!=b[j+1])) j=nex[j];
if(a[i]==b[j+1]) ++j;
kmp[i]=j;
}
for(R int i(1);i<=lna;++i) if(kmp[i]==lnb) fw(i-lnb+1,1);
for(R int i(1);i<=lnb;++i) fw(nex[i],0);
Heriko Deltana;
}
Z function
别看它还有另一个名字扩展 KMP,但是实际上 KMP 算法与 Z-函数除了看起来思想上很像,Z-函数比 KMP 能实现的功能好像多一点外,没有任何联系。[5] —— \(\tt{Dfkuaid}\)
而扩展 KMP 算法其实与 KMP 没有多大关系。[1] —— 天梦
ExKMP 和 KMP 只是有着相似的思想,其实两者本质上关系不大。—— \(\tt{Heriko Deltana}\)
HD 的本质第 114514 条:HD 喜欢异口同声。—— \(\tt{Heriko Romanno}\)
下面先来定义一下 \(z\) 函数:
对于长度为 \(len\) 的字符串 \(S\),定义函数 \(z(i)\) 表示 \(S[i,en−1]\),即以 \(S[i]\) 开头的后缀与 \(S\) 的最长相同前缀(\(\tt{Longest Common Prefix, LCP}\))的长度。特别地,我们定义 \(z(0)=0\)。\(z\) 被称为 \(S\) 的 Z-函数。[5]
求 Z Func
定义与声明
-
匹配段(\(\tt{Z-Box}\)):我们称 \([x,x+z(x)-1]\) 为 \(x\) 的匹配段;
-
记录右端点最靠右的匹配段为 \([l,r]\)。
-
若无特殊说明,下文中下标均从 0 开始。
线性球阀
BF 的方法就不说了,直接暴力枚举即可。
下面说一下线性做法:
我们假设 \(z(0),z(1),\cdots,z(i-1)\) 都已经求出,下面考虑如何求 \(z(i)\)。
我们在计算 \(z(i)\) 的时候,保证 \(l < i\),\(l\) 和 \(r\) 初值均为 \(0\)。
如果有 \(i \le r\),因为根据 \(z\) 函数定义有 \(S[l,r]=S[0,r-l]\),那么就有 \(S[i,r]=S[i-l,r-l]\)。
于是 \(S[i,len-1]\) 和 \(S\) 的 LCP 的长度就只有如下两种可能:
-
当 \(z(i-l) < r-i+1\) 时。
-
当 \(z(i-l)\ge r-i+1\) 时。
1
我们先来讨论第一种情况:
根据函数的定义我们能够知道 \(S[i-l,i-l+z(i-l)-1]=S[0,z(i-l)-1]\),同时我们又知道 \(S[i-l,r-l]=S[i,r]\),所以相同前缀的必然小于等于 \(z(i-l)\),否则就与当前情况下的 \(z(i-l)\) 相悖。
于是就有 \(z(i)=z(i-l)\)。
2
在第二种情况下,我们应当先使 \(z(i)=r-i+1\),然后尽力扩展。
如同上面这张图,我们只能确定 S[i,r]=S[i−l,r−l] 相同,后面的无法确定。[5]
当 \(i>r\) 之后,因为没有可以利用的已知信息了,就直接 BF 扩展即可,结束扩展之后若有 \(i+z(i)-1>r\) 就更新 \(l=i,r=i+z(i)-1\)。
然后就有如下代码:
Code
I void Zfunction()
{
for(R LL i(1),l(0),r(0);i<lnb;++i)
{
if(i<=r and z[i-l]<r-i+1) z[i]=z[i-l];
else
{
z[i]=Hmax(0ll,r-i+1);
while(i+z[i]<lnb and b[z[i]+i]==b[z[i]]) ++z[i];
}
if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}
}
ExKMP
ExKMP 是 Z 函数的一种运用,下面多算了个 P 函数用来匹配(
洛谷模板 P5410:
template<typename J>
I J Hmax(J x,J y) {Heriko x>y?x:y;}
template<typename J>
I J Hmin(J x,J y) {Heriko x<y?x:y;}
CI MXX=2e7+5;
char a[MXX],b[MXX];
LL lna,lnb,z[MXX],p[MXX],ana,anb;
I void Zfunction()
{
for(R LL i(1),l(0),r(0);i<lnb;++i)
{
if(i<=r and z[i-l]<r-i+1) z[i]=z[i-l];
else
{
z[i]=Hmax(0ll,r-i+1);
while(i+z[i]<lnb and b[z[i]+i]==b[z[i]]) ++z[i];
}
if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}
}
I void Pfunction()
{
for(R LL i(1),l(0),r(0);i<lna;++i)
{
if(i<=r and z[i-l]<r-i+1) p[i]=z[i-l];
else
{
p[i]=Hmax(0ll,r-i+1);
while(i+p[i]<lna and a[p[i]+i]==b[p[i]]) ++p[i];
}
if(i+p[i]-1>r) l=i,r=i+p[i]-1;
}
while(p[0]<Hmin(lna,lnb) and b[p[0]]==a[p[0]]) ++p[0];
}
S main()
{
// freopen("RNMTQ.in","r",stdin);
scanf("%s%s",a,b);
lna=strlen(a),lnb=strlen(b);
Zfunction();z[0]=lnb;Pfunction();
for(R LL i(0);i<lnb;++i) anb^=((i+1)*(z[i]+1));
for(R LL i(0);i<lna;++i) ana^=((i+1)*(p[i]+1));
fw(anb,1),fw(ana,1);
Heriko Deltana;
}
End
大约是写了挺长时间的,但是感觉自己写的很少,所以我到底写了个啥(
参考资料
-
[1] C++ 基础 - map 与 unordered_map —— 罗晓
-
[2] 字符串算法入门 —— 字符串 hash,KMP,扩展 KMP —— hyl天梦
-
[3] KMP 算法详解 —— labuladong
-
[4] [字符串入门] KMP 算法 —— Dfkuaid
-
[5] [字符串入门]Z-函数(扩展 KMP) —— Dfkuaid