0 引言:

题目

KMP用于, 在文本字符串 (或称文本串,字符串)s 中,
找出模式串(或称匹配串) pattern 出现的起始位置;
举例:
在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf

并返回在字符串中开始出现模式串 pattern 的 下标位置;

code

class Solution:
    def strStr(self, haystack: str, needle: str) -> int:
            string = haystack;  pattern = needle

            m, n = len(string), len(pattern)

            if n == 0: return 0

            p_str, p_pat = 0, 0

            next_arr = self.compute_next(needle)

            while  p_str < m:  # 当字符串指针没有遍历完时;
                
                if string[p_str] == pattern[p_pat]: # 当两个指针对应位置上的元素相同时, 同时移动指针;
                   p_str += 1
                   p_pat += 1
                

                if p_pat == n:  # 当模式串指针已经遍历完模式串时, 则返回对应的下标索引;
                   return  p_str - p_pat

                #  当对应位置上的元素不同时,并且字符串指针没有到达末尾时候, 需要考虑两种情况, 模式串是否在初始位置;
                elif p_str < m and string[p_str] != pattern[p_pat]  : 
                     
                   if p_pat == 0:  # 模式串已经在初始位置, 则移动字符串指针;
                      p_str += 1
                    
                   elif p_pat != 0:
                        p_pat = next_arr[p_pat -1]
            

            return -1
                      
                
    
    def compute_next(self, pattern:str):
        j = 0;  # j  作为前缀串的末尾, 也表示当前最长的相同前后缀的长度;
        i = 1   #  i 后缀串末尾, 从1 开始用, 用来遍历next 数组的索引;

        size = len(pattern)
        next_arr =  [0] * size

        while i < size:
            
            if pattern[i] ==  pattern[j]:
                #  当前两个指针的对应位置上的字符相同时, 给next 赋值,并且两个指针同时后移;
                next_arr[i] = j +1 # 因为j 从0开始;
                j  += 1
                i  += 1

            elif pattern[i] != pattern[j]:
                # 当两个指针对应位置上的元素不向同时, 需要考虑前缀串指针是否在起始位置来 处理;

                if j == 0: # 当前缀串末尾指针已经回退到末尾位置, 则给next 数组赋值; 并且后缀串指针往后移动;
                  next_arr[i] = 0
                  i += 1
                
                elif j != 0:
                    j = next_arr[j-1] # 回退到 next[j-1] 位置
        
        return next_arr
class Solution:
    def strStr(self, haystack: str, needle: str) -> int:
        string, pattern = haystack, needle
        m = len(string)
        n = len(pattern)

        if n == 0 : return 0
        p_str, p_pat = 0,0 # 分别用于表示字符串的指针和 模式串的指针;
        next_arr =  self.compute_next(pattern) # 获取next 数组;

        # 当字符串指针没有遍历到 结尾时, 继续遍历
        while p_str < m:

            # 当 两个指针对应位置上的元素相同时, 说明匹配上, 两指针同时后移;
            if  string[p_str] == pattern[p_pat]:
                p_str += 1
                p_pat += 1

            if p_pat == n:
                # 当模式串的指针已经遍历完 模式串时, 说明此时已经匹配上了;
                # 则返回 当前 匹配上的位置;
                return  p_str - p_pat  # 因为字符串的指针总是后移的, 所以

            elif string[p_str] != pattern[p_pat]  and p_str < m:

                if p_pat == 0: # 当模式串已经回退到起始位置时, 则此时移动字符串指针;
                    p_str += 1
                
                else: # 当模式串没有回退到初始位置时, 则使用 next 数组进行回退;
                    p_pat = next_arr[p_pat -1]
        # 遍历完成之后,
        return -1 



    def compute_next(self,pattern):
            j = 0   # 前缀串的终止下标;
            i = 1  #  后缀串的终止下标

            length = len(pattern)
            next = [0] * length

            # 因为后缀串的终止下标始终,是向后移动的;
            # 所以使用i 来标记数组的索引;
            while  i < length:

                #  当子串中, 前缀串的终止位置与 后缀串的终止位置上的元素相同时,
                if pattern[j] == pattern[i]:
                    # 则此时存在的最长相同 前后缀的长度是 j 的索引 +1
                    next[i] = j +1
                    j += 1
                    i += 1;  # 将两个指针同时向后移动;


                elif pattern[j] != pattern[i]:
                    # 当两个对应位置上的元素不相同时;

                    if j == 0: # 如果此时J 已经在初始位置;则 给最长公共长度赋值为0;
                        next[i] = 0   # 每次数组赋值后, 后缀串的终止下标都要,向后移动; 前缀串的下标,只有两个对应位置上元素相同时,才会后移;
                        i += 1

                    elif j != 0:
                        j = next[j-1]  # 前缀串的下标跳转到 next[j-1] 的 索引位置上;


            return next
