KMP算法

切入

去这里看:

http://81.68.192.120/blog/102

这个算法是我去年开始看的,当时看的是韩顺平在尚硅谷https://www.bilibili.com/video/BV1E4411H73v?p=161讲的那个视频。当时我就没看懂。不过后来参考了一些其他人写的博客https://www.cnblogs.com/zzuuoo666/p/9028287.html,想了想好像懂了。如今刷力扣的时候又遇到了这个题,很离谱这题竟然标了个“简单”,真是一言难尽。

虽然说韩顺平讲的那个视频有很多关键的东西他没讲,不过他给出的这版代码属实还是很经典的。

代码

KMP算法的代码很简单,很整洁,在力扣28题里可以这样表达:

class Solution {
    public int strStr(String haystack, String needle) {
        if(needle.length() == 0) {
            return 0;
        }

        //kmp,先求next数组
        int[] next = new int[needle.length()];
        //单个元素的字符串公共子串的长度为0
        next[0] = 0;
        int j = 0;
        for(int i = 1; i < needle.length(); i++) {
            while(j > 0 && needle.charAt(i) != needle.charAt(j)) {
                j = next[--j];
            }

            if(needle.charAt(i) == needle.charAt(j)) {
                j++;
            }

            next[i] = j;
        }

        //利用部分匹配表去匹配
        j = 0;
        for(int i = 0; i < haystack.length(); i++) {
            while(j > 0 && haystack.charAt(i) != needle.charAt(j)) {
                j = next[j - 1];
            }

            if(haystack.charAt(i) == needle.charAt(j)) {
                j++;
            }

            if(j == needle.length()) {
                return i - j + 1;
            }
        }
        return -1;

    }
}

首先可以看到这代码两个for循环,里面其实一直在重复一些东西。之所以是这种简单的流程才正让人看不懂。写的太简单的东西就是这样。这里确实是有点大道至简的味道。

思想

核心思想

子串匹配母串的时候,当最终匹配失败的时候,暴力匹配的方法是,将子串向右移动一位,然后继续从两个串的头开始比较。这种方法有所冗余,因为当匹配失败的时候,子串前面有一些字符其实是匹配成功了的。如果你粗暴的往后只移动一位,那无疑是忽略了很多前面子串匹配成功所包含的信息,比如说:

  • BBC ABCDABABCDABCDABDE
  •      ABCDABD

可以看到当子串和母串匹配了6个字符以后,子串第七个字符和母串匹配不上了。在这种情况下,虽然A和D没有成功匹配,但是子串ABCDABD的2到4位,即BCD,都曾经匹配成功过,而且这三个是不可能和子串第一位,即A,匹配成功的。所以此时需要直接右移4位,即变成:

  • BBC ABCDABABCDABCDABDE
  •            ABCDABD

但是这里其实移动4位之后的AB也是不用比较的,直接从C开始比较,也就是i指针并不用回退,改变j指针就好。

为什么是移动4位呢?

这里需要参考另一个概念,即最大前缀后缀公共长度

  • 当字符串只包含a这个字符时,认为长度为0
  • 当字符串是ab时,前缀为a,后缀为b,长度为0
  • 当字符串是aba时,前缀为a、ab,后缀为a、ba,长度为0
  • 当字符串是abcab时,前缀为a、ab、abc、abca,后缀为b、ab、cab、bcab,长度为2

于是在这种规则下,子串ABCDABD的前6位,即ABCDAB,的最大前缀后缀公共长度就是2。第7位匹配不上的时候,需要移动的位数就是\(6-2=4\)。这种情况下,实际上是直接跳到了下一个相似的模式之中,因为在母串中,这些匹配成功且不是最大公共前缀和后缀的字母,是不可能和子串的开头匹配成功的,所以子串可以移动直接跳过它们。例如:

  • BBC ABADABABCDABCDABDE
  •      ABADABD

中间的AD是不可能和开头的AB成功匹配的,就直接跳过它们。假设中间这些字母能成功和子串开头匹配,那长度就不可能为现在的2。

如何证明?

反证法

biji309.jpg

用上图这种“形象”的方法,如果把上面和下面抽象位正在匹配的母串和子串。现在假设黑色的部分是最长公共前缀后缀,那匹配不成功时只需要移动到子串的头和母串的尾黑色区域重合。但是现在假设黑色部分和红色部分可以匹配,正如上图所示,那势必要求方块部分和圆圈部分是可以匹配的,那么这样最长公共前缀后缀就绝不是上图的黑色部分了,与假设产生了矛盾。

代码中的疑点

next数组的构建

首先,代码需要构建出一个next数组,这个数组的长度就是子串的长度,数组中每一个位置填写的数字,就是当前部分子串(以当前下标为尾)的最大前缀后缀公共长度。于是数组的第一个位置就是0,代表单个字符形成的字符串最大前缀后缀公共长度是0。

	int[] next = new int[needle.length()];
	//单个元素的字符串公共子串的长度为0
	next[0] = 0;
	int j = 0;
	for(int i = 1; i < needle.length(); i++) {
		while(j > 0 && needle.charAt(i) != needle.charAt(j)) {
     			j = next[--j];
		}

    		if(needle.charAt(i) == needle.charAt(j)) {
        		j++;
    		}

        	next[i] = j;
	}

遍历这个子串的时候,去填充这个数组。从数组的第2个位置开始填充。

while(j > 0 && needle.charAt(i) != needle.charAt(j)) {
                j = next[--j];
            }

这句是什么意思?

可以假设有ABCDEF...XYZABCDF这样一个子串,自然next数组的前26个位置都是0,第27个位置由于来到了A,所以填1(A),28个位置填2(AB),29个位置填3(ABC),30个位置填4(ABCD),31个位置填什么呢?自然是0。因为next[30] != next[3],所以j = next[2],即0。这里看出j = next[--j]其实是一个递归过程

2357092202105150102227384845596591.png

如上图所示,是一个正在设置next数组的子串,当你想要找到子串末尾E对应的数字时,发现E和j对应的字符F(在标号2右上侧)不一致,这时next[- -j]就定位到了E字符(在标号1的右上侧),这时你发现E和E是相等的字符,就找到了这个长度,就是\(4 + 1 = 5\)了。你会发现2与3是可以匹配的,而2与1也是可以匹配的,这样1与3就是可以匹配的了。

这样,next数组就构建出来了。

匹配

匹配的过程代码和构建数组时,是极其类似的。

 //利用部分匹配表去匹配
        j = 0;
        for(int i = 0; i < haystack.length(); i++) {
            while(j > 0 && haystack.charAt(i) != needle.charAt(j)) {
                j = next[j - 1];
            }

            if(haystack.charAt(i) == needle.charAt(j)) {
                j++;
            }

            if(j == needle.length()) {
                return i - j + 1;
            }
        }
        return -1;

思路可以说和暴力匹配的时候差不多,如果母串和子串能匹配上,就i和j分别自增,看下一位。如果匹配不上,这时候子串就要移动了,移动在宏观表现为j指针的变化,j现在指向next[j - 1]了。如果不相等就递归一直往前找,这样可以保证j在减小的同时,j后面那部分模式串和i后面的模式串是可以匹配的。最终j有可能归0,即回到了子串的开头。

posted @ 2022-03-09 17:01  imissinstagram  Views(41)  Comments(0)    收藏  举报