「字符串匹配算法 3/3」KMP (Knuth–Morris–Pratt)字符串匹配算法

KMP (Knuth–Morris–Pratt)字符串匹配算法比较适合查找一个匹配。多个的话使用 Rabin-Karp 改进版比较好。

本文结构如下(对应边栏的目录):

  1. 各种资料上的 KMP 为什么那么那么难懂,区别和注意事项;
  2. KMP 算法的简单介绍和特性;
  3. KMP 如何工作的;
  4. KMP 的工作逻辑;
  5. KMP重点next数组是怎么计算得到的;
  6. 为什么next数组能计算出合适的偏移量;
  7. 完整代码

之所以介绍如何工作再介绍工作逻辑是为了让你先看看 KMP 跑起来是什么样的,这样再看理解就好懂了。不要硬啃“如何工作”那章,我知道很多博客例子不怎么好,而且很少也不全面。(原始论文的这个例子真的覆盖到了需要的三种情况,非常好,就是有点长)

我在后续章节也举了很多例子,其他例子也可以帮助记忆的。而且看完了再回头看这个原始论文的例子一眼就懂,节省时间。

一些吐槽

KMP 是极大成者,三个独立发明者全是狠人:

  • Donald Knuth:TAOCP、TeX 作者,1974 年图灵奖得主。
  • James H. Morris:卡内基梅隆大学计算机科学名誉教授。
  • Vaughan Pratt:Knuth 的学生,20 个月读完斯坦福博士,2000年成为斯坦福大学的名誉教授。

这个背景(尤其是 Knuth 的存在)导致这个算法背后的原理没那么容易理解,我花了一天才搞明白 KMP 是什么,以及论文和一堆教材上讲的有什么关系和区别。这就使得学习这个算法的过程有点搞笑。

在学习 KMP 的时候,我发现在国内的教材上是按照朴素算法的改进来介绍的,说是增加了next数组(部分匹配表,PM),这个表记录了子串中每个位置的最大相等前后缀的长度。这个介绍我不知道你有没有看懂这种介绍,我反正没看懂。

然后我发现《算法导论》思路是按照有限自动机的改进来介绍的,也提到了求前缀,而且数学性极强。懂了点思路,但是对代码怎么写毫无头绪。

这两个给的解释不能让我满意。

网上的博客和代码要么就是参考国内教材给解释,要么就是参考算法导论给解释,要么没解释,所以结果也是一样。

最后我跑去看了 KMP 的初始论文,才明白为什么国内教材和《算法导论》二者思路上都说不清,因为这个算法太巧妙了,很难简单的说清楚。而且虽然 Knuth 现在是大家,但是这篇论文写的太早了,很多写作手法还不是很成熟,当时论文也没有固定格式,所以写的结构很随意。

然后最搞笑的来了,各种资料上计算 KMP方法不统一,所以我没有看到有考计算next数组的,因为这个不唯一。有多不唯一呢?

  • 字符编号从 0 开始还是 1 开始。
  • next数组编号从 0 还是 1 开始。
  • next[0]为 0 还是 1。

做个排列组合吧,一个模式理论上有 8 种test数组(实际上没有这么多,因为字符和数组编号是对应的,差不多 3~4 种是有的)。每种数组对应的计算方法不太一样,不过结果是一样的。所以如果你要考试,老老实实背书上的方法,别和我一样研究算法本身。

本文的 KMP test数组计算方法采用原始论文的版本,也就是字符串、next编号从 1 开始。不过代码部分会按照 C 的样式,也就是字符串、next编号从 0 开始。

什么是 KMP 算法

字符串匹配算法一般都由预处理和匹配两个阶段,预处理是对匹配字符串(模式)进行一些处理,后续进行匹配的时候速度就快很多。

但是匹配字符串的时候,无论是朴素还是有限自动机,都会出现匹配不成功,然后需要“往回倒”的情况,也就是对同一部分检查多次。

“往回倒”是指匹配字符串中往回倒,而不是待匹配字符串中。

