1. 前言
本篇文章记录一下自己学习KMP算法的过程
(1)KMP的概念简单原理概念讲解:KMP算法易懂版-天勤率辉2020全程班公开一期讲解
https://www.bilibili.com/video/av49930100?from=search&seid=7674416431768104972
(2)参考学习博客:https://blog.youkuaiyun.com/v_july_v/article/details/7041827
(3)在此博客基础上加入自己的理解和注释,最后附上c++版的实现代码
2. 暴力匹配算法
假设现在我们面临这样一个问题:有一个文本串S,和一个模式串P,现在要查找P在S中的位置,怎么查找呢?
如果用暴力匹配的思路,并假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置,则有:
如果当前字符匹配成功(即S[i] == P[j]),则i++,j++,继续匹配下一个字符;
如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0。相当于每次匹配失败时,i 回溯,j 被置为0。
理清楚了暴力匹配算法的流程及内在的逻辑,咱们可以写出暴力匹配的代码,如下:
int ViolentMatch(char* s, char* p)
{
int sLen = strlen(s);
int pLen = strlen(p);
int i = 0;
int j = 0;
while (i < sLen && j < pLen)
{
if (s[i] == p[j])
{
//①如果当前字符匹配成功(即S[i] == P[j]),则i++,j++
i++;
j++;
}
else
{
//②如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0
i = i - j + 1;
j = 0;
}
}
//匹配成功,返回模式串p在文本串s中的位置,否则返回-1
if (j == pLen)
return i - j;
else
return -1;
}
举个例子,如果给定文本串S“BBC ABCDAB ABCDABCDABDE”中间含有空格,和模式串P“ABCDABD”,现在要拿模式串P去跟文本串S匹配,整个过程如下所示:
1. S[0]为B,P[0]为A,不匹配,执行第②条指令:“如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0”,S[1]跟P[0]匹配,相当于模式串要往右移动一位(i=1,j=0)
2. S[1]跟P[0]还是不匹配,继续执行第②条指令:“如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0”,S[2]跟P[0]匹配(i=2,j=0),从而模式串不断的向右移动一位(不断的执行“令i = i - (j - 1),j = 0”,i从2变到4,j一直为0)
3. 直到S[4]跟P[0]匹配成功(i=4,j=0),此时按照上面的暴力匹配算法的思路,转而执行第①条指令:“如果当前字符匹配成功(即S[i] == P[j]),则i++,j++”,可得S[i]为S[5],P[j]为P[1],即接下来S[5]跟P[1]匹配(i=5,j=1)
4. S[5]跟P[1]匹配成功,继续执行第①条指令:“如果当前字符匹配成功(即S[i] == P[j]),则i++,j++”,得到S[6]跟P[2]匹配(i=6,j=2),如此进行下去
5. 直到S[10]为空格字符(空格在字符串中也要匹配),P[6]为字符D(i=10,j=6),因为不匹配,重新执行第②条指令:“如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0”,相当于S[5]跟P[0]匹配(i=5,j=0)
6. 至此,我们可以看到,如果按照暴力匹配算法的思路,尽管之前文本串和模式串已经分别匹配到了S[9]、P[5],但因为S[10]跟P[6]不匹配,所以文本串回溯到S[5],模式串回溯到P[0],从而让S[5]跟P[0]匹配。
而S[5]肯定跟P[0]失配。为什么呢?因为在之前第4步匹配中,我们已经得知S[5] = P[1] = B,而P[0] = A,即P[1] != P[0],故S[5]必定不等于P[0],所以回溯过去必然会导致失配。那有没有一种算法,让i 不往回退,只需要移动j 即可呢?
答案是肯定的。这种算法就是本文的主旨KMP算法,它利用之前已经部分匹配这个有效信息,保持i 不回溯,通过修改j 的位置,让模式串尽量地移动到有效的位置。
3. KMP算法
3.1 定义
Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP算法”,常用于在一个文本串S内查找一个模式串P 的出现位置,这个算法由Donald Knuth、Vaughan Pratt、James H. Morris三人于1977年联合发表,故取这3人的姓氏命名此算法。
下面先直接给出KMP的算法流程(如果感到一点点不适,没关系,坚持下,稍后会有具体步骤及解释,越往后看越会柳暗花明☺):
假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置
如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;
如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。(也就是让next[j]对应的模式串的位置,与文本串i位置对齐,由于next[j]小于j,可以想象成将模式串向有移动了,移动的距离就是j-next[j])
换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置j - 失配字符对应的next 值(next 数组的求解会在下文的3.3.3节中详细阐述),即移动的实际位数为:j - next[j],且此值大于等于1。
很快,你也会意识到next 数组各值的含义:最长公共前后缀的长度,代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如如果next [j] = k,代表j 之前的字符串中有最大长度为k 的相前缀,有长度为k的后缀,且前后缀内容相同。
此也意味着在某个字符失配时,该字符对应的next 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到next [j] 的位置)。如果next [j] 等于0或-1,则跳到模式串的开头字符,若next [j] = k 且 k > 0,代表下次匹配跳到j 之前的某个字符,而不是跳到开头,且具体跳过了k 个字符。
继续拿之前的例子来说,当S[10]跟P[6]匹配失败时,KMP不是跟暴力匹配那样简单的把模式串右移一位,而是执行第②条指令:“如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]”,即j 从6变到2(后面我们将求得P[6],即字符D对应的next 值为2),所以相当于模式串向右移动的位数为j - next[j](j - next[j] = 6-2 = 4)。
向右移动4位后,S[10]跟P[2]继续匹配。为什么要向右移动4位呢,因为移动4位后,模式串中又有个“AB”可以继续跟S[8]S[9]对应着,从而不用让i 回溯。相当于在除去字符D的模式串子串中寻找相同的前缀和后缀,然后根据前缀后缀求出next 数组,最后基于next 数组进行匹配(不关心next 数组是怎么求来的,只想看匹配过程是咋样的,可直接跳到下文3.3.4节)。
下面从模式串的角度来解释一下,为什么按照最长公共子串的长度对应的位置,与文本串对齐的时候可以加快匹配且不会漏掉中间的可能的匹配的原因。
如下图假设模式串在X那个变量 处失配,则将模式串向后移动使得,x前面的所有字符的最长相同前缀(pos1处的前缀)移动到最长相同后缀失配的位置(pos2处的后缀),移动之后的位置,为了区别开来,我们用pos11,pos22来代替。
我们知道在第一幅图中字符X失配,但是X之前的AB....AB这个很长的字符串是匹配的,也就是说,仅仅是X导致的不匹配。由于前缀和后缀是一模一样的,那我们把前缀移动到与后缀对齐的位置,如第二幅图,那么前缀其实是能和文本串对应位置匹配上的,因为之前的后缀在相同位置与文本串匹配上了,我们的前缀和后缀相同,自然也就能匹配上了。这样pos22处在原始失配的基础上变成一个新的字符Y看它是否与文本串匹配,由于前缀跳过多步与后缀对齐,能加速运算。你可能要问,前缀跳过了那么多,会不会中间会漏掉一些可能匹配的位置呢?答案不会,分析见下一段。
为什么模式串将前缀移动到与后缀对齐的位置,中间跳过那么多的数,而不会漏掉可能的匹配位置呢?
现在我们假设存在一个中间的假想状态,前缀对齐到中间位置,而这个假想位置还能够满足在失配位置之前依然匹配,也就是红框标记位置依然匹配,我们知道这个红框位置,是模式串前面的一部分,在没有移动之前对应着内容就是绿色框的位置,可以推出红色框内容==绿色框内容,移动后变成了红框位置依然能和文本串对应位置匹配上,而在没有移动之前能与文本串匹配的是紫色框中的内容,这样可以推出:红色框内容==紫色框内容,综合两个推论就可以得出,绿色框的内容==紫色框内容
换句话说绿色框和紫色框是相同公共前后缀,且长度比AB这个公共前后缀还要长,这显然是矛盾的,因为我们假设的前提就是AB是最长的相同前后缀。进一步反推出,如果AB为最长相同前后缀,那中间一定不会存在可能是模式串与文本串匹配的位置,因此我们可以放心大胆的将前缀移动到后缀位置。
3.2 步骤
①寻找最相同前后缀的长度
对于P = p1 ...pj-1 pj,寻找模式串P中长度最大且相等的前缀和后缀。如果存在
p1 ...pk-1 pk = pj-k+1...pj-1 pj,
那么在包含pj的模式串中有最大长度为k的相同前缀后缀。举个例子,如果给定的模式串为“abab”,那么它的各个子串的前缀后缀的公共元素的最大长度如下表格所示:
比如对于字符串aba来说,它有长度为1的相同前缀后缀a;而对于字符串abab来说,它有长度为2的相同前缀后缀ab。
②求next数组
next 数组考虑的是除当前字符外的最长相同前缀后缀(也就是当前元素前面的所有字符的相同前后缀),所以通过第①步骤求得各个前缀后缀的公共元素的最大长度后,只要稍作变形即可:将第①步骤中求得的值整体右移一位,然后初值赋为-1,如下表格所示:
比如对于aba来说,第3个字符a之前的字符串ab中有长度为0的相同前缀后缀,所以第3个字符a对应的next值为0;而对于abab来说,第4个字符b之前的字符串aba中有长度为1的相同前缀后缀a,所以第4个字符b对应的next值为1(相同前缀后缀的长度为k,k = 1)。
③根据next数组进行匹配
匹配失配,j = next [j],让模式串的前缀位置对齐到后缀失配位置,相当于模式串向右移动的位数为:j - next[j]。(如下面这幅图)换言之,当模式串的后缀pj-k pj-k+1, ..., pj-1 跟文本串si-k si-k+1, ..., si-1匹配成功,但pj 跟si匹配失败时,因为next[j] = k,现在就需要借助最长相同前后缀来做,由于最长相同前后缀的不包含pj,因此需要在不包含pj的的模式串p0到pj-1中找最大长度为k 的相同前缀后缀,即p0 p1 ...pk-1 = pj-k pj-k+1...pj-1,故令j = next[j],从而让模式串的前缀位置对齐到后缀失配位置,相当于让模式串右移j - next[j] 位,使得模式串的前缀p0 p1, ..., pk-1对应着文本串 si-k si-k+1, ..., si-1,而后让pk 跟si 继续匹配。如下图所示:
综上,KMP的next 数组相当于告诉我们:当模式串中的某个字符跟文本串中的某个字符匹配失配时,模式串下一步应该跳到哪个位置。如模式串中在j 处的字符跟文本串在i 处的字符匹配失配时,下一步用next [j] 处的字符继续跟文本串i 处的字符匹配,相当于模式串向右移动 j - next[j] 位。
接下来,分别具体解释上述3个步骤。
3.3 解释
3.3.1 寻找最长相同前缀后缀
如果给定的模式串是:“ABCDABD”,从左至右遍历整个模式串,其各个子串的前缀后缀分别如下表格所示:(前后缀,是指去掉当前字符之后的前面子串中查找的前后缀)
也就是说,原模式串子串对应的各个前缀后缀的公共元素的最大长度表为(下简称《最大长度表》):
3.3.2 基于《最大长度表》匹配
因为模式串中首尾可能会有重复的字符,故可得出下述结论:
失配时,模式串向右移动的位数为:已匹配字符数 - 失配字符的上一位字符所对应的最大长度值
下面,咱们就结合之前的《最大长度表》和上述结论,进行字符串的匹配。如果给定文本串“BBC ABCDAB ABCDABCDABDE”,和模式串“ABCDABD”,现在要拿模式串去跟文本串匹配,如下图所示:
1. 因为模式串中的字符A跟文本串中的字符B、B、C、空格一开始就不匹配,所以不必考虑结论,直接将模式串不断的右移一位即可,直到模式串中的字符A跟文本串的第5个字符A匹配成功:
2. 继续往后匹配,当模式串最后一个字符D跟文本串匹配时失配,显而易见,模式串需要向右移动。但向右移动多少位呢?因为此时已经匹配的字符数为6个(ABCDAB),然后根据《最大长度表》可得失配字符D的上一位字符B对应的长度值为2,所以根据之前的结论,可知需要向右移动6 - 2 = 4 位。
3. 模式串向右移动4位后,发现C处再度失配,因为此时已经匹配了2个字符(AB),且上一位字符B对应的最大长度值为0,所以向右移动:2 - 0 =2 位。
4. A与空格失配,向右移动1 位。
5. 继续比较,发现D与C 失配,故向右移动的位数为:已匹配的字符数6减去上一位字符B对应的最大长度2,即向右移动6 - 2 = 4 位。
6. 经历第5步后,发现匹配成功,过程结束。
通过上述匹配过程可以看出,问题的关键就是寻找模式串中最大长度的相同前缀和后缀,找到了模式串中每个字符之前的前缀和后缀公共部分的最大长度后,便可基于此匹配。而这个最大长度便正是next 数组要表达的含义。
3.3.3 根据《最大长度表》求next 数组
给定字符串“ABCDABD”,它的next 数组如下:
把next 数组跟之前求得的最大长度表对比后,不难发现,next 数组相当于“最大长度值” 整体向右移动一位,然后初始值赋为-1。意识到了这一点,你会惊呼原来next 数组的求解竟然如此简单:就是找最大对称长度的前缀后缀,然后整体右移一位,初值赋为-1(当然,你也可以直接计算某个字符对应的next值,就是看这个字符之前的字符串中有多大长度的相同前缀后缀)。
3.3.4 通过代码递推计算next 数组
接下来,咱们来写代码求下next 数组。
基于之前的理解,可知计算next 数组的方法可以采用递推:
1. 如果对于值k,已有p0 p1, ..., pk-1 = pj-k pj-k+1, ..., pj-1,相当于next[j] = k。
此意味着什么呢?究其本质,next[j] = k 代表p[j] 之前的模式串子串中,有长度为k 的相同前缀和后缀。
2. 下面的问题是:已知next [0, ..., j],如何求出next [j + 1]呢?
对于P的前j+1个序列字符:
若从子串的前面看,p[k] == p[j],则next[j + 1 ] = next [j] + 1 = k + 1;
若p[k ] ≠ p[j],如果此时p[ next[k] ] == p[j ],则next[ j + 1 ] = next[k] + 1,否则继续递归前缀索引k = next[k],而后重复此过程。 相当于在字符p[j+1]之前不存在长度为k+1的前缀"p0 p1, …, pk-1 pk"跟后缀“pj-k pj-k+1, …, pj-1 pj"相等,那么是否可能存在另一个值t+1 < k+1,使得长度更小的前缀 “p0 p1, …, pt-1 pt” 等于长度更小的后缀 “pj-t pj-t+1, …, pj-1 pj” 呢?如果存在,那么这个t+1 便是next[ j+1]的值,此相当于利用已经求得的next 数组(next [0, ..., k, ..., j])进行P串前缀跟P串后缀的匹配。
一般的文章或教材可能就此一笔带过,但大部分的初学者可能还是不能很好的理解上述求解next 数组的原理,故接下来,我再来着重说明下。
如下图所示,假定给定模式串ABCDABCE,且已知next [j] = k(相当于“p0 pk-1” = “pj-k pj-1” = AB,可以看出k为2),现要求next [j + 1]等于多少?因为pk = pj = C,所以next[j + 1] = next[j] + 1 = k + 1(可以看出next[j + 1] = 3)。代表字符E前的模式串中,有长度k+1 的相同前缀后缀。
但如果pk != pj 呢?说明“p0 pk-1 pk” ≠ “pj-k pj-1 pj”。换言之,当pk != pj后,字符E前有多大长度的相同前缀后缀呢?很明显,因为C不同于D,所以ABC 跟 ABD不相同,即字符E前的模式串没有长度为k+1的相同前缀后缀,也就不能再简单的令:next[j + 1] = next[j] + 1 。所以,咱们只能去寻找长度更短一点的相同前缀后缀。
结合上图来讲,若能在前缀“ p0 pk-1 pk ” 中不断的递归前缀索引k = next [k],找到一个字符pk’ 也为D,代表pk’ = pj,且满足p0 pk'-1 pk' = pj-k' pj-1 pj,则最大相同的前缀后缀长度为k' + 1,从而next [j + 1] = k’ + 1 = next [k' ] + 1。否则前缀中没有D,则代表没有相同的前缀后缀,next [j + 1] = 0。
那为何递归前缀索引k = next[k],就能找到长度更短的相同前缀后缀呢?这又归根到next数组的含义。我们拿前缀 p0 pk-1 pk 去跟后缀pj-k pj-1 pj匹配,如果pk 跟pj 失配,下一步就是用p[next[k]] 去跟pj 继续匹配,如果p[ next[k] ]跟pj还是不匹配,则需要寻找长度更短的相同前缀后缀,即下一步用p[ next[ next[k] ] ]去跟pj匹配。此过程相当于模式串的自我匹配,所以不断的递归k = next[k],直到要么找到长度更短的相同前缀后缀,要么没有长度更短的相同前缀后缀。如下图所示:
所以,因最终在前缀ABC中没有找到D,故E的next 值为0:
模式串的后缀:ABDE
模式串的前缀:ABC
前缀右移两位: ABC
读到此,有的读者可能又有疑问了,那能否举一个能在前缀中找到字符D的例子呢?OK,咱们便来看一个能在前缀中找到字符D的例子,如下图所示:
给定模式串DABCDABDE,我们很顺利的求得字符D之前的“DABCDAB”的各个子串的最长相同前缀后缀的长度分别为0 0 0 0 1 2 3,但当遍历到字符D,要求包括D在内的“DABCDABD”最长相同前缀后缀时,我们发现pj处的字符D跟pk处的字符C不一样,换言之,前缀DABC的最后一个字符C 跟后缀DABD的最后一个字符D不相同,所以不存在长度为4的相同前缀后缀。
怎么办呢?既然没有长度为4的相同前缀后缀,咱们可以寻找长度短点的相同前缀后缀,最终,因在p0处发现也有个字符D,p0 = pj,所以p[j]对应的长度值为1,相当于E对应的next 值为1(即字符E之前的字符串“DABCDABD”中有长度为1的相同前缀和后缀)。
综上,可以通过递推求得next 数组,代码如下所示:
//计算前缀表
void get_next(char p[], int next[], int n){
next[0] = 0;
int k = 0; //指向前缀的结尾
int j =1; //指向模式串的末尾,看【0,j】之间最长公共子串的长度,从1开始,也就是从第二个元素开始,因为第一个元素已经初始化为0了
while (j < n){
if (p[j] == p[k]){ //如果j位置和k位置相等,说明公共前缀边长了,next[j]=k+1
next[j] = k+1;
k++; //前缀后缀指针都往后移动,计算下一个做准备
j++;
}
else{ //如果p[j] 不等于 p[k]时,说明当前前缀长度k太长,找不到合适的后缀与之相同,
//k太大,解决思路就是将其范围缩小,怎么缩小,因为我们知道k前面位置处的字符已经计算了相同前后缀
//可以在他们的基础上查找可能匹配的前后缀,另k=next[k-1]
if (k > 0){
k = next[k - 1];
}
else //如果一开始的时候,p[j]!=p[k],如果单靠k=next[k-1]方式回溯的话,k一直卡在0的位置,j也不能后移,这里要解决这个特殊情况,让j可以后移
{
next[j] = 0;
j++;
}
}
}
}
用代码重新计算下“ABCDABD”的next 数组,以验证之前通过“最长相同前缀后缀长度值右移一位,然后初值赋为-1”得到的next 数组是否正确,计算结果如下表格所示:
从上述表格可以看出,无论是之前通过“最长相同前缀后缀长度值右移一位,然后初值赋为-1”得到的next 数组,还是之后通过代码递推计算求得的next 数组,结果是完全一致的。
3.4 KMP的时间复杂度分析
相信大部分读者读完上文之后,已经发觉其实理解KMP非常容易,无非是循序渐进把握好下面几点:
如果模式串中存在相同前缀和后缀,即pj-k pj-k+1, ..., pj-1 = p0 p1, ..., pk-1,那么在pj跟si失配后,让模式串的前缀p0 p1...pk-1对应着文本串si-k si-k+1...si-1,而后让pk跟si继续匹配。
之前本应是pj跟si匹配,结果失配了,失配后,令pk跟si匹配,相当于j 变成了k,模式串向右移动j - k位。
因为k 的值是可变的,所以我们用next[j]表示j处字符失配后,下一次匹配模式串应该跳到的位置。换言之,失配前是j,pj跟si失配时,用p[ next[j] ]继续跟si匹配,相当于j变成了next[j],所以,j = next[j],等价于把模式串向右移动j - next [j] 位。
而next[j]应该等于多少呢?next[j]的值由j 之前的模式串子串中有多大长度的相同前缀后缀所决定,如果j 之前的模式串子串中(不含j)有最大长度为k的相同前缀后缀,那么next [j] = k。
如之前的图所示:
接下来,咱们来分析下KMP的时间复杂度。分析之前,先来回顾下KMP匹配算法的流程:
“KMP的算法流程:
假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置
如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;
如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。”
我们发现如果某个字符匹配成功,模式串首字符的位置保持不动,仅仅是i++、j++;如果匹配失配,i 不变(即 i 不回溯),模式串会跳过匹配过的next [j]个字符。整个算法最坏的情况是,当模式串首字符位于i - j的位置时才匹配成功,算法结束。
所以,如果文本串的长度为n,模式串的长度为m,那么匹配过程的时间复杂度为O(n),算上计算next的O(m)时间,KMP的整体时间复杂度为O(m + n)。
最后附上C++版本的完整代码
vector<int> get_next(string pattern){
if (pattern.size() < 1) return{};
int n = pattern.size();
int k = 0;//前缀指针
int j = 1; //后缀指针
vector<int>next(n, 0);
next[0] = 0;
while (j < n){
if (pattern[j] == pattern[k]){
next[j] = k + 1;
k++;
j++;
}
else{
//往前回溯
if (k > 0) {
k = next[k - 1];
}
else{ //防止一开始就不相等,强行让其前进
next[j] = 0;
j++;
}
}
}
return next;
}
void move_next(vector<int>&next){
//右移一位前缀表,前缀的表的含义不包含当前位置的字符
for (int i = next.size()-1; i >= 1; i--){
next[i] = next[i - 1];
}
next[0] = -1;
}
void kmp_search(string text, string pattern){
//生成前缀长度表
vector<int>next = get_next(pattern);
//移动前缀表
move_next(next);
//按照前缀表来快速匹配
int i = 0;//文本串
int j = 0;//模式串
while (i < text.size()){
//如果模式串和文本串的某一个块完全匹配
if (j == pattern.size() - 1 && text[i] == pattern[j]){
printf("found a match: [%d,%d]\n",i,j);
j = next[j]; //前缀对齐后缀找后面的其他的匹配
if (j == -1){ //防止走到模式串的最开始位置
i++; j++;
}
}
//相等的时候,继续向后匹配
if (text[i] == pattern[j]){
i++;
j++;
}
else{
//保持i不动,让模式串的前缀对齐后缀
j = next[j];
//j有可能迭代到-1的位置,这时候要整体对齐
if (j == -1){
i++;
j++;
}
}
}
}
//测试kmp算法 c++版本
void test_kmp_cpp(){
string pattern = "ABABCABAA";
vector<int>next= get_next(pattern);
for (int i = 0; i < next.size(); i++){
printf("%d\n", next[i]);
}
string text = "ABDDxABABCABAAxDDxABABCABAAxEEAA";
kmp_search(text, pattern);
}