if __name__ == "__main__":
    obj1 = Solution()
    str1 = obj1.strStr("abeababeabf", "abeabf")
    # str1 = obj1.strStr("hello", "ll")
    # str1 = obj1.strStr("aaaaa", "bba")
    # str1 = obj1.strStr("aaaaa" ,"bba")
    print(str1)

算法实现的关键步骤

#  算法中实现的关键点是:
      模式串与 字符串的比较过程是:
          1. 当前两个 指针对应位置上的元素相同时, 则 两指针同时后移;
          2. 当模式串指针已经遍历完 模式串的长度时, 返回 索引,为两个指针的差值;
          3. 如果 当前两个指针对应位置上的元素不同时, 且字符串指针没有到达末尾时,此时需要考虑 两类情况;
             3.1 当模式串指针在初始位置时, 则 将字符串指针往后移动;
             3.2 模式串指针不在初始位置时, 则 模式串指针回退到 next 数组中前一个索引位置上所对应的数值;


       next  前缀数组的生成过程;
          1. 初始化 next数组, 且第一个位置上数值为0,  前缀串末尾 j 从0 开始, 后缀串末尾i 从1 开始;

          2. 当后缀串没有到达末尾时, 开始遍历, 移动后缀串指针i;
             2.1  当前两个指针 对应位置上的元素相同时, 给next 数组赋值,  并且两指针同时后移动;
                 next[i] = j +1; 等于 当前 前缀串的位置 +1 , 前缀串是从0 开始的;

             2.2  当两个指针对应位置上的元素不同时, 此时分 前缀串末尾指针 是否在起始位置考虑;
               2.2.1 当前缀串指针在起始位置时,  给当前数组赋值, 并移动后缀串指针;
               2.2.2  当前缀串指针不在起始位置时, 将前缀串指针 回退到 next[j-1]位置处。 前一个索引在数组中的取值;

    #  当字符串与模式串匹配时,可跳转回退的指针是模式串中的指针,  后移指针是字符串中的指针;
    #  当在模式串中,生成next 数组时, 可回退的指针是前缀下标指针, 后移的是后缀下标指针;

KMP的主要贡献:

  1. KMP 字符串的指针不会往前回退, 只会不断往后移动;
  2. 模式串中的指针,回退过程中, 总是先寻找是否存在相同的前缀串, 然后回退到相同前缀串的后一个位置, 直到没有相同前缀串后, 模式串的指针才回退到初始位置; 这样做的效果,是通过模式串已匹配部分中存在相同的「前缀」和「后缀」来加速下一次的匹配, 从而模式串指针不必每次都回退到初始位置;

构造最长相同前缀后缀表

KMP 主要是 构造出一个
最长相同前缀后缀表, 记作LPS 数组,LPS (Longest Prefix which is also Suffix) array
该LPS 数组 的作用时, 当 模式串 与 字符串 在匹配过程中, 匹配失败时, 模式串的指针不需要跳转到最开始位置, 而是根据存放在 LPS 数组中的数值,跳转到对应下标的索引, 从而避免冗余的比较,即不需要回退到开始的地方重新比较。

分析:

字符串中指针不退回到「发起点」意味着什么?

其实是意味着:随着匹配过程的进行,字符串的指针不断右移,本质上是在不断地在否决一些「不可能」的方案。

当字符串指针从 i 位置后移到 j 位置,不仅仅代表着「字符串」下标范围为 [i,j) 的字符与「模式串」匹配或者不匹配,更是在否决那些以「字符串」下标范围为 [i,j)为「匹配发起点」的子集。

在具体实现过程中,构造了最长相同前缀后缀next数组;
然后在字符串与模式串的匹配的过程中,调用 next 数组;

