KMP算法

这篇文章与其说是介绍KMP算法,不如说是我学习KMP算法的笔记。一些老生常谈理解起来没问题的部分我就不重复写了。某些细节上详细写一下我的理解和思路,希望能对大家有帮助。

KMP算法的作用

KMP算法用于解决字符串匹配问题。即:
现有A和B两个字符串,需要找出B在A中第一次出现的位置。
比如A = “banana”, B = “na”。这种情况下,输出的结果应该是2,即A串下标为2的位置。


KMP算法所需基础知识

前缀

对于字符串A,包括首字符 且 不包括尾字符 的 所有子串 都是A的前缀。比如:
A = “apple”, 那么A的前缀有四个,分别是 “a”、“ap”、“app”、“appl”。

后缀

对于字符串A,不包括首字符 且 包括尾字符 的 所有子串 都是A的前缀。比如:
A = “apple”, 那么A的后缀有四个,分别是 “e”、“le”、“ple”、“pple”。

不难发现,同一个字符串的前缀个数和后缀个数是相等的,都为 字符串长度-1

最长公共前后缀

对于字符串B,如果它的某个前缀和某个后缀相等,我们把这一对前后缀叫作B的公共前后缀。B的所有公共前后缀中,长度最长的被叫作最长公共前后缀。
B = “goodgoodgood”,那么B有2个公共前后缀,分别是”good"和“goodgood”。并且,B的最长公共前后缀为“goodgood"。

最长公共前后缀表

对于字符串B = ”goodgoodgood",规定子串起始位置为0,停止位置为i(0 <= i < B.length()),我们可以对应每个i,求出B的最长公共前后缀表。
当i = 0时,子串为 “g", 前后缀都为空,无公共前后缀,最长公共前后缀长度为0。
当i = 1时,子串为 “go", 前缀为“g",后缀为“o",无公共前后缀,最长公共前后缀长度为0。

当i = 4时,子串为 “goodg", 前缀为“g"、”go"、“goo”、“good”,后缀为“g"、”dg"、“odg”、“oodg”,存在公共前后缀“g",最长公共前后缀长度为1。
当i = 5时,子串为“goodgo",同理,存在公共前后缀”go“,最长公共前后缀长度为2。

当i = B.length()-1时,子串为“goodgoodgood",存在公共前后缀”good“、“goodgood”,最长公共前后缀长度为8。
由此,我们可以制作一个A的最长公共前后缀表:

字符串Bgoodgoodgood
对应下标01234567891011
最长公共前后缀长度000012341238

最长公共前后缀表的意义
我们之所以要花这么多力气去得到最长公共前后缀表,就是因为这个表能让我们直接判断下次的起始位置是 B[1] ~ B[i] 中的哪一个。

激动人心的找规律游戏又开始了~ 我们用上面B = ”goodgoodgood"的例子,看看每个位置匹配失败时,下一次匹配的起始位置。
i=0:第1个字符就不一样,直接pass,从匹配失败的位置往后挪一位就是下一次匹配的起始位置。
i=1:第1个字符一样,第2个字符不一样,匹配失败的位置就是下一次匹配的起始位置(匹配失败位置回退0位)。
i=2:前2个字符一样,第3个字符不一样,匹配失败的位置就是下一次匹配的起始位置(匹配失败位置回退0位)。
i=3:前3个字符一样,第4个字符不一样,匹配失败的位置就是下一次匹配的起始位置(匹配失败位置回退0位)。
i=4:前4个字符一样,第5个字符不一样,匹配失败的位置就是下一次匹配的起始位置(匹配失败位置回退0位)。
i=5:前5个字符一样,第6个字符不一样,但发现已匹配部分的最后1个字符和第1个字符相等(都是’g’),所以这最后1个字符很有可能是答案的起始位置,下一次匹配的起始位置从这个字符开始(匹配失败位置回退1位)。
i=6:前6个字符一样,第7个字符不一样,但发现已匹配部分的最后2个字符和前2个字符相等(都是’go’),所以这最后1个’g’字符很有可能是答案的起始位置,下一次匹配的起始位置从这个字符开始(匹配失败位置回退2位)。
i=7:前7个字符一样,第8个字符不一样,但发现已匹配部分的最后3个字符和前3个字符相等(都是’goo’),所以这最后1个’g’字符很有可能是答案的起始位置,下一次匹配的起始位置从这个字符开始(匹配失败位置回退3位)。
i=8:前8个字符一样,第9个字符不一样,但发现已匹配部分的最后4个字符和前4个字符相等(都是’good’),所以这最后1个’g’字符很有可能是答案的起始位置,下一次匹配的起始位置从这个字符开始(匹配失败位置回退4位)。
i=9:前9个字符一样,第10个字符不一样,但发现已匹配部分的最后1个字符和第1个字符相等(都是’g’),所以这最后1个字符很有可能是答案的起始位置,下一次匹配的起始位置从这个字符开始(匹配失败位置回退1位)。
i=10:前10个字符一样,第11个字符不一样,但发现已匹配部分的最后2个字符和前2个字符相等(都是’go’),所以这最后1个’g’字符很有可能是答案的起始位置,下一次匹配的起始位置从这个字符开始(匹配失败位置回退2位)。
i=11:前11个字符一样,第12个字符不一样,但发现已匹配部分的最后3个字符和前3个字符相等(都是’goo’),所以这最后1个’g’字符很有可能是答案的起始位置,下一次匹配的起始位置从这个字符开始(匹配失败位置回退3位)。
i=12:前12个字符一样,终止,返回答案。

