前言

数据结构,一门数据处理的艺术,精巧的结构在一个又一个算法下发挥着他们无与伦比的高效和精密之美,在为信息技术打下坚实地基的同时,也令无数开发者和探索者为之着迷。

也因如此,它作为博主大二上学期最重要的必修课出现了。由于大家对于上学期C++系列博文的支持,我打算将这门课的笔记也写作系列博文,既用于整理、消化,也用于同各位交流、展示数据结构的美。

此系列文章,将会分成两条主线,一条“数据结构基础”,一条“数据结构拓展”。“数据结构基础”主要以记录课上内容为主,“拓展”则是以课上内容为基础的更加高深的数据结构或相关应用知识。

欢迎关注博主,一起交流、学习、进步,往期的文章将会放在文末。


这一篇,我们来讨论一个问题——字符串的模式匹配问题,同时介绍一种经典算法KMP算法。

说起KMP算法,博主还真是有不少话想说。

咱就先不说博主这是第五次才把他学会,就这个算法名字而言,它就经常被戏称为 看毛片 算法。但其实他是三位发明该算法的大佬(Knuth,Morris,Pratt)名字的简写。

这个算法简洁、高效、且优美。通过定义一个巧妙地函数,完美的解决了一个字符串的基础难题:模式匹配。那么就让我们先从这个问题入手吧。

模式匹配问题

模式匹配问题很好理解。

给定两个字符串:模式串P和文本串s,求出p在s中出现的位置

举个例子:

给定文本串S:qwerabcdabcrewq给定模式串P:abc问P在S中所有的出现位置给定文本串S:qwerabcdabcrewq\\ 给定模式串P:abc\\ 问P在S中所有的出现位置给定文本串S:qwerabcdabcrewq给定模式串P:abc问P在S中所有的出现位置

对于这个例子,一眼看去就能找到所有答案;

两个答案分别是4和8;

但是计算机可没有“一眼看去”的技能,他只能通过算法来得到这个问题的答案。

朴素的模式匹配算法

用算法来实现模式匹配的任务,一个最朴素的想法,是将模式串逐位的放在文本串上进行验证。

也就是这样:

这个算法实现起来不难,两重循环,分别固定模式串起始位置,验证匹配。

//C
void match(char * p,char * s){int lp = strlen(p);int ls = strlen(s);for(int i = 0;i < ls - lp + 1;i++){for(int j = 0;j < lp;j++){if(s[i + j] != p[j])break;if(j == lp - 1)printf("%d ",i);}}
}

运行:

答案没有问题。

但是朴素算法的弊端就在于其复杂度太高,从刚才的算法实现中不难看出其复杂度为 O(nm) 其中n为模式串的长度,m为文本串的长度。

这个复杂度伴随着文本串和模式串的增长而成平方增加。这种复杂度是不能接受的!!!

究其原因就在于它在执行过程中进行了太多次没有意义的跳转和比较,有些位置的比较实际上是不需要的

比如,对于如下匹配:

文本串:abababaac文本串:abababaac文本串:abababaac
模式串:abaa模式串:abaa模式串:abaa

当第四个字符失配时,按照朴素算法的思路,应该放弃将模式串与文本串第一位对应,转为对应第二位再进行判定,但实际上,模式串并不需要在第二位上面继续比较,而可以直接去第三位的位置进行这个过程,因为那里是以a开头的。

要改进这个思路,就需要让模式串失配时,在保证算法正确性的基础上,让模式串尽可能多的向后移动,进而提高效率。这就需要利用已有的信息或者模式串本身的特点。

为了解决这个问题,下面我们需要引入一个函数,它能体现字符串本身的特性,并且能够很好地指导该字符串在匹配过程中失配时的行为。

KMP算法

对朴素算法改进的讨论

