算法学习之KMP算法

338 阅读10分钟

首先,什么是KMP算法?KMP算法简单来说就是一个能够提高字符串的比较效率的算法,其主要用于字符串匹配上。KMP算法的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了

具体我们可以举个例子,假设现在我们有文本串aabaabaaf,接着我们希望文本传中第一个出现模式串aabaaf的字符的下标。如果我们使用暴力解法的话,那我们必然是嗯比较,就嗯比较,然后解题,但这样的效率是O(n^2),是需要改良的效率。这个时候我们的KMP算法就派上用场了,假设我们用KMP算法来寻找的话,那么首先我们会定义两个指针,一个指向文本串,另一个指向模式串,如果其相等,那么我们会令其同时前进,显然当我们文本串的指针指向第二个b的时候,就不相等了。如果是暴力解法,那么必然我们会到下一位上重新进行比较,但是在KMP算法里,我们文本串的指针会进入下一位,然后模式串的指针会到b的位置然后继续比较,然后我们就能够将模式串遍历完,此时返回对应的下标就可以了。

那么这里我们重点要明确的事情是,为什么KMP算法可以将模式串的指针准确定位到b中呢?其实它是靠前缀表来达到定位的效果的,我们一般用next或者prefix来代表前缀表,其是一个数组,我们的算法可以通过这个数组的值来跳跃到指定的下标来节省比较的时间。因为有一些比较,是完全没有必要进行的,我们可以跳过这些判断。

那么什么是前缀表呢?再了解这个之前,我们要先理解什么是前缀和后缀,还是以我们的文本串为例来说明上面各种名词的定义

首先前缀指的是一个字符串里包含首字符,而不包含尾字符的所有子串。比方对于我们的模式串aabaaf而言,a、aa、aab、aaba、aabaa都是前缀,但是aabaaf就不是前缀了,因为其包含了尾字符

而后缀指得是一个字符串里包含尾字符,而不包含首字符的所有子串。比方说对于我们的模式串aabaaf而言,f、af、aaf、baaf、abaaf都是后缀,但是aabaaf就不是后缀了,因为其包含了首字符

而我们的前缀表,其实里面存放的就是每个下标所截取的子字符串的最长相等前后缀的值。比方,对于我们的模式串aabaaf而言,其下有子串a、aa、aab、aaba、aabaa、aabaaf,首先我们来看a,a没有前缀和后缀,因为只有一个a的时候不符合前后缀的定义,因此a我们直接赋值为0,代表其最长的相等前后缀的值为0。而aa,其前缀为a,后缀为a,那么其最长相等前后缀的值为1。对于aab而言,因为其尾字符为b,显然其值为0。对于aabaa,其最长公共前后缀为2,当且仅当前缀aa,后缀为aa时。而对于aabaaf,其最长公共前后缀为0。综上,那么最终就可以获得一个前缀表,其下存放的值为{0,1,0,1,2,0}

注意啊,我们这里的前后缀的数法不是前缀从前往后数,后缀从后往前数找对称的啊,而是两边都是从前往后数找对称的啊。这里之所以前面的也可以得到相同的结果,是因为我们的例子总是有aa的字符串,其是不分前后的。但如果我们换成分前后的字符串,那么差异就会出来了

其实所谓的最长相等前后缀,就是指定一个字符串,然后找到其成最大成对称的两边的字符串的长度,这是最简单的想法。

当我们的指针在模式串中前进的时候,如果模式串里的指针和文本串里的指针所指向的字符是一样的,那么我们就一起前进,大家无事发生。但是一旦出现不同的情况,那么模式串里的指针就会寻找其上一个前缀表里的值,然后直接跳跃到这个值所对应的下标,从那个下标处重新开始比较。其实这个也比较好理解,既然文本串遍历的内容是根据模式串的内容来判断是否相同的,那么当模式串中的前后缀有相同的内容时,就意味着在文本串中也同样存在,我们让文本串直接进入下一次判断,结合模式串事先确定好的前缀表,可以跳过已经判断好的内容

最后我们可以总结下前缀表的作用是前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。同时前缀表记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。

关于前缀表的原理作用如何还有不明白的话,可以参照这个回答,其将理论知识更进一步进行了讲解www.zhihu.com/question/21…

