背景
KMP 是经典的字符串匹配算法,在大学课本里面都是讲到过的,不过我感觉课本都讲的太生硬了,很容易忘记。大多数博客也是讲的云里雾里的,一开始就来个公式+部分匹配表,反正我感觉对于没有计算机基础的童鞋来说还是有点不容易理解,经过前两天在开发者头条上看到的一篇关于kmp算法的讲解,我看完了于是有了整理下来的冲动。
说明:
- 其中i为主串的起始位置的下标
- 其中j为模式串匹配失败位置的下标
- 比如要在a=‘abbcf’,b=‘sjldfsdabbcfdsfs’,要在b中找a的,则a为模式串,b为主串
首先要明确的一点是,KMP 算法本身包含两个步骤:
- KMP 算法本身的思想与策略;
- 利用动态规划的思想来求一个字符串所有前缀子串的最大共同前后缀子串的长度(也就是一直在说的 next 数组);
两个部分其实关系不大,但很多文章都很喜欢将其混在一起讲。我们首先先讲第一步。
暴力匹配(也就是一个一个的匹配)
例如:实现 substr, 在字符串 haystack 中,找字符串 needle 的位置,不存在则返回-1.
最符合直觉的方法就是穷举法,遍历 haystack 中每个字符,对于每个字符,都尝试以该字符作为开始于 needle 进行逐个字符串的比较,匹配上了则返回当前的 i。整个过程可以看做是模式串(needle)从对齐 haystack 第一个字符开始逐步一个一个往后移动的过程。
假设 haystakc=”Hello Hella”, need=”Hella”, 过程如下:
i=0:
Hello Hella
Hella
i=1:
Hello Hella
Hella
i=6:
Hello Hella
Hella(matched)
整个过程一共执行了 6 次检查。
代码如下:
实例一:
def match_str(haystakc, need):
temp = 0
main_str_len, son_str_len = len(haystakc), len(need)
while temp <= main_str_len - son_str_len:
if haystakc[temp:temp + son_str_len] == need:
return temp
else:
temp += 1
else:
return -1
实例二:
def naive_matching(t, p):
m, n = len(p), len(t)
i, j = 0, 0
while i < m and j < n: # i==m说明找到匹配
if p[i] == t[j]: # 字符相同,开始匹配下一对
i, j = i + 1, j + 1
else: # 字符不同,开始t中的下一个位置
i, j = 0, j - i + 1
if i == m: # 找到匹配,返回下标
return j - i
return -1
haystakc = 'Hello Hella'
need = 'Hella'
print(match_str(haystakc, need))
print(naive_matching(haystakc, need))
尝试优化
分析一下刚才 i=0 的执行过程。H、e、l、l 都匹配成功,仅最后的 o 和 a 匹配失败。此时移动模式串 Hella,我们很容易发现接下来的几步(i=1到 i=5)都是徒劳无功,我们是否可以利用先验知识(比如 Hell 之前已经匹配成功)来避免这几次比较的浪费呢?
首先做一个大胆的假设,当基于主串的第 i 个字符,一旦模式串的第 j 位匹配失败。当 j>0时,则主串的 [i, i+j)区间直接跳过,从 i+j位置开始搜索,当 j 小于=0 时,则直接从 i+1的位置开始搜索,简单的来说就是比如有:
i=0
Hello Hella
Hella(此时 j = 4, 代表模式串第五位匹配失败)
则下一次直接从 i+j = 4位置开始搜索
i=4
Hello Hella
Hella(j=0,失配,下一次 i=i+1 = 5)
i=5
Hello Hella
Hella(j=0,失配,下一次 i=i+1 = 6)
i=6
Hello Hella
Hella(matched)
特殊情况
上面的是一个假设,有没有可能存在不满足该假设的 bad case 呢。比如看以下的例子, 主串=”HelloHelloHead” 模式串=”HelloHead”
i=0
HelloHelloHead
HelloHea (a 和 l 不匹配,此时 j=7)
按照刚才的公式,直接跳过已经匹配好的部分,跳到 i+j = 7处开始下一次匹配
i=7
HelloHelloHead
HelloHead (匹配失败,i=i+1)
i=8
HelloHelloHead
HelloHead (匹配失败)
我们知道这个 case 应该是能够匹配成功才对,但是按照我们的假设流程却失败了。通过分析过程能够发现,本质上是我们跳过得太多了。那到底要跳过多少比较合适呢?先回头看看例子
i=0
HelloHelloHead
HelloHea (a 和 l 不匹配,此时 j=7)
j=7 的位置不匹配,但能看到前面两个位置是 H 和 e并且是匹配成功的,加上模式串的开头,也是 H 和 e,看起来可以**直接把开头的 He,对齐到后面的 He**,也就是i=5 的位置
i=5
HelloHelloHead
HelloHead(matched)
那更新一下我们的假设,当匹配失败时,直接跳到 i+j的位置开始新的匹配,但对于某些case,需要少跳几步
kmp算法
KMP 算法的本质就是定义了什么样的情况需要少跳,以及具体少跳几步。
- 其实从刚才的 case 不难发现,需要少跳的步数就是模式串已经成功匹配的部分的共同前后缀的长度, 比如刚才的 HelloHead,已经成功匹配的部分是 HelloHe,这个字符串具备共同的前后缀 He,长度为2.
- 共同的前后缀简单的理解就是前缀和后缀相同的字符串,比如 ABBA,共同前后缀是 A; ABBABB,共同前后缀是 ABB;ABBABBAAC, 无共同前后缀。
- 基于此,我们进一步更新我们的假设,当匹配失败时,如果已经匹配的模式子串无共同前后缀,则直接跳到 i+j的位置开始新的匹配,若存在共同子串,则跳转到 i+j-共同子串长度的位置开始新的匹配
- 因为模式串每一位都可能发生失配,所以我们需要求出模式串所有前缀子串分别的最大相同前后缀子串长度。比如模式串是 ABCDABC,我们需要分别求出 A、AB、ABC、ABCD、ABCDA、ABCDAB、ABCDABC 分别的最大相同前后缀的长度,比如 ABCDAB 是 2(AB),ABCDABC是 3(ABC),而 ABCDA 是 1(A)。这部分最后的结果存放在一个数组,普遍称之为 next 数组,其中next数组得出来了后,需要讲next数组每一位向后移动一位,这是next数组的第一个位置为-1,模式串最长的字串的最长公共前后缀不需要计入next数组。
- 例如字符串“ABCDABD”,它的next数组如下
失配时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值(可以自行验证,这些都是前人已经总结好的规律)
动态规划的思想求 next 数组
def matching_KMP(t, p, pnext):
j, i = 0, 0
n, m = len(t), len(p)
while j < n and i < m:
if i == -1 or t[j] == p[i]:
j, i = j + 1, i + 1
else:
i = pnext[i]
if i == m:
return j - i
return -1
def gen_pnext(p):
i, k, m = 0, -1, len(p)
pnext = [-1] * m
while i < m - 1:
if k == -1 or p[i] == p[k]:
i, k = i + 1, k + 1
pnext[i] = k
else:
k = pnext[k]
return pnext
haystakc = 'Hello Hella'
need = 'Hella'
pnext=gen_pnext(need)
print(matching_KMP(haystakc, need, pnext))