算法训练Day9| LeetCode28. 找出字符串中第一个匹配项的下标(KMP算法)

 链接:28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)

KMP算法理论基础知识

KMP的经典思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。

1. 什么是KMP

说到KMP,先说一下KMP这个名字是怎么来的,为什么叫做KMP呢。

因为是由这三位学者发明的:Knuth,Morris和Pratt,所以取了三位学者名字的首字母。所以叫做KMP

2.KMP有什么用

KMP主要应用在字符串匹配上。

KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。

所以如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。

Example: 要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf。

算法训练Day9| LeetCode28. 找出字符串中第一个匹配项的下标(KMP算法)_第1张图片

算法训练Day9| LeetCode28. 找出字符串中第一个匹配项的下标(KMP算法)_第2张图片

可以看出,文本串中第六个字符b 和 模式串的第六个字符f,不匹配了。如果暴力匹配,会发现不匹配,此时就要从头匹配了。

但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符b继续开始匹配,前缀表的作用就是在不匹配的时候,告诉你下一个应该跳去什么位置继续匹配。

3. 什么是前缀表?

next数组就是一个前缀表(prefix table)。

前缀表有什么作用呢?

前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。

前缀表是啥?

记录在模式串中下标i之前(包括i)的字符串中,每个位置的最长相等前后缀。

最长相等前后缀的概念是啥?

字符串的前缀:是指不包含最后一个字符的所有以第一个字符开头的连续子串;

后缀:是指不包含第一个字符的所有以最后一个字符结尾的连续子串;

前缀表要求的就是相同前后缀的长度,此时就要问了前缀表是如何记录的呢?

"aabaaf" 
# 前缀包含 a;aa;aab;aaba,aabaa
# 后缀包含 f;af;aaf;baaf;abaaf;
# 所以它的最长相等前后缀长度为0

"a" #无前缀无后缀,最长相等前后缀长度为0
"aa" # 最长相等前后缀长度为1
"aab" # 最长相等前后缀长度为0
"aaba" # 最长相等前后缀长度为1
"aabaa" # 最长相等前后缀长度为2

人工从肉眼看,模式串“aabaaf”的前缀表是[ 0,1,0,1,2,0 ]

4. 前缀表的信息为啥就可以带我们跳去正确的地方?

我们用肉眼观察写出了前缀表,现在思考,为什么有了前缀表之后,那为啥就能告诉我们 上次匹配的位置,并跳过去呢?

回顾一下,刚刚匹配的过程在下标5的地方遇到不匹配,模式串是指向f,如图:

算法训练Day9| LeetCode28. 找出字符串中第一个匹配项的下标(KMP算法)_第3张图片

 然后就找到了下标2,指向b,继续匹配:如图:

算法训练Day9| LeetCode28. 找出字符串中第一个匹配项的下标(KMP算法)_第4张图片

 以下这句话,对于理解为什么使用前缀表可以告诉我们匹配失败之后跳到哪里重新匹配 非常重要!也就是上面的图示说明的意思:

下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面从新匹配就可以了。

所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。

5. 如何计算前缀表?

我们前面用肉眼观察得出了模式串的前缀表如下:

可以看出模式串与前缀表对应位置的数字表示的就是:下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。

算法训练Day9| LeetCode28. 找出字符串中第一个匹配项的下标(KMP算法)_第5张图片

 那么在实际的代码中我们如何计算出前缀表呢? 这个过程实际上也用到了前缀表自身去生成前缀表。