前缀表我们一般使用next数组来进行表示,不过表示的方式也有许多种,我们这里采用的方式是最基本的方式,就是直接查找对应的子串中的最长相等子串并在对应的下标中记录。另外一种记录方式是让next数组下标所记录的内容整体右移一位,最左的位置赋予-1,如果采用这种方式,那么我们的前缀表就应该表示为{-1,0,1,0,1,2},采用这种方式构建前缀表的话,之后一旦有不相符的字符,就不用查找其后一位前缀表所记录的最长相等子串,直接查找原地的就行了。还有一种记录方式是让前缀表整体减一,采用这种方式我们的前缀表就应该表示为{-1,0,-1,0,1,-1},采用这种方式解题,那么我们查找对应要跳跃的位置时,要重新把1加上。说实话我不明白第三种方式有什么用,颇有种脱裤子放屁的感觉

不过无论是哪种方式都好,其本质都是一样的,只不过是代码的处理上会稍微有些分别罢了。我们这里教学,采用第一种方式来构建我们的前缀表

那么学习完上面的理论知识之后,我们现在来学习如何构建我们的前缀表。我们这里采用边构造代码边讲解的形式来完成我们的学习。当然,首先我们应该定义好我们的方法是吧,我们就将这个方法定义为getNext,这个方法的形参是一个next数组和一个模式串,然后我们要在这个方法里完成前缀表的构建。如果直接开始构建显然难度有点大,没头没尾的,这个时候我们就可以采取分步解题做法,将难题一步步解开来。我们分析我们的前缀表,我们可以将其分为四个步骤,1、初始化。2、前后缀不相同时。3、前后缀相同时。4、给next数组赋值。确定了这四步之后我们就来一步步完成

首先我们来解决第一步,初始化。我们现在初始化的时候确定好我们的大致思路,首先,要求出最长相等前后缀,必然要有前缀和后缀,因此我们定义两个变量i,j。前者表示后缀的末尾,后者表示前缀末尾,这样我们就可以来比较了。然后初始化,第一位的字符由于前后缀的特殊性,其值必然为0,我们直接给他赋值为零。(注意,这个步骤是有意义的,因为后面的值的确认是要结合前面的值的,如果我们没有初始值,那么后面的值就无法确定)既然j表示的是前缀的末尾,那么j初始化时就应该为0,从前缀子串的第一位开始。而i代表的后缀末尾,那么i就可以从1开始直到结尾,这样就能表示后缀子串。我们可以将j定义在外面,而将i用一个for循环来定义。

这里我们希望我们求得前缀表的方式是采用动态规划的方式来求得的,因此其实我们这里还可以这么理解,就是i代表的其实是子串的分割线,i在什么位置代表其子串的长度为多少,同时也代表我们求的是哪个子串的最长相等字符串。而j则是用于赋值的数,我们利用动态规划的思想,每次我们判断此处的最长公共字符串是否相等,主要依靠我们前面所确定的数。

这样我们代码的整体结构就算是实现完毕了,接着我们先来处理前后缀不相同时的情况。当我们的前后缀不相同时,我们就让j取到我们前缀表的数组的前一位,这里其实是利用了前面的结果来进行判断,当然,这里要加上j大于0的条件,这样才能够防止数组下标越界异常。

然后我们来处理前后缀相同时的情况,当前后缀相同时,我们就让j++,这就代表着将其最长相等字符串的值加一,然后我们在对应的地方上赋值,代表着我们给i代表的字符串的最长相等字符串下了定义。这样遍历一遍我们就可以得到我们的前缀表

那么最后我们可以构造我们的求前缀表的算法如下

class Solution {
    public int[] getNext(int[] next, String s) {
        int j = 0;
        next[0]=0;
        for (int i = 1; i < s.length(); 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;
    }
}

我们还有一个让前缀表整体右移一位的版本

class Solution {
    public int[] findRestaurant(int[] next, String s) {
        int j = -1;
        next[0]=j;
        for (int i = 1; i < s.length(); i++) {
            while (j>=0 && s.charAt(i) != s.charAt(j+1)){
                j = next[j];
            }
            if(s.charAt(i)==s.charAt(j+1)){
                j++;
            }
            next[i]=j;
        }
        return next;
    }
}

这里其实利用了动态规划的思想,可能听着会觉得有些懵懵懂懂的,没关系其实,因为我也懵。可以先把代码记着,以后学到动态规划那章之后我们再来重新理解这个算法也不迟