LPS 数组,也称作最长相同前缀后缀数组, 其本身便是利用了 假设字符串中 会存在重复的字符串,

复杂度分析

如果 模式串中 本身 不存在重复 子串时, 此时其生成 LPS 数组中的数值都是存储的0, 这表明每次 模式串与 字符串匹配失败时, 模式串的指针都会退回到起始点;

最坏情况复杂度:KMP 最坏情况时间复杂度为 𝑂(𝑚+𝑛)
其中 𝑚 是文本长度, 𝑛是模式长度。即使模式没有重复的内容,这一点仍然成立,因为 LPS 数组仍然有助于避免冗余比较。

无回溯:与可能在不匹配时在文本中回溯的简单字符串匹配算法不同,KMP 确保文本中的每个字符最多处理一次,从而导致线性复杂性。

相比于暴力求解 两层for 循环的方式, O(m *n )

1. 什么是最长相同前缀后缀表

最长相同前缀后缀有的也称 前缀表, 该表是一个数组,
数组中的每一个数值表示的意义如下:

以当前数组下标索引为结束位置, 使用字母j表示, 取出模式串中以该字母为结束位置对应长度的子串,在当前子串中存在的最长相同前缀后缀的长度是多少,即该数值表示了在当前子串上 具有相同的前缀串和后缀串的长度是多少。

该前缀表通常用一个 next 数组表示,即上文提到的 LPS 数组, 记作LPS 数组,LPS (Longest Prefix which is also Suffix) array ,

该数组中存储了, 模式串中每个位置上对应的最长相等前缀后缀的长度数值;

Python项目里ckpts是什么_后缀


举个例子 , 以上图的匹配串(模式串): a b e a b f 说明;

1.1 前缀串

是指 以第一个字符为开头的所有连续子串, 但不包括最后一个字符;

  • 换个说法, 即以第一个字符开头的所有连续子串,但是不包括整个字符串本身

模式串 a b e a b f : 所有的前缀有:
a ;
a b;
a b e;
a b e a;
a b e a b;

注意:
当字符串只有一个字符时, 便不存在前缀串后缀串的概念;
此时最后一个字符即第一个字符;

1.2 后缀串

是指以最后一个字符为结尾的所有连续子串, 但不包括第一个字符;

  • 换个说法, 即以最后一个字符为结尾的所有连续子串,但是不包括整个字符串本身

模式串 a b e a b f : 所有的后缀串有:
f;
b f;
a b f;
e a b f;
b e a b f;

注意上面,是为了说明,什么是前缀串, 后缀串,

而实际的LPS数组 的构建, 则是通过将模式串的依次从左到右,进行增长,来确定数组中每个位置上的数值,

即后缀串的末尾字符不是上面的从一开始就确定好从整个模式串的结尾开始的, 而是 后缀串的末尾字符是随着子串逐个向后移动的, 最后才会到达模式串的结尾, 看懂下面这个表格才是实际的 LPS数组的构建过程

a b e a b f;

Python项目里ckpts是什么_数据结构_02

cur

当前子串

前缀串

后缀串

最长相同前缀后缀串

相同串长度

next[cur]

0

a

None

None

None

0

0

1

a b

a

b

None

0

0

2

a b e

a b

b e

None

0

0

3

a b e a

a b e

b e a

a

1

1

4

a b e a b

a b e a

b e a b

a b

2

2

5

a b e a b f

a b e a b

b e a b f

None

0

0

注意, 上述 next 数组中值, 这里我们是通过人眼观察得来的相同的串长度;

那么实际在操作中, 如何得到 当前子串上的最长相同前缀后缀长度;

2. 最长相同前缀后缀表的构建分析过程

最长相同前缀后缀表, 记作LPS 数组,LPS (Longest Prefix which is also Suffix) array , 国人翻译过来多数叫做为 next数组,

  1. 该next 数组的长度 = 模式字符串的长度;
  2. 该next [i] 数组中的值 = 代表了在模式串中下标 i 的位置上,最长相同前缀后缀的长度;

即 next 数组,
首先, next 数组的意义:

在模式串中构造了最大相同前缀后缀 next 数组, 该next数组中的值 Vaule 代表了:
在模式串中, 前缀下标 Python项目里ckpts是什么_字符串_03与后缀下标 Python项目里ckpts是什么_字符串_04, 两个位置上 所对应的元素不相等时:
模式串中的 前缀下标 Python项目里ckpts是什么_字符串_03

