前言
这周在学数据结构常见算法时遇到了很多的坑,真的是一步一个坑,先是背包问题,又是这个KMP算法,听的是尚硅谷韩老师的java数据结构与算法,他刚开始讲的算法思路还是听清晰的,但是一到代码上…
一言难尽。KMP最核心的一部分十分难理解,韩老师也是一笔带过,我搜了许多篇博客,专门为此算法做一个总结。
KMP算法所解决问题
KMP是为了解决字符串匹配问题,如判断一个字符串str1= “ABCDAB ABCDABCDABDE” 是否包含另一个字符串str2 = “ABCDABD”,并找到其位置。
关于字符串的查找算法又很多种实现方式,大家最容易想到的应该是暴力匹配算法。
暴力匹配思路
其思路就是,i 和 j 从左到右进行匹配。
- 我们定义两个变量指针 ,一个指针 i 指向 str1 的起始字符,另一个指针 j 指向 str2 的起始字符
- 如果相等 i 和 j 就往下一个字符移动进行对比
- 如果不相等,让 j 回到 0 ,i 回到 i - j + 1的位置,就是把之前比较过的数都回退,i 从下一个数,j 从 0 ,继续比较。
不相等回退:
4.直到 j 的值与 str2 的长度相等,说明已经找到对应的字符串
代码实现
package com.atguigu.Algorithm.KMP;
/**
* @author lixiangxiang
* @description KMP 暴力匹配
* @date 2021/8/13 16:25
*/
public class ViolentMatch {
public static void main(String[] args) {
String str1 = "ABCDAB ABCDABCDABDE";
String str2 = "BCDABC";
int i = 0;
int j = 0;
while (i < str1.length() && j < str2.length()) {
//如果两字符相等,继续往下比
if (str1.charAt(i) == str2.charAt(j)) {
i++;
j++;
} else {
//如果不相等,i j 回退
i = i -j + 1;
j = 0;
}
if (j == str2.length()) {
System.out.println("str2 在str1 中的起始位置为 "+ (i - j));
return;
}
}
System.out.println("未找到str2");
}
}
KMP 算法思想
之前的想法虽然可以解决改类问题,但实在是太暴力了,算法时间复杂度很高。而KMP 是一个解决模式串在文本串是否出现过,如果出现过,最早出现的位置的经典算法。我们可以看下我们之前算法的问题。
发现规律
当我们的 j 第一次查找到D时,我们发现最后一位和 i 对应的字符不一样,没办法我们只能回溯,而我们直接将 i 回溯到了第二个元素B的位置,
但如果要我们自己考虑这个问题,我们绝不会把i回溯到 B。 因为我们知道,如果 i 从B、C、D开始绝对不可能跟str2的 j 相等的。
我们会选择从第五个元素A开始。
一个基本事实是,当空格与D不匹配时,你其实知道前面六个字符是"ABCDAB"。KMP算法的想法是,利用已经部分匹配这个有效信息,保持i指针不回溯,通过修改j指针,让模式串尽量地移动到有效的位置
所以,整个KMP的重点就在于当某一个字符与主串不匹配时,我们应该知道j指针要移动到哪?
如上述例子 我们 i 所在位置为空格,j 所在位置为D时
空格和D不匹配了,我们 j 应该移动到哪呢,正确答案是移动到第三位,我们可以观察 i 紧邻的 AB 与 i 紧邻的AB 相等 ,所以我们可以将 j 移动到第三位。
我们继续往下比较发现 C 和 空格还是不匹配,那么此时我们的 j 应该移动到哪呢。我们往下看发现j 之前已经没有字符能与 i 之前和AB之后的字符匹配了(其实现在就剩 i 一个了)。 所以我们只能将 j 移动到最开头的位置 。
再继续的话 j 已经没法再往前移动了,这时候只能是 i 往后移。
j 一直往下判断都匹配,得到我们想要的字符串位置开头就在 i 位置上。
总结 :我们可以得出这样一个结论,j要移动的下一个位置k。存在着这样的性质:最前面的k个字符和j 前面紧邻的k个字符是一样的。
比如说第一次 j 移动的是两格。如下图:
第二次我们的j没有移动,j 就在开头位置。是因为 我们并不能找到一个这样的一个位置k,我们只能让 j 在开头
在这里我们用数组 T[] 来表示 str1 字符集合, 数组P[] 来表示 str2字符集合。
如果用数学公式来表示是这样的P[0 ~ k-1] == P[j-k ~ j-1]
证明为什么移动K位
然后咱们证明下为什么不用去比较k之前的数了。。(考验数学的时候到了)
- 当 T[i] != P[i] 时,
- 有 T[i-j ~ i-1] == P[0 ~ j-1] (表示之前已经比较过的数,他们是相等的)
- 如果 P[0 ~ k-1] == P[j-k ~ j-1] (如果 P 数组 前k个数和 j 之前的 k个数 相等)
- 得到T[i-k ~ i-1] == P[0 ~ k-1] (这点很难理解,如果转换为白话文就是 可以得出 T 数组 i 前面 k 个数 必然和 P 数组的前 K 个数相等,这是根据第一个条件我们p [j - k ~ j - 1] 已经在之前 跟 T数组确认过是相等的了,)
总结下这个推导过程表达的意思,
-
首先我们要在 T[i] 和 P[i] 不相等的情况下(废话,要是相等还用移位吗… 直接往下走就得了)
-
我们要充分利用我们已经比较过的字符。(T数组 i 前面 j 个和 P 数组前面 j 个数,我们已经比过了,都匹配。)
-
如果 我们的 P 数组 有这样一个规律: 数组前 k 个数 和 j 前面 k 个数 相等。
-
我们可以确定 i 前面 k 个数 跟 p 前面k 个数绝对相等,不用比较了。
这就是为什么我们只要将 j 移动到 k 位置就行了。
如何求K
我们推出上面的规律后,重点就变成如何 求这个 (这些)K了?
因为在P的每一个位置都可能发生不匹配,也就是说我们要计算每一个位置 j 对应的k,所以用一个数组next来保存,next[j] = k,表示当T[i] != P[j]时,j指针的下一个位置。
而整个算法最关键的部分也在于此。下面先给出求 next[] 的代码
public static int[] getNext(String ps) {
char[] p = ps.toCharArray();
int[] next = new int[p.length];
next[0] = -1;
int j = 0;
int k = -1;
while (j < p.length - 1) {
if (k == -1 || p[j] == p[k]) {
j++;
k++;
next[j] = k;
} else {
k = next[k];
}
}
return next;
}
这个代码是目前流传最广的求next数组的方法,诈一看很难理解,许多人并不知道为什么这样求,只是死记硬背。下面我们来推导一下:
推导前我们要记住一点 next[j] 的值 (也就是K) 表示:当P[j] != T[i] 时,j指针的下一步移动位置 我们在这里称为 匹配值
为什么叫匹配值呢,其实我们这个next[]数组早就有人给出了定义,叫《部分匹配表》/…
匹配值就是j在每一个位置所对应的k值,这个匹配表是如何产生的呢?
匹配表的产生
-
首先,要了解两个概念:“前缀"和"后缀”。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。
-
"部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。以"ABCDABD"为例,
- "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],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0。
下面我们根据这个匹配表来推论上述代码的为什么这样写。
代码推导
1. 为什么next[0] = -1;
当j 为 0时,如果i 和 j 不匹配,匹配值应该是多少?
j 是 0 字符串就是空,不可能有前缀和后缀所以 我们将next[0]标识为-1,方便后期判断。
**2. j++;k++; next[j] = k;这段代码从何而来 **
- 请看下图
我们发现此规律:
当P[k] == P[j]时,
有next[j+1] == next[j] + 1
这个规律的原因是什么呢,很简单
如上述例子,我们需要计算 j 的匹配值,就是求 “ABCAB”的共有元素 ,可以看出其 为“AB”,所以匹配值为2
当我们求j+1的匹配值时,如果我们知道了,P[k] = P[j] = “C” 那么共有元素肯定是“ABC”了,所以匹配值为2 + 1;
所以j+1的匹配值 p[j+1] 就等于p[j]的匹配值+1
用公式我们也可推出
- 因为 p[0 ~ k -1] == p[j-k ~ j-1];
- 又因为 p[k] == p[j]
- 所以 p[0 ~ k - 1] + p[k] == p[j - k ~ j - 1] + p[j]
- 所以 p[0~k] == p[j-k ~ j]
- 根据结论 p[0 ~ k-1] == p[j-k ~ j-1]时, next[j] = k 那么, p[0~k] == p[j-k ~ j] 时 next[j + 1] = k +1;
- 所以 next[j+1] = next[j] +1 = k + 1;
这个就结论就对应代码 next[++j] = ++k 即: j++ ; k++ ;next[j] = k ;
3. 当 P[k] != P[j] 时 k = next[k];
情况如下图
我们找 j - 1 的匹配值时,[A B A C D A B A B]的 共同元素为[A B A]
当我们继续往下找 j 的匹配值 时,[A B A C D A B A B C] 发现 p[k] != P[j] 即[A B A C] 与[A B A B] 不匹配 ,此时我们要找 j 的匹配值 肯定没法通过之前的方式 next[j+1] = next[j] + 1 来找了。
那咋办呢,没办法只能进行回退,那要对谁进行回退呢?肯定是k了,前面的 j 都已经找到了匹配值了。
重要的是我们要将k回退到哪呢?
我们在寻找next[j+1] 时,刚开始寻找的是与前缀串[A B A C]相同的后缀串,但我们发现后缀串为[A B A B] 不匹配。这时我们是不是应该从新寻找共同元素。而这个过程是不是相当于寻找k的匹配值 next[k] 呢?就如下图所示:
其实还有一种思路可以理解为什么 k = next[k] ,因为k是j的下一步移动位置,如果 p[k] != p[j]的话,我们需要继续往下走,那么就相当于j往下下一步走,k 是 j 的下一步位置,那么next[k] 就是 j 的下下一步位置。所以k = next[k]
ok,这就是匹配表的求解思路。这点困扰了我好久,硬是看了一天才理解。以上这些都是我对这个算法的理解,如果有更好的看法请在评论留言大家一起讨论讨论。
在此特别感谢“孤~影” 博主的博客 https://www.cnblogs.com/yjiyjige/p/3263858.html
以及阮一峰大佬的博客http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html
我的思路都来源这两位大佬的启发。感谢!!!
next数组弄明白之后我们就可以写KMP算法了。
package com.atguigu.Algorithm.KMP;
/**
* @author lixiangxiang
* @description KMP
* @date 2021/8/13 16:25
*/
public class KMPSearch {
public static int KMP(String str1, String str2) {
int i = 0;
int j = 0;
int[] next = getNext(str2);
while (i < str1.length() && j < str2.length()) {
//如果两字符相等,继续往下比
if (j == -1 || str1.charAt(i) == str2.charAt(j)) {
i++;
j++;
} else {
//如果不相等,j 回退到 next[j]
//i = i -j + 1;
j = next[j];
}
// 如果 j == str2.length 说明匹配成功
if (j == str2.length()) {
return i - j;
}
}
return -1;
}
/**
* description: 获取匹配表
*
* @author: lixiangxiang
* @param str 字符串
* @return int[]
* @date 2021/8/16 11:06
*/
public static int[] getNext(String str) {
char[] p = str.toCharArray();
int[] next = new int[p.length];
next[0] = -1;
int j = 0;
int k = -1;
while (j < p.length - 1) {
if (k == -1 || p[j] == p[k]) {
j++;
k++;
next[j] = k;
} else {
k = next[k];
}
}
return next;
}
}
网上很多文章有提到 这个获取匹配表的算法有缺陷,就是当 p[j+1] 和 p[k+1] 相等时应让k回退到 next[k] 这里我来解释一波。
如下例:
这个获得匹配表应该是 [ -1,0,0,1 ]
按算法执行 应该将 j 移动到 第二个元素B的位置
而很显然,其不相等,这步操作是无意义的,这类操作有个共同特点就是 p[j] = p[k] 因为k就是我们下一步要移动的位置,如果p[j] 都和T[i]不相等了,自然p[k] 也和T[i] 不相等,那我们就果断跳过。
那往下跳应该跳到哪?自然就是往j的下下一步跳,他的下一步是k 下下一步就是 next[k] 。
所以我们可以在得知 p[j] = p[k] 时,让其 下一步next[j] 跳到下下一步 next[k]
改进后的匹配表算法。
/**
* description: 匹配表改进
*
* @author: lixiangxiang
* @param str 字符串
* @return int[]
* @date 2021/8/16 11:06
*/
public static int[] getNext(String str) {
char[] p = str.toCharArray();
int[] next = new int[p.length];
next[0] = -1;
int j = 0;
int k = -1;
while (j < p.length - 1) {
if (k == -1 || p[j] == p[k]) {
j++;
k++;
if (p[j] == p[k]) {
next[j] = next[k];
}else {
next[j] = k;
}
} else {
k = next[k];
}
}
return next;
}