万恶的字符串DSA(加载中...)

前言

这个字符串的算法实在是令人头大。模拟起来也不是很尽人意。就来这里总结一下。不然自己看的比较累。半夜机房里都是在喊这个算法怎么这么妙

这里写图片描述

一、hash(哈希)

  • 这个其实还比较简单 ,学过哈希表的知道内部的原理其实和进制转换一样,只不过这里用到了一个指数来进行转换和取模。接下来进行详解。
    这个其实很简单orz

  • 首先,hash的原理是讲一个字符串转化成一个数,对于每一位上的字符将其转换成ascll码(其实如果是英文字母也可以转换成它的字母序号),然后就像进制转换一样把这个数表示出来。

  • 而这里的进制的 b a s e 需要是个质数(如果这个 b a s e 是4,那你进制转换很容易被卡掉,不同的字符串表示成相同的数据)。

  • 来个公式表示下这个数据:

    H a s h = ( i = 0 l e n 1 s [ i ] b l e n i 1 ) m o d p

  • 但有同学会问了,如果这个字符串很长,那么 l o n g l o n g 存不下怎么办?我是不会告诉你还会有__int128的。那就需要进行取模了(真的黑科技)。而尽量对一个质数进行取模(理由和 b a s e 的挑选一样)。当然也可以用 u n s i g n e d i n t u n s i g n e d l o n g l o n g 来进行取模,可以进行自动溢出,但风险稍微大点orz。

来一段简短的代码理解一下orz

//自动溢出版
typedef unsigned long long ull; //用ull偷个懒而且可以自动溢出
const int MAXN=200010;
const ull base=233;//常规base233
char ch[MAXN];

ull hashs(char *s){

    int len=strlen(s);

    ull sum=0;

    for (int i=0;i<len;i++){

        sum=sum*base+(ull)s[i]-'a';
        //进制转换的常规操作,就是求上面的那个公式,自动溢出orz
    }

    return sum;

}
//取模版
typedef unsigned long long ull; //用ull偷个懒而且可以自动溢出
const int MAXN=200010;
const ull base=233,MOD=1e9+7;//常规base233
char ch[MAXN];

ull hashs(char *s){

    int len=strlen(s);

    ull sum=0;

    for (int i=0;i<len;i++){

        sum=(sum*base+(ull)s[i]-'a')%MOD;
        //进制转换的常规操作,就是求上面的那个公式,自动溢出orz
    }

    return sum;

}
  • 但还是存在可能会被数据卡掉的情况。(出题老师的智商为什么这么高orz)
  • 那就要介绍新一种骚操作叫双哈希。就是一个数对两个质数取模(最好是孪生素数但博主的数学不是很好呀..也解释不了为什么orz)。然后对于一个字符串产生两个不同的Hash数进行判断。
  • 但是很不幸,这里时间换准确性的代价有点大。慢了一两倍的样子所以实用的时候可能会超时(原来对的点T了,原来WA的点对了orz)。
    这里写图片描述
//结构体版
typedef unsigned long long ull; 
const int MAXN=200010;
const ull base=233,MOD1=1e9+7,MOD2=1e9+9;//
char ch[MAXN];

ull hashs(char *s,ull MOD){

    int len=strlen(s);

    ull sum=0;

    for (int i=0;i<len;i++){

        sum=(sum*base+(ull)s[i]-'a')%MOD;

    }

    return sum;

}

struct node{

    int h1,h2;

}num[MAXN];

int main(){

    num[1].h1=hash(ch,MOD1),num[1].h2=hash(ch,MOD2);

}
//pair版
typedef unsigned long long ull; 
const int MAXN=200010;
const ull base=233,MOD1=1e9+7,MOD2=1e9+9;//这两个是孪生素数
char ch[MAXN];

ull hashs1(char *s,ull MOD){

    int len=strlen(s);

    ull sum=0;

    for (int i=0;i<len;i++){

        sum=(sum*base+(ull)s[i]-'a')%MOD;

    }

    return sum;

}

pair<ull, ull> num[maxn];

