把KMP匹配算法讲清楚

234 阅读13分钟

写在前面

如果觉得有所收获,记得点个关注和点个赞,感谢支持。
KMP算法是真的折磨我够呛的,记不清是大一还是大二的时候就接触了这个算法,当时研究的一知半解,然后就用起来了,顶多只能说是敲多了几遍,大致的结构流程能够写出来,然后就一直用了很久到现在。虽然使用的过程中一直有磕磕碰碰,但是够用所以就没有进行深究,知道前段时间,包括今天,莫名其妙遇到了许多使用KMP思想,并进行变种的算法思路,以及算法题的解法,我才终于下定决定要把KMP算法搞明白,写下这篇博文,尝试吃透讲明白KMP。过程中遇到了许多优秀的关于KMP的文章,比如阮一峰的文章July的文章JBoxer的文章知乎上的解答,有兴趣的可以直接跳转过去查阅。

直接一点进行匹配

两个字符串,一个是主串,一个是模式串,我们要在主串中进行匹配,找到主串中是否存在模式串,我们的最直观的思路是什么,就是按部就班的挨个进行匹配,可能这样直接说不利于理解,那么我直接用图来动态化的帮助理解(下面的一些用图,我借用前辈的一些图,我就不画了,因为就算自己画也差不多一个意思)。

  • 第一步,我们肯定是挨个进行匹配,假设字符串"BBC ABCDAB ABCDABCDABDE“是主串,字符串”ABCDABD"是模式串,这个时候,我们用主串的第一个字符和模式串的第一个字符进行比较。因为BA不匹配,所以模式串后移一位。
    在这里插入图片描述
  • 第一个字符匹配失败了,下一步当然就是模式串往后移动一步,然而,因为BA不匹配,模式串继续往后移。
    在这里插入图片描述
  • 按照上面的这样的思路,直到主串中有一个字符,与模式串的第一个字符相同为止。
    在这里插入图片描述
  • 下一步就是,接着比较主串和模式串的下一个字符,在这个例子中,下一个字符还是相同。
    在这里插入图片描述
  • 直到字符串有一个字符,与模式串对应的字符不相同为止。
    在这里插入图片描述
  • 这时,最自然的反应是,将模式串整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把"搜索位置"移到已经比较过的位置,重比一遍。
    在这里插入图片描述

KMP的基本思想

上面的一种思路说白了就是自然反应,没有啥任何技巧的思路,这种思路的时间复杂度太高了,下面我们来了解KMP的基本思想以及一些必要的概念,为后面的next的推导提供帮助。

  • 一个基本事实是,当空格D不匹配时,你其实知道前面六个字符是"ABCDAB"。KMP算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,继续把它向后移,这样就提高了效率。
    在这里插入图片描述
  • 怎么做到这一点呢?可以针对模式串,算出一张《部分匹配表》(Partial Match Table)。这张表是如何产生的,后面再介绍,这里只要会用就可以了。
    在这里插入图片描述
  • 已知空格D不匹配时,前面六个字符"ABCDAB"是匹配的。查表可知,最后一个匹配字符B对应的"部分匹配值"为2,因此按照移动位数 = 已匹配的字符数 - 对应的部分匹配值的公式算出向后移动的位数:因为 6 - 2 等于4,所以将搜索词向后移动4位。
    在这里插入图片描述
  • 因为空格不匹配,搜索词还要继续往后移。这时,已匹配的字符数为2("AB"),对应的"部分匹配值"为0。所以,移动位数 = 2 - 0,结果为 2,于是将搜索词向后移2位。
    在这里插入图片描述
  • 因为空格A不匹配,继续后移一位。接着继续逐位比较,直到发现CD不匹配。于是,移动位数 = 6 - 2,继续将搜索词向后移动4位。
    在这里插入图片描述
  • 逐位比较,直到搜索词的最后一位,发现完全匹配,于是搜索完成。如果还要继续搜索(即找出全部匹配),移动位数 = 7 - 0,再将搜索词向后移动7位,这里就不再重复了。
    在这里插入图片描述

部分匹配表(PMT)思路原理

首先,要了解两个概念:“前缀"和"后缀”。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。如下图
在这里插入图片描述
“部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。以”ABCDABD"为例,

字符串前缀集合后缀集合最长共有元素长度
A0
ABAB0
ABCA, ABBC, C0
ABCDA, AB, ABCBCD, CD, D0
ABCDAA, AB, ABC, ABCDBCDA, CDA, DA, A1
ABCDABA, AB, ABC, ABCD, ABCDABCDAB, CDAB, DAB, AB, B2
ABCDABDA, AB, ABC, ABCD, ABCDA, ABCDABBCDABD, CDABD, DABD, ABD, BD, D0