确实很啰嗦,但希望到这个程度,大家都能理解最长公共前后缀表的意义。看上面标红的数字,有没有发现和最长公共前后缀表很像?如果你是发明这个算法的人,你会如何利用这个最长公共前后缀表每个位置匹配失败时,下一次匹配的起始位置之间的关系?


字符串匹配优化算法思想历程

相信字符串匹配的问题大家都会用暴力解决,那KMP具体利用了哪些我们没用到的信息来降低复杂度呢?
看一个例子:
A = "NeverSayNoNeverSayNever", B = "NeverSayNever"。
如果用暴力解法,挨个字母作为首字母去遍历,当第一次遍历(首字母为A[0] = ‘N’)时,会在检查到A[9] != 'e’时立刻停止本次遍历,并从A的下一个字母A[1] = ‘e’开始第二次遍历。
可能有些小伙伴在使用暴力解法时,心里就有一种隐隐的直觉:很明显A[1]~A[7]都不可能是B的起始位置,直接从A[8]开始下一次遍历就好

我们来剖析一下这个直觉的由来,不出意外的话,99%的人思路都是:

本次匹配是从A[0]开始的,匹配失败后应该从0往后的位置开始下一次匹配。B是以’N’开头的字符串,而A[1]~A[7]都不是’N’,所以1 ~ 7不可能是B在A里的起始位置。

那如果,我把A的内容改成A = "NNverSayNoNeverSayNever",A[1] = ‘N’了, 那你觉得1会是正确答案吗?
很明显,也不是。为什么呢,因为B是以’Ne’开头的字符串,而A[1]虽然是’N’,但A[2]却不是‘e’。
那如果,我把A的内容改成A = "NNeerSayNoNeverSayNever",A[1] = ‘N’了, 而且A[2] = ‘N’了那你觉得1会是正确答案吗?
同理,不是。为什么呢,因为B是以’Nev’开头的字符串,而A[1]虽然是’N’,A[2]也是‘e’,A[3]却不是‘v’。
。。。。。。
你细品,是不是有点前后缀内味儿了?
透过现象看本质,我们让C等于A[0]~A[7],即C = "NeverSay“。我们之所以觉得1~7不可能是答案,无非就是看出了一个信息:C的任一后缀与B的任一前缀都不相等。


由此,我们可以有一个大致的猜想:
截止到遍历停止的位置,该位置之前所有字符组成一个字符串C。若C存在某个后缀,刚好和B的某个前缀相等,那么每一个符合条件的后缀的起始位置都有可能是我们要找的答案,这时候就需要从最长后缀的起始位置开始下一次遍历。若C不存在与B任一前缀相等的后缀,那么C中任一位置都不可能是答案,直接从截止位置开始新的遍历就好。

这正是KMP算法看问题的微妙之处:

每次匹配都是和B比较,即每次匹配都是从B[0]开始,逐个对比。匹配失败时,从A中对应的B[0]往后的位置开始下一次匹配。因为在位置9之前,A和B的字符是一样的,所以我们既可以像普通思路一样在A寻找下次匹配的起始位置,或者在B中寻找下次匹配的起始位置再对应到A中都是可以的。但有了B的最长公共前后缀表这个工具后,我们可以直接得到下次匹配的起始位置相对当前B字符串的位置,然后再对应到A字符串中

那在这个例子中,匹配到 A[9] != B[9],KMP算法通过最长公共前后缀表发现已匹配部分( B[0] ~ B[8] = “NeverSayN")的最长公共前后缀长度为1,就知道在匹配失败的位置向前回退1位就是下次匹配的起始位置。


KMP算法思想

我先把算法的步骤说一遍,然后再逐个解释每一步操作的实现方式和意义。
我们设A = ”goodgoodgoodclover“是原字符串,B = ”goodgoodclover“是匹配字符串。

  1. 生成B的最长公共前后缀表
字符串Bgoodgoodclover
下标012345678910111213
最长公前后缀00001234000000
  1. 生成跳转表next
字符串Bgoodgoodclover
下标012345678910111213
回退-10000123400000
  1. 若遇到匹配失败的情况,假设匹配失败位置在B中的下标为i,则在匹配失败位置回退 next[i]即为下一次匹配的起始位置。
     
    在这个例子中,我们从A[0]开始第一次匹配,发现A[8] != B[8]。再查询next[8] = 4,所以我们下一次遍历从A[8-4]即A[4]开始即可,而不需要再判断A[2]、A[3]、A[4]。

一些小疑问

  1. 为什么必须是后缀(e.g. 为什么不能包含首字符)
    既然出现了截止情况,那就说明,你已经验证过首字符不是正确答案了,不是吗?
    其实,每一次尝试匹配,都是一次验证潜在答案的过程。如果验证失败,那这次匹配就不是答案,下一次匹配时,当然不应该把这一次匹配的A中子串首字母考虑进去。

  2. 为什么必须是前缀 (e.g. 为什么不能包含尾字符)
    如果包含尾字符,说明全部字符都匹配了,即已找到答案。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值