前言 :本文章经过3天整理,向大家解释为什么KMP算法这样一个不难的算法会"越来越难懂",所以实际上,本文适合有一定基础的读者学习(即能够使用前缀表进行KMP算法运算并了解原理),如果没有这部分基础,可以先去了解一下(随便找个网课然后力扣通过一次找出字符串中第一个匹配项的下标)
KMP基础
我们先从第一个有疑惑的地方,前缀表开始讲解(这里我们先称为next数组)
如果对于前缀表完全理解,可以直接看深入学习模块然后进行下一步学习。
next数组(PM)
KMP算法的核心就在于求next数组
ini
public int[] getNextArr(String s){
int len=s.length();
int j=0;
int[] next=new int[len];
for(int i=1;i<len;i++){
while(j>0&&s.charAt(i)!=s.charAt(j)){
j=next[j-1];
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
next[i]=j;
}
return next;
}
仔细分析上述代码:
代码解析
- 我们只遍历一次目标字符串,一次一字符:每一次遍历要求出对应的前缀长度
- 难以理解的是中间的while循环,核心在于j的值不断回溯然后不断和char(i)进行比较,如果相等就直接找到了最长的前缀:
编辑
- 如上图,我们保证每一次循环,j都是上一次前缀末尾,i是当前遍历,如果不相等,像上面这样一红一黄,j就回溯。
编辑
- 如图,进行回溯后,会到(j-1)位置对应的前缀的下一个,由于对称,会出现这样图示的多个相等块的情况,再次比较,都是黄色,此时,j就是最大前缀的末尾巴。
- j++,为下一次做准备。
疑惑
很容易理解,我们找到所有对称前缀后缀,一一比较他们的下一个就能找到,如果没有,那next[i]就是0,但是还有一个问题,我们回溯next数组并不是全部回溯,而是部分回溯,这使得很多人疑惑:
"为什么找到的j就是最长前缀的末尾?为什么通过已经确定 的next数组的部分值就可以确定j的值?"
编辑
你可能会想到这样的情况,我们回溯j不是回到next[j-1],换一个,例如next[j-2] ,如图,然后和之前一样,我们得到一个图,此时按照next[j-1],我们得到紫色,即需要进行第二次循环,下一次匹配成功。这时可能会发生这样的情况,最终经过第二次循环的next[j-1]可能比next[j-2]要小!!!
事实上,这个问题的可以转化为如图: 当粉红色的不满足时,为什么是去前面的深粉红,而不是和比粉红小但是和其同级的蓝色(深粉红在粉红内,我们称其为子级)?
编辑
因为这种情况被包含在了j-1上:
图中满足红框相等、黄框也相等
- 当相差一个时:
编辑
- 当相差两个时:
编辑
不难发现,在这样的情况下我们得出的结果和j-1一样!!
需要注意的是,我们的疑惑是正确的,例如:相差两个且红框为偶数时:如图,虽然我们的结果都是xyxy,但是利用j-1得出的是子级(棕色)不是黄框!!!
编辑
一个其他的问题
在写上面的代码时,我尝试遍历的next数组的所有元素:
ini
public int[] getNextArr(String s){
int len=s.length();
int j=0;
int[] next=new int[len];
for(int i=1;i<len;i++){
int c=i;
while(j>0&&s.charAt(i)!=s.charAt(j)){
c--;
if(c>=0) j=next[c];
else break;
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
next[i]=j;
}
return next;
}
惊讶的发现无法成功运行!现在发现不可以直接这样做,应为next数组的元素和字符串是一 一对应的,所以,我们在遍历的时候,通过j-1的方式可以保证底是char(i-1),而通过i-1却无法保证!当然
利用前缀表计算KMP的代码
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []next=getNextArr(needle);
//接下来就是对于主串的遍历,也是一个字符一个字符地遍历:
for (int i = 0, j = 0; i < n; i++) {
//此时的j对标i,比较j和i的值即可
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
j = next[j - 1];
}
if (haystack.charAt(i) == needle.charAt(j)) {
j++;
}
if (j == m) {
return i - m + 1;
}
}
return -1;
}
public int[] getNextArr(String s){
int len=s.length();
int j=0;
int[] next=new int[len];
for(int i=1;i<len;i++){
while(j>0&&s.charAt(i)!=s.charAt(j)){
j=next[j-1];
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
next[i]=j;
}
return next;
}
}
得到了正确的next数组后,接下来的循环就是针对主串了,此时,i代表了主串的当前元素,j代表了待比较的串当前元素,如果相等,j++、i++,如果不等 ,j回溯到next[j-1]即可,因为next[j]代表了是以j(发生冲突的元素)为底,都已经冲突了当然不能以它为底。然后继续比较即可。
以上是KMP算法的核心思想,其实next数组就是表明了对于每一个发生冲突的元素都有一条链来来进行匹配。
深入学习
学习完上面的内容,实际上就可以设计KMP算法来解决问题了,以下的内容其实和KMP的核心不是很相关,我们会讲解"next"数组、nextval、探讨KMP算法是如何"越来越难理解的"。
我们已经搞懂了KMP算法,在进行更深一步的学习前,还需要清楚以下前提:
- 我们在上面求得的next数组很多人称之为PM数组,然后在此基础上求得的才是next数组,接下来我们沿用这种说法
- 我们的PM数组实际的含义:PM[i]表示以i结尾最长相同前后缀的长度,同时用于j的索引更新使得j表示为前缀的下一个字符的索引。这点一点要牢记
- 好了,接下来继续加油吧!!
next数组
第一个next数组
在前面的学习中,我们遍历PM数组总是要进行j-1操作,所以不妨返回一个next数组,记录直接要回溯的位置,实际上就是整体向右边移动 。
以字符串'ababaaababaa'为例子
编辑
你会发现实际上是PM往右移动了一步,这使得next[i]失去意义,但是next[j]就直接表示j要去的位置
使用next数组
Java复制代码
第二个next数组
编辑
实际上是第一个next数组每个元素都加一的结果,这个其实用于主串下标从1开始时,这种情况没什么好讨论的,我们之后的next数组都是第一个。
问题及引导
在我们创造第一个next数组时,我们往左边添加了一个-1,细心的读者会发现在我给出的代码中添加一个0而不是-1 也一样能够达到我们想要的结果,为什么?
请看下节:KMP为什么越来越难懂?
KMP为什么越来越难懂?
很多人在学习KMP算法时会产生无数个疑问,例如为什么next数组最左边是-1?而且越看相关博客、相关题解越多疑惑、越懵逼。
请记住,读到这里,你已经具备使用KMP算法的能力,所以不要怀疑自己,错的是这个世界!这是我的一个小猜测:不同实现KMP算法的方式导致的KMP"越来越难懂" 。
我们收录两个处理例子来说明这个问题:
循环选择:
看过很多KMP相关题解的都知道,实现KMP需要两次循环,一次在求next数组时,一次在和主串配对时,这个循环就导致了很多问题。
我们采用的循环主要有:
- for-while循环:之前的代码都是for-while循环
- while循环
这些循环的实现方式就导致了例如next数组左边到底补谁的问题。
好了,在之前的next数组章节中,我们使用了很笨的方法,现在我们将用两种循环实现next数组;
for-while循环
遍历next数组
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []next=getNextArr(needle);
for (int i = 0, j = 0; i < n; i++) {
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
j = next[j];//只用改变这个地方
}
if (haystack.charAt(i) == needle.charAt(j)) {
j++;
}
if (j == m) {
return i - m + 1;
}
}
return -1;
}
public int[] getNextArr(String s) {
int len = s.length();
int[] next = new int[len];
next[0] = -1;
int i = 1;
int j = 0;
while (i < len - 1){
if(j == -1||s.charAt(i) == s.charAt(j)){
j++;
i++;
next[i] = j;
} else {
j = next[j];
}
}
next[0]=0;//这里将第一个元素改为0;不改也可以
return next;
}
}
for-while遍历next对其第一个元素没有要求,它能够很好地处理开头(但是建议使用补0而不是-1,稍后我们会讲解为什么)。相较于遍历pm数组,只用改变一个地方即可
遍历pm数组
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []pm=getPmArr(needle);
//遍历pm
for (int i = 0, j = 0; i < n; i++) {
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
j = pm[j-1];//就这里改一下就好
}
if (haystack.charAt(i) == needle.charAt(j)) {
j++;
}
if (j == m) {
return i - m + 1;
}
}
return -1;
}
public int[] getPmArr(String s){
int len=s.length();
int j=0;
int[] pm=new int[len];
pm[0]=j;
for(int i=1;i<len;i++){
while(j>0&&s.charAt(i)!=s.charAt(j)){
j=pm[j-1];
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
pm[i]=j;
}
return pm;
}
}
生成next数组
ini
public int[] getNextArr(String s){
int len=s.length();
int j=0;
int[] next=new int[len];
next[0]=0;
for(int i=1;i<len-1;i++){//for循环到len-2
while(j>0&&s.charAt(i)!=s.charAt(j)){
j=next[j];//改成[j]
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
next[i+1]=j;//这里改变
}
return next;
}
这个大有来头,生成next数组,实际上只用讨论1~len-1个,所以这里到 len-2 即可,可以省去一次判断
生成pm数组
ini
public int[] getPmArr(String s){
int len=s.length();
int j=0;
int[] pm=new int[len];
next[0]=j;
for(int i=1;i<len;i++){
while(j>0&&s.charAt(i)!=s.charAt(j)){
j=pm[j-1];
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
pm[i]=j;
}
return pm;
}
While循环
遍历next数组
对于next数组,避不开的是讨论第一个值是否要为-1,我的回答是不用。
我们先来看第一段while循环遍历next数组的代码:
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []next=getNextArr(needle);
// 接下来就是对于主串的遍历,也是一个字符一个字符地遍历:
// 遍历next
int i=0;
int j=0;
while(i<n&&j<m){
if(j==-1||haystack.charAt(i) == needle.charAt(j)){
j++;
i++;
}else{
j=next[j];
}
if(j==m){
return i-m;
}
}
return -1;
}
public int[] getNextArr(String s) {
int len = s.length();
int[] next = new int[len];
next[0] = -1;
int i = 1;
int j = 0;
while (i < len - 1){
if(j == -1||s.charAt(i) == s.charAt(j)){
j++;
i++;
next[i] = j;
} else {
j = next[j];
}
}
return next;
}
}
我们要求next数组的第一个值为-1以此来表示开头,不然在例如开头元素就不相等的情况下,j会一直恒为0,导致无限循环。但是我们可以添加条件来解决这个问题:
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []next=getNextArr(needle);
// 接下来就是对于主串的遍历,也是一个字符一个字符地遍历:
// 遍历next
int i=0;
int j=0;
while(i<n&&j<m){
if(haystack.charAt(i) == needle.charAt(j)){
j++;
i++;
}else if(j!=0){
j=next[j];
}else{
i++;
}
if(j==m){
return i-m;
}
}
return -1;
}
public int[] getNextArr(String s) {
int len = s.length();
int[] next = new int[len];
next[0] = -1;
int i = 1;
int j = 0;
while (i < len - 1){
if(j == -1||s.charAt(i) == s.charAt(j)){
j++;
i++;
next[i] = j;
} else {
j = next[j];
}
}
next[0]=0;//这里将第一个元素改为0;
return next;
}
}
所以我认为while循环遍历next数组是不是-1都可以
遍历pm数组
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []pm=getPmArr(needle);
int i=0;
int j=0;
while(i<n&&j<m){
if(j==-1||haystack.charAt(i) == needle.charAt(j)){
j++;
i++;
}else if(j!=0){
j=pm[j-1];
}else{
i++;
}
if(j==m){
return i-m;
}
}
return -1;
}
public int[] getPmArr(String s){
int len=s.length();
int j=0;
int[] pm=new int[len];
pm[0]=j;
for(int i=1;i<len;i++){
while(j>0&&s.charAt(i)!=s.charAt(j)){
j=pm[j-1];
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
pm[i]=j;
}
return pm;
}
}
对于pm数组,最开始的while无法处理j-1的情况,即对于j=0无法处理,在更改后就可以了
生成next数组
ini
public int[] getNextArr(String s) {
int len = s.length();
int[] next = new int[len];
next[0] = -1;
int i = 1;
int j = 0;
while (i < len - 1){
if(j == -1||s.charAt(i) == s.charAt(j)){
j++;
i++;
next[i] = j;
} else {
j = next[j];
}
}
return next;
}
生成pm数组
ini
public int[] getPmArr(String s) {
int len = s.length();
int[] pm = new int[len];
pm[0] = 0;
int i = 1;
int j = 0;
while (i < len){
if(s.charAt(i) == s.charAt(j)){
j++;
pm[i] = j;
i++;
}else if(j!=0){
j = pm[j-1];
}else{
pm[i] = j;
i++;
}
}
return pm;
}
无论是哪个循环,生成pm和next区别就是i是是否先+1。
总结
不难看出,实际上无论是哪个循环,经过变式都可以统一,但是不同的处理方式也就造成了不同的题解和博客, 使得读了不同博客的读者产生疑惑,也就使得KMP算法越来越难懂。
PM整体减1:
其实我觉得没什么意义。
nextval
实际上,使用了KMP算法,我们仍然存在很多无意义运算,例如模式串"aaaab"与主串"aaabaaaab"进行配对时,第四位字符a、b不匹配,这时主串b需要依次跟第三位a,第二位a,第一位a依次作对比。nextval就是对next数组的改进,减少无意义对比。
原理
先讲如何求nextval :nextval第一个元素恒为-1(对于下标为0开始),之后相等取nextval,不等取next:
- 第一个数,为-1
- 第二个数,next[1]表示b和第一个字符即a做比较,不相同,取next[1]即为1
- 第三个数,a和a相同,取nextval对应的数,即为-1
- .......
编辑
原理 :实际上,当发生冲突时,j会回溯到位置M,在next数组中即next[j],所以我们这里实际上是比较char(j)和char(next[j]),由于char(j)发生冲突了,所以如果两个相等,说明char(next[j])也会发生冲突,如果两个不相同,说明这个值有效可以对比,我们就取next数组对应的值即可。
我们还没讲为什么相等时nextval取值为对应的nextval,我这样解释:next数组的元素实际上类似一条条链,是j在遍历时会经过的地方,而nextval的取值过程就是重新构造一条链,我们取上述链的0~4来看:
编辑
可以看出,重新创造的链会过滤掉重复的元素!!即存储的nextval表示过滤过的元素。
然后我们就得到一个崭新nextval数组,但是nextval数组和next数组的循环是一模一样的吗?
问题--遍历nextval
我们在KMP为什么越来越难懂部分提到,使用for-while循环来比较主串和模式串时,在next数组中最好补0而不是-1,这里给出原因。
看下列代码:
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []next=getNextArr(needle);
for (int i = 0, j = 0; i < n; i++) {
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
j = next[j];
}
if (haystack.charAt(i) == needle.charAt(j)) {
j++;
}
if (j == m) {
return i - m + 1;
}
}
return -1;
}
public int[] getNextArr(String s){
int len=s.length();
int j=0;
int[] next=new int[len];
next[0]=j;
for(int i=1;i<len;i++){
while(j>0&&s.charAt(i)!=s.charAt(j)){
j=next[j-1];
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
next[i]=j;
}
int[] NewNext=new int[len];
for(int i=1;i<len;i++){
NewNext[i]=next[i-1];
}
NewNext[0]=0;
int[] nextval=new int[len];
nextval[0]=-1;
for(int i=1;i<len;i++){
int c=NewNext[i];
if(s.charAt(c)==s.charAt(i)){
nextval[i]=nextval[c];
}else{
nextval[i]=NewNext[i];
}
}
return nextval;
}
}
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []next=getNextArr(needle);
// 接下来就是对于主串的遍历,也是一个字符一个字符地遍历:
// 遍历next
int i=0;
int j=0;
while(i<n&&j<m){
if(j==-1||haystack.charAt(i) == needle.charAt(j)){
j++;
i++;
}else{
j=next[j];
}
if(j==m){
return i-m;
}
}
return -1;
}
public int[] getNextArr(String s){
int len=s.length();
int j=0;
int[] next=new int[len];
next[0]=j;
for(int i=1;i<len;i++){
while(j>0&&s.charAt(i)!=s.charAt(j)){
j=next[j-1];
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
next[i]=j;
}
int[] NewNext=new int[len];
for(int i=1;i<len;i++){
NewNext[i]=next[i-1];
}
NewNext[0]=0;
int[] nextval=new int[len];
nextval[0]=-1;
for(int i=1;i<len;i++){
int c=NewNext[i];
if(s.charAt(c)==s.charAt(i)){
nextval[i]=nextval[c];
}else{
nextval[i]=NewNext[i];
}
}
return nextval;
}
}
实测中,例子1也就是for-while循环发生错误,而例子2while循环运行良好!
这是因为for-while会直接访问-1,当我们使用next循环时,只有开头一个-1,而要访问它必须是j=0,这种情况下又无法访问next[j],而在nextval中,我们可能会存在很多-1,j不等于0也可以直接访问使得j为-1,导致后面的if报错,而whlie循环可以捕获j=-1的情况。
生成nextval
我们的例子采用原始方法求next数组然后再求nextval,以下提供更为优美的方法:
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []next=getNextArr(needle);
int i=0;
int j=0;
while(i<n&&j<m){
if(j==-1||haystack.charAt(i) == needle.charAt(j)){
j++;
i++;
}else{
j=next[j];
}
if(j==m){
return i-m;
}
}
return -1;
}
public int[] getNextArr(String s) {
int len = s.length();
int[] next = new int[len];
next[0] = -1;
int i = 1;
int j = 0;
while (i < len - 1){
if(j == -1||s.charAt(i) == s.charAt(j)){
j++;
i++;
if(s.charAt(j)==s.charAt(i)){
next[i]=next[j];
}else{
next[i]=j;
}
} else {
j = next[j];
}
}
return next;
}
}
只要再next数组赋值的那个地方改一下就好
for-while的版本:【以第一个元素为0】
ini
class Solution {
public int strStr(String haystack, String needle) {
int n = haystack.length(), m = needle.length();
if (m == 0) {
return 0;
}
int []next=getNextArr(needle);
for (int i = 0, j = 0; i < n; i++) {
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
j = next[j];
}
if (haystack.charAt(i) == needle.charAt(j)) {
j++;
}
if (j == m) {
return i - m + 1;
}
}
return -1;
}
public int[] getNextArr(String s){
int len=s.length();
int j=0;
int[] next=new int[len];
next[0]=0;
for(int i=1;i<len-1;i++){//for循环到len-2
while(j>0&&s.charAt(i)!=s.charAt(j)){
j=next[j];//改成[j]
}
if(s.charAt(i)==s.charAt(j)){
j++;
}
if(s.charAt(j)==s.charAt(i+1)){
next[i+1]=next[j];
}else{
next[i+1]=j;
}
}
return next;
}
}
很感谢看到这的朋友,欢迎指正错误、交流讨论。