总体的方法如下:

  1. 第一步,初始化,前缀表next的第一个元素一定为0,next = [ 0 ] ;然后初始化 j = 0 ,j 的含义是前缀的末尾位置的index,所以说 j+1就是前缀的长度= 最长相等前后缀的长度;初始化 i =1, i 的含义是后缀末尾位置index,因为后缀不能包括首尾元素,所以从index=1位置开始,同时从index= 0到index= i 的位置,就是当前需要求 最大相等前后缀长度的字符串范围,我们可以利用i的遍历,输出next[i]中的对应的值;
  2. 第二步,当s[i] == s[j] 的时候,这时候,最长相等前后缀的长度就是j+1,更新 j+=1继续比较;
  3. 第三步,当s[i] ≠s[j]的时候,此时,j前后缀不同了,j 需要根据next中已有的信息,回退到合适的位置,到前后缀更短但之前比对过已经一样的位置再继续比较,其实这时候后缀串就相当于文本串了,一直往后遍历,不回头,前缀串相当于模式串,根据具体情况回退到合适位置;回退到哪里呢?实际上也是查j-1处next数组的值,然后回退到对应下标的位置,为什么?再画图理解一下:这个过程应该是while的,不停回退,直到j=0,退到最前面。
  4. 第四步,更新next数组;这时候, 不管是以上哪种情况,j的数值就是最长相等前后缀长度的数值,更新next[i] = 即可。

 6. 总结利用前缀表解题的过程

前缀表求出来了,再来看一下如何利用 前缀表找到 当字符不匹配的时候指针应该移动的位置。

算法训练Day9| LeetCode28. 找出字符串中第一个匹配项的下标(KMP算法)_第6张图片

 

算法训练Day9| LeetCode28. 找出字符串中第一个匹配项的下标(KMP算法)_第7张图片

 

找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。为什么要前一个字符的前缀表的数值呢,因为要找前面字符串的最长相同的前缀和后缀。所以要看前一位的 前缀表的数值。前一个字符的前缀表的数值是2, 所有把下标移动到下标2的位置继续比配。最后就在文本串中找到了和模式串匹配的子串了。

7. 前缀表和next数组

很多KMP算法的时间都是使用next数组来做回退操作,那么next数组与前缀表有什么关系呢? next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(或者右移一位,初始位置为-1)之后作为next数组。

为什么这么做呢,其实也是很多文章视频没有解释清楚的地方。

其实这并不涉及到KMP的原理,而是具体实现,next数组即可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为-1)。

如果是右移一位,在查前缀表的时候,就不用查不匹配位置的前一位对应的next数值,而是直接查不匹配位置的next数值就可以了;

如果是统一减一,那还是查不匹配位置的前一位对应的next数值,要记得再加上1。


LeetCode28. 具体解题部分

1. 思路

step1. 算出前缀表next数组

根据KMP的理论知识,首先需要算出前缀表next数组。

前缀表是:记录下标i之前,包括i,有多大长度相同前后缀,在某个字符不匹配的时候,前缀表会告诉你,下一步应该跳到什么位置。

定义指针i和j, i指向后缀的末尾,j指向前缀的末尾。

  1. 初始化: 将i初始为1,j初始为0,next[0]初始为0。 这样初始化,是因为,第一个元素的最大相同前后缀一定为0,j为前缀的末尾,所以0到j 的字符串长度,就是前后缀的字符串长度。以 a a b a a f 为例,初始化之后,next[0] =0 ,i = 1,j =0。
  2. 当碰到元素相同的情况时,这时候,0-j 那段前缀和以i结尾的相同长度的后缀是相等的,所以,j需要先往后移动一位,此时j的坐标值,就是next[i]处需要填写的值,所以,再赋值next[i]=j, 接着i也往后移动一位,继续比较。
  3. 碰到元素不同的情况,j需要回退,回退到哪里呢?

比如在模式串: a a a a b中,当j指向第4个a,i指向第一个b的时候,next已经有值=[ 0, 1,2,3]。此时的前缀是a,a,a ,a(j)后缀是a,a,a,b(i)。我们已知现有前后缀的前三位是可以完全对上的,于是找 j 前面一位的next数值,得到2。说明前缀a0,a1,a2(数字仅仅代表下标位置)中,a0 a1 =a1 a2, 而前缀a1 a2 = 后缀的a2 a3, 所以a0,a1 =a2 a3 ,j 调到a1后面的下标2处,继续比较。

总结: j 回退位置查找前一位元素的Next数组数值,回退到该处。如果回退后还是不相等,且没有回退到数组初始处,那么继续同理回退,直到相等为止。

如果回退到j=0处,不再回退,此时前后缀为0,仍然是赋值next[i] = j=0