int main(){

    a[i]=make_pair(hash(ch,MOD1),hash(ch,MOD2));

}
  • 其实还有些问题会问你求子串的Hash。也可以这个用Hash水掉一些题。
  • 首先还是要明确这个是类似进制转换的原理的,那么在进制当中的求子串长度我们是这样子求的:

h i = ( i = 0 l e n 1 s [ i ] b l e n i 1 ) m o d p
h [ l . . r ] = ( h r h l 1 b r l + 1 ) m o d p

  • 大概就这样子把orz,剩下的就是hash的骚法运用了..这个东西要靠练习才会有思路orz,但hash确实是十分强大的工具..能够用来骗分。

二、马拉车(Manacher)

  • 听说这个算法197几年就有了orz。
  • 这个算法针对的是回文串的问题。首先比较普遍的利用对称性来打的 O ( n 2 ) 算法。就是枚举每一个点然后来进行前后扫描求得最大值。而manacher和他们不一样!他是..有优化的枚举对称ojz…. (其实前期并没什么卵效果)
  • 首先你得要会之前的那个朴素的 O ( n 2 ) 算法。
  • 博主自学的时候是看这个自学的orz——博客传送门
  • 首先解决一个问题,奇偶字符串的处理会比较的麻烦,那我们就进行插入#
    的操作,在两个字符之间插入#
  • 例如abcdefg改变之后就会变成#a#b#c#d#e#f#g#
  • 然后为了防止数组越界我们在数组之前加上一个不怎么用到的字符比如$,结尾加上\n,那么最终就会变成$#a#b#c#d#e#f#g#\n

预处理的代码:

using namespace std;

const int MAXN=30000;

char s[MAXN],new_s[MAXN];

void init(){

    int len=strlen(s);

    new_s[0]='$';

    new_s[1]='#';

    int t=2;

    for (int i=0;i<len;i++){

        new_s[++t]=s[i];

        new_s[++t]='#';

    }

    new_s[t]='\n';

}
  • 接下来进行操作。
  • 我们来处理一个 F 数组,是从当前的字符出发到新串尾的最长的半径。
  • 从当中可以看出,我们所需要的回文串的长度就是我们现在所求的的半径长度-1。即 r = F [ i ] 1
  • 那现在就是求 F 数组。
    这里写图片描述

  • 图中的i是我所需要求 F 的点,而j是i关于id的对称点, F [ j ] 是已经处理好了。 m x 是id的半径长度所管辖的最后一个点,即 m x = i d + F [ i d ] 。那么我们先找到

    F [ i ] = m i n ( F [ j ] , m x i d )

  • 而这里我们不需要去求j,因为根据对称性可以知道:

    j = i d ( i i d ) = 2 i d i

  • 然后再根据原来的朴实算法进行左右的对称枚举判断。

int f[MAXN];

int Manacher(){

    int len=strlen(new_s);

    int id=0,mx=0,max_len;

    for (int i=1;i<len;i++){

        if(i<mx){

            f[i]=min(f[2*id-i],mx-id);

        }else{

            f[i]=1;

        }
        while(s_new[i-f[i]]==s_new[i+f[i]])

            f[i]++;

        if(mx<f[i]+i){

            id=i;

            mx=f[i]+i;

        }

        max_len=max(max_len,f[i]-1);

    }

    return max_len;

}
  • 其实这一个算法在前期并没有什么优势,因为最开始的mx一直在更新所以优势并不是很大,但大的数据情况下优势就比较明显了,可以减少枚举一大段 F [ i ]
  • 复杂度分析:
  • 由于所探测的i的初始枚举起点和mx和对称点j有关,而这也是唯一的优化点,那么就需要讨论 F ( j ) 的位置。但....我不会告诉你们我不会算复杂度的测算得出最坏情况下复杂度是 O ( n )
  • 这个算法还是比较和谐的,也是很好的理解的。
    这里写图片描述

三、KMP

