字符串四姐妹

这里说几种处理字符串问题的经常使用方法
包括 哈希Hash,KMP,Trie字典树 和 AC自动机 四种算法

哈希 Hash

哈希算法是经过构造一个哈希函数,将一种数据转化为可用变量表示或者是可做数组下标的数数组

哈希函数转化获得的数值称之为哈希值数据结构

经过哈希算法能够实现快速匹配与查找函数

字符串 Hash

通常用于寻找一个字符串的匹配串出现的位置或次数优化

思考一下,若是咱们要比较两个字符串是否相同,应该如何作ui

若是一个字符一个字符的对比,当两个字符串足够长时,时间代价太大spa

可是若是把每段字符串处理成一个数值,而后经过比较数值来肯定是否相同,就能够作到 \(O(1)\) 实现这个操做3d

而处理每段字符串数值的函数,就是上面说的哈希函数指针

对于哈希函数的构造,一般选取两个数互质的 \(Base\)\(Mod\) \((Base<Mod)\),假设字符串 \(S=s_1s_2s_3……s_n\)code

则咱们能够定义哈希函数 \(Hash(i)=(s_1\times Base^{i-1} +s_2\times Base^{i-2}+……+s_i\times Base^{0})\mod Mod\)

其中 \(Hash(i)\) 就是前 \(i\) 个字符的哈希值

而后,对于 \(Base\) 的次方咱们也能够将它存储到一个数组 \(Get[]\) 中,使得 \(Get[i]=Base^{i}\),这样能够实现 \(O(1)\) 查询

\(Tips:\) 使用 unsigned long long 经过它的天然溢出,能够省去哈希函数中取模的一步

以上步骤的代码实现比较容易,以下:

Get[0]=1;
for(int i=1;i<=m;i++) Get[i]=Get[i-1]*base; 
for(int i=1;i<=m;i++) val[i]=val[i-1]*b+(uLL)s1[i];

构造完哈希函数以后,要想求一个字符串某个区间内的哈希值怎么办

根据哈希函数的构造方式,不难想到,\(Hash\{l,r\}=Hash(r)-Hash(l-1)\times Base^{r-l+1}\)

换成代码也就是这样:

inline int gethash(int l,int r){
    int len=r-l+1;
    return val[r]-val[l-1]*get[len];
}

哈希表

一种高效的数据结构,对于查找的效率几乎等同于常数时间,同时容易实现

好比说,要存储一个线性表 \(A=\{k_1,k_2,k_3,k_4,……,k_n\}\)

咱们能够开一个一维数组,而后依次存储

但查找时会十分不便,当 \(n\) 足够大时,即便二分查找也仍需 \(O(\log n)\) 的时间去查找某个元素

咱们能够开一个数组 \(B[n]\),在存储时使得 \(B[k_i]=k_i\),能够将查找的时间降到 \(O(1)\),可是会形成极大的空间浪费

因此能够对此进行优化,使得 \(B[k_i\!\!\mod 13]\),这样数组大小只需开到 \(12\) 便可

不过又会出现另外一个问题,那就是会发生冲突,好比 \(B[1]=B[14]=1\)

所以咱们考虑对数值相同的数记一个链表,查找时只查找对应的链表便可,时间复杂度取决于实际的链表长度,这就是哈希表

多产生的代价仅仅是消耗较多的内存,至关于用空间换取时间

另外一方面,哈希函数也是决定哈希表查找速率的重要因素,由于只要哈希值分布足够均匀,查找的复杂度就会尽可能小

对于哈希函数的构造,能够选择多种方法,好比除余法,乘积法,基数转换法等,只要尽可能规避冲突都是能够的

为了规避不一样的字符串出现相同的哈希值这种冲突,能够选择较大的模数,也能够构造多个哈希函数,比较当全部哈希值相同时才断定两个字符串相同

STL 库中的 \(unordered\_map\) 内部就至关于一个哈希表,其存储信息为无序的 \(pair\) 类型,插入速度较低,但查找速度更高,这里很少赘述

具体代码实现以下:

void init(){
    tot=0;
    while(top) adj[stk[top--]]=0;
}//初始化哈希表(多组数据时使用)

void insert(int key){
    int h=key%b;//除余法
    for(int e=adj[h];e;e=nxt[e])
	if(num[e]==key) return;
    if(!adj[h]) stk[++top]=h;
    nxt[++top]=adj[h];adj[h]=tot;
    num[tot]=key;
}//将一个数字 key 插入哈希表

bool query(int key){
    int h=k%b;
    for(int e=adj[h];e;e=nxt[e])
        if(num[e]==key) return true;
    return false;
}//查询数字 key 是否存在于哈希表中

注:由于我基本不写哈希表,因此此代码来自《信息学奥赛一本通提升篇》

\[\]

\[\]

KMP 算法

一种改进的字符串匹配算法,处理字符串匹配问题

由 D.E.Knuth,J.H.Morris 和 V.R.Pratt 同时发现,所以人们称它为克努特—莫里斯—普拉特操做,简称 KMP 算法

