首先,祝屏幕前的你新年快乐~~~🎆
之前写过一篇数据结构树状数组的文章,其目的一来是加深自己对树状数组的印象,二来是防止自己太久不用可能会忘记,以备不时之需。今天,和树状数组一样,总结一下数据结构中的另一个算法,KMP算法。
说实话,距第一次了解到KMP算法已经好几年了,这些年自己平时在刷题过程中遇到需要使用KMP算法的地方其实并不是很多。每次看完KMP,当时自以为是理解了,但往往长时间不用之后就会忘记,所以这里也对KMP算法做一个总结,以便复习使用,常看常新。
本文全文参考《算法笔记》,并结合自己的理解进行介绍,示例代码均使用C++。
s[0, k]
表示字符串s从索引0开始、以索引k结束的子串;s
的前缀子串表示以s[0]
为第一个字符的子串,s
的后缀子串表示以s
的最后一个字符为末尾字符的子串。前后缀子串的长度要求均小于s
。
问题
KMP算法于1976年由Knuth、Morris 和 Pratt三位提出,目的是解决字符串匹配的问题。字符串匹配问题并不少见,简单说就是给定两个字符串,判断一个字符串是否包含或被包含于另一个字符串。也就是说,给定两个字符串text
和pattern
,判断pattern
是否为text
的字串。这里我们只考虑pattern
比text
长度更小或相等的情况。(pattern
长度更大的情况不可能为子串,实际代码中加个判断即可)
显然,最简单的方法就是双重循环,按照每个字符进行匹配,很容易可以写出下列代码:
bool isSubStr(string text, string pattern) {
for (int i = 0; i <= text.length() - pattern.length(); i++) {
for (int j = 0; j < pattern.length(); j++) {
if (text[i + j] != pattern[j]) {
break;
}
if (j == pattern.length() - 1) {
return true;
}
}
}
return false;
}
假设text
的长度为m
,pattern
的长度为n
,那么以上算法的时间复杂度即为O(mn)
。当m
、n
的数量级很大时,例如10^5
,显然无法接受。
该算法复杂度过大的原因在于每次匹配失败后,都会从头开始匹配pattern
。但实际上,有时当某次匹配失败时,我们不需要从头匹配pattern
。我们以text = "abababaabc"
,pattern = "ababaab"
为例看一下中间的某一个过程。
如图1所示,按照上面代码的逻辑,i
表示text
中将要匹配的字符索引,此时i = 5
;j
表示pattern
中已匹配的最后一个字符索引,此时j = 4
。可以看到,text
和pattern
中的第0至4位已成功匹配,接下来要匹配的是text
的第五位和pattern
的第五位。显然text[5] != pattern[5]
,即text[i] != pattern[j + 1]
。因此,按照上面的算法,接下来我们会回退j
至0,并让pattern
向右移动一个位置,以重新从头匹配text
。
但实际上,观察上图我们可以发现,每次匹配失败时,并不需要重新从头匹配,即j
并不需要回退至0: 我们可以将pattern
向右移动k个位置,i
不变,j
变为j-k
,使得text[i] == pattern[j - k + 1]
,且pattern[j - k + 1]
前的子串与text[i]
前的对应长度子串相等。这样,保证j
以及j
之前的pattern
子串与text
中的相应子串已匹配,当k越小时,需要重新匹配的字符越少。显然,这样的k最小等于2,如下图2所示。
图中,我们向右移动两个位置后,j =2
,相应的pattern
子串pattern[0, 2] = "aba"
与text
中对应的子串text[2, 4] = "aba"
相等。因此,这部分子串不要重新再匹配,减小了时间代价。
如果我们每次匹配失败时都能找到这样的一个k
,使得j
回退的值最小,且pattern[0, j]
不需要重新再匹配,就能大大减小算法的复杂度。那么,我们如何找到这样的k
呢?
从上文可以看到,在匹配失败时,text
和pattern
中已匹配的子串为s = ababa
,回退需满足的条件为
text[i] == pattern[j - k + 1]
,且pattern[j - k + 1]
前的子串与text[i]
前的对应长度子串相等,即pattern[0, j - k] == text[i - (j - k + 1), i - 1]
先不用纠结条件2中的各种i、j、k,只需要知道两个子串相等即可,就像图2中的绿色方框部分一样。
如图3所示,条件1很好理解。对于条件2,我们要找到的最小k,需要保证pattern[j - k + 1]
前的子串与text[i]
前相同长度的子串相等,即图中红色部分子串与蓝色部分子串同长度的后缀子串相等(红色匹配蓝色后缀,注意图中红色部分的长度不确定,图中的“三个”只是举例)。而蓝色子串是回退前已匹配的子串s = ababa
,红色子串为s
的前缀子串。要使得k最小,等价于使得以上两个相等字符串的长度最大。因此,以上条件2即为寻找一个最大的j' = j - k
,使得字符串s的前缀s[0,j']
与s相同长度的后缀子串s[j - j', j]
相等。
综上,如果我们对于每一个位置,都能求得这样一个对应的j'
,那么我们就可以以最小的代价回退j
。这个j'
的求法,就是我们接下来要介绍的next
数组。
Next数组
给定一个字符串s
,长度为n。对于s
的子串s[0,i]
来说,其前缀子串表示为s[0, k]
,后缀子串表示为s[i-k, i]
。则数组next[i]
表示对于子串s[0,i]
而言,使得前缀子串s[0, k]
与后缀子串s[i-k, i]
相等的最大的下标k(即最大长度减一)。注意这里前后缀子串可以重叠,但不能等于s[0,i]
,即前后缀子串不能为本身。
对于长度为1的字符串,由于前后缀子串不能为本身,所以不存在相等的前后缀子串,此时,我们的next[0]
记为-1
。
我们以s = "ababaa"
为例,结合下图理解一下next
数组。图中,每个小图的上部分为后缀子串,下部分为前缀子串。
i = 0
时,子串s[i] = "a"
。显然,字符串长度为1时,前后缀字符串不相等,next[0] = -1
。i = 1
时,子串s[i] = "ab"
。前缀子串有{"a"}
,后缀子串有{"b"}
,无相等子串,next[1] = -1
。i = 2
时,子串s[i] = "aba"
。前缀子串有{"a", "ab"}
,后缀子串有{"a", "ba"}
,最长相等子串:"a"
,故next[2] = 0
。i = 3
时,子串s[i] = "abab"
。前缀子串有{"a", "ab", "aba"}
,后缀子串有{"b", "ab", "bab"}
,最长相等子串:"ab"
,故next[3] = 1
。i = 4
时,子串s[i] = "ababa"
。前缀子串有{"a", "ab", "aba", "abab"}
,后缀子串有{"a", "ba", "aba", "baba"}
,最长相等子串:"aba"
,故next[4] = 2
。i = 5
时,子串s[i] = "ababaa"
。前缀子串有{"a", "ab", "aba", "abab", "ababa"}
,后缀子串有{"a", "aa", "baa", "abaa", "babaa"}
,最长相等子串:"a"
,故next[5] = 0
。
这里再次强调一下,next
数组存的是i
结尾子串最长相等前后缀的前缀子串最后一位的下标。结合上面的例举分析与图4,可以多理解几遍,加深印象。
好了,了解了next
数组的定义后,下面我们讲一下如何用递推求解next
数组。我们令i
表示当前待匹配的后缀子串的字符索引,j
表示next[i-1]
,即求next[i-1]
中已求得的最长前缀子串的最后一个字符索引。于是,我们在求解next[i]
时,主要包含两个过程:
s[i] == s[j + 1]
,且s[j + 1]
前的子串与s[i]
前的对应长度子串相等
读起来可能有点拗口,但其实跟第一节提到的两个条件本质上是一样的。光看上面的过程没理解没关系,我们还是直接用例子来讲解:依旧假设s = "ababaa"
,我们以求next[4]
、next[5]
为例看一下递推过程。
先看next[4]
,如图5所示,我们已求得next[3] = 1
,对应的最长前缀字符串为ab
。此时,待匹配的i
为4,j = next[3] = 1
。显然有s[i] == s[j + 1]
且s[j + 1]
前的子串ab
与s[i]
前的对应长度子串相等,故,我们可以直接令next[4] = j + 1
,即next[4] = 2
。
我们再来看next[5]
,如图6所示。我们已求得next[4] = 2
,对应的最长前缀字符串为aba
。此时,待匹配的i
为5,j = next[4] = 2
。显然有s[i] != s[j + 1]
,因此,我们需要回退j
(将下方的前缀子串向右移动),使得图6中最右侧小图满足s[i] == s[j + 1]
且红色部分与aba
的后缀子串相等。(注意这里的红色部分长度不固定,图中的一个只是举例,并不表示长度为1。)
考虑到红色部分为aba
的前缀子串,且j
的回退要最小。这就意味着,对于图6中的最右侧的图,要满足aba
的前缀子串(红色)与aba
的后缀子串(绿色上部)相等的长度最大——这正好是next
数组的定义。因此,下一个要回退到的位置即为next[j]
。
这里从另一个角度解释一下:j要回退到子串s[0, 2] = "aba"
的最长前缀子串最后一个字符的位置,即next[2]
。而这个下标2即为上一步的next[i-1]
,也就是我们的j = next[4]
。因此,下一个待回退的j即为j = next[j]
。
所以,我们发现图6中间小图的s[i] != s[j + 1]
时,就让j
回退,直到满足s[i] == s[j + 1]
或者j == -1
(此时表示无法再回退)为止。最终,next[5]
的求解过程如下:
综上,我们求next
数组的步骤如下:
- 初始化
next[0] = -1
以及j = next[0]
- 从索引1开始遍历字符串s,每次遍历时:
- 不断令
j = next[j]
,直到s[i] == s[j + 1]
或j = -1
- 若
s[i] == s[j + 1]
,next[i] = j + 1
;否则,next[i] = j
- 不断令
代码如下:
void getNext(const string &pattern, vector<int> &next) {
int len = pattern.size();
next.resize(len);
next[0] = -1;
int j = -1;
for (int i = 1; i < len; i++) {
while (j != -1 && pattern[i] != pattern[j + 1]) {
j = next[j];
}
if (pattern[i] == pattern[j + 1]) {
j++;
}
next[i] = j;
}
}
了解了next
数组以后,就可以很清晰地了解KMP算法。
KMP算法
上一节的next
数组是求字符串s自己与自己的匹配,而KMP算法则是在这个的基础上进行text
与pattern
的匹配,主要的思路都是一样的。首先,我们先按照上一节的思路求得pattern
的next
数组,之后用pattern
去匹配text
的字符。遇到不匹配的情况时,就按照求得的next
数组进行回退即可。以text = "ababaa"
,pattern = "abaa"
为例看一下其中一个匹配过程:
如图8所示,我们依旧让i
表示被匹配字符串text
的待匹配字符索引,j
表示匹配字符串pattern
的已匹配子串的最大索引。图8中,i = 2, j = 1
,有s[i] == s[j + 1]
,因此,匹配成功,i
、j
自增1进行下一次匹配。
如图9所示,此时,i = 3, j = 2
的字符。显然,此时有s[i] != s[j + 1]
,所以,我们需要对j
进行回退。由于我们已经求出了text
的next
数组,next[j] = next[2] = 0
。因此,我们令j
回退至j = 0
,此时s[i] == s[j + 1]
(图中中间小图),匹配成功,i
、j
自增1进行下一次匹配(图中右小图)。如此循环,若i
遍历结束时我们的j == pattern.length() - 1
,表示pattern匹配成功;否则,匹配失败。
由此,我们可以得出KMP算法的思路,注意初始化的为pattern
的next
数组(因为j
是在pattern
字符串上回退的):
- 首先初始化
pattern
的next
数组,以及j = -1
,表示pattern
已匹配的末位。 - 从0开始遍历
text
的每个字符text[i]
- 不断令
j = next[j]
,直到text[i] == pattern[j + 1]
或j = -1
- 若
text[i] == pattern[j + 1]
,则j++
- 不断令
- 若最终
j == pattern.length() - 1
,说明pattern
为text
子串,返回true;否则,返回false。
代码如下:
bool KMP(const string &text, const string &pattern) {
int m = text.length(), n = pattern.length();
vector<int> next;
getNext(pattern, next);
int j = -1;
for (int i = 0; i < m; i++) {
while (j != -1 && text[i] != pattern[j + 1]) {
j = next[j];
}
if (text[i] == pattern[j + 1]) {
j++;
}
if (j == n - 1) {
return true;
}
}
return false;
}
可以看到,next
数组的实际含义可以理解为当j + 1
位的字符失配时,j
可以回退至next[j]
的位置,即下一个回退的位置。此外,KMP算法的代码与上一节求next
数组的算法非常相似,区别其实仅在于前者是不同字符串之间的匹配,后者为字符串自己的匹配。两者均用到了next
数组,前者用的是pattern
的数组,后者则用的是自己当前匹配索引前面已求出的数组。
当然,如果要求返回的是text
中pattern
子串的起始索引,那么只需将return true;
改为return i - j;
即可。
匹配次数
除了解决text
中是否含有pattern
子串的问题,KMP还可以解决text
中是否含有多少个pattern
子串的问题。以text = "ababab"
,pattern = "ababa"
为例,如下图所示:
上图的左侧,已经完成了一次pattern
的匹配。此时,i
加一变为5,同时我们需要回退j
以进行新的匹配。显然,有了上文的了解,此时我们的j
依旧回退至next[j]
的位置即可。因为此位置保证了pattern
的最长相等前后缀,并且使得j
回退的位置最小、j
之前的子串匹配。最终,我们可以写出以下的代码:
// 统计text中pattern出现的次数
int KMP(const string &text, const string &pattern) {
int m = text.length(), n = pattern.length();
vector<int> next;
getNext(pattern, next);
int j = -1, ans = 0;
for (int i = 0; i < m; i++) {
while (j != -1 && text[i] != pattern[j + 1]) {
j = next[j];
}
if (text[i] == pattern[j + 1]) {
j++;
}
// 主要就是这里不一样
if (j == n - 1) {
ans++;
j = next[j];
}
}
return ans;
}
时间复杂度分析
从代码中看到,每一个for循环中都有一个while循环,似乎相比于开头提到的双重for
循环解法没有很大的优势,但其实仔细分析一下,可以得出KMP的时间复杂度为O(m + n)
的结论:
首先,for
循环的循环次数为O(m)
级别。而对于j
而言,最多在每次循环中最多只会增加一次,故最多只会增加m
次。对于while
循环,while
循环代表着j
的回退,即j
的减小。因此,while
循环总共最多只会执行m
次,因此多于m次的话j
就小于-1了。所以,整个for
循环的时间复杂度为O(m)
。
同样的道理,对于next
数组的求解其时间复杂度为O(n)
,故KMP的时间复杂度为O(m + n)
。
Next数组优化
KMP算法的核心就在于next
数组,表示当j+1
位匹配失败时j
应回退的位置。但实际上,next
数组还有优化空间:
如上图所示,text = "ababacab"
,pattern = "ababab"
。这次,我们不同于上文中的j
,我们用i
表示pattern中已匹配的子串的末尾。考虑匹配text[5]
。
对于第一次匹配(粉色),此时i = 4
,显然pattern[i + 1] != text[5]
,因此,我们回退i
至next[i] = 2
。
第二次匹配(蓝色),此时i = 2
,显然pattern[i + 1] != text[5]
,我们继续回退i
至next[i] = 0
。
第三次匹配(黄色),此时i = 0
,显然pattern[i + 1] != text[5]
,我们继续回退i
至next[i] = -1
。
最终,i = -1
,无法再回退,后面的逻辑按照上一节的思路继续匹配即可,我们这里分析一下上面这么多次回退的原因。
主要问题在于,上面的每一次回退(粉、蓝、黄),回退后的pattern[i + 1]
都是一样的,都是b
。也就是说,对于这几次回退,都有pattern[i + 1] = pattern[next[i] + 1]
,均为字符b
,使得回退次数过多(KMP代码中的while循环多次执行)。那么,有没有一种方法,能够让next
数组一步到位,使得next[i]
“一口气”就能回退到对应的位置;即,上图中蓝色部分的next[4]
能够直接等于白色部分的-1
?
方法是有的,主要就是在getNext
的初始化函数里做一个判断:我们对于每一个i
,最后对next[i]
赋值时,我们判断一下是否有s[i + 1] == s[next[i] + 1]
。若是,那么直接让next[i] = next[next[i]]
;否则,还是按照原来的逻辑对next[i]
赋值。
举例来说,对于上图,在i = 2
,求next[2]
时,原来的逻辑是让next[2]
为0。而优化后的逻辑中,s[i + 1] = s[3] = 'b'
,s[next[i] + 1] = s[1] = 'b'
,两者相等。因此,我们直接令next[2]
等于next[0]
,即next[2] = -1
。求next[4]
时,同理。
综上,优化后的代码如下,注意j == -1
时,不需要修改赋值逻辑。此外,我们用j
代替了next[i]
,因此s[next[i] + 1]
就转变为了s[j + 1]
:
void getNextVal(const string &s, vector<int> &nextVal) {
int len = s.length();
nextVal.resize(len);
nextVal[0] = -1;
int j = -1;
for (int i = 1; i < len; i++) {
while (s[i] != s[j + 1] && j != -1) {
j = nextVal[j];
}
if (s[i] == s[j + 1]) {
j++;
}
// below is the only different
if (j == -1 || s[i + 1] != s[j + 1]) {
nextVal[i] = j;
} else {
nextVal[i] = nextVal[j];
}
}
}
可以看到,此时的数组不再表示“i
结尾子串最长相等前后缀的前缀子串最后一位的下标”这一含义,变为了“当i + 1
位字符失配时,应该回退到的最佳位置”这一含义。也因此,上面的代码里将数组名称改为了nextVal
。(KMP算法主体的代码不需要任何的改动)
有了这个优化以后,我们无论是在KMP算法的代码里,还是getNextVal
的代码里,均可以将while
改成if
,因为每次只回退一次就可能保证最佳位置。当然,不该对代码运行逻辑也没有影响~
我个人平时还是使用nex
数组居多,只有数据量很大时采用nextVal
。
有限状态机
这里再从另一个角度理解一下KMP算法:有限状态机。所谓有限状态机,了解过体系结构的朋友应该不陌生,简单说就是某个状态,根据不同的输入会转变为不同的状态。如下图,对于字符串s = "ababab"
的匹配,起始状态为0,终止状态为6。
对于待匹配的字符串,如果在状态0,输入字符a
,就会转为状态1;若在状态1,输入字符b
,就会转为状态2,以此类推。若最终能过到达状态6,则说明待匹配字符串中包含有s
。
若在任何状态遇到其他的字符输入,就会按照图中的回退箭头回退到某些状态:例如状态4若收到除a
以外的字符,则转换为状态2。结合前面的内容,其实图中的这些回退箭头,实际上就代表了next
数组。(注意不是nextVal
)
总结 & 后记
总体而言,KMP算法还是非常地巧妙的。虽然刚上手时理解起来需要一定的时间,但理解之后的代码量其实并不大。核心就是理解next
数组的定义、作用,理解了next
数组,基本上可以说就理解了KMP 90%的精髓了。
当然,还是那句话,KMP算法虽然非常精妙,但终究只是一种数据结构,一个工具。有时候如果为了用而用,没有对实际需求/问题起到实质性的帮助,反而有点舍本逐末的意思。我们并不应该拿着锤子找钉子,而是时刻备好这把备受偏爱的锤子,当特定钉子出现的时候能够用得上。
同时,KMP算法在看了一遍后短时间内可能能够理解,但过了一段时间后很可能又会有点淡忘。因此,还是需要多多温习,常看常新。
最后,这篇文章花了七个多小时,一万一千字左右。如果能够帮助到你,那就是有价值的。