KMP算法详析

1,077 阅读1分钟
title: KMP算法
date: 2022-04-14 22:36:56
tags: 算法
categories: 数据结构

什么是KMP算法

KMP算法由Knuth、Morris和Pratt三位学者发明,这个算法取三主要名字的首字母,故叫做KMP算法。

这个算法用于字符串匹配,其核心思想就是利用已经匹配的部分,来简化整个重新匹配的机制,避免每次匹配失败都从头再来。

这么做,还是为了节约时间成本,花费额外的空间,来实现对时间复杂度的大幅减轻。

一个例子

我们先来看一个经典例子:

给出一个文本串:aabaabaaf

和一个模式串: aabaaf

需要我们在文本串中找出第一个匹配模式串的位置。

暴力算法

当然,我们还是可以使用非常暴力的算法对此进行解决。

for( i = 0; i<wenBenChuan.length;i++){
    for(let n = 0 ;n< moShiChuan.length;){
    if(wenBenChuan[i+n] == moShiChuan[n]&&wenBenChuan[i+moShiChuan.length] ==moShiChuan[moShiChuan.length -1]){
    n++;
    }else{
    break;
    }
    if(n == moShiChuan.length-1)
    return i+1;
  }
}

不难看出,在双循环的顶级加持之下,暴力算法的时间复杂度到达了傲人的o(n*n),我们对暴力法进行拷打后发现,只要出现字符串不匹配,暴力法都会选择前功尽弃,然后首位向后移动一格,重新做人,这样无疑会使时间复杂度高举不下。

结合上述案例,也就是当我们从第一个开始寻找匹配至aabbaa时,下一个字符f是无法和文本串中的b配对的,此时暴力法将会非常无脑的从第二个字符即第二个a开始搜寻,这无疑让搜寻花费的时间陡然上涨。

那么有没有一种方法,使得时间复杂度能够得到一个维度的下降呢?

方法是有的,但既然我们想要直接降低时间复杂度,代价往往就是会牺牲空间复杂度。既然暴力法选择前功尽弃,那我们就需要对“前功”进行考虑、分析、并加工,让其辅助我们定位下一个寻找的起始点。而参考“前功”时,我们需要用到前缀表!!!

前缀表

这里我们不由得提出两个问题:什么是前缀表?为什么是前缀表?

前缀表是用于记录最长前后缀的表,最长前后缀,即一个字符串中的前缀和后缀相同的部分的长(取最大值,但长度小于该字符串本身),例如aabaa的最长前后缀为2,abcab的最长前后缀为2,abcba的最长前后缀为1,abccbb的最长前后缀为0。aabaaf的前缀表如下

下标012345
aabaaf
前缀表010120

知道了前缀表里面的内容是什么,那为什么要使用前缀表呢?

既然我们已经知道了一个字符串的最长前后缀,那就可以利用它进行简化跳跃,例如上例中,当我们匹配到f时,发现匹配失败,此时我们观察此刻已经完成匹配字符串的前缀表,不难发现下标为4时(aabaa)对应的最长前后缀的值最大(2),下标为4前的字符串中,前两个字符(aa)和后两个字符相同(aa) ,也就是说用于被匹配的字符串(文本串)中匹配出错的字符(b)前面的两个字符 (aa)和用于匹配的字符串(模式串)的前面两个字符相同(模式串前两个字符 == 模式串后两个字符 模式串后两个字符 == 文本串中匹配出错的字符前面的两个字符)

由于文本串目前指向位置(b)的前面两个字符和模式串的前面两个字符相同,所以当我们开始新一轮匹配时,可以理所应当的跳到模式串的第三个字符和文本串进行匹配。

简而言之,完成匹配的模式串的前缀表中最大值对应的下标字符及其之前的字符中,存在部分相同的前缀和后缀(我们将之成为公共前后缀),每当我们进行下一次匹配时,将公共前缀移动至上一次公共后缀的位置,便可实现快速定位,继续进行匹配。

这就是KMP算法的核心思想,若N为模式串长度,M为文本串长度,那么KMP算法的时间复杂度就是o(m+n)。

前缀表的实现

我们先对前缀表(此后称为next数组)进行生成。声明一个函数getNext(int* next, const string* s) ---此处使用C++语言,具体思想不变。

先对该函数进行一个初始化

next[1]=0;
//next[j]:其值=第J字符前面j-1为字符组成的子串的最大前后缀值+1,此处next数组从下标1开始有意义
int i=1,j=0;
//i为next数组的索引,同时也是当前主串正在匹配的字符位置

函数实现的核心想法:如果next[j]不等于Pj,那么next[j+1]可能的次大值为next[next[j]+1],以此类推即可高效求出next[j+1],每一次只需要找到当前子串索引j对应的字符、前缀表索引j对应的值m,以及子串索引m对应的字符,并将这两个字符进行对比

(说的很好,下次别再说了)

那这是为什么捏,我们不妨从一个例子进行推理。

已知next[1],next[2]....next[j],求next[j+1]

我们假设next[j+1]=k,那么前k-1个字符和后k-1个字符重合,即P1P2...Pk-1=Pj-k+1.....Pj-1

如果Pk = Pj,那么P1P2...Pk=Pj-k+1.....Pj,即前k个字符和后k个字符重合,则next[j+1]=k+1

若二者不等,假设next[k]=m(k一定小于j),则有P1...Pm=Pk-m+1...Pk

故,得:P1...Pm=Pk-m+1...Pk=pJ-K+1...Pm-k+j-1=Pj-m+1.....Pj-1(根据P1P2...Pk-1=Pj-k+1.....Pj-1可得),即前m-1个字符和后m-1个字符重合,此时,如果Pm =Pj,那么next[j+1]=m+1,反之则重复上述步骤,继续进行递推。

还不懂?“偷”几张图看看

LgFHun.png LgFbBq.png LgFqH0.png LgFOEV.png

实现代码:

void getNext(int* next, const string* s){
    next[1]=0;
    inti=1,j=0;
    while(i<=s.size()){
        if(j==0 || s[i]==s[j]) next[++i] = ++j;
        else j = next[j]
    }
}

KMP算法实现

代码:

void getNext(int* next, const string* s){
    next[1]=0;
    inti=1,j=0;
    while(i<=s.length){
        if(j==0 || s[i]==s[j]) next[++i] = ++j;
        else j = next[j]
    }
}
//haystack 为文本串  needle为模式串
void KMPstr(string haystack ,string needle){    
    if(needle.length == 0)return 0;
    int next[needle.size()];
    getNext(next,needle);
    int j=0;
    for(int i=0;i<haystack.size();i++){
        while(j>0 && haystack[i]!=needle[j]){
            j = next[j]
        }
        if(haystack[i]==needle[j]){
            j++;
        }
        if(j == needle.size()){
            return (i-needle.size()+1)
        }
    }
    return -1;
}