思考一下,若是咱们要比较两个字符串,从而肯定其中一个字符串是否为另外一个的子串,应该怎么作

咱们从两个字符串的第一个字符开始,逐个比较,当遇到不匹配的字符,就需从新匹配

若是从头开始从新匹配,可能前面许多字符已经彻底相同,匹配过程时间代价较大

那咱们能够根据匹配的字符串相同先后缀的长度来肯定指针回溯位置

根据此原理,从上一个与当前字符相同的位置开始匹配,能够省掉许多无用的匹配过程,从而优化时间复杂度

这个操做的实现是经过处理出一个存储下一次从头匹配位置的数组 \(Nxt[]\),当某一位失配时,回溯到它对应的位置开始匹配,此位置以前的字符已确保彻底相同

实例如图所示:

对于存储下一次从头匹配位置的数组的处理,能够根据递推来肯定,就是用前面的值推出后面的值

在处理某一位的回溯数组时,考虑一下当前位置是否有可能由前一个位置的状况所包含的子串获得,也就是考虑一下是否 \(Nxt[i]=Nxt[Nxt[i-1]]+1\)

而后再造样例手玩一下就能够了

具体代码实现以下:

Nxt[1]=0;Fir=0;
for(int i=1;i<m;i++){
    while(Fir>0&&s2[Fir+1]!=s2[i+1]) Fir=Nxt[Fir];
	if(s2[Fir+1]==s2[i+1]) ++Fir;
	Nxt[i+1]=Fir;
}//处理 Nxt 数组

Fir=0;
for(int i=0;i<n;i++){
    while(Fir>0&&s2[Fir+1]!=s1[i+1]) Fir=Nxt[Fir];
        if(s2[Fir+1]==s1[i+1]) ++Fir;
   	if(Fir==m){ans++;Fir=Nxt[Fir];}
}//统计匹配个数

能够看到预处理 \(Nxt[]\) 和主程序很像,由于预处理其实也是一个匹配串自我匹配的过程

\[\]

\[\]

Trie字典树

踹树

字典树是一种像字典同样的树,可用于处理字符串出现或匹配问题

具体说,就是选定一个根节点,而后以每一个字符做为一个转移状态连出一条边,造成一个树状结构

树上的节点自己无实际意义

而所谓转移状态,就是知足 当前所匹配字符与某一条边上的字符相同 且 当前处于这条边的始点时,能够转移到这条边的终点并匹配下一个字符

这也是自动机的原理:知足条件即可转移

自动机能够是单向的,也能够是双向的

一般题目会给定若干个模式串,每一个模式串由若干个字符相同。建树时,就以这些模式串来建树

假设咱们给定了三个模式串

3
abc
de
afg

要将其建成一棵字典树

具体的建树方法以下:

先看第一个模式串,因为树目前只有一个根节点,因此一路新建下去,从根节点到叶节点每条边的转移状态依次是abc的每一个字符

再看第二个模式串,虽然树如今有了一条链,可是发现没有以d为转移状态的字符,所以另开一条链,也一路新建下去,方法同第一个模式串

最后看第三个模式串,发现有以a为转移状态的边,就沿此边向下遍历到 \(1\) 点,而后发现没有以f为转移状态的边,就从当前点另开一条链,一路新建下去

最后造成的字典树模型图如上图所示

建树过程当中要维护的变量有 \(Nxt[i][j]\) 表示一条从节点 \(i\) 连出去的以 \(j\) 为转移状态的边,\(Flag[i]\) 标记以当前节点结尾的字符串是否存在

对于完成建树,或者说完成插入以后的操做,其实与插入操做差很少

要求出一个字符串是否存在于字典树中,就从根节点开始,每次找 转移状态与当前位置的字符相同的边遍历,若最后能找完就返回 \(true\),若中间失配则返回 \(false\)

若要求一个字符串在字典树中能找到的最末位置,只需多维护一个计数器,在遍历的同时统计,直到失配或者所有找完时再返回计数器的值就行了

具体代码实现以下:

struct Trie{
    int Nxt[maxn][26],cnt;
    bool flag[maxn];
  
    void insert(char *s) {//插入字符串
        int p=0,len=strlen(s+1);
        for(int i=0;i<len;i++){
            int c=s[i]-'a';
            if (!Nxt[p][c]) Nxt[p][c]=++cnt;
            p=Nxt[p][c];
        }
        flag[p] = 1;
    }
  
    bool find(char *s) {//查找字符串是否出现 
        int p=0,len=strlen(s+1);
        for(int i=0;i<len;i++) {
            int c=s[i]-'a';
            if(!Nxt[p][c]) return 0;
            p=Nxt[p][c];
        }
        return flag[p];
    }
}Tri;

\[\]

\[\]

AC自动机

AC自动机,又叫 Aotumaton,是以自动机形式实现字符串查找匹配等其余操做的算法

由于我的感受本身没法做出一个更加全面简介的概述了,因此在这里放一个 OI-Wiki 概述

用另外一句话说,就是经过在 Trie字典树 上跑 KMP 来处理问题

失配指针

