字符串匹配算法
最普通的字符串匹配算法就是以文本每个字符为起点一个个按顺序与模式串匹配。实现起来简单但效率很低。1977年,三位科学家Donald Knuth、Vaughan Pratt 和James H. Morris 联合发表了字符串匹配算法:KMP算法。
kmp算法可以提高字符串匹配的效率,通过利用模式串的"部分匹配"信息 来避免在失配时从头开始重新匹配,即分析已匹配的字符串的真前缀 和真后缀的长度,来确定下一次匹配的最佳起始位置。
KMP算法的优点
与最简单的字符串相比,kmp算法有很多优点。KMP算法中,发生失配时,它不会将模式串仅仅移动一位,而是利用已经匹配成功的那部分子串的信息,计算出模式串可以安全地、大幅度地向右"跳跃" 到下一个可能匹配的位置。比如ABABABC
中找ABABC
,从文本中第一个开始,暴力匹配会在第三个字符不匹配时从文本第二个A还是重新匹配,而kmp算法会从把模式串中第二个A与文本第三个A对齐,从第三个A开始判断,避免了无效判断(后续会说明为什么可以跳过第二个A)。
txt
文本: A B A B A B C
模式: A B A B C
^ 第一次,这里不匹配('A' != 'C')
暴力匹配法:
文本: A B A B A B C
模式: A B A B C
^ 第二次,从第二个字符开始匹配('A' != 'B')
kmp:
文本: A B A B A B C
模式: A B A B C
^ 第二次,跳过一个字符进行匹配('A' != 'C')
kmp算法实现步骤
next数组是什么
实现kmp算法,最重要的是求出next
数组,这个数字储存着模式串的"部分匹配"信息。
观察模式串AABBAABB
,我们可以发现,前边的AABB
(真前缀)和后边AABB
(真后缀)是完全相同的部分。若匹配时,最后一个B与文本失配比如AABBAABC
,就可以根据上边的规律就可以跳过无用的匹配,直接把第二个B与C对齐再进行匹配。可以观察到,因为前边所说的规律,下边画横线的部分正好可以与原文本匹配。next数组就存储着上边的规律信息,是kmp的关键,它会告诉我们,当某个模式串字符(C)失配时,应当把模式串哪个字符与文本中的字符(B)对齐继续匹配。
txt
文本: A A B A A B B ...
模式: A A B A A B C ...
^ 第一次,这里不匹配('A' != 'C')
文本: A A B A A B B ...
模式: A A B A A B C ...
- - - ^ 第二次,跳过多个字符进行匹配('A' != 'C')
如何求next数组
第一次接触kmp算法,可能会对next数组的求法搞得很乱,这是kmp算法的核心掌握了next数组求法也就基本理解了kmp算法。
未处理的next数组:raw_next
试想模式串"ABAB",现在给出了它未经过特定处理的next数组 ,假设为raw_next
, raw_next = [0, 0, 1, 2]。(求出raw_next数组后,经过简单的变化即可得到next数组)。
txt
模式: A B A B
raw_next: 0 0 1 2
index 0 1 2 3
raw_next存储着模式串的"部分匹配"信息 ,也就是先前所说的"规律"。给定的"ABAB"
中,真前缀与真后缀相同的长度为2,所以设raw_next对应第二个B的值为2,也就是raw_next[3] = 2,体现着从这个(第二个B)向前2个字符与模式串开头两个字符是相同的。
若现在给模式串增添一个字符A呢?raw_next应该怎么的变化才能继续体现先前的性质?
当我们新增字符A时,我们已经知道,新增字符A前,有两个字符(第二个AB)可以与模式串的前缀俩字符匹配。那我们只需要知道新增字符A是否与模式串第三个字符相同就可以知道模式串前后缀中多少个连续字符是相同的。通过对比,第三个字符是A,与增添的A相同,所以新的A对应的raw_next值为3.
txt
old模式: A B A B
raw_next: 0 0 1 2
new模式: A B A B A
raw_next: 0 0 1 2 3
如果新增加的是C呢?与第三个A比较后发现不同,则其对应值为0,代表从最后一位向前0个字符与模式串前0个字符是相同的。
txt
old模式: A B A B
raw_next: 0 0 1 2
new模式: A B A B C
raw_next: 0 0 1 2 0
raw_next数组求解总结
观察上边的求解过程,新增一个字符时,我们可以用到先前的raw_next信息来推断新增字符对应raw_next的值。总的来说,就是获取先前的连续信息,来推断新的连续信息。从模式串第一个字符设置为0开始,利用递归和上述规律,即可快速求解raw_next数组。
txt
模式: A
raw_next: 0
新增字符B,查询前一个字符A对应的raw_next值val(0),并与val对应位置的模式串字符对比,
若相同则给B的raw_next设为val(0) + 1,若不同则设为0
模式: A B
raw_next: 0 0
新增字符A,查询前一个字符B对应的raw_next值val(0),并与val对应位置的模式串字符对比,
若相同则给B的raw_next设为val(0) + 1 = 1,若不同则设为0
模式: A B A
raw_next: 0 0 1
新增字符B,查询前一个字符B对应的raw_next值val(1),并与val对应位置的模式串字符对比,
若相同则给B的raw_next设为val(1) + 1 = 2,若不同则设为0
模式: A B A B
raw_next: 0 0 1 2
从raw_next到next
经过上述步骤,我们可以便捷的求出raw_next数组。它与真正的next数组有什么差别呢?
以ABABABAB
为例,下面给出了它的raw_next和next数组:
txt
模式串: A B A B A B A B
raw_next = [0, 0, 1, 2, 3, 4, 5, 6];
next = [-1,0, 0, 1, 2, 3, 4, 5];
将raw_next右移一位
模式串: A B A B A B A B
raw_next = [0, 0, 1, 2, 3, 4, 5, 6];
next = [-1,0, 0, 1, 2, 3, 4, 5];
我们可以发现,右移后的的raw_next与next,除了next的第一位和raw_next的最后一位,都完美重合,仅需把raw_next的第一位置为-1,抛去最后一位,即可获得next数组。
next数组用法
求出next数组,我们如何使用呢?
以开头给出的第一个例子为例:当第一次匹配到字符C时与文本发生了失配,此时观察next数组。原本的raw_next数组记录的是 从当前字符(包括当前字符
)开始往前几位与模式串从头开始往后几位是相同的。经过右移,next记录的就是 从当前字符(不包括当前字符
)开始往前几位与模式串从头开始往后几位是相同的。
这么做的好处很明显,当发生失配时,我们可以知道,这个字符前边有next[i]个字符与开头时重合的,由于前边的next[i]个字符已经与模式串的这些字符匹配过,我们就可以放心的跳过这几个已匹配的字符。
txt
文本T: A B A B A B C
模式P: A B A B C
^ 第一次,这里不匹配('A' != 'C')
next: -1 0 0 1 2
获取C对应next的值,C前的AB与文本AB匹配,且模式串前两个字符A和B与C前的AB相同,即可跳过重新对文本中第二个AB的匹配。
kmp:
文本T: A B A B A B C
模式P: A B A B C
^ 第二次,将next[2]对应字符对齐匹配('A' != 'C')
为什么可以跳过文本的第二个字符B呢?因为Next数组已经帮我们排除了这种可能性。当模式串只移动1位时,它要求模式的"AB"前缀匹配文本的"BA"。但从第一次匹配我们知道,文本的T[1]T[2]确实是"BA",而模式的P[0]P[1]是"AB","AB" ≠ "BA"是确定无疑的。Next数组的值Next[4]=2正是这种判断的数学化体现:它告诉我们移动2位是唯一可能成功的选择,而移动1位等中间位置都绝无可能。
txt
文本T: A B A B C
模式P: A B A B C
(这个匹配是不可能的)
next数组的改进
上述步骤,我们已经基本求出了next数组,但它是否真的完美无缺呢?求字符串"AAAAA"的next数组。
按照上边的说法,其next数组应该是next = [-1, 0, 1, 2, 3]。这个是正确的,但当我们实际使用是,就会发现一些问题
txt
文本T: A A A A B A A A A A
模式P: A A A A A
^ 第一次,这里不匹配('A' != 'B')
文本T: A A A A B A A A A A
模式P: A A A A A
^ 第二次,这里不匹配('A' != 'B')
文本T: A A A A B A A A A A
模式P: A A A A A
^ 第三次,这里不匹配('A' != 'B')
......
通过观察上边的例子,我们会发现,明明知道A与B不匹配,却还是会一个个的往前把模式串的字符A与文本的B匹配。
如何避免呢?观察模式串P的raw_next数组构建过程。当第二个A添加进来时,与第一个A相同,所以raw_next[1] = 1。此时对应的next[1]就是0,意思是说当A发生失配时回到P[0]继续匹配,但我们知道P[0]与失配的P[1]是相同的,已经没有进行匹配的必要了.
txt
模式串P : A
raw_next : 0
next: -1
模式串P : A A
raw_next : 0 1
next: -1 0
模式串P : A A A
raw_next : 0 1 2
next: -1 0 1
...
如何实现呢?当我们通过原始方法构造出next数组后,我们可以通过一下过程进行优化。即遍历next的值,若P[next[i]] == P[i],则说明发生了上述情况,需要将next[i]设为nent[next[i]]的值。
例如AAAAA
,原式next为[-1, 0, 1, 2, 3]。优化过程如下
txt
P : A A A A A
next: [-1, 0, 1, 2, 3]
^ 检测位置,P[next[1]] == P[1]
P : A A A A A
next: [-1, -1, 1, 2, 3]
^ 检测位置,P[next[2]] == P[2]
P : A A A A A
next: [-1, -1, -1, -1, 3]
^ 检测位置,P[next[3]] == P[3]
...
P : A A A A A
next: [-1, -1, -1, -1, -1]
使用新的next进行匹配:
txt
文本T: A A A A B A A A A A
模式P: A A A A A
^ 第一次,这里不匹配('A' != 'B')
文本T: A A A A B A A A A A
模式P: A A A A A
^ 第二次,直接将P[-1]与B对齐。可以看到,直接跳过了B,这
也是将next[0]赋为-1 的原因。
结语
一个人能走多远不在于他在顺境时能走的多快,而在于他在逆境时多久能找到曾经的自己。 ------------KMP。
感谢阅读,希望这篇文章能帮到你,如有不足,还请批评指正。