2.1 next 数组初始化

注意有的地方,是将j 从 -1 开始, 然后整个next 数组中的数值要全部减一才与最长相同前缀后缀中的数值相等;两者在 只是通过不同的方式 实现了, 原理上是相同的。

此处, j 从0 开始,目的便于理解, 这样 next 数组中 的数值 与 最长相同前缀后缀串的 数值 就相等, 对应起来;

注意j 有两层含义, 1.代表了前缀串的终止位置, 2.也代表了此时, 以i为结束位置的后缀串中,此时存在的最长相同前后缀的长度

由于j 前缀串的终止位置移动是双向的, 即可以往后走, 也可以往前面回溯; 所以使用 i 后缀串的 终止位置为移动方向, 始终往后走 所以遍历时使用 i 为 遍历指针;

j  代表前缀串的 终止位置; 从 0 开始
i  代表后缀串的 终止位置; 从 1 开始;

 初始化next[0] = 0, 因为此时,j=0, i = 1 时, 
 前缀串 和后缀串 此时不存在相同的前后缀。

next[0] = 0 ,  

j = 0
i = 1
next[0] = 0

2.2 前缀后缀上的元素相同时: p[j] == p[i]

当 前缀串终止位置j上的元素 ==  后缀串起始位置i上的元素时,
此时,表明两者拥有相同的子串, 
相同子串的长度 =  前缀串的终止位置 + 1 = j + 1;
将相同子串的长度 (j + 1) 放入到 next[i] 数组中, 

此时, Next[i] 数组中数值的意义, 
表示当模式串中, j与 i 对应位置上的元素相同时, 相同子串的长度;

if  pattern[j] == pattern[i]:
   next[i] = j + 1
    i += 1
    j += 1

Python项目里ckpts是什么_后缀_06

2.3 前缀后缀上的元素不同时: p[j] != p[i]

当 前后缀 对应末尾 位置上的元素不相同时,
则需要考虑 j 是否 在初始位置上;

  1. 当j 已经在初始位置上时, 则 给当前数组赋值, 且后缀串指针i 后移;
    next[i] = 0; i 后移, j 不变,后缀串j 不变,是为了等待 p[j] == p[i];
  2. 当j 不是在 初始位置时, 此时 j 需要回退到 next[ j - 1] 的位置上

注意有个关键的地方 需要理解:
为什么 回退到的位置 j = next[ j - 1 ]; 理解 这个是 核心中的核心!!!

首先 next[ i ] 中的数值, 代表了最长相同前缀后缀的长度;
即当 前缀终止下标 为 j , 后缀起始下标为i 时, 此时 两者拥有最长相同串的长度为 next[i];

那么当 next[ j - 1] = Value 时,

if j == 0  and p[j] != p[i]:
   next[i] = 0
   i += 1
 elif j != 0  and  p[j]  != p[i]:
  j = next[j - 1]

Python项目里ckpts是什么_数据结构_07

2.4 循环重复上述过程, 直到 i 到达下标末尾:

当 j, i 对应位置上的元素 匹配 与 不匹配 的情况 发生时, 继续循环重复上述的操作 , 直到后缀下标i 走到 模式串中的末尾下标,从而构建出整个 next 数组中的 数值;

Python项目里ckpts是什么_数据结构_08


Python项目里ckpts是什么_字符串_09

2.5 步骤小结

在模式串中构造最长相同前缀后缀 LPS数组, 此时模式串中需要构建两个指针:

前缀串的起始位置固定为第一个字符, 终止下标的作用为双向的,可回退指针(指可跳转到前面), 这里称为前缀串指针

后缀串的起始下标,作用为单向的向后移动指针,这里称为后缀串指针, 终止位置为当前子串的最后一个字符

对于前缀串而言,由于起始第一个字符是固定的, 变化的是终止字符, 所以使用 j 代表终止位置, 由于前缀串会往前回溯移动, 所以是双向移动的

对于后缀串而言, 终止位置也是在变化的, 但是移动方向始终是往后移动的,直到到达模式串末尾, 所以使用i 表示后缀串的起始位置

