字符串 5. 实现 strStr() (KMP算法初探)

字符串 5. 实现 strStr() (KMP算法初探)

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

代码随想录

难度 3 - 简单(但是个人觉得用KMP算法解决并不简单)

(可以直接拉到最后看KMP算法的python实现,已做好详细注释,可结合注释进行理解)

  • 看题目感觉用python不难实现,因此直接给出代码如下:

  • 代码v1,利用python的字符串比较:

    class Solution:
        def strStr(self, haystack: str, needle: str) -> int:
            if len(needle) > len(haystack):
                return -1
    
            for i in range(len(haystack)-len(needle)+1):
                if haystack[i:i+len(needle)] == needle:
                    return i
    
            return -1
    
  • 然后又写了一版手动遍历字符串,进行逐字符比较的代码v2:

    class Solution:
        def strStr(self, haystack: str, needle: str) -> int:
            if len(needle) > len(haystack):
                return -1
            elif len(needle) == 0:
                return 0
    
            for i in range(len(haystack)-len(needle)+1):
                j = 0
                flag = -1
                for j in range(len(needle)):
                    if haystack[i+j] != needle[j]:
                        flag = -1
                        break
                    else:
                        flag = 0
                if flag == 0:
                    return i
    
            return -1
    
  • 阅读代码随想录,知还有一种KMP算法:

    (怎么这么难!)

  • KMP算法:

    (引述部分均摘自代码随想录)

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

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

    1. 前缀表(prefix table):

      • 什么是前缀表:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。

      • next数组就是一个前缀表。

      • 前缀表有什么作用呢?

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

      • 为了清楚地了解前缀表的来历,我们来举一个例子:

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

      • 请记住文本串模式串的作用,对于理解下文很重要,要不然容易看懵。

      • 此时就要问了前缀表是如何记录的呢?

        首先要知道前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配。

        这也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。

    2. 最长公共前后缀:

      • 文章中字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串
      • 后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串
      • 正确理解什么是前缀什么是后缀很重要!
      • 那么网上清一色都说 “kmp 最长公共前后缀” 又是什么回事呢?
      • 我查了一遍 算法导论 和 算法4里KMP的章节,都没有提到 “最长公共前后缀”这个词,也不知道从哪里来的,我理解是用“最长相等前后缀” 更准确一些。
      • 因为前缀表要求的就是相同前后缀的长度。
      • 而最长公共前后缀里面的“公共”,更像是说前缀和后缀公共的长度。这其实并不是前缀表所需要的。
      • 所以字符串a的最长相等前后缀为0。 字符串aa的最长相等前后缀为1。 字符串aaa的最长相等前后缀为2。 等等.....。
    3. 为什么一定要用前缀表:

      • 那为啥前缀表就能告诉我们 上次匹配的位置,并跳过去呢?
      • 举例:要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf
      • 从头开始匹配的过程在下标5的地方遇到不匹配,模式串是指向f;
      • 然后就找到了下标2,指向b,继续匹配;
      • 下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。
      • 所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。
    4. 如何计算前缀表:

      • 其实就是在出现不同元素的指针之前已经遍历过的模式串子串sub_s,然后找出它的前缀和后缀最长能有多长是相同的,

        也就是说,sub_s应当形如前缀a+中间b+后缀a,

        而指针从模式串对应出错处(注意是模式串的指针折返)返回后缀a的开头处之前,

        再继续遍历即可。

      • 其实是只需要计算模式串的前缀表

        因为只要文本串中从某一字符与模式串进入匹配开始,

        若一旦出现不匹配字符,该字符前面的模式串对应长度部分,两串内容是一致的,

        此时只需要直接使用模式串的前缀表,找到前面匹配子串的对应最大相等前后缀,

        然后就可以回退到指定位置了。

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

      • 长度为前1个字符的子串a​,最长相同前后缀的长度为0。(注意字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。)

      • 长度为前2个字符的子串aa​,最长相同前后缀的长度为1。

      • 长度为前3个字符的子串aab​,最长相同前后缀的长度为0。

        以此类推: 长度为前4个字符的子串aaba​,最长相同前后缀的长度为1。 长度为前5个字符的子串aabaa​,最长相同前后缀的长度为2。 长度为前6个字符的子串aabaaf​,最长相同前后缀的长度为0。

      • 那么求得的最长相同前后缀的长度就是对应前缀表的元素

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

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

      • 找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。

        为什么要前一个字符的前缀表的数值呢,因为要找前面字符串的最长相同的前缀和后缀。

        所以要看前一位的 前缀表的数值。

      • 前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续比配。

      • 最后就在文本串中找到了和模式串匹配的子串了。

    5. 时间复杂度分析:

      其中n为文本串长度,m为模式串长度,

      因为在匹配的过程中,根据前缀表不断调整匹配的位置,

      可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。

      所以整个KMP算法的时间复杂度是O(n+m)的。

      暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大地提高了搜索的效率。

    6. 构造next数组(前缀表):

      《代码随想录》对于这一部分的过程解释个人觉得冗长而且难以理解,不如直接结合代码随想录中的动图以及整体代码直接理解。因此就不在此摘录了。

      更为清晰的解析可以参考这一链接:KMP 算法详解 - 知乎

      里面的内容已很详尽。

      在此仅摘录一些个人看到觉得比较重要的点:

      1. KMP 算法永不回退 txt的指针 i,不走回头路(不会重复扫描 txt),而是借助 dp数组中储存的信息把 pat移到正确的位置继续匹配,时间复杂度只需 O(N),用空间换时间,所以我认为它是一种动态规划算法。

      2. 还有一点需要明确的是:计算这个 dp数组,只和 pat串有关。意思是说,只要给我个 pat​,我就能通过这个模式串计算出 dp​ 数组,然后你可以给我不同的 txt​,我都不怕,利用这个 dp​ 数组我都能在 O(N) 时间完成字符串匹配。

      3. 用状态转移去理解KMP算法会比较好。将模式串所生成的前缀表看作一个状态转移表,那么后面就很好理解了。

        KMP 算法最关键的步骤就是构造这个状态转移图。要确定状态转移的行为,得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符;确定了这两个变量后,就可以知道这个情况下应该转移到哪个状态。

      4. KMP 算法的核心逻辑

        字符串 5. 实现 strStr() (KMP算法初探)_第1张图片

  • 以下给出个人对KMP算法构建的代码以及详细注释,以便理解参考:

    class Solution:
        def getNext(self, needle: str) -> List[int]:
            # 辅助函数,用于获取模式串needle对应的前缀表next数组
    
            # 初始化
            next = [0 for _ in range(len(needle))]
            j = 0 # j是当前的最大相等前后缀长度,可以理解为前缀长度
            next[0] = 0 # 下标0处的字符只能跳转至自身
    
            for i in range(1, len(needle)):
                # 如果i处与j处字符不同,则需要j指针左移一格
                # (因为当前j处不满足相等前缀了),
                # 然后根据左移一格处(也就是j-1处)的next值(也就是状态转移目的地),
                # 向目的地转移,并再次比较转移后的j与i处的字符,
                # 如果不同,则重复上述状态转移,直到j指向0,或者j和i处字符相同为止
                while j > 0 and needle[i] != needle[j]:
                    j = next[j-1]
    
                # 如果i处与j处字符相同,则当前最大相等前后缀增加一
                if needle[i] == needle[j]:
                    j += 1
    
                # 储存当前i对应的j,也就是当前最大相等前后缀长度
                next[i] = j
            return next
    
        def strStr(self, haystack: str, needle: str) -> int:
            if len(needle) == 0:
                return 0
            elif len(needle) > len(haystack):
                return -1
    
            # 获得状态转移前缀表
            next = self.getNext(needle)
    
            # 开始匹配,此处算法和getNext其实是基本一致的
            j = 0 # 遍历 needle 的指针,表示当前匹配到子串的位置
            for i in range(len(haystack)):
                # 如果当前字符不匹配,通过 next[j-1] 回退 j
                while j > 0 and haystack[i] != needle[j]:
                    j = next[j-1]
                # 如果当前字符匹配,则将 j 向后移动,表示继续匹配子串的下一个字符
                if haystack[i] == needle[j]:
                    j += 1
                # 如果 j 达到 needle 的长度,表示子串 needle 在 haystack 中完整匹配成功
                # 这里是needle的长度而不用-1是因为,如果最后一个字符匹配上了,j还是要+1
                # 因此终止条件应当为j == len(needle)
                if j == len(needle):
                    return i - len(needle) + 1
            return -1
    
  • KMP算法比较复杂,但是实际上,辅助函数和匹配过程的算法基本一致。如果实在理解不了,可以先背住再慢慢做题理解。

你可能感兴趣的:(Mophead的小白刷题笔记,leetcode,python,代码随想录,字符串,KMP算法)