目录
一、简单题复习 K M P KMP KMP 算法
问题引入:LeetCode 28. 实现 strStr()
题目链接:28. 实现 strStr()
文章讲解:代码随想录
视频讲解:帮你把KMP算法学个通透!(理论篇)
帮你把KMP算法学个通透!(求next数组代码篇)
思路
本题是经典的字符串匹配问题,因此可以使用 K M P KMP KMP 算法进行求解。由于笔者在学校学习中, K M P KMP KMP 算法的理论与实际操作考察约等于无,在此单独开一篇来复习该算法的理论思想与代码实现.
二、 K M P KMP KMP ( K n u t h − M o r r i s − P r a t t ) (Knuth-Morris-Pratt) (Knuth−Morris−Pratt)算法
1. K M P KMP KMP 算法基本内容
K M P KMP KMP 算法是一种典型的用于字符串匹配的高效算法,实现功能为在原串(文本串)中找到一个模式串是否出现,若出现则返回第一次出现的下标;
K M P KMP KMP 对比之前的暴力算法与 B F BF BF 算法,最大的优势在于对文本串只进行一次遍历,从而大大降低了算法的时间复杂度;
K M P KMP KMP 算法的基本思想为:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。
如何实现记录一部分匹配内容并递推呢?需要借助
K
M
P
KMP
KMP 算法的核心技术:前缀表.
2. 前缀表
I I I. 基本概念
前缀:不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀:不包含第一个字符的所有以最后一个字符结尾的连续子串。
前缀表:记录一个字符串从开头到当前位置的子串中,最大相等前后缀长度的一个数组。
在
K
M
P
KMP
KMP 算法中,前缀表使用一个next[]
数组来表示;
I I II II. 具体生成 / 求法
注意:生成的一定是模式串(需要查找的字符串)的前缀表!!!
如何求解或生成一个next[]
数组,以下面一个字符串为例:
以该字符串
S
S
S 求解倒数第二个下标的next
值为例;next
数组记录了当前下标以前的子字符串的最大相等前后缀值,因此我们只看包含
i
i
i 位置以前部分的字符串;可以看到,我们可以找到一对相等的前后缀 “
a
b
a
aba
aba” ,并且就是最大的相等前后缀,因为更长的前后缀分别为 “
a
b
a
c
abac
abac” 与 “
c
a
b
a
caba
caba” ,前后缀并不相等;那么在next[i]
的位置记录下最大相等前后缀长度
3
3
3,完成下标
i
i
i 位置的前缀表求解,其他位置的next
值同理。
以上部分为手写计算时的求解方法,具体的生成函数我们在后续代码部分进行详解。
I I I III III. Why 前缀表?
使用前缀表的作用主要就体现在当前字符匹配不合适时,可以快速检索到已经匹配过的字符串,从而省去了回退的操作;
那么为什么前缀表可以完成记录并重新匹配的操作?怎样完成这样的操作?
我们以上图的一个模式串pattern
和文本串text
的匹配为例,可以看到当前两个串的前七个字符是可以完全匹配的,而当前所指的i
和j
两个位置的字符不匹配,若是暴力算法,则会回退到text
的第二个字符位置重新开始匹配;而
K
M
P
KMP
KMP 算法利用最大相等前后缀的方法如下:
注意到pattern
和text
的前七个字符已经相等了,而j
前面一个位置的最大相等前后缀为
3
3
3 ,即有
a
b
a
aba
aba 这个相等的前后缀,那么pattern
的前三个字符和text
中i
之前的三个字符都是
a
b
a
aba
aba ,那么pattern
中前四位就有可能和text
中包含i
的前四位字符相等。这种情况下我们就可以将pattern
字符串的
a
b
a
aba
aba 前缀挪到text
字符串的
a
b
a
aba
aba 后缀的位置,比较他们后面的各个字符是否相等:
可以发现我们“挪”字符串的过程相当于把模式串pattern
后移了,体现在数组指针上就是i
不动,而j
前移指向某个位置;
j
前移的位数则是由我们的next
数组所决定,如图所示,第一次是在模式串字符
g
g
g 的位置出现不匹配,那么j
回退一位找到next[j-1]
的值,指向的就是应该前移指向的下标
3
3
3.
简单来说,next
数组储存了最大的相等前后缀长度,在当前字符不匹配时,因为前面匹配的若干个元素已经相等,有相同的前后缀,使用next
数组可以快速跳转到下一个可能匹配的下标,从而继续在文本串中的遍历匹配,而优化掉了回退的操作。
三、代码实现
1. n e x t next next 数组构建
定义一个专用于求解next
的函数getNext
void getNext(string s, vector<int> &next)
构建步骤主要有如下三步:
- 初始化
- 处理前后缀不相同的情况
- 处理前后缀相同的情况
(1)初始化
由于我们定义的getNext
是无返回值的函数,因此需要在匹配函数的部分提前定义一个next
,使用引用传入getNext
函数中,长度应与模式串s
长度相同;
对next
内容的初始化为:
int j = 0;
next[0] = j;
对于next
数组的构建方法有几种方法,笔者比较偏好从
0
0
0 开始的构建方式,并且在上面讨论的前缀表是从
0
0
0 开始的,能保持本篇文章的一致性;
(2)循环迭代构建
next
数组的生成算法会依赖到循环迭代的结构,因此为便于理解和编写程序,在此分析并写出next
数组生成的递归函数;
假设当前生成的是下标为i
的next
值,要求前i+1
位字符串的最大相等前后缀,此时有两种情况:
s
[
i
]
=
s
[
j
]
s[i]=s[j]
s[i]=s[j] 与
s
[
i
]
≠
s
[
j
]
s[i]\ne s[j]
s[i]=s[j];
s
[
i
]
=
s
[
j
]
s[i]=s[j]
s[i]=s[j] 时,显然此时的相等前后缀可以在前一个位置上的前后缀延伸一个长度,那么此时直接让next[i] = next[i-1]+1
即可,而由于j
记录的是当前的前缀长度,所以可以先j++
,最后将j
的值赋给next[i]
;
s
[
i
]
≠
s
[
j
]
s[i]\ne s[j]
s[i]=s[j] 则要复杂一些:
在出现当前字符不匹配的情况时,也就意味着我们匹配next[i-1]+1
长度的前后缀失败了,所以应该退而求其次,去匹配next[i-1]
长度的前后缀,若再不匹配就继续递推…直到找到一个匹配的前后缀为止;然而这种方法和我们的迭代方法很难相容,原因在于后缀的前面几位很难确定是否与前缀相等,暴力匹配又会大大增大时间复杂度;
对于该问题,可以在生成next
数组的同时使用next
数组,形成一个递归关系:
如图,对于一个字符不匹配的情况,我们发现可以寻找当前匹配的“前后缀的前后缀”;因为当前i
位的前后缀匹配失败了,那么参考上面两张图,前后缀继续匹配必然需要前缀的右边界向左移动,后缀的左边界向右移动;只有当移动到后缀的第一位和前缀的第一位相等的时候,才有可能匹配成功;(停止条件)
我们刚才说的停止条件,实际上在图中相当于:在前缀蓝色框里面再找一个子前缀,在后缀的蓝色框里面再找一个子后缀,让子前缀和子后缀相等,这个时候前两位前缀有可能和后两位后缀相等,那么此时子前缀的位置将是下一个j
的位置,重新进行j
和i
位置的字符匹配;
在代码中如何实现快速找到子前缀的位置呢;我们注意到,两个蓝色框内的字符串是完全相等的,也就是说前缀蓝色框的子后缀和后缀蓝色框的子后缀是一样的,那么刚刚说的停止条件实际上是:在前缀的蓝色框中寻找最大相等前后缀,而最大相等前后缀恰恰可以用我们的next
数组找到;
具体操作将j
向前退一位查询next
的值,即为j
应该跳转到的子前缀的位置,继续比较j
和i
位置的字符即可,若相等那么跳转到相等情况赋值即可,若不相等则重复上面的操作.
将上述的操作归纳为递归方程列写如下:
j ( i ) = { n e x t [ i − 1 ] + 1 , s [ i ] = s [ j ] n e x t [ j ( i ) − 1 ] , s [ i ] ≠ s [ j ] j_{(i)} = \begin{cases} next[i-1]+1, & \text s[i]=s[j]\\ next[j_{(i)}-1], & \text s[i]\ne s[j] \end{cases} j(i)={next[i−1]+1,next[j(i)−1],s[i]=s[j]s[i]=s[j]
如此我们便可以根据递归方程编写next
数组的循环迭代生成代码了.
void getNext(string s, vector<int> &next){ //构建next数组
next[0] = 0;
int j = 0;
for(int i = 1; i < s.size(); i++){
while(j > 0 && s[i] != s[j]){ //若不等,则j回退
j = next[j-1]; //寻找当前前缀的子前缀
}
if(s[i] == s[j]){ //若相等,那么相等前后缀长度+1
j++;
}
next[i] = j; //赋值给当前的next
}
}
2. 使用 n e x t next next 数组进行匹配
使用next
进行匹配的过程我们在 Why 前缀表?部分已经讲解了理论部分,因此此处不过多赘述,只简单讲解一下代码;
寻找模式串在文本串中第一次出现的位置,我们从文本串开头开始遍历,用i
作为文本串里的指针,j
作为模式串里的指针开始循环
int j = 0;
for (int i = 0; i < haystack.size(); i++)
如果当前文本串和模式串中指向的字符相等,那么i
和j
都前进一位,由于i
的增量在for
循环中已经包含,所以只需要给j
增量就好
if (haystack[i] == needle[j]) {
j++;
}
若当前文本串和模式串中指向的字符不等时,j
就需要前移寻找可能匹配的新位置,j
前移的位数则是由我们的next
数组所决定,那么j
回退一位找到next[j-1]
的值,则next[j-1]
的值就是新的j
,从新的j
位置开始匹配;
while(j > 0 && haystack[i] != needle[j]) {
j = next[j - 1];
}
当模式串中字符全部匹配结束,即j
的位置到达模式串末尾,代表寻找到匹配的子串,利用下标简单计算输出即可;若完整的走完了for
循环,则代表遍历完文本串仍然没有找到匹配的下标,那么返回
−
1
-1
−1.
3. 完整代码
class Solution {
public:
void getNext(int* next, const string& s) {
int j = 0;
next[0] = 0;
for(int i = 1; i < s.size(); i++) {
while (j > 0 && s[i] != s[j]) {
j = next[j - 1];
}
if (s[i] == s[j]) {
j++;
}
next[i] = j;
}
}
int strStr(string haystack, string needle) {
if (needle.size() == 0) { //剪枝
return 0;
}
vector<int> next(needle.size());
getNext(&next[0], needle); //调用函数构建next数组
int j = 0;
for (int i = 0; i < haystack.size(); i++) {
while(j > 0 && haystack[i] != needle[j]) {
j = next[j - 1];
}
if (haystack[i] == needle[j]) {
j++;
}
if (j == needle.size() ) {
return (i - needle.size() + 1);
}
}
return -1;
}
};