比如说ABCBCD中匹配BCD匹配到第二个B的时候,匹配失败:

  • 朴素会从第一个C处(因为前面的B不成功)继续进行比较,这时候匹配字符串下次依旧是从第一个元素B开始匹配。
  • 有限自动机虽然留在当前的B处,下一次匹配依旧是C,但这是进行了回退操作,最后发现还在这。

前者次次从头匹配(“头”是指匹配字符串,也就是模式的“头”),后者有些不从头开始,有了一些改进,所以后者要比前者快一些。

算法预处理时间匹配时间
朴素算法 0 0 0 O ( ( n − m + 1 ) m ) O((n-m+1)m) O((nm+1)m)
有限自动机算法 Θ ( m ∣ ∑ ∣ ) \Theta(m|\sum|) Θ(m) Θ ( n ) \Theta(n) Θ(n)

但是如果你看过上一篇文章的话,就知道有限自动机预处理阶段是蛮复杂的,不光要判断一张表中能到下一状态的,还要设置“往回倒”到哪里。那么能不能不“往回倒”呢?也就是对目标字符串的某一个字符匹配过之后,不会再次进行匹配。

而这个方法就是 KMP,相比较传统方法来说,就是减少了“回退”的阶段,直接让匹配字符串右移,不会重复对比字符。

而且这样就只需要一个一维数组,而不是一个二维数组。而且数组长度为匹配字符串长度,而不是匹配字符串长度 x 输入所有可能值的数量,所以无论时间和空间上都有了巨大进步。

时间上是因为预处理阶段的工作量减少了,自然对应的时间也少了

算法预处理时间匹配时间
朴素算法 0 0 0 O ( ( n − m + 1 ) m ) O((n-m+1)m) O((nm+1)m)
Rabin-Karp Θ ( m ) \Theta(m) Θ(m) O ( ( n − m + 1 ) m ) O((n-m+1)m) O((nm+1)m)
有限自动机算法 Θ ( m ∣ ∑ ∣ ) \Theta(m|\sum|) Θ(m) Θ ( n ) \Theta(n) Θ(n)
KMP Θ ( m ) \Theta(m) Θ(m) Θ ( n ) \Theta(n) Θ(n)

但是就像其他的字符串匹配算法一样,KMP 的预处理是整个算法里最最要也是最复杂的部分。毕竟右移匹配很简单,但是移多少是要算的。

KMP是如何工作

使用原始论文中的例子来解释一下 KMP 是如何工作的。

下图是匹配字符串和对应的next数组元素:

请添加图片描述

下图中,上面是匹配字符串(模式),中间是字符串,下面表示要检查的字符:

请添加图片描述

可以看到第一个就不匹配,所以右移匹配字符串1-next[1] = 1位,然后根据next[1]=0,接下来依旧从头开始对比。结果发现比到第 4 个的时候不匹配:
请添加图片描述

此时next[4]=04-next[4]=4-0=4,所以右移 4 位,下一次依旧从头开始。接着对比,到第八个的时候发现不匹配了,如下:

请添加图片描述

这时候next[8]=58-next[8]=8-5=3,右移 3 位,然后从第五个字符开始对比。

如果你和我一样很奇怪为什么这里只移动 3 个,后面“为什么j-next[j]就是匹配模式的偏移量”章节会给你一个解释的。

这次到第五个不匹配了:
请添加图片描述

这时候next[5]=15-next[5]=5-1=4,所以右移 4 个,从第一个元素开始继续匹配,结果又是第 8 个不匹配:

请添加图片描述

所以右移 3 位,然后从第五个字符开始对比。这次发现全匹配了,如下:

请添加图片描述

KMP的工作逻辑

KMP 的预处理和匹配使用的是同一种算法,这点不光在《算法导论》中有所提及,在原始论文中也是这么说的。

二者的区别在于:

  • 预处理是对匹配字符串(模式)自己进行匹配;
  • 匹配是对匹配字符串(模式)和另一个字符串进行匹配。

