KMP思路解析与代码实现——next数组与nextVal数组实现详解

1.引入思考

在这里插入图片描述

我们首先假设是一个毫无算法基础的同学,会怎么写这道题呢?

应该是对这两个字符串进行 依次匹配。虽然繁琐了一点,但总能匹配出结果。其实这也就是算法开始的基础和雏形。KMP没有在开辟一个新方法,而只是对原有的基础方法进行浓缩,提炼。

当我们看到以上两个字符串,用我们的肉眼可以清晰的从第一次匹配,跳到第二次匹配。为什么呢?因为当我们匹配到第一个 d 时,发现匹配失败了,那么至少主串前缀中 b c 两个字符串,都无法匹配上模式串中的 a b,我们只需要找到在原来已经经过匹配的主串中(即 a b c a b),是否还有能和模式串的头部( a b c a b d)能匹配上的。如果有,我们就移动到能匹配上的位置。如果没有,我们直接将模式串的首部移动模式串在 d 的位置。

理解了跳跃型的匹配,那么我们怎么精确的找到第二次匹配的位置呢?

如果我们一次性从第一个字符串,跳到最后一个字符串,如(a a c a a), a 和 a 能匹配上,但是由于跨度之大,我们错过了 a a 和 a a 的匹配,有可能正确的匹配答案就在从 a a 开始的字符串中。所以我们要竭尽可能的让从尾部开始的匹配长度越长,长到我们能匹配到的极限,才能不会遗漏匹配。

那这个首尾的匹配是否有规律的呢

我们是在每一次匹配的循环中,去检索已经匹配过的首尾,即在循环之外再加一层循环,只为了得到下一次跳跃匹配的位置(这样明显听起来就更繁琐了),还是有没有更便捷的方法,让我们最终能使算法更简洁?

注意到,当我们需要匹配首尾位置时,即 模式串与主串的前部分是能匹配上的,只是当前索引的字符串匹配不上,我们才要移动模式串的位置。所以可得,我们每次 需要查询首尾的字符串,都是存在于模式串中的

我们甚至可以在开始匹配前,通过研究这个模式串,能得到我们每一次匹配失败时,需要查询首尾信息的子串!这样我们一遇到无法匹配的情况,就通过我们提前已经找到的,在该子串中首尾能匹配上的最长字符串,移动到下一个位置!

所以我们算法的第一个核心,就是研究模式串,找到模式串中从索引 0 开始的每一个子串,它的最长首尾匹配字符串长度(即模式串为 a b c a b 时,从索引 0 即 a 开始,a / a b / a b c / a b c a ,这些子串的首尾信息 )!

2.研究模式串

2.1 普遍情况

我们先假设一个比较普遍的情况,模式串为 | a b a c a b a b |d,我们先匹配到 d 最后一位,这种情况
在这里插入图片描述

2.1.1 匹配算法

对于从索引 0 - 7 的情况,我们要怎么匹配得到它的最长首尾匹配字符串呢?最先想到的应该是暴力算法。

  • 暴力算法:使用双层循环,或者双指针,依次遍历,找到最长匹配的首尾字符串
//伪代码--双层循环
//这是第一层循环,求解一个最长字符串,我们要对模式串的每一个子串,都求出最长匹配字符串
int maxLen = 0;
for(int i = 1 ; i < str.len;i++){
    //从当前字符串开始匹配,如果与首字符串相等,就依次匹配,直到不匹配位置,算出当前长度
		int start = i;
    //每次从首字符串开始匹配
 	 	int head = 0;
  	while( start < str.len && head < str.len && str[start] ==str[head]){
   	 	start++;
    	head++;
  	}
    //如果匹配到了最后一位,说明满足首尾有相等字符串,存储改长度
    if( start == str.len){
       	maxLen = Math.max(maxLen,head + 1);
        //一般来说,从左开始往右执行,最先满足条件的应该为最长字符串了,所以跳出循环即可
        break;
    }
}

但这种方法始终不够简洁,且一个 for + while 的循环,仅能求出一个子串的最长匹配字符串,我们需要的是模式串的 每一个子串 的最长匹配字符串。这显然增大的工作量,说不定还没有暴力算法来得快。

那我们是不是可以用到动态规划呢
在这里插入图片描述

当我们要求 0 - 7 的首尾字符串时,可不可以利用 0 - 6 的首尾字符串信息?

我们假设 0 - 6 的首尾字符串长度表示为 d[6] = 3,这是已知信息。再假设当前模式串为 modelStr。

在求 d[7] 时,我们会遇到以下几种情况:

  • 如果 modelStr[3] == modelStr[7],且d[6] = 3,则相对于 d[6] 我们的首尾匹配长度增加一位,为 d[7] = d[6] + 1;
  • 如果 modelStr[3] != modelStr[7] 时,我们可以知道 d[7] <= d[6] 一定成立,也就是在 0 - 2 中找是否还有能和尾部匹配上的;那我们先找在 0 - 2 内部,有没有首尾匹配串呢?

