KMP算法
基本思想:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配
1 KMP简介
- 适用条件:字符串匹配
- 作用:当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配
- 重点:记录已经匹配的文本内容【next数组】
2 最长相等前后缀
- 前缀:指不包含最后一个字符的所有以第一个字符开头的连续子串
- 后缀:指不包含第一个字符的所有以最后一个字符结尾的连续子串
3 前缀表
-
定义:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀
-
作用:
- 前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配
- 当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置
Ⅰ. 举例:
要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf
文本串中第六个字符b 和 模式串的第六个字符f,不匹配了。如果暴力匹配,发现不匹配,此时就要从头匹配
但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符b继续开始匹配
Ⅱ. 为什么前缀表可以告诉我们匹配失败后跳到哪里重新匹配?
以要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf为例
下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以
注意:这里我也想了很久,为什么下一个匹配点用最长相等前后缀来算
问题1:当前不匹配处与和后缀相等的最长前缀处后面的匹配点中间的那些点一定匹配不成功吗?
一定,反证法:当前不匹配处前串记作s,不匹配点记作a,如果在中间某点可以匹配成功,则此时不匹配点a处前面均可以匹配上,a前串记作s1,则s1是s的前缀,与s1匹配上的主串部分记作s2,由于s与主串都相同,则s2是s的后缀,又由于s1与s2相同,那么s1,s2是s中相等的前后缀,由于此处是中间某点,那么s1长度一定要比最长前缀长,这与假设条件不符,因为假设中中间点不是最长前缀处。
由上可知,和后缀相等的最长前缀处后面的匹配点即合适的下一个匹配位置。
简单理解:当遇到不匹配情况时,想知道主串中这个点前最多有多少点能和模式串开头匹配上,省去一些匹配次数。那么主串中这个点前的串相当于后缀,模式串开头相当于前缀,则看最长相等的前后缀即可。检查最长相等的前缀后面的点是否与当前不匹配点匹配上就可以。
问题2:为什么是最长的前后缀处?
如果用短的前后缀,中间会落下匹配情况,例如主串aabaabaafa,模式串aabaaf,用短的前后缀a和a,将下一个匹配点定为用第二个a去匹配主串b,落下了前一个匹配点。
- 具体做法:前缀表来记录相同前后缀的长度
前缀表计算举例:
a 0; aa 1; aab 0; aaba 1; aabaa 2; aabaaf 0
前缀表的使用
找到的不匹配的位置,看它的前一个字符的前缀表的数值是多少(找前面字符串的最长相同的前缀和后缀),将下标移动到前一个字符的前缀表数值继续匹配即可
使用举例
以要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf为例
前一字符的前缀表数值为2,则把下标移动到b处继续匹配
4 构造next数组
- next数组:可以是前缀表,也可以实现为前缀表统一减一,初始位置为-1,只是具体实现方法不同,原理是一样的
- 构造next数组:即计算模式串s前缀表的过程
- 初始化
- 处理前后缀不相同的情况
- 处理前后缀相同的情况
Ⅰ. 初始化
int j = 0;
next[0] = j;
定义两个指针 i i i和 j j j, j j j指向前缀末尾位置, i i i指向后缀末尾位置
对next数组进行初始化赋值
Ⅱ. 处理前后缀不相同的情况
for (int i = 1; i < s.size(); i++) {
代码解析:
j j j 初始化为0,那么 i i i 就从1开始,进行 s [ i ] s[i] s[i] 与 s [ j ] s[j] s[j]的比较,故遍历模式串 s s s的循环下标 i i i 要从1开始
while (j > 0 && s[i] != s[j]) { // 前后缀不相同了
j = next[j-1]; // 向前回退
}
代码解析:
s [ i ] s[i] s[i] 与 s [ j ] s[j] s[j]不相同,也就是遇到前后缀末尾不相同的情况,就要向前回退
s [ i ] s[i] s[i] 与 s [ j ] s[j] s[j] 不相同,就要找 j j j前一个元素在next数组里的值(就是next[j-1])
注意:
问题1:为什么用while?
回退是一个连续回退的过程,i指向后缀末尾,j指向前缀末尾,j还控制着前后缀长度。可以理解成用前缀去匹配后缀,如果不匹配,则回退,一直不匹配则需要一直回退,所以用while。
问题2:为什么不匹配看前一个元素的next数组值?
可以理解为用前缀匹配后缀,即将前缀看成模式串,后缀看成主串,如果不匹配,看不匹配点前能匹配上多少点,即看最长相等前后缀,则要看前一元素next数组里面的值。
Ⅲ. 处理前后缀相同的情况
if (s[i] == s[j]) { // 找到相同的前后缀
j++;
}
next[i] = j;
代码解析:
如果 s [ i ] s[i] s[i] 与 s [ j ] s[j] s[j] 相同,那么就同时向后移动 i i i 和 j j j 说明找到了相同的前后缀,同时还要将 j j j(前缀的长度)赋给 n e x t [ i ] next[i] next[i], 因为 n e x t [ i ] next[i] next[i]要记录相同前后缀的长度
- 整体构建代码
void getNext(int* next, const string& s){
int j = 0;
next[0] = j;
for(int i = 1; i < s.size(); i++) { // 注意i从1开始
while (j >= 0 && s[i] != s[j]) { // 前后缀不相同了
j = next[j-1]; // 向前回退
}
if (s[i] == s[j]) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
可以以上述例子为例,自己演示一下上述代码,理解一下
注意:
演示这里的代码可以发现,每次i向后移动一位,只要直接比较当前j的位置和i位置元素是否相等,相等的话j+1后记录j为next[i]。这是因为此时j的位置是i位置前串的最长相等前后缀的前缀的下一位,即j前面的元素和i前面的元素相同,那么只需要判断i,j位置的元素相不相同即可。这也同时解释了为什么需要连续回退。
5 使用next数组匹配
- 解决问题:在文本串s里找是否出现过模式串t
int j = 0; // 因为next数组里记录的起始位置为0
for (int i = 0; i < s.size(); i++) { // 注意i就从0开始
while(j >= 0 && s[i] != t[j]) { // 不匹配
j = next[j-1]; // j 寻找之前匹配的位置
}
if (s[i] == t[j]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (t.size()) ) { // 文本串s里出现了模式串t
return (i - t.size() + 1);
}
}
时间复杂度分析:
暴力穷举:O(m*n) — 模式串m,主串n
KMP算法:O(m+n) — 构建next数组m,使用数组匹配n
5 总结
KMP算法理解起来还是比较困难,看的时候遇到了很多问题,不过基本能想明白了,二刷的时候要再看看。
LeetCode28-实现strStr()
题目描述:给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回 -1 。
输入:haystack = “sadbutsad”, needle = “sad”
输出:0
解释:“sad” 在下标 0 和 6 处匹配。
第一个匹配项的下标是 0 ,所以返回 0 。
题目链接:https://leetcode.cn/problems/find-the-index-of-the-first-occurrence-in-a-string/
解题思路
- 暴力求解
- KMP算法求解
1 暴力求解
- 外循环遍历主串,内循环遍历模式串,若模式串与主串完全匹配,则返回当前主串下标
class Solution(object):
def strStr(self, haystack, needle):
"""
:type haystack: str
:type needle: str
:rtype: int
"""
i = 0;
while(i < len(haystack)):
if haystack[i] == needle[0]:
flag = 0;
for j in range(len(needle)):
if i+j < len(haystack) and needle[j] == haystack[i+j]:
continue;
else:
flag = 1;
break;
if flag != 1:
return i;
i += 1;
return -1;
class Solution(object):
def strStr(self, haystack, needle):
"""
:type haystack: str
:type needle: str
:rtype: int
"""
m, n = len(haystack), len(needle)
for i in range(m):
if haystack[i:i+n] == needle:
return i
return -1
2 KMP算法求解
- 算法详细解析如上
# next数组统一-1,起始位置记作-1的写法
# 算法详细解析写的是next直接存前缀表的写法
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
a = len(needle)
b = len(haystack)
if a == 0:
return 0
next = self.getnext(a,needle)
p=-1
for j in range(b):
while p >= 0 and needle[p+1] != haystack[j]:
p = next[p]
if needle[p+1] == haystack[j]:
p += 1
if p == a-1:
return j-a+1
return -1
def getnext(self,a,needle):
next = ['' for i in range(a)]
k = -1
next[0] = k
for i in range(1, len(needle)):
while (k > -1 and needle[k+1] != needle[i]):
k = next[k]
if needle[k+1] == needle[i]:
k += 1
next[i] = k
return next
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
a = len(needle)
b = len(haystack)
if a == 0:
return 0
i = j = 0
next = self.getnext(a, needle)
while(i < b and j < a):
if j == -1 or needle[j] == haystack[i]:
i += 1
j += 1
else:
j = next[j]
if j == a:
return i-j
else:
return -1
def getnext(self, a, needle):
next = ['' for i in range(a)]
j, k = 0, -1
next[0] = k
while(j < a-1):
if k == -1 or needle[k] == needle[j]:
k += 1
j += 1
next[j] = k
else:
k = next[k]
return next
3 心得体会
使用了KMP算法,二刷的时候自己写KMP算法。
LeetCode459-重复的子字符串
题目描述:给定一个非空的字符串 s ,检查是否可以通过由它的一个子串重复多次构成。
输入: s = “abab”
输出: true
解释: 可由子串 “ab” 重复两次构成。
题目链接:https://leetcode.cn/problems/repeated-substring-pattern/
解题思路
- 暴力求解
- 移动匹配
- KMP算法求解
1 暴力求解
- 外循环确定重复子串,内循环看整个字符串是否能由这个子串构成
- 外循环:以第一个字母为开始的子串就可以,所以一个for循环获取子串的终止位置就可以了
- 力扣超时
class Solution(object):
def repeatedSubstringPattern(self, s):
"""
:type s: str
:rtype: bool
"""
for i in range(1, len(s)):
substr = s[:i];
j = 0;
while(j < len(s)):
flag = 0;
for k in range(len(substr)):
if j+k >= len(s):
flag = 1;
break;
if substr[k] != s[j+k]:
flag = 1;
break;
if flag == 1: break;
j += len(substr);
if j == len(s): return True;
return False;
2 移动匹配
- 若s内部由重复子串构成,则前面有相同的子串,后面有相同的子串
- 用 s + s,这样组成的字符串中,后面的子串做前串,前后的子串做后串,就一定还能组成一个s
- 判断字符串s是否由重复子串组成,只要两个s拼接在一起,里面还出现一个s的话,就说明是由重复子串组成
- 在判断 s + s 拼接的字符串里是否出现一个s的的时候,要刨除 s + s 的首字符和尾字符,这样避免在s+s中搜索出原来的s,我们要搜索的是中间拼接出来的s
class Solution {
public:
bool repeatedSubstringPattern(string s) {
string t = s + s;
t.erase(t.begin()); t.erase(t.end() - 1); // 掐头去尾
if (t.find(s) != std::string::npos) return true; // r
return false;
}
};
3 KMP算法求解
- 在由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串
# next减一实现
class Solution:
def repeatedSubstringPattern(self, s: str) -> bool:
if len(s) == 0:
return False
nxt = [0] * len(s)
self.getNext(nxt, s)
if nxt[-1] != -1 and len(s) % (len(s) - (nxt[-1] + 1)) == 0:
return True
return False
def getNext(self, nxt, s):
nxt[0] = -1
j = -1
for i in range(1, len(s)):
while j >= 0 and s[i] != s[j+1]:
j = nxt[j]
if s[i] == s[j+1]:
j += 1
nxt[i] = j
return nxt
# next前缀表实现
class Solution:
def repeatedSubstringPattern(self, s: str) -> bool:
if len(s) == 0:
return False
nxt = [0] * len(s)
self.getNext(nxt, s)
if nxt[-1] != 0 and len(s) % (len(s) - nxt[-1]) == 0:
return True
return False
def getNext(self, nxt, s):
nxt[0] = 0
j = 0
for i in range(1, len(s)):
while j > 0 and s[i] != s[j]:
j = nxt[j - 1]
if s[i] == s[j]:
j += 1
nxt[i] = j
return nxt
4 心得体会
这个题目只看了视频,不想细看文章解析了,二刷如有需要:459解析
总结
字符串部分比较麻烦,KMP匹配理解起来也比较困难,后续要再好好看看。
字符串类类型的题目,往往想法比较简单,但是实现起来并不容易,复杂的字符串题目非常考验对代码的掌控能力。
双指针法是字符串处理的常客。
KMP算法是字符串查找最重要的算法,但彻底理解KMP并不容易。
双指针法总结:双指针总结