字符串匹配究极大招【KMP】:带你一步步从原理到构建
前言
一文带你了解如何去理解并实现KMP算法。本文用于记录自己的学习过程,同时向大家进行分享相关的内容。本文内容参考于 代码随想录 同时包含了自己的许多学习思考过程,如果有错误的地方欢迎批评指正!
KMP原理
首先来知道什么是KMP,KMP是由三位学者发明的:Knuth,Morris和Pratt,所以取了三位学者名字的首字母。所以叫做KMP。其主要是应用在字符串匹配上面的。KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。那么通过什么方式可以利用已经匹配的信息呢,这时候就需要next数组了。next数组本质上就是一个前缀表。
什么是前缀表
在弄清楚什么是前缀表的时候,我们得先知道什么是前缀、后缀。前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
举个例子来说哈,对于字符串abcdfe来说,abc子串的所有前缀为a和ab,所有后缀为b和bc
那么前缀表的作用是什么呢?前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。如图所示,当b和f不匹配的时候,不会从头开始,而是会跳到b来进行匹配。所以到这我们就可以知道了什么是前缀表:即记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀
如何构建前缀表
长度为前1个字符的子串a
,最长相同前后缀的长度为0。(注意字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。)因为其没有前后缀,所以第一个字符必定为0.
长度为前2个字符的子串aa
,最长相同前后缀的长度为1。其前缀a和其后缀a最长相等为1
长度为前3个字符的子串aab
,最长相同前后缀的长度为0。无最长相等的前后缀
以此类推: 长度为前4个字符的子串aaba
,最长相同前后缀的长度为1。 长度为前5个字符的子串aabaa
,最长相同前后缀的长度为2。 长度为前6个字符的子串aabaaf
,最长相同前后缀的长度为0。
那么把求得的最长相同前后缀的长度就是对应前缀表的元素,如图:
可以看出模式串与前缀表对应位置的数字表示的就是:下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
next数组
说到这,我们就得回到next数组了。那么做字符串匹配的时候,很多时候都是以next数组进行回退的,那么next数组与前缀表又是什么关系呢?其实本质上next数组就是前缀表,当然有些人对前缀表有不同的处理,所以next数组的形式可能会有不同,但其本质上都是一样的,逻辑都是相通的。这里直接讲述让前缀表作为next数组(别的处理有全部减一或者整体向右边移动一位)。
这里我们来讲述如何构建next数组。(以python代码为例)
构建next数组有三个重要步骤:
- 初始化
- 处理前后缀不相同的情况
- 处理前后缀相同的情况
初始化:初始化的不同,决定了你的next数组的形式。我们这里将定义两个指针i和j,j指向前缀末尾位置,i指向后缀末尾位置。我们这里将j初始化为0,并且将next数组的第一位初始化为0.
处理前后缀不相同的情况:因为j初始化为0,那么i就从1开始,进行s[i] 与 s[j]的比较。所以遍历模式串s的循环下标i 要从 1开始,如果 s[i] 与 s[j]不相同,也就是遇到 前后缀末尾不相同的情况,就要向前回退。怎么回退呢?next[j]就是记录着j(包括j)之前的子串的相同前后缀的长度。那么 s[i] 与 s[j] 不相同,就要找 j前一个元素在next数组里的值(就是next[j-1])。
处理前后缀相同的情况:如果 s[i] 与 s[j ] 相同,那么就同时向后移动i 和j 说明找到了相同的前后缀,同时还要将j(前缀的长度)赋给next[i], 因为next[i]要记录相同前后缀的长度。
最后其整体代码如下:
def getNext(self, next: List[int], s: str) -> None:
j = 0
next[0] = 0
for i in range(1, len(s)):
while j > 0 and s[i] != s[j]:
j = next[j - 1]
if s[i] == s[j]:
j += 1
next[i] = j
使用next数组做匹配
好了,最重要的来了,前面讲述了那么多,其根本就是为了字符串匹配做工作。我们要在文本串s里 找是否出现过模式串t。以下是具体的思路及其步骤。
-
首先定义两个下标j 指向模式串起始位置,i指向文本串起始位置。
-
那么j初始值依然为0,为什么呢? 依然因为next数组里记录的起始位置为0。
-
i就从0开始,遍历文本串。
-
接下来就是 s[i] 与 t[j ] (因为j从0开始的) 进行比较。
-
如果 s[i] 与 t[j ] 不相同,j就要从next数组里寻找下一个匹配的位置。
-
如果 s[i] 与 t[j + 1] 相同,那么i 和 j 同时向后移动.
-
如何判断在文本串s里出现了模式串t呢,如果j指向了模式串t的末尾,那么就说明模式串t完全匹配文本串s里的某个子串了。
-
要在文本串字符串中找出模式串出现的第一个位置 (从0开始),所以返回当前在文本串匹配模式串的位置i 减去 模式串的长度,就是文本串字符串中出现模式串的第一个位置。
并且我们来看其时间复杂度,用暴力法解题即两个for循环,时间复杂度为O($n^2$),而用KMP算法来解题,其时间复杂度为O($m+n$),其m和n分别为字符串s和模式串t的长度,通常模式串t不会很大,所以一般是可以忽略的。
所以最后完整的代码为:
def strStr(self, haystack: str, needle: str) -> int:
if len(needle) == 0:
return 0
next = [0] * len(needle)
self.getNext(next, needle)
j = 0
for i in range(len(haystack)):
while j > 0 and haystack[i] != needle[j]:
j = next[j - 1]
if haystack[i] == needle[j]:
j += 1
if j == len(needle):
return i - len(needle) + 1
return -1
实战演练
28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)
class Solution:
def getNext(self, next: List[int], s: str) -> None:
j = 0
next[0] = 0
for i in range(1, len(s)):
while j > 0 and s[i] != s[j]:
j = next[j - 1]
if s[i] == s[j]:
j += 1
next[i] = j
def strStr(self, haystack: str, needle: str) -> int:
if len(needle) == 0:
return 0
next = [0] * len(needle)
self.getNext(next, needle)
j = 0
for i in range(len(haystack)):
while j > 0 and haystack[i] != needle[j]:
j = next[j - 1]
if haystack[i] == needle[j]:
j += 1
if j == len(needle):
return i - len(needle) + 1
return -1