(该过程是博主本人对引出算法的过渡讨论,不影响后续对失败函数的叙述,可以选择观看。当然,同博主一起讨论这部分内容可能会帮助理解算法

遵从上面的指导思想,我们想要在模式串失配时可以尽可能快速的移动到下一个合适的地方进行后续匹配。

在刚才的例子中,我们找到了文本串中下一个同模式串开头的相同的地方。但不能总是使用这个策略,因为这个策略仍旧要比较所有的后续字符,本质上与普速算法没有区别。

不妨设:
模式串P:p0p1p2...pm模式串P:p_0p_1p_2...p_m模式串P:p0​p1​p2​...pm​
文本串S:s0s1s2....sn文本串S:s_0s_1s_2....s_n文本串S:s0​s1​s2​....sn​

进一步想,如果可以,直接跳到下一个与头两个元素相同的位置,将会比跳到下一个与首元素相同的位置更优,会再少比较一次。

...sksk+1sk+2...sn...s_ks_{k+1}s_{k+2}...s_n...sk​sk+1​sk+2​...sn​
p0p1...pm\;\;\;p_0p_1...p_mp0​p1​...pm​
(sk=p0,sk+1=p1)(s_k = p_0,s_{k+1}=p_1)(sk​=p0​,sk+1​=p1​)

那么三个呢,四个呢?肯定更优。甚至于如果直接能匹配m个,那任务就完成了2333。再仔细想,这样的想法本质上是希望模式串能够跳转到匹配其最长前缀的地方。

虽然想法是好的,但是问题就在于无从得到在文本串中出现前缀的所有地方,一来模式串中的前缀有m个,二来前缀也是字符串,匹配前缀本身又是个规模更小的模式串匹配问题,这导致问题不但没有简单,反而更复杂了。

所以对于找到下一个匹配某个前缀的位置的问题,我们需要换个思路。

注意到在朴素算法匹配的过程中,未被扫描到的文本串内容与模式串的匹配关系是未知的,而被匹配过的内容却是已知的。

举个例子:

在当前匹配中,前三个位置是匹配成功的,在模式串第四位失配了。此时在文本串中4之前的位置匹配结束,我们不关心,7之后的内容未经匹配,匹配结果未知。但是在4到6之间是已经同模式串匹配过的地方,匹配关系是明确的。

如果能在已经知道匹配关系的区间,找到下一个模式串的前缀,并且以失配的前一位为结尾的位置,就可以直接跳过去了。

用一张图表示:

稍加思索不难发现一个问题,这个位置只和模式串的字符排列特点决定的,与文本串无关。换句话说,是模式串本身的某个前缀中,其某个真前缀与对应长度的后缀内容相同。

即:
对于模式串P:p0p1p2...pm对于模式串P:p_0p_1p_2...p_m对于模式串P:p0​p1​p2​...pm​
任取0<j≤m,可能存在0≤k<j,满足任取 0 < j≤ m,可能存在0≤k<j,满足任取0<j≤m,可能存在0≤k<j,满足
p0p1..pk=pj−kpj−k+1..pjp_0p_1..p_k = p_{j - k}p_{j - k + 1}..p_jp0​p1​..pk​=pj−k​pj−k+1​..pj​

例如:abcabd中例如:abcabd中例如:abcabd中
取j=4,可得abcab则有k=1或2取j=4,可得abcab\\则有k = 1或2取j=4,可得abcab则有k=1或2
满足a=a或ab=ab满足a = a或ab = ab满足a=a或ab=ab

如果知道了每个长度k对应的j,那么每次匹配失败时的跳转问题就能够很好解决了。当然需要注意的是,不是每个前缀都有前后缀相同的情况,例如abc就没有这样的j。

到这里,为方便进一步讨论,是时候对这些概念下个定义了。

失败函数定义

我们定义失败函数f(j)为字符串的前缀p0p1...pjp_0p_1...p_jp0​p1​...pj​中,最长的相等真前缀与真后缀的长度。对于不存在这样k的情况,我们约定其函数值为-1.

即:
f(j)={kk为满足0⩽k<j,且p0p1...pk=pj−kpj−k+1...pj的最大整数−1其他情况f(j) = \left\{\begin{matrix} k \, &k为满足0\leqslant k<j,且p_0p_1...p_k = p_{j-k}p_{j-k+1}...p_j的最大整数\\ &-1\quad其他情况 \end{matrix}\right. f(j)={k​k为满足0⩽k<j,且p0​p1​...pk​=pj−k​pj−k+1​...pj​的最大整数−1其他情况​

KMP算法进行模式匹配

有了对失败函数的定义,下面就可以尝试使用失败函数来进行模式匹配了。

我们设匹配过程中,对模式串的指针为j,文本串的指针为i每次匹配时:

  • 若新位置的字符(si,pjs_{i},p_jsi​,pj​)能够匹配,那么就将模式串和文本串指针向后移动(i++,j++),重复过程,匹配下一位。
    • 若模式串的指针指向末尾,则表明完成了一次匹配,找到了文本串中出现的一个模式串,输出出现位置(i - j),再将模式串指针置于其失败函数处f(j - 1) + 1(加1是为了指向待匹配的新的字符)
  • 若新位置的字符失配(si,pjs_{i},p_jsi​,pj​)
    • 当该字符不是模式串首位时,将模式串的指针向前移动到上一位的失败函数处f(j - 1)+1
    • 否则将文本串匹配位置向后移动一位(i++)
  • 若文本串的指针指向末尾,整个匹配过程结束。

使用C语言实现上述过程:

//C
void kmp(char *p,char *s,int *f){int lp = strlen(p);int ls = strlen(s);int idxP = 0;int idxS = 0;while(idxS < ls){if(s[idxS] == p[idxP]){idxS++;idxP++;if(idxP == lp){printf("%d ",idxS - idxP);idxP = f[idxP - 1] + 1;}}else{if(idxP == 0){idxS++;}else{idxP = f[idxP - 1] + 1;}}}
}

运行一下:

可以证明的是,这个算法的复杂度为O(n+m)O(n+m)O(n+m)。

一下就从乘积复杂度降至线性复杂度,这个复杂度是我们可以接受的。

失败函数的实现

不难看出,要实现kmp算法,重中之重就在于能求解出模式串的失败函数。

下面就来屡一下如何求解失败函数。

求解失败函数,我们需要使用到数学归纳法的思想,也就是如何利用之前已经求出的结果通过递推关系来得到后面的结果。

假设当前要求解f(i),i>0f(i),i > 0f(i),i>0,f(k),0≤k<if(k),0 \leq k <if(k),0≤k<i已知。

其实不难发现,相邻的失败函数值至多增加1,相当于前后缀都向后拼接一位,
如abcababcababcab中有ab=abab=abab=ab,拼上一位ccc后有abc=abcabc=abcabc=abc。
于是f(i)f(i)f(i)一定不会超过f(i−1)+1f(i-1)+1f(i−1)+1,那么就从这一步判起。

  • 若sf(i−1)+1=sis_{f(i - 1)+1} = s_{i}sf(i−1)+1​=si​,也就是如下情况:

那么f(i)=f(i−1)+1f(i) = f(i-1) +1f(i)=f(i−1)+1。

  • 那如果sf(i−1)+1≠sis_{f(i - 1)+1} \neq s_{i}sf(i−1)+1​​=si​呢?也就是如下情况:

当失配时,可使当前的前缀变短,尝试找到仅次于f(i)f(i)f(i)的第二长度j<f(i)j < f(i)j<f(i),使得jjj满足
s0s1...sj=si−jsi−j+1...sis_0s_1...s_j = s_{i-j}s_{i-j+1}...s_{i}s0​s1​...sj​=si−j​si−j+1​...si​

如果再次失配,那就进而寻找更小的jjj直到最后配对成功或者完全失配。
为了寻找这个jjj,让我们来回顾一下失败函数的非-1时的定义,即s0s1...sis_0s_1...s_{i}s0​s1​...si​中长度为f(i)f(i)f(i)的前后缀是完全相等的,那么可以得到:
s0s1...sf(i)=si−f(i)si−f(i)+1..sis_0s_1...s_{f(i)} = s_{i - f(i)}s_{i - f(i)+1}..s_is0​s1​...sf(i)​=si−f(i)​si−f(i)+1​..si​
形象点说,就是上图中两段绿色的子串完全相等。
那么后缀中取到的子串必然能够在前缀中取到。那么长度为jjj的后缀既是长度为iii的子串的后缀,也是长度为f(i)f(i)f(i)子串的后缀,即对于jjj有:
s0s1...sj=sf(i)−jsf(i)−j+1..sf(i)s_0s_1...s_j = s_{f(i) - j}s_{f(i) - j+1}..s_{f(i)} s0​s1​...sj​=sf(i)−j​sf(i)−j+1​..sf(i)​
形象点表述为:

只要理解到这里,答案就呼之欲出了。这个jjj其实就是f(f(i))f(f(i))f(f(i))!!

因此根据上面的分析,终于可以得到迭代递推的算法:

  • 在匹配的开始可令j=f(i)j=f(i)j=f(i)。特别的,令f(0)=−1f(0)=-1f(0)=−1
  • 失配时令j=f(j)j=f(j)j=f(j),继续这个过程
  • 配对成功,即sj+1=sis_{j+1}=s_isj+1​=si​时,即可得到答案f(i)=j+1f(i)=j+1f(i)=j+1
  • 若全程匹配失败,即jjj迭代至−1-1−1时,失败函数取其他情况,即f(i)=−1f(i)=-1f(i)=−1

上述过程使用代码实现也是惊人的简洁:

//C
int * getF(char * p){int len = strlen(p);int * f = (int*)malloc(sizeof(int) * len);for(int i = 1,j;i < len;i++){j = f[i - 1];while(j >= 0 && p[j + 1] != p[i])j = f[j];if(p[j + 1] == p[i])j++;f[i] = j;}return f;
}
public int[] getF(String s){char[] str = s.toCharArray();int[] f = new int[str.length];f[0] = -1;for(int i = 1,j;i < len;i++){j = f[i - 1];while(j >= 0 && str[j + 1] != str[i])j = f[j];if(str[j + 1] == str[i])j++;f[i] = j;}return f;
}

该算法的操作次数为O(n)O(n)O(n)

模式匹配的另一种解法

模式匹配当然不仅只有上面一种求法,失败函数也不会只有一种用法。

我们将模式串PPP和文本串SSS
按照如下方式拼接:
p0p1...pm∗s0s1...sn其中′∗′为分割字符,该字符不能出现在P和S中p_0p_1...p_m*s_0s_1...s_n\\其中'*'为分割字符,该字符不能出现在P和S中 p0​p1​...pm​∗s0​s1​...sn​其中′∗′为分割字符,该字符不能出现在P和S中
例如:
模式串:abc文本串:qwerababcga拼接串:abc#qwerababcga模式串:abc\\ 文本串:qwerababcga\\ 拼接串:abc\#qwerababcga 模式串:abc文本串:qwerababcga拼接串:abc#qwerababcga
接着对拼接串求解失败函数。

由于我们添加了分割字符,因此所有的失败函数值都不会超过∣P∣|P|∣P∣,从分隔符后面开始,其失败函数f(i),∣P∣≤i≤∣P∣+∣S∣f(i),|P| \le i \le|P|+|S|f(i),∣P∣≤i≤∣P∣+∣S∣的意义又可以被描述为:文本串中以si−∣p∣为结尾的字符串能匹配上模板串中的位数文本串中以s_{i-|p|}为结尾的字符串能匹配上模板串中的位数文本串中以si−∣p∣​为结尾的字符串能匹配上模板串中的位数
即下图中两段相等:

p0p1..⏟f(i)..pm#s0s1....si−m⏟f(i)...\underbrace{p_0p_1..}_{f(i)}..p_{m}\#s_0s_1..\underbrace{..s_{i-m}}_{f(i)}... f(i)p0​p1​..​​..pm​#s0​s1​..f(i)..si−m​​​...

所以,如果出现f(i)=∣P∣−1f(i) = |P|-1f(i)=∣P∣−1:
p0p1...pm⏟f(i)#s0s1....si−m⏟f(i)...\underbrace{p_0p_1...p_{m}}_{f(i)}\#s_0s_1..\underbrace{..s_{i-m}}_{f(i)}... f(i)p0​p1​...pm​​​#s0​s1​..f(i)..si−m​​​...

即文本串中出现了一个完成的模式串,也就找到了一个匹配。

算法复杂度来源于计算失败函数和遍历寻找答案,最终复杂度仍然为O(n+m)O(n +m)O(n+m)

示例代码(求模式串在文本串中出现的所有位置):

//C
#include<stdio.h>
#include<string.h>
#include<malloc.h>int * getFailed(char * s){int l = strlen(s);int * f = (int*)malloc(sizeof(int) * l);f[0] = -1;for(int i = 1,j;i < l;i++){j = f[i- 1];while(j >= 0 && s[i] != s[j + 1])j = f[j];if(s[i] == s[j + 1])j++;f[i] = j;}return f;
}int main(){char a[200050];char b[200050];//a是文本串,b是模式串scanf("%s%s",a,b);int la = strlen(a);int lb = strlen(b);int * f = getFailed(strcat(strcat(b,"$"),a));for(int i = 0;i < lb;i++){printf("%d ",f[i]);}printf("\n");for(int i = lb - 1;i < la + lb + 1;i++){if(f[i] == lb - 1){printf("%d",i - lb * 2);break;}if(i == la + lb){printf("-1");}}free(f);
}

失败函数与前缀函数

有一些地方在描述kmp算法时引入的是前缀函数,比如Oi Wiki。

那里给他的定义为:

其与失败函数的定义存在出入的地方:

  • 当存在相同的真前缀与真后缀时,前缀函数函数值含义描述为真前缀和真后缀的长度,而非失败函数描述的最后元素的下标,由于下标是从0开始计算的,前缀函数值比失败函数值多1。

  • 对于没有相同的真前缀与真后缀的情况,使用前缀函数的定义其相同长度为刚好为0,而失败函数给其特殊定义为-1,同样是前缀函数比失败函数多1

所以,综上所述,失败函数f(i)f(i)f(i)和前缀函数π(i)\pi(i)π(i)满足关系:f(i)=π(i)−1f(i) =\pi(i)-1f(i)=π(i)−1,二者可以相互转化,本质上描述的性质相同。

最后,KMP算法的精髓就在于失败函数的定义及求解。对于这样一个精致的描述字符串特殊性质的函数,其妙用一定不止匹配字符串一处,它还可以用以处理更多有关字符串的问题,大家可以在Oi Wiki上找到这些用法。


往期博客

  • 【数据结构基础】数据结构基础概念
  • 【数据结构基础】线性数据结构——线性表概念 及 数组的封装
  • 【数据结构基础】线性数据结构——三种链表的总结及封装
  • 【数据结构基础】线性数据结构——栈和队列的总结及封装(C和java)

参考资料:

  • 《数据结构》(刘大有,杨博等编著)
  • 《算法导论》(托马斯·科尔曼等编著)
  • 《图解数据结构——使用Java》(胡昭民著)
  • OI WiKi

【数据结构与算法基础】模式匹配问题与KMP算法相关推荐

  1. 数据结构与算法之美笔记——基础篇(下):图、字符串匹配算法(BF 算法和 RK 算法、BM 算法和 KMP 算法 、Trie 树和 AC 自动机)

    图 如何存储微博.微信等社交网络中的好友关系?图.实际上,涉及图的算法有很多,也非常复杂,比如图的搜索.最短路径.最小生成树.二分图等等.我们今天聚焦在图存储这一方面,后面会分好几节来依次讲解图相关的 ...

  2. 【模式匹配】之 —— KMP算法详解及证明

    一    RevisionsHistory 1 一       Revisions History 二       前言 三       关于算法学习 四       KMP算法始末 KMP算法是用来 ...

  3. 【KMP算法详解——适合初学KMP算法的朋友】

    相信很多人(包括自己)初识KMP算法的时候始终是丈二和尚摸不着头脑,要么完全不知所云,要么看不懂书上的解释,要么自己觉得好像心里了解KMP算法的意思,却说不出个究竟,所谓知其然不知其所以然是也. 经过 ...

  4. 算法基础--优惠券问题(贪心算法)

    算法基础–优惠券问题(贪心算法) 近期某商场由于周年庆,开启了"0元购"活动.活动中,消费者可以通过组合手中的代金券,实现0元购买指定商品. 聪明的小团想要用算法来帮助他快速计算: ...

  5. 【数据结构与算法】之深入解析KMP算法的核心原理和实战演练

    一.简介 ① 概念 KMP 算法是一种改进的字符串匹配算法,由 D.E.Knuth,J.H.Morris 和 V.R.Pratt 提出的,因此称它为克努特-莫里斯-普拉特操作,简称 KMP 算法. K ...

  6. 算法基础:常用的排序算法知识笔记

    1.算法外排序分类 2.冒泡排序 冒泡排序(Bubble Sort)属于交换排序,它的原理是:循环两两比较相邻的记录,如果反序则交换,直到没有反序的记录为止. 实现算法: /** * 冒泡排序优化后的 ...

  7. 程序员的算法课(11)-KMP算法

    一.KMP算法定义 [百度百科]KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特-莫里斯-普拉特操作(简称KMP算法). ...

  8. 算法练习day16——190404(KMP算法)

    1.KMP算法 1.1 简介 研究的是子串(必须连续). int getIndexOf(String str1,String str2); 用的就是KMP算法,不在时返回-1:在时返回在原串中的位置. ...

  9. 算法基础:图的相关算法知识笔记

    一.图的相关算法 1.图的分类知识 如下图: 2.生成树概念 对连通图进行遍历,过程中所经过的边和顶点的组合可看做是一棵普通树,通常称为生成树. 连通图的生成树具有这样的特征:边的数量 = 顶点数 - ...

最新文章

  1. SIM PIN Lock
  2. 设计模式--简单工厂(Factory)模式
  3. Python用泰勒公式模拟函数
  4. c语言二进制转十进制(附完整源码)
  5. Comet OJ - 2019 六一欢乐赛
  6. Docker run 命令 参数说明
  7. 练习-自定义分页功能的实现
  8. java list 元素排序_对arraylist中元素进行排序实例代码
  9. centos7 关闭防火墙的指令_Centos7.3防火墙配置
  10. android模拟器 bridge,ADB (Android Debug Bridge)简介
  11. linux 终端 快捷键
  12. 百度有啊 真的还有啊
  13. diy nas配置推荐2020_NAS的DIY配置
  14. 无法连接到本地daytime服务 connect到daytime时返回-1 处理
  15. 【图灵学院02】大型互联网技术之数据库分库分表
  16. OpenCV - C++ - cv::Scalar
  17. Android 第三方应用跳转到qq进行聊天(qq咨询)
  18. 在数据库EMIS1中;把日志文件初始大小修改为10MB,增长量为每次增长5MB,最大大小保持不变。
  19. unix时间戳和毫秒时间戳
  20. 第七届ACM趣味编程循环赛 Round #1 - ldq 的遗产题解

热门文章

  1. 《Spring源码深度解析 郝佳 第2版》SpringBoot体系分析、Starter的原理
  2. TempConvert2
  3. 第二课 如何在WINDOWS环境下搭建以太坊开发环境
  4. python-模拟高铁售票系统
  5. Go语言channel的底层
  6. macOS系统中将多个png文件合成到一个icns文件
  7. 【Android App】物联网中指南针、计步器、感光器、陀螺仪的讲解及实战演示(附源码 超详细必看)
  8. Flutter 集成umeng_common_sdk 1.2.4打包出现‘SDK初始化失败,请检查是否集成umeng-asms-1.2x.aar库’
  9. 苹果iOS系统刷机 for Mac
  10. vue安装包蓝奏云下载