java数据结构与算法刷题目录(剑指Offer、LeetCode、ACM)-----主目录-----持续更新(进不去说明我没写完):https://blog.csdn.net/grd_java/article/details/123063846 |
---|
一、概述
什么是KMP算法 |
---|
- 我们拿LeetCode28题为例,暴力解法时间复杂度O(m*n), 而KMP算法时间复杂度O(n + m),空间复杂度O(m),所以,KMP算法是一个提升字符串匹配效率很好的一个算法。但是我们要注意一点,不同的环境和不同的编程语言,KMP的效率未必好过其它的算法,例如优化后的BM算法拥有比KMP更好的平均时间复杂度(注意是平均时间复杂度更好,算法本身理论上的时间复杂度,KMP更好)
- 暴力解法的效率
- KMP的效率提升
- 上面这道28题,仅仅说明,在这道题所规定的场景下,KMP确实发挥出了它应有的实力。但是在实际工作场景中,有时候会遇到种种不太适配的场景,这些时候KMP的表现也确实会受到多方面的影响,从而导致综合下来看,此时其它算法的表现更加优秀。
KMP算法很难,如果拿下,可以很好的提升你的编程思维,但是实际工作中也确实很少有场景会使用到它。不过,虽然完整的KMP算法不常用,但是如果单拿出它其中某一个步骤的思想,却往往能在很多不同的场景中,发挥奇效。
总结起来就是,很多问题,其实只需要使用KMP算法中的某一个步骤的思想就可以很好的解决并提高效率。但是如果你只记住了代码,每次使用KMP都必须把完整代码写出来使用的话,就会陷入杀鸡焉用牛刀的困境,造成不必要的时间和空间上的浪费。
KMP的简单概括,以及暴力解法的不足 |
---|
- KMP算法:重复出现的,不要重复比较,跳过它。如何实现这个效果呢?只需要使用一个数组来记录重复出现的次数。
- 解决模式串在文本串是否出现过,如果出现过,获取最早出现的位置的经典算法
- 常用于在一个文本串S内查找一个模式串P出现的位置,此算法由Donald Knuth、Vaughan Pratt、James H.Morris三人于1977年联合发表,故取三人姓氏命名此算法为Knuth-Morris-Pratt,简称KMP算法
- 利用之前判断过的信息,通过一个next数组,保存模式串中前后最常公共子序列的长度,每次回溯时,通过next数组找到,前面匹配过的位置,省去大量计算时间
- 暴力匹配存在的问题:例如ABCDABEABCDABD 匹配 ABCDABD
ABCDAB
EABCDABD从头匹配子串ABCDAB
D,可以发现最后一个字母D不匹配E,也就是说ABCDABE 这个串 肯定不匹配ABCDABD- 但是在未知的情况下,我们无法像人脑一样,知道这个串匹配不了,从而向后移一位,从E开始继续观察是否匹配,比如:后面的EABCDABD继续匹配子串ABCDABD, 此时我们发现E不匹配,再次向后移一位进行ABCDABD匹配ABCDABD ,此时发现成功匹配了。
但是计算机可不像我们人脑一样,可以进行这样的思考
。- 所以暴力匹配中,我们不确定是否后移一位就匹配了,因此,只能一个个后移比较
- 暴力匹配哪里可以优化呢?
- 我们先看简单的匹配:ABCDABE和ABCDABD发现E和D不匹配,使用暴力解法,需要后移,将剩余的BCDABE再次匹配ABCDABD
- 此时,我们发现,又不匹配,如果我们想要匹配,最起码应该A开头吧,也就是说,再往后面移动,也没用,直到下一个A开头的
- 但是计算机不是人,使用暴力解法,还是会按部就班的继续匹配。例如接下来它会用CDABE匹配 ABCDABD,然后又发现不匹配,再次进行DABE匹配 ABCDABD… 以此类推
- 那么我们可以发现,BCD这三个开头,已经确定不匹配,没必要重复匹配,应该跳过,直接匹配下一个A。例如:直接跳到ABE匹配 ABCDABD
二、KMP思想
各位注意KMP解决的最大的问题是:假设我们匹配时,发现匹配到某个字符匹配不下去了,此时我们究竟是将已匹配的字符全部直接跳过,还是说只能跳过一部分。
- 例如:ABAB
A
BD匹配ABABD
,从头匹配时,我们发现红色的位置是匹配不上的- 如果我们全部跳过,就只剩下BD匹配ABABD,显然不正确
- 此时我们应该跳过字符串
AB
ABABD中前面两个标红的AB,进行剩下的ABABD匹配ABABD。此时成功匹配。
KMP的解决思路:例如ABCDABEABCDABD 匹配 ABCDABD |
---|
- 对匹配串进行处理。我们先对ABCDABD这个子串做文章,判断匹配串某个位置不匹配时,应该怎么跳过,最终存储到next数组中。
- ABCDABE匹配ABCDABD,如果到D不匹配,应该直接变成ABE匹配ABCDABD,因为我们要找到下一个AB开头的
- 再比如ABCD
E
FGTTTTT匹配ABCDA
BD,如果第一次到E
不匹配,应该直接变成EFGTTTTT匹配ABCDEFG,因为前面的ABCD全部不匹配,直接跳过- 总结一下,可以发现,ABCD
AB
D这个子串,出现两次AB
,那么如果一直匹配到ABCDABD才发现不对,就应该直接跳到后面的AB
继续匹配- 而如果没有匹配到出现重复字符,比如匹配到ABCD就发现不对了,那么就应该直接跳过ABCD
- Next数组,如何记录,如何生成数组:是否有重复缀,应该用一个数组记录。比如ABCDABD对应数组应该是[0,0,0,0,1,2,0],代表A重复,长度1,AB重复,长度为2. 这是怎么算出来的呢?-----用前缀,后缀共同匹配法,就是从左到右,依次把前几个字母所组成的字符串列出来,然后再将前缀和后缀列出来,看看前后缀共同拥有的元素长度
- A的前后缀都是空集,前后缀共有0
- AB前缀A,后缀B,共有0
- ABC前缀A,AB。后缀BC,C,共有0
- ABCD前缀A,AB,ABC,后缀BCD,CD,D,共有0
- ABCDA前缀A,AB,ABC,ABCD,后缀BCDA,CDA,DA,A,共有A,长度1
- ABCDAB前缀A,AB,ABC,ABCD,ABCDA,后缀BCDAB,CDAB,DAB,AB,B,共有AB,长度2
- ABCDABD前缀A,AB,ABC,ABCD,ABCDA,ABCDAB,ABCDABD后缀BCDABD,CDABD,DABD,ABD,BD,共有0
- 如何利用数组,跳过该跳过的:ABCDABCABCDABD 匹配ABCDABD,前面我们已经得出Next数组为:[0,0,0,0,1,2,0]
- ABCDABCABCDABD 匹配ABCDABD, C不匹配D,对应Next数组[0,0,0,0,1,2,0],发现是0. 也就是说,当前匹配串ABCDABD,匹配到D后不匹配了,然后Next数组记录的ABCDABD的前后缀重复缀个数为0.
- 这就代表着,前面已经匹配的这7个字符,没有重和部分,无需重复匹配。直接全部跳过。
跳过公式为:已匹配成功字符个数-匹配不成功字符对应Next数组值
。也就是7-0 = 7.- 所以下一次直接跳过7个
ABCDABC
ABCDABD(红色的为跳过的),下一次直接匹配后面的ABCDABD。也就是ABCDABD匹配ABCDABD。此时匹配成功
另一个例子 |
---|
三、代码实现
大家可以简单浏览一下下面这个,如果不好理解,可以看再下面的多注释版本 |
---|
public int strStr(String haystack, String needle) {
int n = haystack.length(),m = needle.length();//为了更快的速度
if(m==0) return 0;
//1、kmp数组
int[] kmpArr = new int[m];
kmpArr[0] = 0;
for (int i = 1,j = 0; i < m; i++) {
//"ABCDABD"
//"i***i"两个i对应的匹配成功,那么匹配成功长度+1,j++,长度为1。说明如果匹配到ABCDA,那么下次可以把ABCD跳过,末尾的A不可以跳过
//" i***i"然后两个i位置又成功,j++,长度为2,也就是说匹配到了ABCDAB,下次ABCD可以跳过,末尾的AB不可以,因为重复了
//最后,j = 2,i = 6,C不匹配D
//j = kmp[j-1] 看看后移后的缀匹配程度,一直到匹配成功,或者j = 0,从开头重新匹配
//这个while循环,主要负责,如果当前不匹配了,是全跳过,还是只能跳过部分
while(j>0 && needle.charAt(i)!=needle.charAt(j)) j = kmpArr[j-1];
if(needle.charAt(i)==needle.charAt(j)) j++;//匹配成功,j长度+1
kmpArr[i] = j;
}
//2、用kmp数组
for (int i = 0,j = 0; i < n; i++) {
//这里和上面代码一样,只不过原来是needle自己比,现在是haystack和needle比较
while(j>0 && haystack.charAt(i)!=needle.charAt(j)) j = kmpArr[j-1];
if(haystack.charAt(i)==needle.charAt(j)) j++;//匹配成功,j长度+1
if(j==m) return i-j + 1;//匹配成功,返回子串起始下标
}
return -1;//没成功
}
多注释版本 |
---|
- 首先,我们需要一个数组,作为NEXT数组,表示匹配不成功后,应该跳过几个字符,进行下次匹配
public int strStr(String haystack, String needle) {
//先搞出kmpArr数组
int[] kmpArr = kmpNext(needle);
//用数组跳过没必要重复比较的
return kmpSearch(haystack,needle,kmpArr);
}
- NEXT数组:这里使用的方法是,记录当前位置匹配成功后,如果要跳,应该跳到哪里。这样的含义是,如果发现匹配到某字符匹配失败,那么证明前面都是匹配成功的,那么就访问失败字符的前一个字符的Next数组值,从而得知应该跳到哪里重新尝试匹配。例如:ABCDAB
D
[0,0,0,0,1,2,0].C匹配失败,但是前面B匹配成功了,那么看看B成功,要跳,应该跳什么位置,我们发现Next数组中记录的是0,所以下次只能从头匹配。
- 首先用一个for循环,来处理从0位置开始,依次的每个子串,例如A,AB,ABC,ABCA,循环变量i代表每个子串的末尾。
- 然后通过while循环,来处理:如果当前的,前后缀不匹配,可以跳过几个字符。
- 如果匹配成功则很简单,例如:ABCD
A
BD [0,0,0,0,1
,2,0]。假设标红的下标为 i,标黄的下标为 j。我们发现标红的这个A,和开头标黄的A成功的进行了前后缀匹配。那么我们可以让j++。它有两个作用
- 指向前缀的位置,也就是它在j++前,指向的就是开头的A。那么现在这个A匹配成功了,我们下个位置需要继续从A匹配吗?显然不用。下次直接尝试匹配B就好了。所以进行j++
- 代表当前,连续的情况下,成功匹配了多少。很明显我们现在仅仅连续匹配成功了这个标红的A。所以长度为1.
- 然后:ABCDA
B
D [0,0,0,0,1,2
,0]。我们发现标红的B,和开头的B成功的进行了前后缀匹配。此时我们就可以再次进行j++;
- 刚刚 j 已经 =1 了。因为前面A匹配成功了,现在B也匹配成功了,那么ABCDA这个子串的前后缀,A与A匹配成功,现在末尾添加一个B,ABCDAB这个子串B与B也匹配成功了,那么代表AB与AB匹配成功了。所以下次应该匹配C
- A已经匹配成,然后紧跟着,B也匹配成功,所以连续情况下,连续匹配了AB,所以长度为2.
- 再然后:ABCDAB
D
[0,0,0,0,1,2,0
]。我们发现标红的D和标黄的C,没有成功进行匹配也就是前缀ABC和后缀ABD没有成功匹配,我们知道
KMP算法思想就是跳过没必要重复比较的
。
- 此时如果继续比较字符更多的前后缀已经没有意义了,因为ABC和ABD已经不匹配了,接下来的ABCD,和DABD就更不行了
- 所以我们此时就需要考虑,是全跳呢?还是只跳一部分,然后看看剩余部分是否前后缀还能匹配
- 因为前面的子串如果匹配失败,应该跳到哪个位置,都已经记录在NEXT数组中了,所以直接从next数组获取即可。
- 既然A
B
C和AB
D匹配失败,但是我们的NEXT数组记录的是,字符匹配成功后,应该跳跃的位置,D和C匹配失败了。但是前面的都匹配成功了,所以我们访问NEXT[ j - 1]
,也就是B
的位置,我们想看看B匹配成功,但是我们要跳跃,应该跳到哪里去。- 我们发现AB这个前缀,压根没有公共前后缀,没办法,只能全跳。也就是回到0位置继续。
- 再举个例子,AA
C
DAAA
[0,1,0,0,1,2,2]AAC和AAA没有匹配成功
- 此时访问[0,
1
,0,0,1,2,2]. NEXT[ j-1 ] = 1,我们发现前缀AA如果匹配成功,下次只需要跳到1位置继续匹配即可。同时j–。- 此时再次匹配,我们发现A
A
CDAAA
匹配成功,则j++,代表公共前后缀长度为2.
- 最后通过一个if语句来处理,是否前后缀匹配,如果匹配,记录成功匹配的字符个数。
//第一步:获取到一个字符串(子串) 的部分匹配值表,KMP数组
public static int[] kmpNext(String dest) {
//ABCDABD [0,0,0,0,1,2,0]
int[] kmpArr = new int[dest.length()];//kmp数组,记录前后缀共同匹配长度
kmpArr[0]=0;//单个字符,例如A的前后缀都是空集,前后缀共有0
//i代表后缀下标,j代表前缀下标和数组对应下标
for (int i = 1,j = 0; i < dest.length(); i++) {
//i = 0 代表A字符,前后缀都是空串
//i = 3 代表ABCD的D,j = 0,最前面的A
//不相等,依然是0
//i = 4 代表ABCDA,的A。j = 0代表最前面的A
//前后缀相等了,但此时j = 0,说明前面没有前后缀匹配的,那么直接记录本次
//j++ = 1 , kmpArr[4] = 1
//i = 5 代表ABCDAB,代表后缀B,j = 1代表前缀B
//相等了,此时j>0,并且相等,那么直接j++即可
//j = 2,kmpArr[5] = 2
//i = 6 代表ABCDABD,代表后缀D,j = 2代表前缀C(ABC)
//此时j>0,并且前后不相等,那么后缀D和C不匹配,需要尝试向前匹配
//剩下他能匹配的,也就是,A和AB,对应j是0和1
//j = kmpArr[j-1],向前移动前缀,kmpArr[1] = 0
//j = 0,前面没有了,判断前后缀现在是否相等
//D 不等于 A 因此D这个后缀,没的匹配
while(j>0&&dest.charAt(j)!=dest.charAt(i)){//如果本次前后缀不匹配
j=kmpArr[j-1];//看看可以跳到哪里,然后继续比较是否前后缀匹配
}
if(dest.charAt(i)==dest.charAt(j)){//如果匹配,长度+1,
j++;
}
//j 就是 长度
kmpArr[i] = j;
}
return kmpArr;
}
- KMP搜索:和NEXT数组逻辑几乎一样,如果遇到匹配不成功的字符,那么就考虑NEXT数组中,前一个位置匹配成功后,应该跳到哪个位置重新匹配。
/**第二步
* kmp搜索
* 依然进行匹配
* 和上面一样,j就是匹配成功的前后缀共同长度
*
*/
public static int kmpSearch(String str1, String str2, int[] kmpArr) {
//遍历
for(int i = 0, j = 0; i < str1.length(); i++) {
//匹配不成功就跳过
//需要处理 str1.charAt(i) != str2.charAt(j), 去调整j的大小
//KMP算法核心点, 可以验证...
//String str1 = "BBC ABCDAB ABCDABCDABDE";
// String str2 = "ABCDABD";
//匹配表next=[0, 0, 0, 0, 1, 2, 0]
//当i = 10,j = 6时,前面ABCDAB都匹配成功,但是现在空格比较D,不相等,此时说明不是子串
//此时进入循环,j = 2,比较空格和C,不相等,再进入循环
//j = 1,比较空格和B,不相等,再进入循环
//j = 0,条件不满足,退出循环
//下一次,将从空格后面继续匹配,避免前面比较过的,重复比较
while( j > 0 && str1.charAt(i) != str2.charAt(j)) {
j = kmpArr[j-1];
}
//匹配成功就继续
if(str1.charAt(i) == str2.charAt(j)) {
j++;
}
//和上面建立kmp数组一样,获得j,匹配成功的长度
if(j == str2.length()) {//如果相等,匹配完全成功
return i - j + 1;//返回子串起始下标
}
//否则,匹配不成功
}
return -1;
}