1. 概述
KMP
算法是一种改进的字符串匹配算法,由D.E.Knuth
、J.H.Morris
和V.R.Pratt
提出,简称KMP
算法
KMP
算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。本质是通过一个next
数组,里面包含了模式串的局部匹配信息,找到模式串中各位置最适合的回溯位置
KMP
算法的时间复杂度为O(m + n)
2. 匹配规则
例如:查找出模式串在主串第⼀次出现的位置
2.1 情况一
主串:S = "abcdefgab"
,模式串T = "abcdex"
如果使用BF
算法,主串和模式串的前五次匹配都一致,直到第六次匹配时字符出现差异
- 此时主串中
i
的位置为6
,模式串中j
的位置也为6
按照BF
算法的逻辑,接下来将主串i
位置移动到2
,模式串j
位置移动到1
,向后依次匹配
如果不能完全匹配,主串继续i + 1
,而模式串j
永远回到1
的位置
主串中的4、5、6...
及后续字符,都会循环这个匹配流程,直到完全命中或主串匹配结束
我们仔细观察模式串,发现它的首字符a
和后面的bcdex
各不相等。但在流程一匹配时,主串和模式串中前五个字符全部匹配成功
这意味着模式串中的a
字符和主串中2 ~ 5
位置的字符一定无法匹配
而BF
算法中的第2 ~ 5
次的匹配流程完全是多余的,我们完全可以在流程一匹配失败之后,直接进入流程六的匹配
之所以进入流程六的匹配,因为在流程一中得到了S[6] != T[6]
的结论,但我们并不知道S[6]
和T[1]
是否可以匹配成功,所以流程六必须保留
2.2 情况二
主串:S = "abcababca"
,模式串T = "abcabx"
第一次匹配,主串和模式串前五位字符一致,第六位匹配失败
根据【情况一】的结论,模式串中首字符a
和第2 ~ 3
位的b
、c
不同,但主串和模式串中2 ~ 3
位已经匹配,所以模式串可以跳过主串的第2~3
位匹配,直接匹配主串的第四位
当S[4] == T[1]
,继续后面字符的比较
这种情况同样具备优化空间,从模式串中得知1 ~ 2
为的ab
和4 ~ 5
位相同,而流程一中得知S[4] == T[4]
且S[5] == T[5]
,所有流程四和流程五的比对都是多余的
更优的方式,当流程一匹配失败后,应该直接进入S[6]
和T[3]
的匹配
3. 算法特点
根据上述规则,不难看出KMP
是在设法利用已知信息,避免将对比位置移回到已经对比过的位置上,而是将它向后移动继续对比,从而提高对比的效率
所以KMP
算法的最大特点,就是避免了不必要的回溯发生
根据【情况一】的示例:
主串:S = "abcdefgab"
,模式串T = "abcdex"
当第一次匹配结束后,i
停留在位置6
,按照KMP算法的特定,我们跳过不必要的匹配流程,下一轮的匹配i
依然从位置6
开始,它会和S[1]
进行对比
由此可见,i
的位置是不需要回溯的。在完整的匹配流程中,i
的值也不可能变小
既然i
不能回溯,在完整的匹配流程中,我们只能不停的改变j
的位置。遇到匹配失败的时候,将j
回溯到最优的位置,继续下一次的对比。而j
的回溯位置,只会取决于模式串T
的内容
4. next数组的推导
j
值取决于当前字符之前的串前后缀的相似度,我们把模式串各个位置j
值变化定义为一个next
数组,那么next
的长度就是模式串的长度
计算模式串T
的next
数组,其实就是在求解模式串的回溯位置
针对next[j]
的函数定义:
我们可以通过以下几种情况,进一步对公式进行分析
4.1 模式串中无任何字符重复
示例:
j
为模式串T
中字符对应的位置,从1
开始当
j == 1
时,next[1] = 0
当
j == 2
时,T[1] ~ T[j - 1]
中只有字符a
,属于其他情况,next[2] = 1
当
j == 3
时,T[1] ~ T[j - 1]
中字符为ab
。由于a != b
,属于其他情况,next[3] = 1
当
j == 4
时,T[1] ~ T[j - 1]
中字符为abc
。由于不包含相同的前后缀,属于其他情况,next[4] = 1
当
j == 5
时,T[1] ~ T[j - 1]
中字符为abcd
。由于不包含相同的前后缀,属于其他情况,next[5] = 1
当
j == 6
时,T[1] ~ T[j - 1]
中字符为abcde
。由于不包含相同的前后缀,属于其他情况,next[6] = 1
当模式串中无任何字符重复,在next
数组推导过程中,只能命中公式图中的第一种情况和第三种情况,所以next数组 = [0, 1, 1, 1, 1, 1]
4.2 模式串中包含相同前后缀
示例:
当
j == 1
时,next[1] = 0
当
j == 2
时,T[1] ~ T[j - 1]
中只有字符a
,属于其他情况,next[2] = 1
当
j == 3
时,T[1] ~ T[j - 1]
中字符为ab
。由于a != b
,属于其他情况,next[3] = 1
当
j == 4
时,T[1] ~ T[j - 1]
中字符为abc
。由于不包含相同的前后缀,属于其他情况,next[4] = 1
当
j == 5
时,T[1] ~ T[j - 1]
中字符为abca
。其中前缀a
和后缀a
相等,通过公式p[1] ~ p[k-1] = p[j - k - 1] ~ p[j - 1]
可以推算出k = 2
,因此next[5] = 2
当
j == 6
时,T[1] ~ T[j - 1]
中字符为abcab
。其中前缀ab
和后缀ab
相等,通过公式可以推算出k = 3
,因此next[6] = 3
k
值的规则:当前后缀相等时,如果前后缀由一个字符组成,则k = 2
。如果前后缀由两个字符组成,则k = 3
。如果前后缀由N
个字符组成,则k = N + 1
4.3 模式串类似于ababaaaba
示例:
当
j == 1
时,next[1] = 0
当
j == 2
时,T[1] ~ T[j - 1]
中只有字符a
,属于其他情况,next[2] = 1
当
j == 3
时,T[1] ~ T[j - 1]
中字符为ab
。由于a != b
,属于其他情况,next[3] = 1
当
j == 4
时,T[1] ~ T[j - 1]
中字符为aba
。其中前缀a
和后缀a
相等,通过公式可以推算出k = 2
,因此next[4] = 2
当
j == 5
时,T[1] ~ T[j - 1]
中字符为abab
。其中前缀ab
和后缀ab
相等,通过公式可以推算出k = 3
,因此next[5] = 3
当
j == 6
时,T[1] ~ T[j - 1]
中字符为ababa
。其中前缀aba
和后缀aba
相等,因为中间的a
可以被前缀和后缀共用。通过公式可以推算出k = 4
,因此next[6] = 4
当
j == 7
时,T[1] ~ T[j - 1]
中字符为ababaa
。其中前缀a
和后缀a
相等,通过公式可以推算出k = 2
,因此next[7] = 2
当
j == 8
时,T[1] ~ T[j - 1]
中字符为ababaaa
。其中前缀a
和后缀a
相等,通过公式可以推算出k = 2
,因此next[8] = 2
当
j == 9
时,T[1] ~ T[j - 1]
中字符为ababaaab
。其中前缀ab
和后缀ab
相等,通过公式可以推算出k = 3
,因此next[9] = 3
中间的字符,允许被前缀和后缀共用。所以j == 6
时,字符串为ababa
,相同的前后缀为aba
,而不是a
前缀必须从首字符开始,后缀必须从尾字符开始。所以j == 7
时,字符串为ababaa
,相同的前后缀为a
,而不是ab
4.4 模式串类似于aaaaaaaab
示例:
当
j == 1
时,next[1] = 0
当
j == 2
时,T[1] ~ T[j - 1]
中只有字符a
,属于其他情况,next[2] = 1
当
j == 3
时,T[1] ~ T[j - 1]
中字符为aa
。其中前缀a
和后缀a
相等,通过公式可以推算出k = 2
,因此next[3] = 2
当
j == 4
时,T[1] ~ T[j - 1]
中字符为aaa
。其中前缀aa
和后缀aa
相等,通过公式可以推算出k = 3
,因此next[4] = 3
当
j == 5
时,T[1] ~ T[j - 1]
中字符为aaaa
。其中前缀aaa
和后缀aaa
相等,通过公式可以推算出k = 4
,因此next[5] = 4
当
j == 6
时,T[1] ~ T[j - 1]
中字符为aaaaa
。其中前缀aaaa
和后缀aaaa
相等,通过公式可以推算出k = 5
,因此next[6] = 5
当
j == 7
时,T[1] ~ T[j - 1]
中字符为aaaaaa
。其中前缀aaaaa
和后缀aaaaa
相等,通过公式可以推算出k = 6
,因此next[7] = 6
当
j == 8
时,T[1] ~ T[j - 1]
中字符为aaaaaaa
。其中前缀aaaaaa
和后缀aaaaaa
相等,通过公式可以推算出k = 7
,因此next[8] = 7
当
j == 9
时,T[1] ~ T[j - 1]
中字符为aaaaaaaa
。其中前缀aaaaaaa
和后缀aaaaaaa
相等,通过公式可以推算出k = 8
,因此next[9] = 8
当j
的值为3 ~ 9
时,中间可被前后缀共用的字符a
逐渐变多,所以k
值也主键变大
5. 利用代码求解next数组
代码实现:
let t = "abcdex";
let next = getNext(t: t);
func getNext(t : String) -> [Int] {
var i = -1;
var j = 0;
var next = [Int].init(repeating: -1, count: t.count);
while(j + 1 < t.count){
if(i == -1 || t[i] == t[j]){
i += 1;
j += 1;
next[j] = i;
continue;
}
i = next[i];
print("i:\(i)");
}
return next;
}
设置默认值:
i = -1
,j = 0
。根据模式串t
的长度创建next
数组,默认填充-1
-1
表示模式串中没有可回溯的位置。这种情况模式串重新回到首字符,和主串的下一个字符进行新一轮的匹配
遍历字符串,从索引
1 ~ count - 1
当
i == -1
时i += 1
:将i
设置为0
j += 1
:得到下一个索引值next[j] = i
:将i
值的0
赋值到next[1]
的位置中
当
t[i] == t[j]
时i += 1
将可回溯的值+1
j += 1
:得到模式串当前比对字符的下一个索引位置next[j] = i
:将可回溯的值,赋值next[j]
的位置中
当
t[i] != t[j]
时i = next[i]
:将next[i]
中可回溯的值,重新赋值给i
。在下一轮循环中,将回溯位置的字符和当前位置的字符再次进行对比当
i
值回溯到0
时,next[0]
中的-1
会赋值给i
。在下一轮循环中,命中i == -1
的逻辑,next[j]
会被赋值为0
。这意味着当前字符匹配失败后,只能回溯到模式串的首字符
求解代码测试:
模式串
abcdex
求解,next = [-1, 0, 0, 0, 0, 0]
模式串
abcabx
求解,next = [-1, 0, 0, 0, 1, 2]
模式串
ababaaaba
求解,next = [-1, 0, 0, 1, 2, 3, 1, 1, 2]
模式串
aaaaaaaab
求解,next = [-1, 0, 1, 2, 3, 4, 5, 6, 7]
代码求解的流程概述:
默认
next[0] = -1
当
i == -1
时,表示当前对比从头开始,i += 1
,j += 1
,next[j] = i
当
t[i] == t[j]
时,表示对比成功,则i += 1
,j += 1
,同时next[j] = i
,将可回溯的值记录到j
位置中当
t[i] != t[j]
时,表示对比失败,将i
的值回溯到最优位置,则i = next[i]
6. KMP算法的代码实现
代码实现:
let s = "abcacabdc";
let t = "abd";
let next = getNext(t: t);
var i = 0;
var j = 0;
while(i < s.count && j < t.count){
if(s[i] == t[j]){
i += 1;
j += 1;
continue;
}
if(i > s.count - t.count){
break;
}
j = next[j];
if(j == -1){
i += 1;
j = 0;
}
}
var result = 0;
if(j == t.count){
result = i - j + 1;
}
print("\(result)");
获取
next
数组,定义i = 0
、j = 0
,分别标记主串和模式串的字符索引位置遍历匹配主串和模式串,当
i
值查找到主串结尾,且j
值查找到模式串结尾,循环停止当
s[i] == t[j]
时,当前字符匹配成功,主串和模式串的索引位置分别+1
,等待下一轮对比当
s[i] != t[j]
时,同时i
值超过主串与模式串长度的差值,证明主串已经查找到结尾处。这种情况下出现无法匹配的字符,则证明主串与模式串一定不匹配。此时没必要让主串向后查找,也没必要让模式串回溯,直接退出循环即可当
s[i] != t[j]
时,则j = next[j]
,让模式串回溯到最优的位置。如果j == -1
,证明已经没有可回溯的位置了,此时i += 1
让主串向后查找,j = 0
将模式串回到首字符位置遍历结束后,如果
j
值等于模式串的长度,证明模式串中的字符全部匹配成功,则i - j + 1
得到模式串在主串中的起始位置
7. KMP算法的优化
目前KMP
算法还存在一些缺陷,具体的表现在某种情况下,会出现不必要的回溯,增加多余的匹配
7.1 算法存在的缺陷
例如:主串:S = "aaaabcdefgxyzz"
,模式串T = "aaaaax"
当主串与模式串进入第五次对比时,发现不一致的字符
按照KMP
算法的逻辑,j
会回溯到4
的位置,继续和S[5]
进行对比
对比失败,j
值回溯到3
、2
、1
位置依次和S[5]
进行对比,直到j
值无法回溯
当j
值无法回溯,按照规则,i++
,j++
,进行S[6] == T[1]
的对比
回顾此流程,当S[5] != T[5]
,同时T[5] == T[4] == T[3] == T[2] == T[1]
,所以T[5]
对比失败后的4次
回溯都没有任何意义
当模式串中第2
、3
、4
、5
位的字符和首字符a
相同,更合理的做法是使用首字符next[1]
的值,替代next[2 ~ 5]
的回溯值
按照此逻辑,示例中的模式串aaaaax
求解,next = [-1, 0, 1, 2, 3, 4]
,优化后的结果应该是next = [-1, -1, -1, -1, -1, 4]
,从而优化掉几次毫无意义的对比
7.2 算法的优化分析
使用原有的推导逻辑,可以得到next[j]
的回溯值,我们可以对其进一步优化
例如:模式串ababc
原有的推导逻辑:当
j == 3
时,T[1] ~ T[j - 1]
中字符为ab
。由于a != b
,属于其他情况,next[3] = 1
进一步优化逻辑:此时我们得到第三位
a
的回溯值为1
。我们先通过第三位的回溯值,拿到回溯位置上的字符,也就是T[1]
首字符的a
,然后通过T[1]
和当前T[3]
进行对比如果
T[1] == T[3]
,则next[3]
使用next[1]
的回溯值,即:next[3] = next[1] = 0
如果
T[1] != T[3]
,则next[3]
的回溯值不改变,即:next[3] = 1
示例:
next
数组记录推导出的回溯值,同时新增一个nextval
数组,记录优化后的回溯值当
j == 1
时,next[1] = 0
,nextval[1] = 0
当
j == 2
时,T[1] ~ T[j - 1]
中只有字符a
,所以next[2] = 1
。优化:由于T[1] != T[2]
,则nextval[2] = next[2] = 1
当
j == 3
时,T[1] ~ T[j - 1]
中字符为ab
。由于a != b
,所以next[3] = 1
。优化:由于T[1] == T[3]
,则nextval[3] = next[1] = 0
当
j == 4
时,T[1] ~ T[j - 1]
中字符为aba
。由于a == a
,所以next[4] = 2
。优化:由于T[2] == T[4]
,则nextval[4] = next[2] = 1
当
j == 5
时,T[1] ~ T[j - 1]
中字符为abab
。由于ab == ab
,所以next[5] = 3
。优化:由于T[3] == T[5]
,则nextval[5] = next[3] = 0
当
j == 6
时,T[1] ~ T[j - 1]
中字符为ababa
。由于aba == aba
,所以next[6] = 4
。优化:由于T[4] != T[6]
,则nextval[6] = next[6] = 4
当
j == 7
时,T[1] ~ T[j - 1]
中字符为ababaa
。由于a == a
,所以next[7] = 2
。优化:由于T[2] != T[7]
,则nextval[7] = next[7] = 2
当
j == 8
时,T[1] ~ T[j - 1]
中字符为ababaaa
。由于a == a
,所以next[8] = 2
。优化:由于T[2] == T[8]
,则nextval[8] = next[2] = 1
当
j == 9
时,T[1] ~ T[j - 1]
中字符为ababaaab
。由于ab == ab
,所以next[9] = 3
。优化:由于T[3] == T[9]
,则nextval[9] = next[3] = 0
根据上述逻辑,求解next = [0, 1, 1, 2, 3, 4, 2, 2, 3]
,优化后的naxtval = [0, 1, 0, 1, 0, 4, 2, 1, 0]
后续在KMP
算法中,对模式串进行回溯时,使用优化后的naxtval
数组中的回溯值即可
7.3 求解next
数组的优化
代码实现:
let t = "ababaaaba";
let next = getNext(t: t);
func getNext(t : String) -> [Int] {
var i = -1;
var j = 0;
var next = [Int].init(repeating: -1, count: t.count);
while(j + 1 < t.count){
if(i == -1 || t[i] == t[j]){
i += 1;
j += 1;
if(t[i] == t[j]){
next[j] = next[i];
continue;
}
next[j] = i;
continue;
}
i = next[i];
}
return next;
}
当
i == -1
或t[i] == t[j]
时,则i += 1
,j += 1
此后增加优化逻辑,如果
t[i] == t[j]
,则next[j] = next[i]
如果
t[i] != t[j]
,执行原有逻辑,next[j] = i
优化后的代码测试:
模式串
abcdex
求解,next = [-1, 0, 0, 0, 0, 0]
模式串
abcabx
求解,next = [-1, 0, 0, -1, 0, 2]
模式串
ababaaaba
求解,next = [-1, 0, -1, 0, -1, 3, 1, 0, -1]
模式串
aaaaaaaab
求解,next = [-1, -1, -1, -1, -1, -1, -1, -1, 7]
优化后的流程概述:
默认
next[0] = -1
当
i == -1
时,表示当前对比从头开始,i += 1
,j += 1
当
t[i] == t[j]
时,表示对比成功,则i += 1
,j += 1
当
i
和j
分别+1
后,如果t[i] == t[j]
,则next[j] = next[i]
。否则t[i] != t[j]
,则next[j] = i
当
t[i] != t[j]
时,表示对比失败,将i
的值回溯到最优位置,则i = next[i]
总结
概述:
KMP
算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。本质是通过一个next
数组,里面包含了模式串的局部匹配信息,找到模式串中各位置最适合的回溯位置KMP
算法的时间复杂度为O(m + n)
算法特点:
KMP
是在设法利用已知信息,避免将对比位置移回到已经对比过的位置上,而是将它向后移动继续对比,从而提高对比的效率所以
KMP
算法的最大特点,就是避免了不必要的回溯发生算法中
i
的位置是不需要回溯的,而j
的回溯位置,只会取决于模式串T
的内容
next
数组的推导:
next[j]
的函数定义,分为以下三种情况:当
j == 1
时,next[1] = 0
当前后缀相同时,通过公式
p[1] ~ p[k-1] = p[j - k - 1] ~ p[j - 1]
可以推算出k = 2
,因此next[j] = k
其他情况,
next[j] = 1
k
值的规则:当前后缀相等时,如果前后缀由一个字符组成,则k = 2
。如果前后缀由两个字符组成,则k = 3
。如果前后缀由N
个字符组成,则k = N + 1
中间的字符,允许被前缀和后缀共用
前缀必须从首字符开始,后缀必须从尾字符开始
优化后求解next
数组的流程概述:
默认
next[0] = -1
当
i == -1
时,表示当前对比从头开始,i += 1
,j += 1
当
t[i] == t[j]
时,表示对比成功,则i += 1
,j += 1
当
i
和j
分别+1
后,如果t[i] == t[j]
,则next[j] = next[i]
。否则t[i] != t[j]
,则next[j] = i
当
t[i] != t[j]
时,表示对比失败,将i
的值回溯到最优位置,则i = next[i]
KMP
算法的代码实现:
获取
next
数组,定义i = 0
、j = 0
,分别标记主串和模式串的字符索引位置遍历匹配主串和模式串,当
i
值查找到主串结尾,且j
值查找到模式串结尾,循环停止当
s[i] == t[j]
时,当前字符匹配成功,主串和模式串的索引位置分别+1
,等待下一轮对比当
s[i] != t[j]
时,同时i
值超过主串与模式串长度的差值,证明主串已经查找到结尾处。这种情况下出现无法匹配的字符,则证明主串与模式串一定不匹配。此时没必要让主串向后查找,也没必要让模式串回溯,直接退出循环即可当
s[i] != t[j]
时,则j = next[j]
,让模式串回溯到最优的位置。如果j == -1
,证明已经没有可回溯的位置了,此时i += 1
让主串向后查找,j = 0
将模式串回到首字符位置遍历结束后,如果
j
值等于模式串的长度,证明模式串中的字符全部匹配成功,则i - j + 1
得到模式串在主串中的起始位置