这个真的是一个神奇的算法,神奇到你根本不知道他是怎么求出来的orz盯着屏幕看了半天才看懂这个KMP。需要花点时间来理解。停更了两天全部在打CF..(就是我两天爆零的题解),重新回归字符串的怀抱。

  • KMP是用于全字匹配的时候单个模式串和查询串之间的匹配,博主在自学的时候是看这个博客的博客传送门。里面的图不错,有助于理解。
  • 最开始学的匹配是一种 O ( n 2 ) 的算法。
  • 从模式串的头开始匹配,如果匹配i++,j++;如果失配,则i=i-j+1,j=0;
  • 但对于模式串ababacc和查询串ababcddeee,在第五位的c已经失配,但模式串只要向右移动三位就可以重新匹配了但是….沙雕的暴力匹配让你重新回到头字符。而KMP就是重新回到上一个前缀(就是从第一个字符开始的子串)匹配的字符位置,重新进行匹配。

  • KMP有主要的两个步骤、

    • 1、进行nxt数组(Linux下会和next重名)的线性求取。
    • 2、对查询串进行搜索。
  • 关键的步骤就是在nxt的求取,而第一步的本质就是自己与自己的前缀进行匹配。目的是找之前重复匹配的子串

  • 从头开始枚举对应字符,如果当前 j 位置字符与 k 字符匹配,则下一位 j + 1 失配时,会在 k + 1 位重新匹配(因为之前的都是相互递推匹配的),即:
    n x t [ j + 1 ] = k + 1
  • 如果当前位失配,则 k 跳回到 n x t [ k ] 重新进行匹配判断直至头结点。
  • 如果你从运动方式来看,其实还是比较难理解的,但是如果你从你所需要实现的目的去思考,则还是比较好理解的。
#include <iostream>
#include <cstring>
using namespace std;
const int MAXN=20000;
char p[MAXN];//模式串 
int nxt[MAXN];

void get_nxt_val(){

    int k=0,j=1;

    int len=strlen(p);

    nxt[0]=nxt[1]=0;

    while (j<len-1){

        while (k&&p[k]!=p[j]) k=nxt[k];

        if(p[k]==p[j]) nxt[++j]=++k;

        else nxt[++j]=0;

    }

}
  • 还是仔细说下吧。教练说我写的这么短会被打
  • 在全字匹配的时候,你会发现当前位的模式串和查询串失配时,前一位是相互匹配的。那么只要找到当前位之前的字符串和前缀相同的匹配,往后拉模式串就可以了。(这里讲的有点混乱大家还是直接体会目的吧)

  • 打完KMP的nxt求取,就开始进行和查询串的搜索了。这个地方比较简单。唯一和暴力的区别就是模式串不是跳到开头,而是跳到nxt[]。实现当初所追求的目的。
  • 在k位置如果失陪,则跳回到 n x t [ k ] 重新进行匹配,如果跳回到第一位时,需要判断是否是匹配到(因为有可能是因为全失配而回到头),匹配到则k++。
#include <iostream>
#include <cstring>
using namespace std;
const int MAXN=300000;
char p[MAXN],s[MAXN];
int nxt[MAXN];
int KMP_search(){

    int plen=strlen(p),slen=strlen(s);

    for (int i=1;i<slen;i++){

        while (k&&p[k]!=s[i]) 

            k=nxt[k];

        if(p[k]==s[i]) 

            k++;

        if(k==plen) 

            return i-plen+2;

    }

}

四、trie树(字典树,前缀树)

这个算法其实还是比较简单的。看文字就好了,不需要什么图来详细地进行介绍。所以只放一张图。

这里写图片描述

  • trie树的本质是26叉树,而其深度是由单词的长度决定的,用于储存多个单词。也为接下去的AC自动机打好基础。

  • trie有大部分数据结构的两种操作:查询和更新。
  • 先讲trie的储存方式。有两个数组。
  • c h [ i ] [ j ] 当中的值 x 存储的是单词的序号。 i 表示的第 i 号节点,即使下一步的父亲节点。 j 是存储的ascii码或者26位字母当中的序号。(看到这里还比较难理解,但要先知道这几个数是存在的)。
  • 另一个 v a l [ i ] 存储的是附加值,一般是单词的长度或者布尔量, v a l [ i ] > 0 表示这个字母结尾有单词。
  • 树的根节点为空节点。
  • 这个看代码就可以理解了。就不再详细的讲解了。
