kmp算法的原理与实现
本文介绍了 kmp 算法和实现过程和原理讲解,并提供了完整的实现代码。同时,还详细介绍了 PM 数组到 next 数组到 nextval 数组的转变过程,以及它们之前的转换方法,并提供了可运行代码以供同学们进行自我练习与测试。
字符串的前缀,后缀和部分匹配值
前缀:除了最后一个字符外,字符串的所有头部子串。
后缀:除了第一个字符外,字符串的所有尾部子串。
部分匹配:字符串的前缀和后缀的最长相等前后缀的长度。
例:aba
,前缀:{a, ab}
;后缀:{b, ba}
。最长相等前后綴长度:${a, ab} \cap {ba, a} = {a}$。所以长度是 1。
此时,我们可以写出一个字符串的所有子串的部分匹配值(Partial Match, PM):
例:
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
PM | 0 | 0 | 0 | 1 | 0 |
那么,这有什么作用呢?
例如,如果我们要匹配两个字符串:主串:ababcac
和模式串: abcac
编号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
主串 | a | b | a | b | c | a | c |
模式串 | a | b | c | a | c |
我们会发现,在编号是 3 的位置,主串和模式串是不一样的,如果我们是朴素算法,那么应该将模式串移动 1 个字符的位置,来进行下一轮匹配。
但是我们可以通过移动 $MOVE = 已匹配的字符数 - 对应的部分的匹配值$ 这么多来进行下一轮匹配。在当前的这个例子中,就是移动 $MOVE = 2 - 0 = 2$ 个字符。即
编号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
主串 | a | b | a | b | c | a | c |
模式串 | a | b | c | a | c |
这样来匹配。
我们可以发现,对于主串来说,由于不需要重新再比较已经比较过的字符了,所以可以将算法的时间复杂度从 $O(n * m)$ 降到 $O(n + m)$
问:这个使用部分匹配值来进行匹配的原理是什么?
我们知道一个字符串的部分匹配值的意思是:这个字符串最长的前缀与后缀相等的长度。这里有两个限制:一个是最长的,一个是相等的。
例如:对于字符串:aaaa
,这里前缀可以是 {a, aa, aaa}
,后缀可以是 {a, aa ,aaa}
,因此根据这个定义可得,部分匹配值的长度应该是 aaa
的长度,即 3。
对于字符串 ababc
我们可以知道,当主串的第 i 个字符与子串的第 j 个字符不一致的时候,我们可以从前 j-1 个字符组成的子串中,取出长度是部分匹配值长度的前缀,来代替后缀的位置,即:向右移动模式串,直到前缀和移动前的后缀的位置重合,此时,移动的长度为 $MOVE = j - 1 - pm[j - 1]$。
例:当前第 4 个元素不一致,所以从前 3 个字符组成的字符串 aba
中取出长度是部分匹配值(1)的前缀:a
来代替后缀的位置。可以看下面第二个表格:前 3 个字符的后缀是 a
,因此,将前缀的 a 移动到与后缀重合的位置上,然后再继续比较。
编号 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
S | a | b | a | c |
PM | 0 | 0 | 1 | 0 |
编号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
主串 | a | b | a | b | a | c | |
模式串 | a | b | a | c | |||
移动后的模式串 | a | b | a | c |
移动的长度: $MOVE = 3 - 1 = 2$。
next 数组的由来
我们通过前面的学习,可以知道,每一次匹配失败的时候,都需要去找前一个元素的部分匹配值。为了让这个操作方便一些,我们可以将所有的部分匹配值向右移动一格,使得每一次匹配失败的时候,都只需要找自己的部分匹配值。同时,空出来的地方填为-1,多出来的地方截去。
编号 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
S | a | b | c | a | c | |
PM | 0 | 0 | 0 | 1 | 0 | |
PM’ | -1 | 0 | 0 | 0 | 1 | 0(舍去) |
所以,移动的距离可以改为 $MOVE = j - 1 - pm[j]$ (当子串的第 j 个字符不匹配的时候)
相当于将整个子串的比较指针移到 $j = j - MOVE = pm[j] + 1$ 个位置上,为了将这个公式更加简洁,我们将 pm 的每个值都加 1,此时,next 数组就求出来了。
编号 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
S | a | b | c | a | c | |
PM | 0 | 0 | 0 | 1 | 0 | |
PM’ | -1 | 0 | 0 | 0 | 1 | 0(舍去) |
next | 0 | 1 | 1 | 1 | 2 |
next[j]
在这里的意思是:在子串的第 j 个字符与主串发生失配的时候,将子串的指针跳到 next[j]
所指的位置重新与主串进行比较。(子串的位序从 1 开始)
求 nextval 数组
nextval 数组是对 next 数组的优化。可以再次减小比对的次数,优化代码运行的效率。
我们先来看一个例子:
对于一个主串和模式串:aaacaaaac
和 aaaac
我们可以知道:
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | a | a | a | c |
next | 0 | 1 | 2 | 3 | 4 |
我们先用 next 数组的解法来求一下这个问题的匹配过程
编号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
主串 | a | a | a | c | a | a | a | a | c |
模式串 | a | a | a | a | c | ||||
模式串移动 1 | a | a | a | a | c | ||||
模式串移动 2 | a | a | a | a | c | ||||
模式串移动 3 | a | a | a | a | c |
我们可以看到,由于编号 4 的不同,我们经过了 3 次移动以后,才会去判断主串的下一个字符。这里就是 next 数组的不足之处。
我们可以知道,如果在第 4 个字符两个串不匹配的时候,说明当前模式串的字符的主串的字符不一样。如果 next[4]
处的字符和当前的字符一样的时候,那么就可以得出:next[4]
处的字符也和当前的主串的字符不一样。所以当前这个字符如果和 next[4]
字符一样,那么 next[4] = next[next[4]]
。即当前的字符应该跳到 next[4]
字符跳到的位置。如果不一样,那么 next 数值保持不变。
考试时求 next 和 nextval 数组的方法
以字符串 ababaa
为例
S | a | b | a | b | a | a |
---|---|---|---|---|---|---|
k | 1 | 2 | 3 | 4 | 5 | 6 |
next | 0 | 1 | 1 | 2 | 3 | 4 |
nextval | 0 | 1 | 0 | 1 | 0 | 4 |
当遍历到第 i (i > 2)个字符的时候,当前第 i 个元素的 next 值等于前 i-1 个字符的最大的前缀与后缀相等的时候的值 + 1。$i=1$ 时 $i=2$ 时 next 值分别为 0 和 1。
例如:当 i=1 时,那么 next[1] = 0
,当 i = 5
时,看前 i-1 个元素组成的字符串:abab
,可以发现最大的公共前缀后缀串为 ab
。那么当前的 next 值为 length(ab) + 1 = 2 + 1 = 3
。
对于 nextval 数组,第 1 位无脑写 0。对于其他的字符 i (i > 1 ),如果当前 next 数组指向的字符 s[next[i]]
不等于当前的字符,即:s[next[i]] != s[i]
,那么当前 nextval 的值等于 next 的值。如果当前 next 数组指向的字符和当前的字符一样,那么当前的 nextval 的值就正好等于 next 指向的那个字符的 nextval 的值,即: nextval[i] = nextval[next[i]]
。
例:当 i 等于 2 时,next[2] = 1
,此时,当前的字符是 b,next 指向的字符是 a。a 和 b 不相等,所以当前 nextval 的值就是 next 和值 nextval[2] = next[2] = 1
。当 i 等于 3 的时候,此时当前的字符是 a,next 指向的字符也是 a,所以当前的 nextval 的值等于指向的字符的 nextval 的值,即 nextval[3] = nextval[next[3]] = nextval[1] = 0
。
使用代码来实现 kmp 算法
求 pm 数组,next 数组,nextval 数组
1 |
|
使用 next 数组进行模式匹配
求 next 数组的部分在上面的代码中已经打过注释了,所以这个代码中就不写相关部分的注释了。
如果要使用 nextval 数组来求,那么只需要添加一段求 nextval 的代码,然后在匹配部分将 ne
改成 nextval
即可。
1 |
|
总结
求 next 数组有两种方法:一种是先求 pm 数组,然后将 pm 数组整体向右移一格,再整体加 1 即可得到;另一种是根据定义(前 i-1个字符串中,最长匹配前缀的下一格的位序),直接用手模拟出 next[i]
所代表的值。