这个算法的结构为:

把模式放在左端
while 模式没有完全匹配完 and 被匹配的字符串没有穷尽
	while 模式字符和当前字符不同
		右移模式到合适的位置
	前进到被匹配的字符串的下一个字符

代码格式如下:
请添加图片描述

什么叫“右移模式到合适的位置”。这里利用了一个等式:

k = p + j
  • ktext字符串的下标;
  • jpattern字符串的下标;
  • p是一个值,使得等式成立(其实就是偏移量,变换式子为p=k-j就可以看出来)。

一般是固定kp来匹配字符串,因为只要确定两者,就能知道三者的数值。

这个等式是 KMP 的核心想法,KMP 证明正确性就是通过这个来证的,后面计算next也是利用这一点。在“为什么序号-next[序号]就是匹配模式的偏移量”一节中,我会告诉你为什么这个是 KMP 的核心想法。

证明算法正确性就是说这个算法能用数学证明,也就是合理、严谨的。不然就算使用了大量示例检查,也有可能出错。

KMP 的next数组是怎么来的

可以从上面的流程中发现 KMP 的重点就是这个next数组,那这个数组咋来的?

我们来看看原始论文中这个例子的next是怎么算出来的。

原论文在计算next的时候,引入了一个叫f[x]的东西。还记得我开始说 KMP 的预处理和匹配都是一样的算法,只是对象不同,等于下面演示中的j

f[x]就是针对textpattern的,而计算next的时候,二者皆是pattern。不要自找麻烦按照论文中的方法去想问题,就像下面代码中的f[x]是注释,不是必须的。

论文中描述的计算next代码结构如下:

请添加图片描述

说了一堆很难理解,那么继续以论文中的了例子来解释步骤:

初始状况:
text: 		a b c a b c a c a b	
pattern:	a b c a b c a c a b						-> 第一个按照惯例都是 0-1,这里选择next[1]=0
		 (p=text下标;k=p+j;j=pattern下标)