Python项目里ckpts是什么_字符串_03 代表前缀串的终止位置;
Python项目里ckpts是什么_字符串_04 代表后缀串的 终止位置;
两个指针都是向后移动;

注意j 有两层含义, 1.代表了前缀串的终止位置, 2.也代表了此时, 以i为结束位置的后缀串中,此时存在的最长相同前后缀的长度

初始时,
前缀串终止位置 Python项目里ckpts是什么_字符串_03从 0开始;
后缀串终止位置 Python项目里ckpts是什么_字符串_04从 1开始;

next[0] 初始化赋值为 0;
随着Python项目里ckpts是什么_后缀_14 的向右移动,
前缀串, 后缀串也在不断的变化;

使用后缀串的起始位置 作为LPS 数组的下标索引;
当 后缀串起始位置Python项目里ckpts是什么_字符串_04

  • 前缀串指针与后缀串指针对应位置上的元素相同时:
    则此时最大相同前缀后缀的长度 赋值为 = 前缀串指针的位置 + 1;
    后缀串指针, 前缀串指针两者同时 加 1;
  • 前缀串指针与后缀串指针对应位置上的元素不相同时,
    且当前缀串指针已经在初始位置时,
    则此时最大相同前缀后缀的长度赋值为 0;
    后缀串指针 +1; 前缀串指针保持不变;
  • 当前缀串指针不在初始位置时,并且此时前缀串指针与后缀指针对应位置上的元素不相同时:
    前缀串指针回退到 = 具有相同前缀串的后一个位置;

3 字符串与模式串的匹配过程

3.1 两串 匹配时:

当 字符串 位置上的元素 与 模式串 对应位置上的元素 匹配时, 两者的指针 同时 向后移动;

Python项目里ckpts是什么_数据结构_16

3.2 两串不匹配时:

p: 字符串中的指针,
q: 模式串中的指针保持;

  1. 当 字符串的元素与模式串上的元素 在最开始就不 匹配时,
    即 两个指针: p = q = 0时;string[p ] != pattern[ q]
    此时,将字符串中的指针 向后移动, 模式串中的指针保持不变;
  2. 当q >0 时: 字符串的元素与模式串上的元素 仍然不 匹配时,
    则 模式串中的指针 q 回退到 q = next[ q - 1 ]; 字符串中的指针保持 不变;

此过程中:
首先匹配串会检查之前已经匹配成功的部分中里是否存在相同的「前缀」和「后缀」。如果存在,则跳转到「前缀」的下一个位置继续往下匹配:

Python项目里ckpts是什么_数据结构_17


然后, 跳转到下一匹配位置后,尝试匹配,发现两个指针的字符对不上,并且此时匹配串指针前面不存在相同的「前缀」和「后缀」,这时候只能回到匹配串的起始位置重新开始:

Python项目里ckpts是什么_字符串_18


到这里,你应该清楚 KMP 为什么相比于朴素解法更快:

因为 KMP 利用已匹配部分中相同的「前缀」和「后缀」来加速下一次的匹配。

因为 KMP 的原串指针不会进行回溯(没有朴素匹配中回到下一个「发起点」的过程)。

第一点很直观,也很好理解。

我们可以把重点放在第二点上,原串不回溯至「发起点」意味着什么?

其实是意味着:随着匹配过程的进行,原串指针的不断右移,我们本质上是在不断地在否决一些「不可能」的方案。

当我们的原串指针从 i 位置后移到 j 位置,不仅仅代表着「原串」下标范围为 [i,j) 的字符与「匹配串」匹配或者不匹配,更是在否决那些以「原串」下标范围为 [i,j)为「匹配发起点」的子集。

3.3 步骤小结

字符串中的 指针是单向的后移指针, 模式串中指针是双向的可回退指针;
I. 当可回退指针 已经在 初始位置时,并且此时 可回退指针与后移指针 对应位置上的 元素不相同时:
则字符串中的后移指针 +1; 模式串中的可回退指针保持不变;

II. 可回退指针与后移指针 对应位置上的元素相同时:
后移指针; 与 可回退指针 两者同时 加 1;

III. 当 可回退指针 不在初始位置时,并且此时 可回退指针与 后移指针对应位置上的 元素不相同时:
模式串中的可回退指针 回退到 = 具有相同前缀串的后一个位置;(此时调用到 next 数组)