KMP模式匹配算法
KMP模式匹配算法
苏丙榅KMP 算法,全称 Knuth-Morris-Pratt 算法,根据三个作者 Donald Knuth、Vaughan Pratt、James H. Morris 的姓氏的首字母拼接而成的。通过这种模式匹配算法可以大大避免字符串在匹配过程中被重复遍历的情况,它将匹配的时间复杂度提升到了 O(m+n)
1. 使用 PMT 表
部分匹配表(Partial Match Table,简称PMT),它记录了模式串的前缀和后缀的最长相等长度,用于在匹配失败时跳过已经匹配的部分,避免回溯,也就是通过已掌握的信息来规避重复的运算。
"前缀"
:除了最后一个字符外,字符串的所有头部子串"后缀"
:除了第一个字符外,字符串的所有尾部子串
以上图的"ABCDABD"
为例:
- “A”的前缀和后缀都为空集,共有元素的
长度为0
;- “AB”的前缀为[A],后缀为[B],共有元素的
长度为0
;- “ABC”的前缀为[A, AB],后缀为[BC, C],共有元素的
长度为0
;- “ABCD” ,共有元素的
长度为0
;
- 前缀为[A, AB, ABC]
- 后缀为[BCD, CD, D]
- “ABCDA”,共有元素为”A”,
长度为1
;
- 前缀为[
A
, AB, ABC, ABCD]- 后缀为[BCDA, CDA, DA,
A
]- “ABCDAB”,共有元素为”AB”,
长度为2
;
- 前缀为[A,
AB
, ABC, ABCD, ABCDA]- 后缀为[BCDAB, CDAB, DAB,
AB
, B]- “ABCDABD”,共有元素的
长度为0
。
- 前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB]
- 后缀为[BCDABD, CDABD, DABD, ABD, BD, D]
上图中的部分匹配值
就是PMT表
中的值,可以直观的看到每个字符在该表中都有一个与之对应的值:
0
:表示匹配过程中可以跳过匹配的字符个数为0
1
:表示匹配过程中可以跳过匹配的字符个数为1
2
:表示匹配过程中可以跳过匹配的字符个数为2
掌握了如何推导PMT
表中的值之后,我们来看一下在编程的时候如何实现。
1.1 构建PMT表
PMT表中存储的是一个模式字符串自身的前缀和后缀之间的最长匹配长度,具体来说,PMT 表的每个元素 pmt[i]
表示模式串的前 i
个字符中,最长的前缀(也即是模式串的起始子串)和后缀(即结束子串)相等的部分的长度。
先来说一下构建PMT表的步骤:
- 初始化:
- 创建一个长度为模式串长度的数组
pmt
,用来存储部分匹配值。 - 设定第一个元素
pmt[0]
为0,因为长度为1的串没有真正的前缀和后缀。
- 创建一个长度为模式串长度的数组
- 遍历模式串,定义两个辅助指针
i
和j
:i
表示当前字符的位置,j
表示当前匹配的前缀长度- 设置初始值:
i
为1,j
为0。
- 构建过程:
- 如果模式串的第
i
个字符与第j
个字符相同,说明当前字符匹配成功,则pmt[i] = j + 1
,并将i
和j
分别加1。 - 如果不匹配,且
j
不为0,说明当前匹配失败,则将j
回溯至pmt[j-1]
,继续比较。 - 如果不匹配,并且
j
为0,说明无法延续当前的匹配,直接将pmt[i]
设为0,并将i
加1。
- 如果模式串的第
接下来再通过图表的方式为大家描述一下构建PMT
表的过程(实线指针表示当前遍历的位置,虚线指针表示移动之后下一次遍历时的位置,没有虚线指针表示不移动):
上面的算法中并没有通过循环暴力求解,而是采用了递推的方式,在计算最长前后缀长度过程中有两种情况:
已知现有前后缀长度,下一个字符依然相同,共同前后缀长度加1,即
j++; i++
已知现有前后缀长度,下一个字符(假设为A)不同,需要做以下处理:
从不包含当前字符的最长前缀中找到它的最长前缀之后的字符,也就是
pmt[j-1]
位置的字符字符
c
和字符b
不等,在此之前的最长前后缀为abc
对于最长前缀
abc
而言,它的最长前缀为a
,它后面的字符为b
也就是pmt[1]
j
需要回溯到pmt[1]
的位置,对应的公式为:j = pmt[j-1]
比较
pmt[i]
和pmt[j]
的值- 相等:最长前后缀长度加1,即
j++; i++
- 不相等:重复当前步骤(当 j > 0 时)
- 相等:最长前后缀长度加1,即
这种方式的巧妙之处就是不断利用已掌握的信息来避免重复的运算。
以下是使用C++实现构建PMT表的具体步骤:
1 |
|
在上面的示例程序中模式串是ababaca
:
- i = 1, j = 0, 比较
b
和a
,不匹配,pmt[1] = 0
- i = 2, j = 0, 比较
a
和a
,匹配,j++
,pmt[2] = 1
- i = 3,j = 1, 比较
b
和b
,匹配,j++
,pmt[3] = 2
- i = 4, j = 2, 比较
a
和a
,匹配,j++
,pmt[4] = 3
- i = 5, j = 3,比较
c
和b
,不匹配,j = pmt[2] = 1
,继续比较c
和b
,不匹配,j = pmt[0] = 0
,pmt[5] = 0
- i = 6, j = 0, 比较
a
和a
,匹配,j++
,pmt[6] = 1
最终得到的PMT表为[0, 0, 1, 2, 3, 0, 1]
。
1.2 使用 PMT 表进行模式匹配
在构建好PMT表后,KMP算法就可以通过这个表来进行高效的模式匹配。算法在文本中搜索模式串时,通过PMT表可以在匹配失败时直接跳过已经匹配的部分,从而避免重复比较。
KMP算法基于PMT表的详细模式匹配步骤如下:
- 初始化:
- 设定两个指针
i
和j
,分别指向文本串和模式串的当前字符位置。 - 初始时
i
和j
都设为0。
- 设定两个指针
- 遍历文本串:
- 使用指针
i
遍历文本串,同时用指针j
遍历模式串。
- 使用指针
- 匹配过程:
- 如果文本串的第
i
个字符与模式串的第j
个字符相同,说明当前字符匹配成功,则将i
和j
分别加1。 - 如果不相同,且
j
不为0,说明当前匹配失败,则将j
回溯至pmt[j-1]
,继续比较。 - 如果
j
为0且不匹配,说明无法延续当前的匹配,直接将i
加1。
- 如果文本串的第
- 完成匹配:
- 当
j
达到模式串的长度时,说明模式串完全匹配,记录匹配的位置,然后将j
回溯至pmt[j-1]
继续匹配。
- 当
接下来再通过图表的方式为大家演示一下使用KMP算法通过PMT表进行模式匹配的过程(虚线箭头为移动之后的位置):
最后再为大家阐述一下模式串回溯的核心思想:
在匹配过程中,对文本串的遍历是线性的,一直向后永远不会回退
由于模式串和文本串可能已经匹配了若干字符,当下一个字符不匹配时,回去看最后一个匹配成功的字符(即 j-1 位置)在PMT表中对应的数值,该值表示可以跳过的匹配字符数量。
在上图中模式串中跳过的两个字符
ab
和文本串中对应位置的字符是可以完美匹配的。这是因为在匹配失败之前文本串和模式串中都有已经匹配成功的子字符串abcd
。左图到右图的变化,其本质就是让模式串中
abcd
的前缀去匹配文本串中abcd
的后缀,这无论如何都是可以匹配上的,因此在下一轮比较(右图)的时候可以直接跳过模式串中的这两个字符。
以下是使用C++基于以上的步骤说明写出的实现代码:
1 |
|
上面示例程序中的文本串是ababcabcabababd
,模式串是ababd
:
- i = 0,j = 0, 比较
a
和a
,匹配,i++,j++ - i = 1,j = 1, 比较
b
和b
,匹配,i++,j++ - i = 2, j = 2, 比较
a
和a
,匹配,i++,j++ - i = 3, j = 3, 比较
b
和b
,匹配,i++,j++ - i = 4, j = 4, 比较
c
和d
,不匹配,回溯,j = pmt[3] = 2
- i=4, j=2, 比较
c
和a
, 不匹配,回溯,j = pmt[2-1] = 0
- i=4,j=0,比较
c
和a
,无法回溯,i = 5
- i=5,j=0,比较
a
和a
,匹配,i++,j++ - 继续匹配直到找到完全匹配的位置或遍历完文本串。
通过上述过程,KMP算法能够在O(n + m)
的时间复杂度内完成模式匹配,极大地提高了匹配效率。
2. 使用 next 数组
Next数组的作用是记录模式串中各位置的前缀和后缀的最长匹配长度,用于在匹配过程中遇到不匹配时,
模式串应该回溯的位置。与PMT表的核心思想一致,但是记录方式有些许差异:
- PMT 表中的 pmt[i] 表示包括当前字符在内的最长前后缀长度(即可以跳过匹配的字符数量)
- Next 表中的 next[i] 表示当前字符之前(不包含当前字符)的最长前后缀长度(即可以跳过匹配的字符数量)
- 特殊情况:next[0] = -1,因为单一字符不满足公共前后缀的概念设定。
2.1 构建 next 数组
如果想要基于模式串构建 next 数组,其具体步骤如下:
初始化阶段:
- 创建长度为模式串长度的 next 数组
- 设置 next[0] = -1(固定值)
- 初始化两个指针:i = 0(后缀末尾),j = -1(前缀末尾)
主要计算过程:
- 从左到右遍历模式串,计算每个位置的 next 值
- 对于位置 i,比较 pattern[i] 和 pattern[j]:
- 如果相等:i 和 j 都右移,next[i+1] = j+1
- 如果不等:j 回退到 next[j],继续比较(回退机制,见步骤3)
- 如果 j 回退到 -1:i 右移,j 变为 0,next[i] = 0
回退机制:
- 当字符不匹配时,j 指针通过查找 next[j] 快速回退
- 回退过程可能会连续多次,直到找到匹配或回退到 -1
终止条件:
- 当 i 遍历完整个模式串(除最后一个字符)时结束,此时 next 数组构建完成
这个过程本质上是在寻找每个位置的最长相等前后缀,通过已经计算好的 next 值来加速计算过程。
接下来再通过图表的方式为大家描述一下构建next
数组的过程(虚线指针表示移动之后的位置,没有虚线指针表示不移动):
- 第一步:由于 j = -1,执行i++,j++,next[1] = j = 0
- 第二步:i = 1,j = 0
- 比较 pattern[1] 和 pattern[0]:’a’ = ‘a’
- 匹配成功,执行 i++,j++,next[2] = j = 1
第三步:i = 2,j = 1
- 比较 pattern[2] 和 pattern[1]:’b’ != ‘a’
- 不匹配,j 回退:j = next[j] = next[1] = 0
- 再比较 pattern[2] 和 pattern[0]:’b’ != ‘a’
- 不匹配,j 回退:j = next[j] = next[0] = -1
- j = -1,执行i++,j++,next[3] = j = 0
- 步骤4:i = 3,j = 0
- 比较 pattern[3] 和 pattern[0]:’a’ = ‘a’
- 匹配成功,执行 i++,j++,next[4] = j = 1
- 步骤5: i = 4,j = 1
- 比较 pattern[4] 和 pattern[1]:’a’ = ‘a’
- 匹配成功,执行 i++,j++,next[5] = j = 2
步骤6:i = 5,j = 2
- 比较 pattern[5] 和 pattern[2]:’a’ != ‘b’
- 不匹配,j回退:j = next[j] = next[2] = 1
- 比较 pattern[5] 和 pattern[1]:’a’ = ‘a’
- 匹配成功,执行 i++,j++,next[6] = j = 2
以下是使用C++实现构建Next数组的具体步骤:
1 |
|
为了便于大家理解,下面再次复盘一下上面程序中 next 数组的构建过程:
模式串:a b a b a c a
下 标:0 1 2 3 4 5 6
初始化:
next[0] = -1(固定值)
i = 0(后缀指针)
j = -1(前缀指针)
逐步计算:
第一步:计算 next[1],i=0,j=-1
- 由于j=-1,执行i++,j++,next[1] = j = 0
第二步:计算 next[2],i=1,j=0
- 比较 pattern[1] 和 pattern[0]:’b’ ≠ ‘a’
- j 回退到 next[0]=-1
- 由于j=-1,执行i++,j++,next[2] = j = 0
第三步:计算 next[3],i=2,j=0
- 比较 pattern[2] 和 pattern[0]:’a’ = ‘a’
- 匹配成功,执行i++,j++,next[3] = j = 1
第四步:计算 next[4],i=3,j=1
- 比较 pattern[3 ]和 pattern[1]:’b’ = ‘b’
- 匹配成功,执行i++,j++,next[4] = j = 2
第五步:计算 next[5],i=4,j=2
- 比较pattern[4]和pattern[2]:’a’ = ‘a’
- 匹配成功,执行i++,j++,next[5] = j = 3
第六步:计算next[6],i=5,j=3
- 比较 pattern[5] 和 pattern[3]:’c’ ≠ ‘b’
- j 回退到next[3]=1
- 比较 pattern[5] 和 pattern[1]:’c’≠’b’
- j 回退到 next[1] = 0
- 比较 pattern[5] 和 pattern[0]:’c’≠’a’
- j 回退到 next[0]=-1
- 由于 j=-1,执行 i++,j++,next[6] = j = 0
最终next数组:
[-1, 0, 0, 1, 2, 3, 0]
通过构建Next数组,KMP算法能够高效地进行字符串匹配,在匹配失败时利用已知的部分匹配信息,避免重复比较,提高了算法效率。
2.2 使用 next 数组进行模式匹配
KMP算法在匹配过程中除了使用PMT表
还可以使用Next数组
,当遇到不匹配时,可以根据Next数组的信息跳过一些不必要的匹配,从而提高效率。
使用 next 数组进行 KMP 模式匹配的核心步骤如下:
初始化阶段:
- 预先计算模式串的 next 数组,并定义两个辅助指针 i 和 j
- 初始化主串指针 i = 0,初始化模式串指针 j = 0
匹配过程 – 循环比较主串和模式串字符,直到以下任一条件满足:
- 主串遍历完(i 到达末尾)
- 模式串遍历完(找到匹配)
- 发生失配需要移动模式串
处理规则:
当字符匹配时(text[i] == pattern[j]):
- i 和 j 都向右移动
- 如果 j 达到模式串长度,说明找到一个匹配
当字符不匹配时(text[i] != pattern[j]):
j 回退到 next[j] 的位置,i 保持不变
如果 j 变为 -1,则 i 和 j 都右移
关键特点:
- 主串指针 i 永远不会回退
- 模式串指针 j 通过 next 数组实现快速移动
- 找到匹配后,可以继续寻找下一个匹配位置
接下来再通过图表的方式为大家演示一下使用KMP算法通过next数组(从0号位开始)进行模式匹配的过程:
i=4, j=4: ‘c’ ≠ ‘a’ 匹配失败!
j 回退到 next[4] = 2
i=4,j=2:’c’ ≠ ‘a’,匹配失败!
- j 回退到 next[2] = 0
i=4,j=0:’c’ ≠ ‘a’,匹配失败!
- j 回退到 next[0] = -1
j == -1,i++,j++
i=7,j=2:’c’ ≠ ‘a’,匹配失败!
- j 回退到 next[2] = 0
i=7,j=0:’c’ ≠ ‘a’,匹配失败!
- j 回退到 next[0] = -1
j == -1,i++,j++
当模式串和文本串匹配成功之后,i=14,j = 6,数组越界,需要让 j 指向 next 数组最后一个元素,即 j = j-1 = 5
i=14,j=5:’d’ ≠ ‘b’,匹配失败!
- j 回退到 next[5] = 3
i=14,j=3:’d’ ≠ ‘b’,匹配失败!
- j 回退到 next[3] = 1
i=14,j=1:’d’≠’b’ ,匹配失败!
- j 回退到 next[1] = 0
i=14,j=0:’d’ ≠ ‘a’ ,匹配失败!
- j 回退到 next[0] = -1
根据也以上描述,我们就可以把对应的C++代码写出来了:
1 |
|
程序输出的结果为:
1 | Next数组: -1 0 0 1 2 3 |
关于匹配的过程在上面的图例中已经做了非常详细的讲解,此处就不在过多赘述。关键点说明:
主串指针 i 始终向前移动,不会回退
模式串指针 j 在失配时通过 next 数组回退
每次失配都利用 next 数组跳过不必要的比较
在本例中找到了两次完整匹配,这个例子很好地展示了:连续匹配时的前进过程、失配时的多次回退以及 next 数组在加速匹配中的作用。
3. 使用 nextval 数组
3.1 next 数组的缺陷和优化
在上面使用 next 数组进行 KMP 模式匹配的例子中,有这样的一个细节,如下图:
i=4,
j=4
: ‘c’ ≠ ‘a’ 匹配失败!
- j 回退到 next[4] = 2
i=4,
j=2
:’c’ ≠ ‘a’,匹配失败!
- j 回退到 next[2] = 0
i=4,
j=0
:’c’ ≠ ‘a’,匹配失败!
- j 回退到 next[0] = -1
j == -1
,i++,j++对于第 1,2,3 个步骤而言,回溯之前 j 位置的字符值为
a
,连续回溯了两次之后 j 位置的字符值依旧为a
,当再次回溯时 next[j] 的值为 -1 ,停止继续回溯。对于上面的回溯过程其实是可以优化的,如果想要一步到位就需要让 next[4] = next[2] = next[0] = -1,也就是在公共前后缀中如果发现了相同的字符,需要让当前字符(位置记作 i)的 next 元素值等于上一个字符(位置记作j)的 next 元素值,即:next[i] = next[j]。
当在文本串中找到了一个完整的模式串匹配之后,继续向后匹配搜索下一个,具体步骤如下:
当模式串和文本串匹配成功之后,i=14,j = 6,数组越界,需要让 j 指向 next 数组最后一个元素,即 j = j-1 = 5
i=14,j=5:’d’ ≠ ‘b’,匹配失败!
- j 回退到 next[5] = 3
i=14,j=3:’d’ ≠ ‘b’,匹配失败!
- j 回退到 next[3] = 1
i=14,j=1:’d’≠’b’ ,匹配失败!
- j 回退到 next[1] = 0
i=14,j=0:’d’ ≠ ‘a’ ,匹配失败!
- j 回退到 next[0] = -1,匹配结束。
对于第 2,3,4 个步骤而言,回溯之前 j 位置的字符值为
b
,连续回溯了两次之后 j 位置的字符值依旧为b
,当再次回溯时模式串中 j 位置的字符值为 a 不等于 b ,停止继续回溯。对于上面的回溯过程同样可以优化,如果想要一步到位就需要让 next[5] = next[3] = next[1] = 0,也就是在公共前后缀中如果发现了相同的字符需要让当前字符(位置记作 i)的 next 元素值等于上一个字符(位置记作j)的 next 元素值,即:next[i] = next[j]。
基于以上分析就可以完成对 next 数组的优化了,一般我们将优化之后的 next 数组称之为 nextval 数组。
下面是关于 nextval 数组的具体构建过程:
- 第一轮:j = -1,i = 0
- 由于 j = -1,所以 i++, j++, next[1] = 0
- 第二轮:i = 1, j = 0
- pattern[i] != pattern[j],即 ‘a’ != ‘b’
- j 回溯,j = next[0] = -1
- 第三轮:i = 1, j = -1
- 由于 j = -1,所以 i++, j++,i = 2,j = 0
- i,j 指针后移之后需要再比较一次 pattern[i] == pattern[j],’a’ == ‘a’,所以 next[2] = -1
- 相等: next[i] = next[j]
- 不等:next[i] = j
- 第四轮:i = 2,j = 0
- pattern[i] == pattern[j],即:’a’ == ‘a’,i++,j++
- i,j 指针后移之后需要再比较一次 pattern[i] == pattern[j],’b’ == ‘b’,所以 next[3] = 0
- 相等: next[i] = next[j]
- 不等:next[i] = j
- 第五轮:i = 3,j = 1
- pattern[i] == pattern[j],即:’b’ == ‘b’,i++,j++
- i,j 指针后移之后需要再比较一次 pattern[i] == pattern[j],’a’ == ‘a’,所以 next[4] = -1
- 相等: next[i] = next[j]
- 不等:next[i] = j
- 第六轮:i = 4,j = 2
- pattern[i] == pattern[j],即:’a’ == ‘a’,i++,j++
- i,j 指针后移之后需要再比较一次 pattern[i] == pattern[j],’b’ == ‘b’,所以 next[5] = 0
- 相等: next[i] = next[j]
- 不等:next[i] = j
最后再总结下关于 next 数组和 nextval 数组构建过程的主要区别(当 pattern[i] == pattern[j] 条件成立时):
- next 数组:
- i++,j++
- next[i] = j
- nextval 数组:
- i++,j++
- 再次判断 pattern[i] == pattern[j] 条件是否成立
- 成立:next[i] = next[j]
- 不成立:next[i] = j
思路掌握之后,再来说一下代码实现:
1 |
|
3.3 使用 nextval 数组进行模式匹配
使用 nextval 数组进行模式匹配和使用 next 数组进行模式匹配的流程完全相同,区别就是在和某些特定格式的模式串(模式串中有若干重复的子串)就行匹配的时候效率提高了,因为模式串回溯的次数减少了,比较的次数也较少了。
使用 nextval 数组进行模式匹配的代码如下:
1 |
|
关于匹配的具体流程,此处就不再进行逐条分析,可以参考本文档的 2.2 章节,一模一样。