引言
LeetCode:28.实现str Str()
KMP算法的学习始于LeetCode第28题:《实现strStr()》。
这道题给了我们一个字符串 s
和一个字符串 t
,要求在 s
字符串中找出 t
字符串的第一个匹配项的下标(下标从 0
开始)。如果 s
不是 t
的一部分,则返回 -1
。
我们可以很轻易地想到一个暴力搜索的方法,也就是从s
的第i位开始将字符的每一位与t
的每一个字符逐一比较,如果将t
遍历完整了,就匹配成功,否则从s
的第i+1
位重复此过程。Java代码实现如下:
public int strStr1(String s, String t){
int i=0,j=0;
while(i<s.length()){
if(s.charAt(i)==t.charAt(j)){
if(j==t.length()-1){
return i-j;//匹配完成
}
i++;
j++;
}else{
i=i-j+1;//回退到搜索开始的后一位
j=0;
}
}
return -1;
}
这就是我们最初的串模式匹配算法,很简单很直接的思维方式,但同时也很低效很愚蠢。蠢就蠢在就算是已经遍历匹配成功的子串也会重复匹配多次。造成了其时间复杂度为O(mn)
。
互联网、计算机中的字符串成千上万,其长度也成千上万,这种复杂度的算法是不合适的。
初识KMP算法
三个人一起发明了KMP算法,因此有了KMP(Knuth-Morris-Pratt)算法。
要学习KMP算法,要从认识最长相等前后缀开始。
最长相等前后缀
但是在此之前,先要知道KMP算法中的前缀、后缀都是什么。
前缀
一个字符串中,符合以下条件的所有子串,叫做前缀:
- 不含最后一个字符
- 以字符串的第一个字符开头
- 连续
举个例子:字符串aabaaf
的前缀有
a
aa
aab
aaba
aabaa
aabaaf
后缀
一个字符串中,符合以下条件的所有子串,叫做后缀:
- 不含第一个字符
- 以字符串的最后一个字符结尾
- 连续
举个例子:字符串aabaaf
的后缀有
f
af
aaf
baaf
abaaf
aabaaf
最长相等前后缀
明白了前缀和后缀的概念,我们就能理解最长相等前后缀了,就是最长的相等的前缀和后缀的长度。以下两个例子展示了最长相等前后缀:
比如字符串aabaa
,长度为2的前缀和后缀均为aa
。
比如字符串ababa
,长度为3的前缀和后缀均为aba
。
Next数组
在暴力搜索算法中,每次匹配失败时,i
需要回退到原来的位置+1
,j
需要回退为0
,这在KMP算法中得到了改变。
KMP算法定义了一个next
数组,每次匹配失败时,将查询next
数组的对应位置,并将j进行回退。
那么next数组表示什么?
next[i]
等于模式串t
的,0
到i
的子串的最长相等前后缀
的长度。
可能有点绕,但来手算一次就搞清楚了。
手算next数组
next
数组有很多种计算方式,但本质都相同,本文采取其中的一种。
现在给定一个模式串t
,t
为"aabaaf"
,求其next
数组。
next[0]=0:
0到0的子串长度为1
,没有前缀后缀
next[1]=1
: 0到1的子串为"aa"
,最长相等前后缀为"a"
next[2]=0
: 0到2的子串为"aab"
,最长相等前后缀为""
next[3]=1
: 0到3的子串为"aaba"
,最长相等前后缀为"a"
next[4]=2
: 0到4的子串为"aabaa"
,最长相等前后缀为"aa"
next[5]=0
: 0到5的子串为"aabaaf"
,最长相等前后缀为""
因此aabaaf
的next
数组为[0, 1, 0, 1, 2, 0]
使用next数组
在求得next
数组之后,我们必须要关心的是,next
数组怎么用?
现在给定文本串s = "aabaabaaf"
,模式串t = "aabaaf"
。
显然next
数组为[0, 1, 0, 1, 2, 0]
。
分别用i
遍历s
,j
遍历t
。很显然,s[5]='b'
,t[5]='f'
,匹配失败。
此时,将j
赋值为next[j-1]=next[4]=2
继续匹配。
通过这个方法,模式匹配的时间复杂度可降低为O(m+n)
。
代码实现KMP算法
光说不练假把式,必须要通过代码书写才能明白KMP算法为什么难为什么烦。
使用next数组进行匹配搜索
- 通过
t
生成next
数组 - 定义两个指针:
i
遍历字符串s
,j
遍历字符串t
- 比较
s[i]
和s[j]
,如果不等,则令j
为next[j-1]
,重复此过程直到相等或j=0
。 - 如果
s[i]=t[j]
,如果t
匹配完成,那么返回i-j
,否则j
加1
。 i
加1
,如果s
遍历完了,则返回-1
,否则跳回至步骤3
。
public int strStr(String haystack, String needle) {
int[] next = getNext(needle);
System.out.println(Arrays.toString(next));
for (int i = 0, j = 0; i < haystack.length(); i++) {
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
j = next[j - 1];//连续回退,直到i、j字符相同或回退到头
}
if (haystack.charAt(i) == needle.charAt(j)) {
if (j == needle.length() - 1) {//模式串匹配完整
return i - j;
}
j++;//当前字符匹配成功,继续匹配下一个字符
}
}
return -1;
}
计算next数组
计算next数组的步骤过程,其实与以上模式匹配的过程很相似。
private int[] getNext(String s) {
int[] next = new int[s.length()];
//j:前缀末尾位置、i之前包含i的子串的最长相等前后缀长度 i:后缀末尾位置
for (int i = 1, j = 0; i < next.length; i++) {
while (j > 0 && s.charAt(j) != s.charAt(i)) {
j = next[j - 1];//注意要连续回退
}
if (s.charAt(j) == s.charAt(i)) {
next[i] = ++j;//更新next数组
}
}
return next;
}
459、重复的字符串
思路分析
如果一个字符串s
全都是由多个重复子串构成的话,那么我们将两个字符串s
拼在一起,中间也应该至少有一个字符串s
。
因此,只需要将字符串进行拼接,将新字符串的首字符、尾字符删除,再将原字符串作为模式串,新字符串作为文本串进行匹配即可。
代码实现
实际代码实现中,为了节省Java字符串操作时间,可以创建一个新的字符数组ss
,并对原字符串进行拷贝。
public boolean repeatedSubstringPattern(String s) {
char[] t = s.toCharArray();
char[] ss = new char[(t.length << 1) - 2];
System.arraycopy(t, 1, ss, 0, t.length - 1);
System.arraycopy(t, 0, ss, t.length - 1, t.length - 1);
int[] next = getNext(t);
for (int i = 0, j = 0; i < ss.length; i++) {
while (j > 0 && ss[i] != t[j]) {
j = next[j - 1];
}
if (ss[i] == t[j]) {
if (j == t.length - 1) {
return true;
}
j++;
}
}
return false;
}
总结
KMP算法是一个思维复杂,代码简单的算法。这种算法,我们作为算法学习者只要将其背下来即可。