KMP 算法

目录

原创声明

著作权归作者 Handy 所有。商业转载请联系作者获得授权,非商业转载请注明出处。

简介

字符串的算法中,有一个是做模式匹配,让你找子串的位置。

如果用暴力解法,那就是一个双重 for 循环,以主串的每个字符为开头,往后走,看是不是跟子串完全一致。这样的算法时间复杂度是 $O(n \times m)$。有没有更好的算法呢?

KMP(Knuth-Morris-Pratt) 是三个科学家的名字,他们发现,在暴力解法中有些情况没必要从头开始比对,由此提出了基于 match 数组(也称部分匹配表,记录了串 $a_0…a_k$ 的最大相同前后缀)的 “主指针不回退” 的时间复杂度为 $O(n + m)$ 的算法。

代码

主要思想是,主指针一直往前走不回退,让模式串的指针跳转到可以开始匹配的位置(由 next 数组得出),省去不必要的匹配,以此节省时间。由于主指针不回退,因此匹配的时间复杂度是 O(n)。

next 数组表示模式串跳转的位置,从这个位置的下一个字符开始与主串进行比较,构建 next 数组的时间复杂度是 O(m)。

这两部份加起来,时间复杂度是 O(n+m)。

KMP 算法的正确性可以用数学归纳法、形式化方法等来证明,也可以直观理解。

class Solution:
    def kmp(self, main_str: str, sub_str: str) -> int: 
        n,m = len(main_str), len(sub_str)
        if n < m:
            return -1
        if m == 0:  # 处理空模式串
            return 0
        next_arr = self.buildNext(sub_str)
        i, j = 0, 0 # 初始化
        while (i < n) and (j < m):
            if main_str[i] == sub_str[j]:
                # 往前走比对
                i += 1
                j += 1
            elif j > 0:
                # 跳转
                j = next_arr[j - 1] + 1
            else:
                # 跳转到 -1 了
                i += 1
        return i - m if m == j else -1

    def buildNext(self, sub_str: str) -> list: 
        m = len(sub_str)
        next_arr = [-1] * m
        for i in range(1, m):
            j = next_arr[i - 1] # 跟前一个的关系
            while (j >= 0) and (sub_str[i] != sub_str[j + 1]):
                # 不断回退
                j = next_arr[j]
            if sub_str[i] == sub_str[j + 1]:
                # 正好相等
                next_arr[i] = j + 1
            else:
                # 回退到重置状态
                next_arr[i] = -1
        return next_arr

    def strStr(self, haystack: str, needle: str) -> int:
        return self.kmp(haystack, needle)

对应题目:

Python 中使用字符串的find()方法:返回子串第一次出现的索引,如果未找到则返回-1。使用了 Boyer-Moore-Horspool 算法。

Golang 中使用字符串包的 strings.Index() 方法:返回子串第一次出现的索引,如果未找到则返回-1。使用了 Rabin-Karp 算法。

分析

next 数组构建分析之长度定义

事实上,next 数组的构建,看起来像极了动态规划(重叠子问题+最优子结构):

  • 状态的含义:dp[i]: 表示前 i 个字符的最大相同真前缀和真后缀的长度

    Tip

    我们还可以这样直接全部用索引来形式化定义,避免中途需要 长度-1=索引 的问题:$$ match[i] = \begin{cases} k, 满足 p_0 \ldots p_k = p_{i-k}\ldots p_{i} 的最大k (< i) \\ -1, 如果这样的 k 不存在 \end{cases}$$ 本文按一般的动态规划思想用长度定义是为便于理解。 后文也先用长度定义来推导,最后再介绍这种位置定义的推导,第2章的代码是用的位置定义。

  • 状态转移方程:看跟 $dp[i - 1]$ 的关系,如果当前字符 s[i] 匹配上 s[dp[i - 1] - 1 + 1] 就能直接 + 1 (这里 - 1 是字符数组下标和字符串长度的问题, + 1 是下一个字符),否则只能接着回退,看跟 $dp[dp[i - 1]]$ 的关系

    $$ dp[i] = \begin{cases} dp[i - 1] + 1, & s[dp[i - 1] - 1 + 1] = s[i] \\ dp[dp[i - 1]] + 1, & s[dp[dp[i - 1]] - 1 + 1] = s[i] \\ … \end{cases} $$

    还可以画图理解,下方的四个带颜色的块都是相同的,大块套小块:

    buildMatch画图解析,match[i]是最大相同前后缀长度-1,图源:陈越《数据结构》

  • 初始化:dp[0] = -1(从长度角度看这就是 KMP 算法的另一个精妙之处,从位置定义来看,这就是不存在那样的k时的特殊值)

  • 遍历顺序:从左往右

  • 举例推导:以 ABABC 为例