第一次匹配(p=1,k=2,j=1|
text: 		a b c a b c a c a b					
pattern:	  a b c a b c a c a b					-> text[p+j]和pattern[j]不等,next[2]=j=1
第二次匹配(p=2,k=3,j=1|
text: 		a b c a b c a c a b
pattern:	    a b c a b c a c a b					-> text[k]和pattern[j]不等,next[3]=j=1
第三次匹配(p=3,k=4,j=1|
text: 		a b c a b c a c a b
pattern:	      a b c a b c a c a b				-> 相等,next[4]=next[j]=next[1]=0
 (相等的时候只挪检查点,不移动pattern,也就是p不变)
第四次匹配(p=3,k=5,j=2|
text: 		a b c a b c a c a b
pattern:	      a b c a b c a c a b				-> 相等,next[5]=next[j]=next[2]=1
第五次匹配(p=3,k=6,j=3|
text: 		a b c a b c a c a b					
pattern:	      a b c a b c a c a b				-> 相等,next[6]=next[j]=next[3]=1

第六次匹配(p=3,k=7,j=4|
text: 		a b c a b c a c a b
pattern:	      a b c a b c a c a b				-> 相等,next[7]=next[j]=next[4]=next[1]=0
第七次匹配(p=3,k=8,j=5|
text: 		a b c a b c a c a b	
pattern:	      a b c a b c a c a b				-> 不等了,这时候next[8]=j=5
第八次匹配(p=8,k=9,j=1|
text: 		a b c a b c a c a b
pattern:	                a b c a b c a c a b		-> 相等,这时候next[9]=next[j]=next[1]=0
第九次匹配(p=9,k=10,j=1|
text: 		a b c a b c a c a b
pattern:	                  a b c a b c a c a b	-> 不等并且超过,这时候next[10]=j=1

这个结果和论文中的一模一样:

请添加图片描述

也就是说:

  • 如果相等,只挪检查点,不移动pattern,也就是p不变。那么next[k]=next[j]
  • 如果不相等,或者到最后穷尽,只挪检查点,不移动pattern,也就是p不变。那么next[k]=next[j]

不过如果你按照某些教材上的方法算这个next数组,那么会得到一些不一样的。

比如按照前后缀集合数量来算:

  • a的前后缀都为空集,所以为0
  • ab前缀为a,后缀为b,交集为空,还是0
  • abc的前缀为{a,ab},后缀为{c,bc},交集为空,结果为0
  • abca的前缀为{a,ab,abc},后缀为{a,ca,bca},交集为{a},结果为1
    -…

从前几个就能看出很不一样了,每次移动的距离和结果依然是一样的。不过如果你要使用这种方法生成的next数组,那么需要注意编号要从0开始(也就是上图中的j),不然会出错的

有些教材会说只看已匹配的,然后计算移动距离。我觉得这样多此一举,不然把序号处理好更方便和快速。

nextval

我推荐使用原始论文的方法还有一个原因:某些教材上提及 KMP 的时候,会提到另外一个数组nextval,然后告诉你nextval如何好,但这其实就是原始论文的方法。

为什么序号-next[序号]就是匹配模式的偏移量

用数学验证 KMP 的正确性是一个挺复杂的事情,论文中对此进行了叙述,这里不再重复了。这里只使用一些大白话进行解释一些我觉得读者们可能感兴趣的点。

这里使用的next是按论文方法算出来的,如果你用其他的方法得到的,那么自己转换一下。

从上一节的过程中,你会发现next就是当前pattern的下标。而我们匹配的时候操作的是text的下标,如果此时我们知道pattern的下标(也就是next),那么根据前面的等式,就可以计算出偏移量了。

那么现在问题就变成了:自匹配的偏移量和匹配模式的偏移量二者居然相同?

首先要用个例子证明确实存在自匹配的偏移量和匹配模式的偏移量相等:字符串为ABABBABC,模式为ABABCnext数组计算过程为:

初始状况:
text: 		A B C 		-> next[1]=0
pattern:	A B C	
		 (p=text下标;k=p+j;j=pattern下标)
第一次匹配(p=1,k=2,j=1|
text: 		A B C
pattern:	  A B C 		-> text[p+j]和pattern[j]不等,next[2]=j=1
第二次匹配(p=2,k=3,j=1|
text: 		A B C 
pattern:	    A B C 		-> text[p+j]和pattern[j]不等,next[3]=j=1

next结果如下:

序号123
字符ABC
next011

假设我们一开始不知道text字符串的内容,只知道pattern的字符。

text: 		A B A B B A B C
pattern:	A B C

KMP 一开始匹配到了,第三个元素A不匹配,也就是匹配失败了。这时候我们知道text前三个字符为ABXX不等于C,不然就匹配成功了)。不等于C,也就是说有可能等于模式的第一个字符A,所以下次也还是这个位置开始。

这里需要理解一点:不需要记前面的AB,因为能到X这个位置,说明前面是匹配的。

此时,我们再用next看一下,3-next[3]==3-1=2,右移 2 位。发现和上面操作的结果一样:

text: 		A B A B B A B C
pattern:	    A B C

(下面右移的操作你自己那张纸记一下,动手试试看,会发现都一样,多动手学的快)。

和上一次原因一样。现在我们知道text前5个字符为ABABXX依然不等于C,不然就匹配成功了),不等于C,也就是说有可能等于模式的第一个字符A,所以继续移到X

你可能疑惑为什么前面我说不要记开头的AB,但这里ABABX还是写了AB
这只是为了说明位置,我写作。。ABX影响ABC和他最后三个对比吗?不影响吧。KMP 优点就是这个,没有备份拖累自己,也就不用“倒回去”检查。

text: 		A B A B B A B C
pattern:	        A B C

这次一开始匹配失败,我们知道text前6个字符为ABABBXX不等于A,不然就匹配成功了),也就是说X这地方开始的字符串绝对和text匹配不了,所以跳过这个字符。

text: 		A B A B B A B C
pattern:	          A B C

这次匹配成功了。返回序号6(如果你从0开始,那就是5)。

确定相等之后,就要搞明白为什么自匹配的偏移量会等于匹配模式的偏移量呢?

这里要从物理中借个“参照系”的概念过来,这样应该可以帮助你理解。

这个概念不是我想出来的,是原始论文中就有提到“相对”的概念。

匹配字符串(模式)内部也存在“运动”,而匹配字符串(模式)整体是在字符串上“运动”的。这里的“运动”就是指“右移”。

由于“右移”是一个一维空间的运动,而且方向也是固定的,所以我们可以得到一个简单的转化:

绝对坐标=相对原点+相对坐标

这里的“相对原点”就是“匹配字符串(模式)上用来对比的元素”,而“相对坐标”便是“自匹配的偏移量”。

绝对偏移量=相对原点+相对偏移量

而这个式子,是不是就是前面提到的那个等式:

k = p + j

有了这个关系,我们就可以做到每次需要右移的时候,都可以找到对比字符之前重复的部分,然后移动对应的距离。也就是:

相对原点=绝对偏移量-相对偏移量

其中:

  • 这里的相对原点就是原点的移动距离(如果将起点当作0的话),也就是text字符串的下标移动量,再换句话说,这里就是要找的那个合适的移动位置。
  • 绝对偏移量是输入字符串的下标;
  • 相对偏移量是匹配字符串(模式)的下标。

转化之后可以得到:

p=k-j

而这个距离只和匹配字符串(模式)有关,和你要输入的字符串没有任何关系。

说真的,我理解到这一层的时候被震撼到了,这三个人在五十年前想出来的这个算法太巧妙了,太厉害了。

如果你还不理解,那么请见《「字符串匹配算法 2/3」有限自动机匹配字符串算法》的“生成状态转移表格”部分,在这部分最后我举了个例子,解释了这一点,或许能帮到你。
二者这部分思路是一样的,区别在于有限自动机找重复字符串的时候,是带着当前输入字符找的,而 KMP 不包含当前字符。

完整代码

之前提到了预处理和匹配是相同的算法,代码结构也相似。这个代码照着论文写,将序号修改成从0开始,对应的修改一下就完成了。所以 C 实现的代码如下:

#include <stdio.h>

void getNext(char pattern[], int m, int next[]) {
    next[0]=-1;
    int j=0, t=-1;
    while (j<m) {
        while (t>0 && pattern[j] != pattern[t]) {
            t = next[t];
        }
        j++; t++;
        if (pattern[t] == pattern[j]) {
            next[j]=next[t];
        } else {
            next[j]=t;
        }
    }
}

int kmp(char text[], char pattern[], int n, int m, int next[]) {
    int j=0, k=0;
    
    while (j<m && k<n) {
        while (j>0 && text[k]!=pattern[j]) {
            j=next[j];
        }
        k++;j++;
    }
    
    if (j == m) {
        return k-j;
    }
    return -1;
}

int main() {
    char text[]="babcbabcabcaabcabcabcabcacabc";
    char pattern[]="abcabcacab";
    //注意C字符串最后还有个终止符
    int n=sizeof(text)/sizeof(char)-1;
    int m=sizeof(pattern)/sizeof(char)-1;
    
    int next[m];
    for (int i=0; i<m; i++) {
        next[i]=0;
    }
    
    getNext(pattern, m, next);
    
    int index=kmp(text, pattern, n, m, next);
    
    printf("从%d开始匹配\n", index);
    
    return 0;
}

输出结果为:

从18开始匹配

希望能帮到有需要的人~

第一篇:「字符串匹配算法 1/3」朴素和Rabin-Karp - ZhongUncle’s CSDN

参考资料

《算法导论》第 32 章

Knuth, Donald; Morris, James H.; Pratt, Vaughan (1977). “Fast pattern matching in strings”. SIAM Journal on Computing.:这是 KMP 的初始论文。

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值