KMP算法
KMP 算法是三位学者在 Brute-Force 算法的基础上同时提出的模式匹配的改进算法。Brute- Force算法在模式串中有多个字符和主串中的若干个连续字符比较都相等,但最后一个字符比较不相等时,主串的比较位置需要回退。KMP 算法在上述情况下,主串位置不需要回退,从而可以大大提高效率。
模式匹配:举例来说,一个字符串"BBC ABCDAB ABCDABCDABDE"里是否包含另一个字符串"ABCDABD"
1、图解算法
- 在字符串"BBC ABCDAB ABCDABCDABDE"中匹配搜索词"ABCDABD"
- 因为B与A不匹配,搜索词再往后移,直到字符串有一个字符,与搜索词的第一个字符相同为止。
- 接着比较字符串和搜索词的下一个字符,直到字符串有一个字符,与搜索词对应的字符不相同为止。
-
Brute-Force 算法 VS KMP 算法
- Brute-Force:将搜索词整个后移一位,再从头逐个比较。虽然可行,但是效率很差
-
KMP算法:算出《部分匹配表》,不把"搜索位置"移回比较过的位置,继续把它后移,从而提高了效率。
《部分匹配表》如下,先会用即可,后面再学怎么计算;匹配表又称前缀表。
查表可知,最后一个匹配字符 B 对应的"部分匹配值"为2。
部分匹配值
定义: 匹配到当前字符时,前缀和后缀最多有几个字符是一样的。
用处: 既然是一样的,就不用重复比较是否匹配了,以此来提升效率。
例子:
如图,倒数第二个B的部分匹配值为2,是因为前缀和后缀都有相同的两个字符 “AB” 。所以,在此例中匹配到末尾的 D 时匹配失败,那么不用从搜索词第一个字符 A 开始重新匹配,而可以查表获取当前最后一个匹配的字符 B 的部分匹配值,知道有2个已匹配的字符,那么接下来从搜索词第三个字符开始匹配即可。
⭐ 网上流传的方法中,存在有两种部分匹配表,因为很容易混淆,所以这里先捋一下。第二种匹配表与上面匹配表的区别仅仅在于,将每个字符的匹配值都往右移了,第一个字符的匹配值用 -1 补上。
搜索词 | A | B | C | D | A | B | D |
---|---|---|---|---|---|---|---|
部分匹配值 ① | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
部分匹配值 ② | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
他们的区别仅仅在于,代码实现有细节上的不同,表 ① 如上例子所述,要知道有多少个已经匹配的前后缀,需要去查前一个字符的匹配值,而表 ② 因为已经后移了一位,所以不用去查前一个字符的匹配值,直接查当前字符的匹配值就可以了。
- 逐位比较,直到搜索词的最后一位,发现完全匹配,于是搜索完成。
2、算法概述
-
KMP主要应用在字符串匹配上。KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。
-
为什么使用前缀表可以告诉我们匹配失败之后跳到哪里重新匹配?
因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面从新匹配就可以了。
-
前缀表的理解
1、相同前后缀的长度
2、当发生不匹配时,搜索词指针的下一个位置
3、前缀表的计算
首先看两个例子观察前缀表的规律。
前缀表的理解部分已经说了,它的值可以看做搜索词的指针所要指的下一个位置,因此这是我们的观察对象。
个人认为,用指针跳转的思想最容易理解
例子1
感性的认知告诉我们,C 和 D 不匹配,回到搜索词的 B 继续尝试匹配。
**也就是,指针 j 从 D 跳到 B,如下图所示: **
例子2
感性的认知告诉我们,C 和 B 不匹配,回到搜索词的 C 继续尝试匹配。
**也就是,指针 j 从 B 跳到 C,如下图所示: **
结合下图能很好地理解规律,k 是相同的前后缀的长度,也是模式串指针要转到的位置。所以写代码时,我们找到相同的前缀和后缀,前缀的下一个字符位置和就是后缀的下一个字符所对应的前缀值,它记录了如果后缀的下一个字符匹配不上了,去到的相同前缀后面所继续匹配的位置。
得出规律:next[j] = k (next[] 表示前缀表)
翻译过来,next[j](j 指针接下来要跳到的位置)= k
P [ 0 , k − 1 ] = = P [ j − k , j − 1 ] P[0, k-1] == P[j-k, j-1] P[0,k−1]==P[j−k,j−1]
以下是第二种前缀表的计算代码
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]) {
next[++j] = ++k;
} else {
k = next[k];
}
}
return next;
}
前缀表的计算共分为三部分,这里需要记住前面提到的,前缀表的值指示了搜索词的指针移动位置。
1、前缀表的第一个元素的值
当 j 为0时,如果这时候不匹配,j 已经在最左边了,不可能再移动了,这时候要应该是i指针后移。所以在代码中才会有 next[0] = -1 这个初始化,这样后续在查前缀表时,遇到 -1 就知道要让
2、前缀表的其他位置元素的值,含 P[k] == P[j] 和 P[k] != P[j] 两种情况
注意,根据代码我们现在要观察的是 j + 1指针要跳到的位置
- P[k] == P[j]
这里用的是前面发现的规律,当 P[k] == P[j]时,有next[j+1] == next[j] + 1
准确的理解需要补上,k 是 j 不匹配时所跳到的位置,即 k = next[j]
翻译过来就是,当 k 位置的值和 j 位置的值相同时,那么 j + 1 跳的位置为 j 跳到的位置加一
所以代码实现时,只要 P[k] == P[j],就将 k + 1 存入表 next[j + 1] 中
- P[k] != P[j]
这种情况算是求前缀表中最难理解的地方了,很多文章感觉都讲的不是很清楚。
上面这张图画得比较好,我将在它的基础上加入自己的理解进行分享。
从图中可以看出,我们的条件对象,也就是 j 指针和 k 指针的位置,所指元素不同
如果相同就万事大吉了,按照上面的做法去计算就行了
所以这里,我的理解是:
用【k】、【j】代表两个指针所指的值,如果他们相同,则意味着有前缀 A ... 【k】
和 后缀 A ... 【j】
相同,那么这就是我们不用重复匹配的部分,问题是当前情况下他们不相同,所以 j + 1 不能照旧跳到 k + 1。
我们的核心是,寻找相同的最长前后缀,因此解决方法是,让 k 往前跳,跳到出现与后缀相同的前缀为止。
最难的理解地方就在这,为什么要让 k 跳,为什么代码是 k = next[k] ?
P[k] != P[j] 仅仅意味着,没有理想情况下的最长前后缀了,在本图中指 A ... 【k】
和 A ... 【j】
,虽然上一轮有相同的前后缀 “ABA”,但是这一轮多了新的元素后不同了。我们不妨约定当前的前后缀由上一轮的相同串加本轮各自新增的元素组成,也就是当前的前缀 = “ABA” + ‘C’,当前的后缀 = “ABA” + ‘B’。
按照定义,next[k] 记录了 k 位置发生不匹配时指针要跳到的位置。如下图所示,现在我们恰恰可以认为我们在做字符串匹配,k 位置和 j 位置元素不匹配,所以代码 k = next[k] 就是说,让 k 指针跳到不匹配时它应该去的位置,然后继续匹配新增的元素,直到匹配上为止。(有点递归那味了,字符串匹配中用到了字符串匹配)
啰嗦一下,我们需要把握到问题的核心是各自新增的元素不同,在当前位置前后缀有着上一轮的相同串,但是没有匹配的新增元素,所以 k = next[k] 可以把 k 导游到另一个等价于上一轮相同串的位置,此时在新的位置或许就有匹配的新增元素了。
等价的意思是,相同串可能会缩短,比如上图 k 导游到新位置后,相同串从 “ABA” 变为了 “A”,但因为和后缀依旧是相同的,所以看作等价。而不论怎么缩小,后缀末尾的 ‘B’ 是不变的,所以缩小后我们依然要匹配到 “B”才能结束。
最后跟着代码彻底的理解这一过程:
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;
// 根据规律我们计算的是next[j + 1] 的值,故临界值为p的长度减一
while (j < p.length - 1) {
// 匹配上了,则next是上一轮的k+1
// k为-1可以理解为只能从头开始匹配了,所以next也是k+1
if (k == -1 || p[j] == p[k]) {
next[++j] = ++k;
} else {
// 匹配不上,next[k]代表k位置匹配不上时k要去的位置
// 将其赋给k表示让k去到新位置,然后进入下一轮循环继续尝试匹配
k = next[k];
}
}
return next;
}
4、算法实现
a、获取后缀表
b、从模式串和文本串的第一个字符开始循环比较
c、相等则比较下一个字符;不相等则查询前缀表将模式串指针移到新地址
d、比较结束后,如果模式串指针值等于文本串长度,则意味着全部字符都匹配上了
e、匹配失败则返回 -1
class Solution {
public int strStr(String haystack, String needle) {
// 1、获取后缀表
int[] next = getNext(needle);
// 2、从模式串和文本串的第一个字符开始循环比较
int i = 0, j = 0;
while (i < haystack.length() && j < needle.length()) {
// j == -1,此时已经回到模式串的第一个字符,无法再移动模式串了,所以文本串要往下查找
if (j == -1 || haystack.charAt(i) == needle.charAt(j)) {
i++;
j++;
}
else j = next[j];
}
if (j == needle.length())
return i - j;
else return -1;
}
private int[] getNext(String needle) {
int[] next = new int[needle.length()];
if (needle.length() == 0) return next;
next[0] = -1;
int j = 0, k = -1;
while (j < next.length - 1) {
if (k == -1 || needle.charAt(j) == needle.charAt(k)) {
next[++j] = ++k;
} else {
k = next[k];
}
}
return next;
}
}