Note

以下是不以位置定义而以长度定义,来进行算法推导时,遇到的问题及解决:

  1. 如果 dp[i] 定义为字符串 a_0…a_i 的最大相同前后缀长度,则状态转移方程为 dp[i] = dp[i - 1] + 1 if s[dp[i - 1] -1 +1] == s[i] else 继续回退 dp[dp[i - 1]],dp[0] = 0,从左往右遍历,以 ABABC 为例推导如下:
    dp[0] = 0
    dp[1] = ? –> dp[0] = 0 –> s[0] != s[1] -回退到dp[0]-> 死循环了,咱也能改 dp[0] = -1 吗,似乎失去了长度的意义
  2. 如果 dp[i] 定义为字符串 前i个字符 的最大相同前后缀长度,则状态转移方程为 dp[i] = dp[i - 1] + 1 if s[dp[i - 1] -1 +1] == s[i - 1] else 继续回退 dp[dp[i - 1]],dp[0] = 0 & dp[1] = 0,从左往右遍历,以 ABABC 为例推导如下:
    dp[0] = 0
    dp[1] = 0
    dp[2] = ? –> dp[1] = 0 –> s[0] != s[1] -回退dp[dp[1]] =0-> s[0] != s[1] -回退dp[dp[dp[1]]]=0-> 死循环了
  3. 上面两个只修改了 dp[i] 的含义,结果都成了死循环,一点改进以没有,那真正的问题出在哪里?一说出在转移方程:当回退到 0 的位置时,dp[i] = 0;另一说出在初始化:令dp[0] = -1 这个特殊标记!
  4. 以第一个定义为例,当修改转移方程时:如果 dp[i] 定义为字符串 a_0…a_i 的最大相同前后缀长度,则状态转移方程为 dp[i] = dp[i - 1] + 1 if s[dp[i - 1] -1 +1] == s[i] else 继续回退 dp[dp[i - 1]] 如果回退到 0 的位置则 dp[i] = 0,dp[0] = 0,从左往右遍历,以 ABABC 为例推导如下:
    dp[0] = 0
    dp[1] = 0 –> dp[0] = 0 s[0] != s[1] -回退dp[0]=0了-> dp[1] = 0
    dp[2] = 1 –> dp[1] = 0 s[0] = s[2] –> dp[1] = 0 + 1 = 1
    dp[3] = 2 –> dp[2] = 1 s[1] = s[3] –> dp[3] = 1 + 1 = 2
    dp[4] = 0 –> dp[3] = 2 s[2] != s[4] -回退dp[2]=1-> s[1] != s[4] -回退dp[1]=0了-> dp[4] = 0
    缺点是还得特殊判断回退到 0 了,如果有个特殊标记就好了
  5. 以第一个定义为例,修改初始化为特殊标记时:如果 dp[i] 定义为字符串 a_0…a_i 的最大相同前后缀长度,则状态转移方程为 dp[i] = dp[i - 1] + 1 if s[dp[i - 1] -1 +1] == s[i] else 继续回退dp[0] = -1,从左往右遍历,以 ABABC 为例推导如下:
    dp[0] = -1
    dp[1] = 0 –> dp[0] = -1 –> dp[1] = -1 + 1 = 0
    dp[2] = 1 –> dp[1] = 0 s[0] = s[2] –> dp[1] = 0 + 1 = 1
    dp[3] = 2 –> dp[2] = 1 s[1] = s[3] –> dp[3] = 1 + 1 = 2
    dp[4] = 0 –> dp[3] = 2 s[2] != s[4] -回退dp[2]=1-> s[1] != s[4] -回退dp[1]=0-> s[0] != s[4] -回退dp[0]=-1-> dp[4] = -1 + 1 = 0
  6. 我们发现,如果初始化为特殊标记 -1 ,在代码实现上会方便很多,为什么会这样呢?我们定义长度,长度根本不能是 -1 吧?理论层面的确如此,长度是非负整数,但在实现层面 next[i] 的含义是回退位置,这个情况下可以是 -1,表示特殊状态,这本身就是一种边界情况,表示“已经回退到串开始之前”了,相当于一个"初始状态"或"重置状态",并且实现上 -1 加上 1 恰好又是 0,这真是优雅的代码。因此,这种理论和实现上的不一致是可接受的,概念清晰和实现简洁往往需要权衡。如果初始化为 0,在状态转移方程中又不做边界条件的判断,那一定会导致死循环。总之,next[0] = -1 的设计是KMP算法中的一个精妙之处