“部分匹配"的实质是,有时候,字符串头部和尾部会有重复。比如,”ABCDAB“之中有两个”AB",那么它的"部分匹配值"就是2("AB“的长度)。搜索词移动的时候,第一个”AB“向后移动4位(字符串长度-部分匹配值),就可以来到第二个”AB"的位置。PMT系数帮助我们快速定位J,避免i回指,同时减小J回指的长度。

可能说到这里,你或许懂了部分匹配表的工作原理,但是还是比较难想象出来具体前后缀和部分匹配表为什么能够实现我们要的快速定位,他们是怎么扯上关系的?这里我再用图来形象的进行解释。如下图
在这里插入图片描述
以图中的例子来说,在 i 处失配,那么主字符串和模式字符串的前边6位就是相同的。又因为模式字符串的前6位,它的前4位前缀和后4位后缀是相同的,所以我们推知主字符串i之前的4位和模式字符串开头的4位是相同的。就是图中的灰色部分。那这部分就不用再比较了。

PMT和next的关系

到这里,请保证你已经理解了部分匹配表(PMT)的原理以及作用,因为next是与部分匹配表息息相关,甚至可以这么说,next就是部分匹配表。

有了上面的思路,我们就可以使用PMT加速字符串的查找了。我们看到如果是在模式串的 j 位 失配,那么影响 j 指针回溯的位置的其实是第 j −1 位的 PMT 值,所以为了编程的方便, 我们不直接使用PMT数组,而是将PMT数组向后偏移一位。我们把新得到的这个数组称为next数组。这样,我们可以得到PMT和next数组的关系,我们用模式串“abababca”来举例,并求出PMT值和next数组,表如下:
在这里插入图片描述
其中要注意的一个技巧是,在把PMT进行向右偏移时,第0位的值,我们将其设成了-1,这只是为了编程的方便,并没有其他的意义。到这里,我们知道了PMT和next数组的关系,这个时候,我想先给出,在有next数组的帮助下,我们如何进行匹配的代码实现,这样有助于我们认识清楚next数组在KMP算法中的重要性。

public void KMP(String sourceString, String patternString) {
    int i = 0, j = 0;
    int[] next = getNext(patternString); //getNext方法就用于计算next数组
    while (i < sourceString.length() && j < patternString.length()) {
        if (j == -1 || sourceString.charAt(i) == patternString.charAt(j)){
            //if (j == patternString.length()) break; //在这里说明已经完全匹配成功,可以直接退出或者进行其他操作
            i++; j++;
        }else {
            j = next[j];
        }
    }
}

上面的代码一定要去看懂呀,不然模棱两可,然后去看后面的,会一脸懵逼的,可以自己手动的运行一遍,用上面的代码去模拟前面提高的例子。

next数组的求解

到这里,其实KMP的基本主体就已经讲完了,你会发现,其实KMP算法的动机是很简单的,解决的方案也很简单。不过我们还有一个疑问,就是next数组我们要怎么用代码去求解呢?这可是KMP算法的核心呀,不会这个,前面不都白瞎了嘛。不着急,这不就是要准备讲嘛。

经过前面的讲解,我们都知道next数组都是通过PMT转化过来的,而PMT的求解,是通过前后缀的最长公共元素长度来完成的,那么我们有没有很好的求解next数组的方法呢(这里就把next当做PMT的求解了)。现在,我们再看一下如何编程快速求得next数组。其实,求next数组的过程完全可以看成模式串自行进行字符串匹配的过程,即以模式(就是pattern)字符串为主字符串,以模式字符串(就是pattern)的前缀为目标字符串,一旦字符串匹配成功,那么当前的next值就是匹配成功的字符串的长度。

具体来说,就是从模式字符串的第一位(注意,不包括第0位)开始对自身进行匹配运算(因为i=0时,pmt系数为0,在next中,next[-1]我们就直接给-1,前面我们有说过)。 在任一位置,能匹配的最长长度就是当前位置的next值。如下图所示。(这一段可能不太好理解,结合代码来理解)。

  • 首先比较i = 1,j = 0的位置,发现两个不相等,按照上面的说的规则, ij处的字符不相匹配,所以长度为0
    在这里插入图片描述
  • 然后i++,j回到0,继续进行比较,发现 ij处的字符相匹配,长度为1
    在这里插入图片描述
  • 然后这个时候,由于上一个是相等的,所以进行了i++,j++的操作,然后进行比较,发现 ij处的字符相匹配,长度为2
    在这里插入图片描述
  • 按照上一步同样的规则,我们一直匹配到i = 6
    在这里插入图片描述
  • 到了i= 7,发现i,j两处的两个字符不相匹配了,这个时候,按照i++,j回到0的操作继续匹配
    在这里插入图片描述
    出现的情况就上面这些,然后按照上面的规则,一直匹配到结束为止。下面贴出代码,最好就是结合代码过去进行手动运行一遍,加深理解
public int[] getNext(String s) {
    int[] next = new int[s.length()];
    next[0] = -1;
    int i = 0, j = -1;
    while (i < s.length() - 1) {
        if (j == -1 || s.charAt(i) == s.charAt(j)) {
            i++; j++;
            next[i] = j;
        }else {
            j = next[j];
        }
    }
    return next;
}

Next 数组的优化

其实如果看完上面的内容,能够完全理解的话KMP算法就已经是基本学习完了,为什么说基本呢?难道还没有完全学习完么?当然,因为上面的next的求解方法,并不是最优的next数组,也就是说next数组还有优化的空间,那么怎么个优化法呢?不急,我们先来看看这个例子,比如,如果用之前的next 数组方法求模式串“abab”的next 数组,可得其next 数组为-1 0 0 1(部分匹配表0 0 1 2整体右移一位,初值赋为-1),当它跟下图中的文本串去匹配的时候,发现bc失配,于是模式串右移j - next[j] 也就是 3 - 1 =2位。
在这里插入图片描述
右移2位后,b又跟c失配。事实上,因为在上一步的匹配中,已经得知p[3] = b,与s[3] = c失配,而右移两位之后,让p[ next[3] ] = p[1] = b 再跟s[3]匹配时,必然失配。问题出在哪呢?
在这里插入图片描述
问题出在不该出现p[j] = p[ next[j]]。为什么呢?理由是:当p[j] != s[i] 时,下次匹配必然是p[ next [j]]s[i]匹配,如果p[j] = p[ next[j]],必然导致后一步匹配失败(因为p[j]已经跟s[i]失配,然后你还用跟p[j]等同的值p[next[j]]去跟s[i]匹配,很显然,必然失配),所以不能允许p[j] = p[ next[j ]]。如果出现了p[j] = p[ next[j] ]咋办呢?如果出现了,则需要再次递归,即令next[j] = next[ next[j] ]。所以,咱们得修改下求next 数组的代码。

public int[] getNext(String s) {
    int[] next = new int[s.length()];
    next[0] = -1;
    int i = 0, j = -1;
    while (i < s.length() - 1) {
        if (j == -1 || s.charAt(i) == s.charAt(j)) {
            i++; j++;
            //next[i] = j; 不优化前,直接赋值就可以了
            if (s.charAt(i) != s.charAt(j)) {
                next[i] = j;
            }else {
                next[i] = next[j];
            }
        }else {
            j = next[j];
        }
    }
    return next;
}

利用优化过后的next 数组求法,可知模式串“abab”的新next数组为:-1 0 -1 0。可能有些读者会问:原始next 数组是前缀后缀最长公共元素长度值右移一位, 然后初值赋为-1而得,那么优化后的next 数组如何快速心算出呢?实际上,只要求出了原始next 数组,便可以根据原始next 数组快速求出优化后的next 数组。还是以abab为例,如下表格所示:
在这里插入图片描述
只要出现了p[next[j]] = p[j]的情况,则把next[j]的值再次递归。例如在求模式串“abab”的第2个a的next值时,如果是未优化的next值的话,第2个a对应的next值为0,相当于第2a失配时,下一步匹配模式串会用p[0]处的a再次跟文本串匹配,必然失配。所以求第2a的next值时,需要再次递归:next[2] = next[ next[2] ] = next[0] = -1(此后,根据优化后的新next值可知,第2个a失配时,执行“如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符”),同理,第2b对应的next值为0

对于优化后的next数组可以发现一点:如果模式串的后缀跟前缀相同,那么它们的next值也是相同的,例如模式串abcabc,它的前缀后缀都是abc,其优化后的next数组为:-1 0 0 -1 0 0,前缀后缀abc的next值都为-1 0 0

接下来,咱们继续拿之前的例子说明,整个匹配过程如下:

  • S[3]与P[3]匹配失败。
    在这里插入图片描述
  • S[3]保持不变,P的下一个匹配位置是P[next[3]],而next[3]=0,所以P[next[3]]=P[0]与S[3]匹配。
    在这里插入图片描述
  • 由于上一步骤中P[0]与S[3]还是不匹配。此时i=3,j=next [0]=-1,由于满足条件j==-1,所以执行“++i, ++j”,即主串指针下移一个位置,P[0]与S[4]开始匹配。最后j==pLen,跳出循环,输出结果i - j = 4(即模式串第一次在文本串中出现的位置),匹配成功,算法结束。
    在这里插入图片描述