这里引入一段思考:

我们可以用肉眼先得出 d[2] = 1,匹配字符串为 a ,那这个信息是否有用呢?

因为 0 - 2 和 4 - 6 是能匹配,也就是完全相等的。如果 modelStr[0] == modelStr[2] ,那么必然 modelStr[4] == modelStr[6],而我们早就知道 modelStr[0] == modelStr[4] ,所以与之对称的4个数都是相等的,即 0 2 4 6 都相等。

所以 d[2] 的匹配字符串,也必然会匹配到 0 - 6 的尾部,即 modelStr[0] == modelStr[6],我们又一次找到一个首尾匹配的子字符串!所以我们可以接着继续比较在它之后的 1 号是否与我们当前的 7 号字符串相等,便又一次回到我们上面讨论的两种情况循环。

总结来说,我们不断利用已知信息——在前置位匹配完的首尾字符串长度,进行动态递归匹配。

  • 如果存在匹配字符串,再将我们当前的字符串(即 7 号),与匹配完的字符串的后一位(即 3 号)进行匹配,如果相等,我们找到了目标字符串,匹配到此结束;
  • 如果没有,再往更短的字符串匹配,这个更短的字符串,在我们的首部 或者 尾部,单条字符串内部寻找。继续循环匹配,直到结束。

所以这里我们引出一个问题,就是匹配的边界。

2.1.2 边界问题

1.对于尾部

我们首先看尾部,当匹配到 8 号时,说明前面 0 - 7 都已经能匹配上,如果 8 号也能匹配上,则匹配结束;如果 8 号不能匹配上,我们需要查看 d[7] = 2,再把首部的前2位移过来,将 modelStr[2] 与主串匹配,如果依旧不能匹配,我们要再次移动,这点跟我们上一步的过程类似。具体移动,是我们下一步的事,现在先不讨论。

由以上过程我们可以知道,轮到 8 号匹配时,要么结束,要么只需要利用到 d[7] 的信息。再退一步,就算我们要计算 d[8] ,仔细想想,我们的计算 d[8] 什么时候可以用上?得要 modelStr[9] 匹配不上时,我们要看看前 0 - 8 位要怎么移动效率最高吧。可关键是,没有 modelStr[9] 呀,8号能匹配上就已经结束了。

所以对于一个长度 modelStr.len = 9 的字符串,我们只需要得到它前 8 位的首尾匹配字符串,即 0 - 7 号。

2.对于首部
在这里插入图片描述

