Kmp算法

  1. 1. 前缀,后缀,部分匹配值
  2. 2. 部分匹配值的使用
  3. 3. 算法改进
    1. 3.1. 当前的算法
    2. 3.2. 优化
  4. 4. next数组计算代码
    1. 4.1. 书上的错误
    2. 4.2. 书上代码纠正
    3. 4.3. 按照从0开始计算的代码
    4. 4.4. 代码讲解
  5. 5. KMP全部代码
  6. 6. 速成KMP,推荐一个视频。

kmp算法本身匹配的方法理解感觉不太难,最大的难点可能就是next数组的计算,不容易理解。匹配的原理就是,提前算出子串的一个next数组,这个next数组记录了子串中到当前位置的字符串的最长前后缀(前后部分的相同部分的最长长度),利用这个next表,当子串和主串比对的时候,如果不同,就会找next表,利用这个最长长度,计算移动到对应的位置,使得最长前面的部分不用在去匹配,用来提高匹配效率。本文是参考的《王道考研数据结构》上写的,只是记录的一些自己的理解哈。

前缀,后缀,部分匹配值

前缀:除最后一个字符以外,字符串的所有头部子串

后缀:除第一个字符以外,字符串的所有尾部子串

部分匹配值:字符串的前后缀的最长相等前后缀长度

以 “ababa”为例

字符串 前缀 后缀 部分匹配值
a 0
ab {a} {b} 0
aba {a,ab} {a,ba} 1
abab {a,ab,aba} {b,ab,bab} 2
ababa {a,ab,aba,abab} {a,ba,aba,baba} 3

部分匹配值 = (前缀集合 ∩ 后缀集合 ) 的长度

恭喜!这里next数组的雏形就出来了,说实话这就是next数组。真正最后用的也是在这个基础上优化出来的Next数组。接下来就是使用这个数组,和如何优化。

S a b a b a
next 0 0 1 2 3

部分匹配值的使用

主串:a b a b c a b c a c b a b

子串:a b c a c

首先拿到next数组

序号 1 2 3 4 5
S a b c a c
next 0 0 0 1 0

第一次匹配:

ababcabcacbab

abc

a 和 c不匹配。 前面 ‘ab’ 匹配。最后一个匹配字符b对应的匹配值为0

按照 : 移动位数 = 已匹配的字符数 - 对应的部分匹配值

得出: 移动位数 = 2 - 0 = 2

第二次匹配:

ababcabcacbab

​ abcac

b 和 c不匹配。前面’abca’匹配,最后一个匹配字符a对应的匹配值为1

移动位数 = 4 - 1 = 4

第三次匹配:

ababcabcacbab

​ abcac

子串比对完成。主串没有回退,所以KMP算法时间复杂度为O(n + m)

算法改进

当前的算法

移动位数 = 已匹配的字符数 - 对应的部分匹配值

转为伪代码。

1
2
3
Move = (j - 1) - next[j - 1]
//这里 j的值是对应上面的next表里的序号,如果代码里面数组是从0开始算的话。就应该是
Move = j - next[j - 1]

优化

使用部分匹配的时候,每次失败都需要找到前一个元素匹配的部分匹配值,使用起来不太方便。将数组右移一位,这样一旦失败直接取到对应的值

序号 1 2 3 4 5
S a b c a c
next -1 0 0 0 1
  • 第一个元素为右移之后空缺,用-1填充。目的是第一位不匹配,向右移动一位。就是:移动位数 = 已匹配的字符数(0) + 1 = 1
  • 最后一个元素溢出,因为根本用不到。

这样上面的式子就改为

1
2
3
4
//按书上的那个序号算
Move = (j - 1) - next[j]
//数组是从0开始算
Move = j - next[j]

那么计算移动应该到达的位置(子串中指针j应该移动到的位置)

1
2
3
j = j - Move 
= j - ((j - 1) - next[j]) = next[j] + 1 //书上的
= j - (j - next[j]) = next[j] //从0计算

