字符串匹配问题之BF和KMP

344

这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战

字符串匹配问题就是寻找模式串在主串中的位置,存在返回索引,否则返回-1。例如主串abcdef,模式串cde,返回的就是2(就是cde在主串中的起始位置)。

我们先来说一下比较容易想到的暴力匹配(Bruce-Force)算法,即BF算法。

BF算法

BF算法非常简单粗暴,我们以主串abcabde和模式串cab为例子。

假设主串M现在匹配到i位置,模式串S匹配到j位置,开始i=0,j=0,

image.png

M[0]=S[0],因此继续比较下一位,i++,j++,比较M[1]和S[1],M[1]=S[1],继续比较M[2]和S[2], c不等于d,所以主串须移到下一位字符重新开始和模式串的匹配,即此时的i减去已匹配的字符个数j回到起始位置之后再后移一位,即i-j+1,而模式串得从头开始比对,j置为0。 第二轮比较开始,此时i为1,j为0。

image.png

M[1]不等于S[0],i变为2,j仍为0。

image.png M[2]不等于S[0],i变为3,j仍为0。这一次比对之后会发现abd子串已经在主串中找到匹配的地方了,返回起始索引3。

过程比较简单,代码实现如下:


function BF(s1,s2){
    if(!s1.length||!s2.length||s1.length<s2.length){
        return -1;
    }
    let str1=s1.split('');
    let str2=s2.split('');
    let i=0;
    let j=0;
    while(i<str1.length&&j<str2.length){
        if(str1[i]==str2[j]){
            i++;
            j++;
        }else {
            i=i-j+1;//主串须移到下一位字符重新开始和模式串的匹配
            j=0;//模式串从头开始匹配
        }
    }
    return j==str2.length?i-j:-1;//如果模式串在主串中匹配成功,就返回其在主串中的起始位置
    //即i-j(当前位置减掉已经比对的字符个数)
}

但是我们可以发现,暴力匹配的缺点是之前比对时得到的信息在从头开始比对时都没能利用起来,即每一次比对都是彼此割裂的。那KMP算法是如何加快比对速度的呢?我们继续往下看:

KMP算法

这个算法由Donald Knuth、Vaughan Pratt、James H. Morris三人于1977年联合发表,故取这3人的姓氏命名此算法。 在开始讲算法流程之前,我们需要先来说一下几个概念: "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。比如abab的最长前缀就是aba,最长后缀就是bab,而最长公共前后缀就是ab。 而我们的kmp算法中有一个next数组就是用来存放上一次计算中字符串最长前缀和最长后缀的匹配程度,也就是最长公共前后缀的长度。首先来看一下怎么得到这个next数组?

我们人为规定next[0]=-1,不难得到next[1]=0,因为上一次计算中只有一个字符,它没有前缀和后缀,所以最长公共前后缀的长度就是0啦,所以next数组的值从next[2]开始需要进行计算。

我们举ababctk为主串M,ababc为模式串S,假设主串M现在匹配到i位置,模式串S匹配到j位置,next[2]的值看S[1]和S[0]组成的字符串,会发现没有公共前后缀而且已经到了零位置,所以next[2]=0,next[3]的值看S[0]到S[2]组成的字符串,最长公共前后缀的长度为1,所以next[3]=1。next[4]看S[0]到S[3]组成的字符串,上一步已经得到了S[0]=S[2],所以这一次看S[1]和S[3]的值是否相等,相等,所以next[4]=2。最后next[5]看S[0]到S[4]组成的字符,这一次看S[2]和S[4]的值是否相等,不相等而且没有到达0位置,就继续比较S[4]和S[next[2]]的值,不相等,而且此时已经到达0位置,所以next[5]=0。 代码实现如下:


function getNextArray(str2){
    if(str2.length==1)return [-1];
    let next=[];
    next[0]=-1;
    next[1]=0;
    let i=2;//从位置2开始计算
    let cn=0;//跳到的位置
    while(i<str2.length){
        if(str2[i-1]==str2[cn]){
            next[i++]=++cn;
        }else if(cn>0){
            cn=next[cn];//如果str2[i-1]和str2[cn]不相等,而且还没到达0位置,就跳到next[cn]
        }else{
            next[i++]=0;//如果str2[i-1]和str2[cn]不相等,已经到达了0位置,next[i]的值就为0
        }
    }
    return next;
}

得到next数组之后,KMP算法如何利用它去加速呢?我们继续往下走:

image.png

(前面一路绿灯的比较,我们就不赘述啦)当来到M[4]和S[4]的时候,两者不相等,按照之前BF的做法变成下面这样再继续比较:

image.png

但我们的KMP算法不同,根据next[4]的值为2,也就是上一次计算中最长公共前后缀的长度为2,将j设为next[j],也就是到最长公共前缀的后一位继续比对,实现向右移动了j-next[j]位,这样就减少了不必要的比较,实现了加速。如下图所示:

image.png

代码实现如下:

function getIndexOf(s1,s2){
    if(!s1.length||!s2.length||s1.length<s2.length){
        return -1;
    }
    let str1=s1.split('');
    let str2=s2.split('');
    let i1=0;
    let i2=0;
    let next=getNextArray(str2);//获得next数组
    while(i1<str1.length&&i2<str2.length){
        if(str1[i1]==str2[i2]){//如果两个字符相等,继续往下比较
            i1++;
            i2++;
        }else if(next[i2]==-1){//如果两个字符不相等,且模式串处于0位置,则主串往下移动
            i1++;
        }else{
            i2=next[i2];//如果两个字符不相等,且模式串不在0位置,则模式串移动到next[i2]的位置
        }
    }
    return i2==str2.length?i1-i2:-1;
}

至此,我们就把BF算法和KMP算法学习完啦。我感受最深的一点是理解一个算法最好的方法就是举例子然后自己演练一遍。但是自己get到了那个point之后要组织成语言表达出来确实不是一件简单的事情,如果有哪里说的不好,请大家见谅,也可以在评论区进行交流。

(新手上路,请多多指教🐇)