我们先分析三种首部匹配情况:

  • 第0个匹配:当不相等时,我们直接往后移一位
  • 第1个匹配:当 index = 1时不匹配,我们同样往后移动一位
  • 第2个匹配:我们知道 d[2] = 1,出现了首尾相等的字符串,所以我们此时只移动一位。想象如果模式串不是为 a a c ,而是 mainStr= “a b b” ,modelStr = “a b c” 的情况呢,此时 d[2] = 0,表明在我们匹配过的字符串中没有再能与模式串的首部匹配得上的,所以我们不再考虑在前置部分(也就是 “a b")去匹配模式串。

虽然都是对于前两种情况,都是往后移动一位,但是在程序中的含义是不同的。

我们匹配时有一个需要注意的地方时,我们只遍历一次主串,当发现不匹配时,我们移动的是模式串,对于 d[6] = 3 的情况,我们留模式串的前3个字符串,将第四个字符串与 mainStr[7]对齐,即 modelStr[3] == mainStr[7],由于数组的索引从 0 开始,意外发现首尾匹配字符串的长度刚好等于我们匹配对齐的位置。

即 首尾匹配字符串的长度为 3 ,我们把 索引值为 3 的模式串字符串,对齐当前待匹配的主串字符 7 号。
在这里插入图片描述

所以我们得到了一个核心知识点,d[i] = n,这个 n 表示 模式串 下一次对齐的索引,且主串的索引不动。

那么第0个、第1个、第2个匹配的区别究竟在哪里呢?我们看看图。
在这里插入图片描述

  • 第0个匹配:从第0个字符串起就不匹配,此处主串处于索引为 0 的位置,在保持主串不动的前提下,模式串的 -1 索引对齐此时的主串。而对于模式串来说,甚至我们都还没开始比,连前置位字符串都没有,都不存在索引为 0 的字符串作为前置字符串,所以我们写为 d[-1] = -1(这是错误的表达,此处只是为了说明得更清楚)
  • 第1个匹配:第1个字符串能匹配上,但是第二个字符串不能匹配了,我们此时也不讲究什么KMP算法,反正是要往后移 1 位,即此时将主串索引为 1 的位置,对齐模式串索引为 0 的位置,看起来是 d[0] = 0

而我们的前置位字符串只有索引为 0 的 a ,它本身是没有首尾匹配字符串的,因为首尾的字符串不能包含首部的第一个字符串(想一想为什么不能包含?因为包含了首部第一个字符串的首尾匹配字符串,最大长度永远是它本身,既没有意义,同时,运用到匹配时会一直原地移动,无法向后移)。所以 d[0] = 0,发现含义和以上我们作出的移动位置结果是一样的,即 d[0] = 0;

  • 第2个匹配:前两个字符串匹配得上,我们将模式串的 1 号移动对齐主串,即 d[1] = 1

从理解层面,匹配到模式串的索引 2 号时,前置字符串有2个,首尾匹配字符串长度为 1 ,即 a 。所以我们把1个长度的模式串用于前置匹配,从索引 1 开始匹配当前主串的位置。这也是正好利用了数组索引从 0 开始的特性。对于 索引 1 号的模式串,它的首尾匹配字符串的长度为 1 。

到了这里,我们能很清楚的知道关于边界条件的两点:

  • 模式串的最后一位,是不需要计算首尾匹配字符串的
  • 当第一个(索引为 0 )字符串就不匹配时,d[-1] = -1,这时要将模式串移动到当前主串位置的下一位。

既然最后一位不需要,而第一位又从d[-1]开始,那么我们能不能将目前所有位都向右移动一位?

原本含义 d[i] = n ——> 索引为 i 的模式串位置,包含它自己在内的从 0 起的首尾匹配长度为 n

改为 d[i + 1] = n ——> 匹配到 i + 1 位置时,如果发生不匹配的情况,要移动模式串位置了,那么查看该位置的前一个位置 i ,包含 i 在内的首尾匹配长度,根据这个位置 n ,用 modelStr[n] 的字符匹配当前主串的位置。

是不是搞定了?这就是我们所说的 next[] 数组。接下来开始我们的实现。

2.1.3 代码实现

    private static int[] getNext(char[] model){
        int[] next = new int[model.length];
        next[0] = -1;
        int curr = 0;
        int match = -1;
        while (curr < model.length - 1 && match < model.length){
            if( match == - 1 || model[curr] == model[match]){
                curr++;
                match++;
                next[curr] = match;
            }else {
                match = next[match];
            }
        }
        return next;
    }

我们慢慢开始写代码。

我们首先考虑普遍匹配的情况,当我们匹配到 modelStr[n] 的 n 位时,是不是要利用它的前一位的首尾匹配字符串的信息,也就是 next[n - 1] ,如果相等,我们直接在这个基础上 + 1。如果不相等,我们找到 next[n-1]的长度 n,继续匹配。这时候我们要设置一个 currIndex 当前索引,一个记录 next[] 移动信息的 matchIndex。

currIndex 和 matchIndex 从哪里开始呢

我们首先明确这两个指针移动的时候,是不是在匹配成功的时候?彼此都 + 1,currIndex 是移动到下一个字符串匹配matchIndex 因为原值是上一个字符串的前缀长度如果能匹配上那么前缀长度变长,也 + 1。而我们定义 next[] 数组正好是 next[i] 表示 前 i - 1个字符串的前缀长度,那么我们当前 currIndex 的前缀长度,要写到下一个字符串的 next[] 数组中,也正好是在两个指针加完之后赋值,即 next[++currIndex] = ++ matchIndex;

所以我们可以先写出一部分代码:

//先不管它的初始值,设为x 和 y
int curr = x;
int match = y;
while( curr < modelStr.length ){
  if( modelStr[curr] == modelStr[match]){
    curr++;
    match++;
    next[curr] = match;
  }else{
    match = next[match];
  }
}

接着我们考虑它们的初始值

由于我们的 matchIndex 保存的是上一个匹配的位置,所以它 不必每次 特意赋值 matchIndex = next[i - 1],所以我们 只需要 找到 matchIndex 关键的起始值。

我们知道 next[ 0 ] = -1 ,next[ 1 ] = 0 是公用的。那么真正有用的 currIndex = 2 开始,也就是从索引 1 开始匹配前缀,最后赋值到 next[2] = x 。即先从 modelStr[ 0 ] == modelStr[ 1 ]匹配起,也就是 currIndex = 1,和 modelStr = 0开始,我们可以先将 next[ 0 ] 和 next[ 1] 的公用部分提前定义好。

不过我们在循环时一定要考虑matchIndex = next[0] = - 1的情况,这时我们无法再继续匹配和循环,需要移动当前主串的索引至下一个,也给 next[++curr] = 0,这也是我们结束循环的其中一种边界条件。

既然有 matchIndex = -1的情况,那么我们初始值也可以从 matchIndex = - 1,currentIndex = 0 起,这跟我们提前设置两个公共值是一样的。所以我们的代码写为:

private int[] getNext(char[] modelStr){
    int[] next = new int[modelStr.length];
    next[0] = - 1;
		int curr = 0;
		int match = -1;
		while( curr < modelStr.length ){
	    	// match == - 1的情况和匹配成功的情况一样,都是结束循环,同时移动主串和模式串的指针
 	 		if( match == - 1 || modelStr[curr] == modelStr[match]){
   	 			curr++;
   	 			match++;
   	 			next[curr] = match;
 	 		}else{
 	 			//匹配不上就主串不动,模式串一直往前找能匹配的字符,停在这里循环
 	 		  	match = next[match];
  			}
		}
    return next;
}

2.2 特殊情况

在这里插入图片描述

我们可以得到它的 next数组(多加了一个A,更直观一些):

索引01234
字符串AAAAB
next数组-10123

但是有没有 bug 呢?

我们写完之后的 next 最终和暴力解法的次数居然是一样的!
特殊情况匹配

我们发现当匹配到红色框内时,如果主串与模式串发生不匹配,则可确定 a 是不可与当前 currIndex 匹配上的。如果 next[i] 检索到的 modelStr[next[i]] = a 的话,将会依旧无法与主串的 b 匹配!所以我们要继续检索,直到至少模式串拿来匹配的值,是不等于 a 的。

总结来说,我们可以通过一次匹配信息,知道模式串中必定与主串当前索引位置不匹配的值—— a ,所以如果我们通过 next[] 找到的值也为 a 的话,我们直接知道也必定匹配不成功,将往前继续查找能匹配的值。这就是 nextVal 的意义。

而有一个很讨巧的前,我们的 next 是从左到右遍历的,第一次遇到 nextVal 这种情况的 nextVal[] 已经将值替换为最前的 - 1 或者别的值,依次迭代,所以在我们之后发生 nextVal 这种匹配字符串与 next[] 相等的情况,我们使用上一个 next[] 的值就好,不必继续往前遍历,因为此时的值,已经是遍历后刷新的最终值。

我们将代码改为:

private int[] getNext(char[] modelStr){
    int[] next = new int[modelStr.length];
    next[0] = - 1;
		int curr = 0;
		int match = -1;
		while( curr < modelStr.length ){
 	 		if( match == - 1 || modelStr[curr] == modelStr[match]){
   	 		curr++;
   	 		match++;
        if( modelStr[curr] == modelStr[match]){
          	next[curr] = next[match];
        }else{
         		next[curr] = match; 
        }
 	 		}else{
 	 		  match = next[match];
  		}
		}
    return next;
}

至此,我们已经完成了KMP的所有准备工作,很多示例里只给到了计算 next 的部分,nextVal 可当作扩展部分。前期准备工作相当于完成了整个工作的80%,下面我们只需要利用已知的 nextVal 数组,匹配,移动模式串就行了。后续工作都非常简单。

3.正式匹配

我们只需要主串和模式串双双从 0 开始,依次匹配,匹配到不相等的时候,拿 next 数组中的索引值,将模式串的索引改为 next[i] 值,不匹配就一直拿 next[] 的值;匹配就往下走。如果匹配到尽头,modelIndex = -1 了,就双双往后移,跟匹配往下走的操作一样,只是 modelIndex 这时置空从 0 开始了。

如果前面的步骤看懂了的话,这里就很简单了,道理是一样的:

    private static int match(char[] origin,char[] model,int[] nextVal){
        int oriIndex = 0;
        int modIndex = 0;
        while (oriIndex < origin.length && modIndex < model.length){
            if( modIndex == -1 ||origin[oriIndex] == model[modIndex]){
                oriIndex++;
                modIndex++;
            }else {
                modIndex = nextVal[modIndex];
            }
        }
        return modIndex == model.length ? oriIndex - modIndex : -1;
    }

总结来说。KMP的核心既不是制定一个 next 数组一步找到我们下一步匹配的位置,也不是创造一种算法,走另一种捷径。它只是把我们原本的暴力算法优化了一下,将我们肉眼和人脑能处理的信息,通过算法过滤了多余的匹配步骤。

它的 起源 是学会观察模式串的特征。拥有相同前后缀的模式串,能帮助我们定位到下一次匹配的位置。省略了中间按图索骥的过程。

而它又并不能帮我们一步匹配到位(即只移动模式串一次,就可以与主串开启下一个字符串的匹配),因为主串的字符拥有多样性,我们只能确定匹配不上,无法知道到底什么样的字符能匹配上,所以要一次次回溯 next 的数。KMP最频繁的工程就是回溯循环了hh,不管是求 next / nextVal /match 都用到了它,只是在 nextVal 时它更进一步的利用了它回溯的特性。

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值