#include <iostream>
#include <cstring>
#include <vector>
using namespace std;
const int MAXN=300000;
const int sigma=26;
struct Trie{//结构体封装,opp打法
    int ch[MAXN][sigma];
    int val[MAXN];
    int siz;//节点个数
    void clear(){
        memset(ch,0,sizeof(0));
        memset(val,0,sizeof(val));
        siz=1;
    }
    int index(char ch){
        return ch-'a';
    }
    void insert(char *s,int v){
        int u=0,n=strlen(s);
        for (int i=0;i<n;i++){
            int c=index(s[i]);
            if(!ch[u][c]){
                ch[u][0]=siz;
                val[siz++]=0;//建立子节点
            }
            u=ch[u][c];
        }
        val[u]=v;
    }
    void find_(char *s,vector <int> &ans){
        int u=0;
        int len=strlen(s);
        for (int i=0;i<len;i++){
            int c=index(s[i]);
            if(!ch[u][c]) return;//没有找到下一个单词
            u=ch[u][c];
            if(val[u]>0) ans.push_back(val[u]);
        }
    }
}T;

五、AC自动机

同学们看清楚啊这个是AC自动机啊!不是自动AC机啊!

  • 讲道理,这个是把KMP和trie树结合起来了。但其实只要理解了KMP,本质上并不是很难理解。
  • 其实就是trie树多了一步建立fail指针,将fail指针取代了KMP的 n x t [ ] 数组。所以还没看懂的同学还是要先好好研习前两个算法。

  • AC自动机一般有三个步骤:插入单词,建立fail指针,查询。
  • 第1步插入单词与trie树的基本操作相同,也是利用那一串数组和26叉树进行维护。代码还是看上面的那一段吧。
  • 第2步是AC自动机最关键也是相对比较难理解的一步:建立fail指针。内涵本质其实和KMP的nxt相同。都是在当前字符失配时尽可能保证前缀最长程度的匹配而回跳到之前最接近的字符。
  • fail指针的求取要利用到队列维护,大家不懂得还是要记得多画图。
  • 贴一下大佬yybyyb的代码(超详细)
void Get_fail(){//构造fail指针
    queue<int> Q;//队列 
    for(int i=0;i<26;++i){//第二层的fail指针提前处理一下
        if(AC[0].vis[i]!=0){
            AC[AC[0].vis[i]].fail=0;//指向根节点
            Q.push(AC[0].vis[i]);//压入队列 
        }
    }
    while(!Q.empty()){//BFS求fail指针 
        int u=Q.front();
        Q.pop();
        for(int i=0;i<26;++i){//枚举所有子节点
        if(AC[u].vis[i]!=0){//存在这个子节点
            AC[AC[u].vis[i]].fail=AC[AC[u].fail].vis[i];
                                    //子节点的fail指针指向当前节点的
                                  //fail指针所指向的节点的相同子节点 
            Q.push(AC[u].vis[i]);//压入队列 
        }else//不存在这个子节点 
            AC[u].vis[i]=AC[AC[u].fail].vis[i];
                      //当前节点的这个子节点指向当
                      //前节点fail指针的这个子节点
        }            
    }
}
  • 建立fail时的几个要点:

    • 1、根节点的子节点的fail直接指向根节点。需要进行预处理。
    • 2、指向的当前节点是处理当前节点的子节点的fail指针的。
    • 3、当存在子节点时,则子节点指向当前节点的fail指针的相同子节点。
    • 4、当不存在子节点时,当前子节点(注意这里不是fail)指向当前节点fail的相同子节点。
  • 虽然上面四句话看着比较绕。但大家还是根据程序多多画图模拟来进行深入了解。

  • 第3步就是查询了。虽然光说话比较废但是说话吧..

猜你喜欢

转载自blog.csdn.net/qq_42037034/article/details/80975874