next 数组构建分析之位置定义

最后,我们看一下,如果按位置定义来推导,会如何之顺畅:

  1. 定义(找位置): $ dp[i] = \begin{cases} k, 满足 p_0 \ldots p_k = p_{i-k}\ldots p_{i} 的最大k (< i) \\ -1, 如果这样的 k 不存在 \end{cases}$
  2. 转移方程:$ dp[i] = \begin{cases} dp[i - 1] + 1, & s[dp[i - 1] + 1] = s[i] \\ dp[dp[i - 1]] + 1, & s[dp[dp[i - 1]] + 1] = s[i] \\ …\end{cases}$
  3. 初始化:dp[0] = -1 (按定义来)
  4. 遍历顺序:从左往右
  5. 举例推导:ABABC
    dp[0] = -1
    dp[1] = -1 –> dp[0] = -1 –> s[0] != s[1] -回退到-1了-> dp[1] = -1
    dp[2] = 0 –> dp[1] = -1 –> s[0] = s[2] –> dp[2] = 0
    dp[3] = 1 –> dp[2] = 0 –> s[1] = s[3] –> dp[3] = 1
    dp[4] = -1 –> dp[3] = 1 –> s[2] != s[4] -回退dp[1]=-1了-> dp[4] = -1

结果发现,这种按位置的形式化定义,还真畅快,这便是 next 数组的构建。

KMP 举例推导

主串 s:ABABACDA

模式串 p:ABABC

next 数组:-1, -1, 0, 1, -1

  • i = 0, j = 0 –> s[0] = p[0]
  • i = 1, j = 1 –> s[1] = p[1]
  • i = 2, j = 2 –> s[2] = p[2]
  • i = 3, j = 3 –> s[3] = p[3]
  • i = 4, j = 4 –> s[4] != p[4] -j跳转=next[4-1]+1(下一个字符)=next[3]+1=2-> s[2] = s[2] = p[4]
  • i = 5, j = 3 –> s[5] != p[3] -j=next[2]+1=1-> –> s[5] != p[1] -j=next[1]+1=0了-> j 归零,主指针往前走
  • i = 6, j = 0 –> s[6] != p[0] -> j 归零,主指针往前走
  • i = 7, j = 0 –> s[7] = p[0]
  • i = 8, j = 1 –> s 越界推出
  • 未匹配上 –》 m != j –> -1
标签 :
comments powered by Disqus

相关文章

JWT 的应用场景思考

JWT 的应用场景思考 ¶ 简述 JWT ¶ JWT,全称 JSON Web Token。是一种开放标准,用于在各方之间安全传递信息,它是以 Base64 编码 json 对象的 token 。基于 token 的权限验证,与传统的 Session 认证完全不同,它不需要服务端保持 Session 记录,连用户状态都不需要关心。一旦用户登录网站,服务器就会生成 token,之后客户端每次登录时在HTTP的头信息中带上 token 即可。

阅读更多
在电脑上安装 Openwrt 作为旁路网关

🤔场景 ¶ 既然回家了,有了家用路由器,就希望各个设备能够不需要安装对应的软件而直接科学上网、屏蔽广告等。 但原来的路由器太小众,没人提供相应的固件,也就是说没法刷机。 后来,我看到有关旁路网关的介绍,觉得这可能是一种解决方案。

阅读更多
如何在 React 中进行状态管理?使用 Zustand!

计数器 ¶ import { create } from 'zustand' const useStore = create(set => ({ count: 1, inc: () => set(state => ({ count: state.count + 1 })), })) function Controls() { const inc = useStore(state => state.inc) return <button onClick={inc}>one up</button> } function Counter() { const count = useStore(state => state.count) return <h1>{count}</h1> } 用法 ¶ 创建状态 state 操作 action ¶ Basic typescript usage doesn’t require anything special except for writing create<State>()(...) instead of create(...)… import { create } from 'zustand' interface BearState { bears: number increase: (by: number) => void } const useBearStore = create<BearState>()((set) => ({ bears: 0, increase: (by) => set((state) => ({ bears: state.bears + by })), })) const useFishStore = create((set) => ({ salmon: 1, tuna: 2, deleteEverything: () => set({}, true), // clears the entire store, actions included deleteTuna: () => set((state) => omit(state, ['tuna']), true), })) const useSoundStore = create((set, get) => ({ sound: "grunt", action: () => { const sound = get().sound // you still have access to state outside of it through get // ... } }) export default useBearStore 在 react 之外使用呢?

阅读更多