因为每次j = next[j] + 1每次都要+1,很麻烦,把+1放到数组的值里面。这样一旦不匹配就移动到第next[j]的位置即可。

对于数组是从0开始计算的话,不用这个+1操作了,本身就已经是对应的坐标位置了。

OK,以上就是next怎么来的原因。

接下来就是计算next的代码了。

next数组计算代码

书上的错误

1
2
3
4
5
6
7
8
9
10
11
12
//这段是书上写的程序代码,测试用例:"abcac"
void get_next(String T,int next[]){
int i = 1, j = 0;
next[1] = 0;
while(i < T.length){
if(j == 0 || T.ch[i] == T.ch[j]){
++i;++j;next[i] = j;
}
else
j = next[j];
}
}

这块部分我照着改成C++,Kotlin的方式写了,得出来的结果都不对。自己推逻辑感觉没问题,但是计算机算出来就是不对。最终的问题还是出在指针的位置问题。因为书上讲的编号都是以1开头来算的,但是字符串可不是从1开始算的啊。导致字符串和next数组根本是错开的。问题就出在 T.ch[i] == T.ch[j]上面。得到的next数组就是 0,1,1,1,1。当然如果传进来的字符串也是从1开始算的就没问题了。

书上代码纠正

1
2
3
4
5
6
7
8
9
10
11
12
//书上代码更正
void get_next(String T,int next[]){
int i = 1, j = 0;
next[1] = 0;
while(i < T.length){
if(j == 0 || T.ch[i - 1] == T.ch[j - 1]){
++i;++j;next[i] = j;
}
else
j = next[j];
}
}

按照从0开始计算的代码

从0开始的话就不会出现上面的问题,从1开始逻辑没理清就很容易出现错位问题。

1
2
3
4
5
6
7
8
9
10
11
void get_next(String T,int next[]){
int i = 0, j = -1;
next[1] = -1;
while(i < T.length - 1){
if(j == -1 || T.ch[i] == T.ch[j]){
++i;++j;next[i] = j;
}
else
j = next[j];
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//kotlin版
fun String.getNextArr():Array<Int>{
val list = Array(this.length){i -> -1}
var i = 0
var j = -1
while (i < this.length - 1){
if(j == -1 || this[i] == this[j]){
++i
++j
list[i] = j
}
else
j = list[j]
}
return list
}

代码讲解

额,这块还是按照书上从1开始的方式讲解吧。首先是初始化,i = 1,j = 0。i是持续向后走的,j指针是来回跳的。j主要作用就是和i进行比较,如果i,j的字符相等,i,j同时向后移动,并将j的当前位置赋给i对应的next元素,为什么这么做?分开说

  • i,j同时向后移动做的事就是为了下一次循环再次比对,是否还是相等
  • 将j的当前位置赋给i对应的next元素,为的是记录当出现不匹配的时候,跳转的位置。同时也是完成了前面说的右移操作。也就是说这个位置是记录在匹配好的时候i位置的后一位,也就是做了++i之后的操作。
  • 还有一个操作就是判断了 j == 0,刚好是第一个的前一个,j = 0是为了方便后面+1直接刚好是第一个,这样只要不匹配,得到的坐标就是第一个的位置,直接子串拉回第一个,从头判断
  • else部分就是当不匹配也不是最初状态的时候,j回退到上一次匹配到的位置,下次循环紧接着之前的位置继续匹配。

KMP全部代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//kotlin版
fun main(){
var substr = "abcac"
val next = substr.getNextArr()
val str = "ababcabcacbab"
var i = 0
var j = 0
var pos = -1
while (i + substr.length - j != str.length){
if(j == -1 || str[i] == substr[j]){
i++
j++
}else{
j = next[j]
}

if(j == substr.length){
pos = i - j
break
}
}
println(pos)
}

这块没有什么难度了。大家自行分析吧。

速成KMP,推荐一个视频。

觉得文章有些长,可以看这个b站大佬的KMP视频,讲的非常好。

KMP字符串匹配算法1

KMP字符串匹配算法2