字面意思,就是一个在字符串失配时转移所用的指针 \(Fail[]\),相似与 KMP 的 \(Nxt[]\)

KMP 的 \(Nxt[]\) 的原理以及处理方法上面都讲过了,这里就对比一下 \(Fail[]\)\(Nxt[]\)

\(Nxt[]\) 是在失配时回到前一个相同字符的位置,也就是它前面的的最长公共先后缀的结尾位置,但 \(Fail[]\) 是在失配时回到当前前缀状态可匹配的最长后缀状态的起始位置

由于 KMP 只对一个模式串作处理,而 AC自动机 是在字典树上运行,因此要处理多个模式串,因此 AC自动机 作匹配时,同一位置可能会匹配多个模式串

而后说明一下失配指针 \(Fail[]\) 该如何处理:

考虑字典树中当前的结点 \(u\)\(u\)的父结点是 \(p\)\(p\) 经过字符c的边指向 \(u\),即 \(Nxt[p][c]\)。假设深度小于 \(p\) 的全部结点的 \(Fail[]\) 指针都已求得

  • 若是 \(Nxt[p][c]\) 存在:则让 \(u\)\(Fail[]\) 指针指向 \(Nxt[Fail[p]][c]\)。至关于在 \(p\)\(Fail[p]\) 后面加一个字符c ,分别对应 \(u\)\(Fail[u]\)
  • 若是 \(Nxt[p][c]\) 不存在:那么咱们继续找到 \(Nxt[Fail[Fail[p]]][c]\) 。重复上一步的判断过程,一直跳 \(Fail[]\) 指针直到根结点。
  • 若是真的没有,就让 \(Fail\) 指针指向根结点。
    而后就处理完了 \(Fail[]\) 指针

咱们能够简化它的过程,使得它的时间耗费下降,具体方法就是改变字典树的结构,使其成为字典图,方法以下:

  • 若是 \(Nxt[u][i]\) 存在,咱们就将它的的 \(Fail[]\) 指针赋值为 \(Nxt[Fail[u]][i]\)
  • 若是不存在,则令 \(Nxt[u][i]\) 指向 \(Nxt[Fail[u]][i]\) 的状态
    显然,当在一个模式串后添加新字符时,咱们会由原先模式串转移到新模式串的后缀,而后舍弃原模式串的部分前缀

由于上文说过 \(Fail[]\) 指针求的是最长后缀状态,与上面的显然结论相应

修改字典树结构后,尽管增长了许多转移关系,但结点所表明的字符串是不变的,因此这样处理可节省时间

构建与匹配

以上的处理步骤是出如今建树过程当中的

建树,就是先将与根节点所连的点加入一个队列中 BFS,而后每次取出一个点处理对应的全部点的 \(Fail[]\) 指针

用代码实现就是这样:

void Build(){
    for(int i=0;i<26;i++)
	if(Nxt[0][i]) q.push(Nxt[0][i]);
    while(!q.empty()){
        int u=q.front();q.pop();
        for(int i=0;i<26;i++){
       	    if(Nxt[u][i]) Fail[Nxt[u][i]]=Nxt[Fail[u]][i],q.push(Nxt[u][i]);
	    else Nxt[u][i]=Nxt[Fail[u]][i];
	}
    }
}

值得注意的是,入队的是根节点的儿子,而不是它自己,不然它儿子的 \(Fail[]\) 会标记成它自己

能够看出,建树过程实现了两个操做:构建失配指针和创建字典图

插入操做与 Trie字典树 里的插入无异,只需多统计一个出度,以后计数时会用到

对于计数操做,无非也是遍历,而后在存在出度的状况下加上它的出度并更改就完成了

此过程代码实现以下:

int Query(char *s){
    int u=0,ans=0;
    for(int i=1;s[i];i++){
    	u=Nxt[u][s[i]-'a'];
    	for(int j=u;j&&e[j]!=-1;j=Fail[j])
            ans+=e[j],e[j]=-1;
    }
    return ans;
}

固然,具体要进行什么操做也要看实际状况,这种计数只是最简单的一种,有的也须要维护其余变量

最后,若是还不理解其中的某个过程,能够参考 OI-Wiki 对应文章里的例子,经过图示的方法进一步理解 Link

例题

Power Strings
Seek the Name, Seek the Fame
OKR-Periods of Words
A Horrible Poem
L语言
因而他错误的点名开始了
彷佛在梦中见过的样子
AC自动机模板(简单)
AC自动机模板(增强)

写在最后

这四种知识点涵盖的内容其实远不止这些,还有一些更复杂的操做,好比 可持久化字典树、可持久化KMP 等等,因为本人能力问题就不写了

这篇文章是我花了三天多时间一点一点写出来的,是为了写给我和其余像我同样在这方面水平较低的人的,因此我修改了不少遍,但仍是可能会有一些错误,还请多多包涵

至于这篇博客的名字,是同机房的某大佬替我想出来的,为了致敬同机房的另外一个队爷(

但愿这篇文章能让别人有收获吧

相关文章
相关标签/搜索