一、KMP算法
基本原理:
KMP算法(Knuth-Morris-Pratt算法)是一种用于在一个文本串(字符串)中查找一个模式串(子串)的高效算法。它的主要优点是在匹配过程中避免了回溯(backtracking),从而提高了匹配的效率。在字符串匹配算法中,通过求解最长相等前后缀来解决不匹配的情况,利用前缀表记录每个位置的最长相等前后缀,这种方法称为KMP算法。
KMP算法的核心思想:
-
部分匹配表(Partial Match Table):
- KMP算法的关键在于构建部分匹配表,也称为最长前缀匹配表(Longest Prefix which is also Suffix, LPS)。这张表记录了模式串中每个前缀的最长匹配前缀长度。
- 通过部分匹配表,可以根据已经匹配的部分信息,快速调整模式串的位置,跳过不可能匹配的情况,从而避免不必要的比较。
-
匹配过程:
- 在匹配过程中,KMP算法会根据当前匹配的位置和部分匹配表来决定模式串的移动位置,以达到尽量减少匹配次数的目的。
- 当匹配失败时,不会简单地将模式串向后移动一个位置,而是利用部分匹配表中的信息,将模式串移动到一个合适的位置再进行匹配,从而提高效率。
算法步骤概述:
-
构建部分匹配表:
- 根据模式串,构建部分匹配表,计算每个位置对应的最长匹配前缀长度。
-
匹配过程:
- 遍历文本串,同时根据部分匹配表调整模式串的位置,进行匹配。
- 如果当前字符匹配成功,继续匹配下一个字符;如果匹配失败,根据部分匹配表移动模式串的位置,直到找到匹配或者完全匹配失败。
优点:
- 避免回溯:KMP算法通过利用部分匹配表的信息,能够在模式串与文本串匹配过程中,减少不必要的比较,避免回溯到之前已经比较过的位置,从而提高了算法的效率。
- 时间复杂度:KMP算法的时间复杂度为O(m + n),其中m为模式串长度,n为文本串长度,主要由构建部分匹配表和匹配过程决定。
那么如何用前缀表来进行匹配?什么是前缀表?前缀表如何求?
前缀表记录了模式串中每个位置的最长相等前后缀长度,使得在匹配失败时可以跳转到之前已匹配的位置继续匹配。这种方法相比于暴力匹配算法,时间复杂度从O(m*n)降低到了O(m+n)。
首先要理解什么是前缀,什么是后缀,前缀是包含首字母不包含尾字母的子串,后缀是包含尾字母不包含首字母的子串,以例子说明:
文本串: "aabaabaaf"
模式串: "aabaaf"
KMP算法在匹配时,当第一次匹配后,文本串的"b"匹配模式串的"f"匹配失败,这是KMP算法的第二轮匹配会直接从模式串的"b"开始,匹配文本串的第二个"b",那么为什么是这样匹配的呢?为什么会直接从b开始匹配?这就是前缀表的功劳了。
对于模式串来说,它的前缀可以是 a,aa,aab,aaba,aabaa, 后缀可以是f,af,aaf,baaf,abaaf,正是遵循"最长相等前后缀的原则" ,以例子说明:
a的相等前后缀为0
aa的相等前后缀为1 (前缀a,后缀a)
aab的相等前后缀为0
aaba的相等前后缀为1 (前缀a,后缀a)
aabaa的相等前后缀为2 (前缀a,aa,后缀a,aa)
aabaaf的相等前后缀为0
最终得到的模式串前缀表为
(0,1,0,1,2,0)
再我们第一轮找到不匹配的元素后,这时应该找前一位元素的最长相等前后缀是多少
文本串: "aabaabaaf"
模式串: "aabaaf"
前缀表: 010120
模式串中"f"的前一位是a,它的最长相等前后缀是2,那么这个2是什么意思?
它代表着子串"aabaa"中的一个两位后缀aa,同时也有着一个相等的两位前缀aa,由于匹配的后缀不相等造成冲突,因此需要去寻找与其相等的前缀,而前缀表中的这个2,就是下一次匹配时模式串的起始位置(下标), 2是相等前后缀的长度,现在已知了前缀,因此要从该相等前缀的下一个元素开始下一次串匹配。
这时第二次匹配为
文本串: "aabaa baaf"
模式串: "aa baaf"
这时匹配完成,KMP算法结束
总结:
KMP算法通过利用部分匹配表,避免了回溯,提高了模式串匹配的效率,特别适合于需要多次匹配模式串的场景,如字符串匹配、编辑距离计算等。
具体题目:
1、KMP算法经典应用
给你两个字符串 haystack
和 needle
,请你在 haystack
字符串中找出 needle
字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle
不是 haystack
的一部分,则返回 -1
。
示例 1:
输入:haystack = "sadbutsad", needle = "sad" 输出:0 解释:"sad" 在下标 0 和 6 处匹配。 第一个匹配项的下标是 0 ,所以返回 0 。
示例 2:
输入:haystack = "leetcode", needle = "leeto" 输出:-1 解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 。
代码:
首先创建next数组作为字符串的前缀表(前缀表的值统一减一)
public void getNext(int[] next, String s){int j = -1; // 初始化 j 为 -1,表示当前没有匹配的前缀next[0] = j; // 第一个位置的部分匹配值为 -1for (int i = 1; i < s.length(); i++){ // 从第二个字符开始遍历 s 字符串while(j >= 0 && s.charAt(i) != s.charAt(j + 1)){// 如果当前字符不匹配,并且 j 大于等于 0,则通过 next[j] 回溯 j 的值j = next[j];}if(s.charAt(i) == s.charAt(j + 1)){// 如果当前字符匹配,则 j 向前移动一位j++;}next[i] = j; // 记录当前位置 i 处的最长匹配前缀的末尾字符在模式串中的位置}
}
-
初始化:
j = -1
:初始化j
为-1
,表示当前没有匹配的前缀。next[0] = j
:设置next
数组的第一个位置为-1
,因为第一个字符没有前缀。
-
循环计算
next
数组:for (int i = 1; i < s.length(); i++)
:从字符串s
的第二个字符开始遍历到末尾。while(j >= 0 && s.charAt(i) != s.charAt(j + 1))
:- 如果当前字符不匹配,并且
j
大于等于0
,则通过next[j]
回溯j
的值,直到找到一个可以继续匹配的位置或者j
回溯到-1
。
- 如果当前字符不匹配,并且
if(s.charAt(i) == s.charAt(j + 1))
:- 如果当前字符匹配,则将
j
向前移动一位j++
。
- 如果当前字符匹配,则将
next[i] = j
:- 将
j
的值赋给next[i]
,即记录当前位置i
处的最长匹配前缀的末尾字符在模式串中的位置。
- 将
KMP算法
public int strStr(String haystack, String needle) {if (needle.length() == 0) {return 0; // 如果 needle 为空字符串,则返回 0}int[] next = new int[needle.length()]; // 创建 next 数组,用于存储部分匹配表getNext(next, needle); // 调用 getNext 方法,计算 needle 的部分匹配表int j = -1; // 初始化 j 为 -1,表示当前没有匹配的前缀for (int i = 0; i < haystack.length(); i++) {while (j >= 0 && haystack.charAt(i) != needle.charAt(j + 1)) {// 如果当前字符不匹配,并且 j 大于等于 0,则通过 next[j] 回溯 j 的值j = next[j];}if (haystack.charAt(i) == needle.charAt(j + 1)) {// 如果当前字符匹配,则 j 向前移动一位j++;}if (j == needle.length() - 1) {return (i - needle.length() + 1); // 如果 j 移动到 needle 的末尾,则找到匹配的起始位置}}return -1; // 没有找到匹配的子串,返回 -1
}
-
参数和边界情况处理:
strStr
方法接受两个字符串参数haystack
和needle
,其中haystack
是待搜索的主字符串,needle
是要搜索的子字符串。- 如果
needle
的长度为 0,直接返回 0,因为一个空的needle
在任何位置都是匹配的起始位置。
-
初始化
next
数组:- 创建一个
next
数组,用于存储needle
字符串的部分匹配表。
- 创建一个
-
计算部分匹配表:
- 调用
getNext(next, needle)
方法,这个方法计算并填充next
数组,用于在匹配失败时快速确定下一次比较的位置。
- 调用
-
主匹配循环:
- 使用两个指针
i
和j
分别在haystack
和needle
中进行遍历和比较。 j
初始为-1
,表示当前没有匹配的前缀。- 遍历
haystack
中的每个字符,如果当前字符不匹配,并且j
大于等于0
,则通过next[j]
回溯j
的值,直到找到一个可以继续匹配的位置或者j
回溯到-1
。 - 如果当前字符匹配,则将
j
向前移动一位。 - 如果
j
移动到needle
的末尾needle.length() - 1
,则说明找到了完整的匹配子串,返回起始位置(i - needle.length() + 1)
。
- 使用两个指针
-
返回结果:
- 如果循环结束都没有找到匹配的子串,则返回
-1
表示未找到。
- 如果循环结束都没有找到匹配的子串,则返回
在java中提供有直接寻找子字符串的方法indexOf,如
public int strStr(String haystack, String needle) {return haystack.indexOf(needle);
}
indexOf
方法是 Java 字符串类String
的方法,用于查找一个子字符串在原字符串中的位置。它从原字符串的开头开始,逐个位置尝试匹配子字符串,直到找到匹配或者遍历完整个字符串。- 如果找到了匹配的子字符串
needle
,则返回第一次出现的索引位置。 - 如果没有找到匹配的子字符串,则返回
-1
。
这段代码简单而有效地利用了 Java 提供的字符串方法来实现字符串搜索功能。它适用于大多数基本的字符串搜索需求,但需要注意的是,它并没有利用更高级的字符串匹配算法(如 KMP 算法)来提高效率,因此在大数据量或者复杂匹配模式的情况下,可能性能会受到影响。
2、重复的子字符串
题目:
给定一个非空的字符串 s
,检查是否可以通过由它的一个子串重复多次构成。
示例 1:
输入: s = "abab" 输出: true 解释: 可由子串 "ab" 重复两次构成。
示例 2:
输入: s = "aba" 输出: false
示例 3:
输入: s = "abcabcabcabc" 输出: true 解释: 可由子串 "abc" 重复四次构成。 (或子串 "abcabc" 重复两次构成。)
思路:
这里仍采用KMP算法,在最长相等前后缀中,由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串
这是为什么呢?如何寻找是关键
步骤一:因为 这是相等的前缀和后缀,t[0] 与 k[0]相同, t[1] 与 k[1]相同,所以 s[0] 一定和 s[2]相同,s[1] 一定和 s[3]相同,即:,s[0]s[1]与s[2]s[3]相同 。
步骤二: 因为在同一个字符串位置,所以 t[2] 与 k[0]相同,t[3] 与 k[1]相同。
步骤三: 因为 这是相等的前缀和后缀,t[2] 与 k[2]相同 ,t[3]与k[3] 相同,所以,s[2]一定和s[4]相同,s[3]一定和s[5]相同,即:s[2]s[3] 与 s[4]s[5]相同。
步骤四:循环往复。
所以字符串s,s[0]s[1]与s[2]s[3]相同, s[2]s[3] 与 s[4]s[5]相同,s[4]s[5] 与 s[6]s[7] 相同。
正是因为 最长相等前后缀的规则,当一个字符串由重复子串组成的,最长相等前后缀不包含的子串就是最小重复子串。
此时数组对应的next数组的值为:
数组:a b a b a b a b
next[]: 0 0 1 2 3 4 5 6
再比如:
判断子字符串是否重复,只需要用数组长度减去最长相同前后缀的长度相当于是第一个周期的长度,也就是一个周期的长度,如果这个周期可以被整除,就说明整个数组就是这个周期的循环
//创建next数组
for (int i = 2, j = 0; i <= len; i++) {// 匹配不成功,j回到前一位置 next 数组所对应的值while (j > 0 && chars[i] != chars[j + 1]) j = next[j];// 匹配成功,j往后移if (chars[i] == chars[j + 1]) j++;// 更新 next 数组的值next[i] = j;
}
判断是否为重复子字符串
if (next[len] > 0 && len % (len - next[len]) == 0) {return true;
}
完整代码如下:
public boolean repeatedSubstringPattern(String s) {if (s.equals("")) return false;int len = s.length();// 原串加个空格(哨兵),使下标从1开始,这样j从0开始,也不用初始化了s = " " + s;char[] chars = s.toCharArray();int[] next = new int[len + 1];// 构造 next 数组过程,j从0开始(空格),i从2开始for (int i = 2, j = 0; i <= len; i++) {// 匹配不成功,j回到前一位置 next 数组所对应的值while (j > 0 && chars[i] != chars[j + 1]) j = next[j];// 匹配成功,j往后移if (chars[i] == chars[j + 1]) j++;// 更新 next 数组的值next[i] = j;}// 最后判断是否是重复的子字符串,这里 next[len] 即代表next数组末尾的值if (next[len] > 0 && len % (len - next[len]) == 0) {return true;}return false;
}
-
边界条件:
if (s.equals("")) return false;
如果输入的字符串s
是空字符串,则直接返回false
,因为空字符串不可能由重复的子字符串构成。
-
初始化:
int len = s.length();
获取字符串s
的长度。s = " " + s;
在字符串s
的开头加上一个空格作为哨兵,使得字符串的索引从 1 开始,这样方便后续算法中的处理。
-
构造
next
数组:char[] chars = s.toCharArray();
将字符串s
转换为字符数组chars
,便于随后的字符操作。int[] next = new int[len + 1];
创建next
数组,用于存储部分匹配表。
-
KMP 算法核心部分:
for (int i = 2, j = 0; i <= len; i++) { ... }
使用i
和j
两个指针,其中i
表示当前正在匹配的位置,j
表示前缀的匹配长度。while (j > 0 && chars[i] != chars[j + 1]) j = next[j];
如果当前字符不匹配,并且j
大于 0,则通过next[j]
回溯j
的值,直到找到一个可以继续匹配的位置。if (chars[i] == chars[j + 1]) j++;
如果当前字符匹配,则将j
向后移动一位。next[i] = j;
更新next
数组的值,表示当前位置的最长相同前缀后缀长度。
-
判断重复子字符串:
if (next[len] > 0 && len % (len - next[len]) == 0) { return true; }
next[len]
表示整个字符串的最长相同前缀后缀长度。len % (len - next[len]) == 0
表示字符串长度能整除最长前缀后缀的长度,即可以由子字符串重复构成。- 如果上述条件满足,则返回
true
,否则返回false
。
相关资料来源:
https://www.programmercarl.com/0459.%E9%87%8D%E5%A4%8D%E7%9A%84%E5%AD%90%E5%AD%97%E7%AC%A6%E4%B8%B2.html#%E6%80%9D%E8%B7%AF
https://leetcode.cn/problems/repeated-substring-pattern/
今天的学习就到这里了