这部分代码实现:

class Solution(object):
    def strStr(self, haystack, needle):
        """
        :type haystack: str
        :type needle: str
        :rtype: int
        """
    def getNext(self,needle):
        Next = [""for k in range(len(needle))]
        j = 0
        Next[0] = 0
        for i in range(1,len(needle)):
            while j > 0 and needle[i] != needle[j]:
                j = Next[j-1]
            if needle[i] == needle[j]:
                j += 1
            Next[i] = j
        return Next

step2. 利用next数组去做匹配

在得到next数组之后,我们利用next数组去做匹配。文本串为haystack,模式串为needle。首先特殊情况,如果模式串为空,返回值为0。

然后调用getNext函数,得到模式串的Next数组。

文本串的指针为 i ,初始化为0;模式串的指针为 j ,初始化为0。

用 i 指针遍历文本串haystack, 比较haystack[ i ] & needle [ j ]

  1. 如果不相等且 j !=0, j 查询 next [ j-1]回跳,直到跳出循环。
  2. 如果相等,j 往后挪一位继续比较
  3. 如果j 已经到了模式串的最末尾,返回文本串中字符串起始位置
  4. 遍历结束后,j 一直没到末尾的话,返回 -1,表示没找到。

这部分的代码实现

class Solution(object):
    def strStr(self, haystack, needle):
        """
        :type haystack: str
        :type needle: str
        :rtype: int
        """
        if len(needle) == 0:
            return 0
        Next = self.getNext(needle)
        j = 0
        for i in range (len(haystack)):
            while j >0 and haystack[i] != needle[j]:
                j = Next[j-1]
            if haystack [i] == needle[j]:
                j += 1
            if j  == len(needle):
                return (i-len(needle)+1)
        return -1

2. 代码实现

class Solution(object):
    def strStr(self, haystack, needle):
        """
        :type haystack: str
        :type needle: str
        :rtype: int
        """
        if len(needle) == 0:
            return 0
        Next = self.getNext(needle)
        j = 0
        for i in range (len(haystack)):
            # 这里的三行指令顺序严格不可以变
            while j >0 and haystack[i] != needle[j]:
                j = Next[j-1]
            if haystack [i] == needle[j]:
                j += 1
            if j  == len(needle):
                return (i-len(needle)+1)
        return -1

    def getNext(self,needle):
        Next = [""for k in range(len(needle))]
        j = 0
        Next[0] = 0
        for i in range(1,len(needle)):
            # 这里的三行指令顺序严格不可以变
            while j > 0 and needle[i] != needle[j]:
                j = Next[j-1]
            if needle[i] == needle[j]:
                j += 1
            Next[i] = j
        return Next

3. 复杂度分析

时间复杂度:O(m+n)

其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大的提高的搜索的效率。

空间复杂度:O(m)

用于储存生成的Next数组,长度和模式串长度一致。

4. 思考

  1. 记录一个刚刚犯错的点,在这个里面,for循环的那三行的执行顺序是严格不可以变的。
def getNext(self,needle):
        Next = [""for k in range(len(needle))]
        j = 0
        Next[0] = 0
        for i in range(1,len(needle)):
            # 这里的三行指令顺序严格不可以变
            while j > 0 and needle[i] != needle[j]:
                j = Next[j-1]
            if needle[i] == needle[j]:
                j += 1
            Next[i] = j
        return Next

想想如果if放在while的前面,举个例子“ issip”经过if处理之后的j可以直接赋值了,但可能因为满足了while的条件又进入循环,回退到错误的值,比如这个例子就会得到错误的Next = [0,0,0,0,0]

同理,主函数的这个顺序也不可以改变。

Reference:

代码随想录 (programmercarl.com)

本题学习时间:4小时。


ps: 这篇文章是根据我自己的理解和参考代码随想录的讲解写的,感觉比第一次做又理解深入了一点,对我来说最大的难点是求Next数组时j的回退的理解,本题花了4个小时。(求推荐!) 

你可能感兴趣的:(代码随想录训练营,算法,leetcode,职场和发展)