【算法基础】模式匹配:从 BF 到 KMP(图解 + 代码)



KMP 真的一看就懂一做就错三天全忘 好像青年痴呆

遂烂笔头之





1. 模式匹配

这里考察的是模式匹配问题

也就是我给定一个字符串 s,和一个子串 t,我想要找到 s 的一个子串 = t,也就是(在 s 中找到 t 进行匹配的任务)

此时将 s 称为目标串,子串 t 称为模式串

以下主要考虑两种方法:

  • 朴素模式匹配,也就是暴力法,BF 算法
  • 大名鼎鼎 KMP

看完可以在这里练手:

214. 最短回文串

1392. 最长快乐前缀

28. 找出字符串中第一个匹配项的下标




2.Brute-Force 算法

也就是暴力 = BF 方法

2.1 思路

考虑 s 目标串从第 i 个位置开始和 模式串 t 可以匹配上,则我枚举这里的起点 i:

  • 从第一个位置 s[0] 开始,检查 s[0] == t[0]? s[1] == t[1]? … 直到匹配结束或者出现不匹配为止
  • 从第二个为止 s[1] 开始,和 t 从头匹配,也就是 s[1] == t[0] ? s[2] == t[1]? …
  • 循环到匹配上为止;或者当前的位置 i 后面已经没 t 这么长的剩余了,则返回 -1 表示 s 中不存在 t

一般使用的时候用双指针,i 表示当前匹配到 s 的第 i 位;j 表示匹配到 t 的第 j 位;当 s[i] ≠ t[j] 的时候说明匹配失败,j 回到 0,i 回到上一轮匹配开头位置的下一个


2.2 代码示例

提供一个 python 代码,基于 28. 找出字符串中第一个匹配项的下标

class Solution:
    def strStr(self, s: str, t: str) -> int:
    	# 这里的 s 是主串 = 目标串,t 为子串 = 模式串
        for start in range(len(s)-len(t)+1):
            # start 表示从 s 的 start 位置开始匹配 t
            i,j = start,0 # 双指针,指示当前匹配到 s 和 t 的哪一个位置了
            while s[i] == t[j]:
                i += 1; j += 1
                if j == len(t): return start  # 匹配成功
        return -1

这里的 s 的长度为 m,t 的长度为 n,最坏的情况我每一轮匹配都是到最后才发现匹配不上(则每一轮匹配复杂度为 O(n)),同时真的匹配上要到 s 的最末尾(进行 O(m) 次匹配)

则此时的算法复杂度 = O(mn)




3. KMP 算法

考虑传统暴力方法:也就是我这一轮匹配从 s 的 start 位置开始,如果匹配失败了,我就从 s 的 start + 1 位置开始尝试下一次;

所以说这里的复杂度会是 mn,最坏的情况我要把 (s 的每一个字符)都当作和 t 的一轮匹配的开头来尝试一次,会很麻烦

KMP 的思路即:我如果以 start 为开头,匹配到了 start + k 才发现 start + k + 1 匹配不上了,但是我这轮已知了 s 的 start → start + k 和 t 的前 k + 1 个字符是一样的,则我想要利用这个信息快速筛选掉一些我已知不可能的 s 的 start,也就是不再需要将 s 的每一个字符都作为可能的开头来尝试一次了

3.1 整体思路

假设此时我对下面的 s 和 t 进行匹配;一开始以 s 的第一个元素作为匹配的开头进行匹配:
在这里插入图片描述
匹匹匹匹到 a vs c 的时候发现不匹配了,如果是 BF 方法,则此时下一个 start 设置为 s 的第二个字符(也就是 b),t 回到开头,重新开始尝试匹配

但是本轮匹配的时候,我已经发现了 t 的前四个和 s 的前四个是一样的,则我考虑能不能跳过一部分的开头

假设我还是以 s 的第二个元素为新的 start,t 也回到开头,重新开始匹配
在这里插入图片描述
匹配顺序如图,但是注意 s 的绿色部分和 t 的绿色部分是相等的,s 的绿色的部分的信息我已知;则这里的第二轮匹配的前几个检查,其实也就是在检查下图的蓝色部分是否是一模一样的:

在这里插入图片描述

而这里的黑框的部分,也就是 t 的绿色部分的前三个元素 和 后三个元素,这一轮的匹配要是能成功,就要求 t 的绿色部分的 (前三个元素 = 后三个元素)

当然是不行的(。我不用看 s 只用看 t 就知道,这三个元素不相等,则以 s 的下标 1 的元素(=b) 作为 start 来进行匹配肯定不行,这个 start 尝试都不用尝试;

则此时尝试按照 BF ,再把 t 后移一位

在这里插入图片描述
同理,这里如果要匹配成功,则 t 的绿色部分的(前两个字符)和 t 的绿色部分的(后两个字符)需要一样;注意到这里还真的是一样的,说明以 s 的下标为 2 的字符(=a)作为 start 来匹配可能有戏

综上,我考虑:
在这里插入图片描述

  • 我在第 x 轮匹配中,t 和 s 的 start 位置对齐,检查到 k 位置的时候发现 t 和 s 匹配不上,这轮匹配失败
  • 但是我通过这轮匹配知道了 s 的( start → start + k-1) 和 t 的(0 → k-1)是一样的,这里的匹配上了的长度为 k 的区域就是上面的绿色部分
  • 下一轮,如果我将 t 后移 x 个,则这轮匹配可行需要(t 的绿色部分的倒数 k-x 个 = 蓝色黑框部分)和(t 的绿色部分的开头 k-x 个 = 蓝色黑框部分)相同才行

综上,我从 start 开始对齐匹配失败了,则 start = start + 1 开始新的一轮匹配要成功,则需要绿色部分的倒数 k-1 个 = 开头的 k-1 个才行;同理,从 start = start + 1 开始新的一轮匹配要成功,则需要绿色部分的倒数 k-2 个 = 开头的 k-2 个才行 …

那我只要知道最大的一个 k0,使得(t 的前 k 个元素 = 绿色部分)的(倒数 k0 个)=(开头 k0 个) 就行了?因为这里的 k0 是最大的,则 k-1 → k-2 → … → k-k0+1 的这些检查一定不成功,也就是说 start + 1 / + 2 / + … / + k0-1 这些新的 start 都一定不会成功,只要跳过就行;

为了实现上面的判断,我对于任意的一个 k(任意长度的绿色部分)都要知道对应的最大的 k0,使得这个绿色部分的倒数 k0 个 = 开头 k0 个,我用一个数组 next[i] 来进行记录;同时注意这里的 k0 当然不能 = k,否则自己等于自己没有意义;即:

n e x t [ i ] = m a x { k 0 ∣ t [ i − k 0 : i ] = = t [ : k 0 ] , k 0 < k } next[i] = max \{k_0| t[i-k_0: i] == t[:k_0], k_0 < k\} next[i]=max{k0t[ik0:i]==t[:k0],k0<k}

注意这里的绿色部分是不包括 t 的第 k 个元素的

综上,得到 next[i] 后,我的匹配算法可以精简为:

  • t 和 s 的 start 位置对齐,进行匹配;到 t[k] 的时候发现匹配不成功
  • start += k-next[k];
  • 主串 s 的指针:i = max(start, i)
    • 其实这里的 i 是可以不动的,只是要处理一下被 start 超过的情况
  • 模式串 t 的指针:j = max(next[k], 0)
    • 只是处理一下 k = 0,一个都没匹配上的情况,此时 next[0] = -1
  • 继续尝试匹配

3.2 计算 next[i]

那么现在问题就是怎么得到 next[i]

注意到 next[i] 和 s 是无关的,它是针对 t 做的预处理

3.2.1 基本思路

再搬下来一遍 n e x t [ i ] = m a x { k 0 ∣ t [ i − k 0 + 1 : i + 1 ] = = t [ : k 0 + 1 ] , k 0 < k } next[i] = max \{k_0| t[i-k_0+1: i+1] == t[:k_0+1], k_0 < k\} next[i]=max{k0t[ik0+1:i+1]==t[:k0+1],k0<k}

规定 next[0] = -1,k = 0 的时候绿色部分为 0啥也没有 ;同理 next[1] = 0,这里的绿色部分只有一个字符,如果和自己相等了就是全匹配了,自己等于自己没意义;设置为 0

考虑如果我已知了 next[i],想要知道 next[i+1]

则分成两种情况:

  • 情况一:t[i] == t[next[i-1]] 也就是说相较于 i-1,此时新加入的元素刚好可以顺着往下匹配,则这里的 next[i] = next[i-1] + 1

比如这里的 next[3] 考察的是黄色部分,此时倒数的一个(a) 和开头的一个(a)是相等的,我已知 next[3] = 1,则 next[4]?
在这里插入图片描述
注意到对于 next[4],t[3] 加入黄色部分,而 t[3] 和上一个 next[3] 匹配后的下一个位置接着匹配(也就是深色的两个 b),则此时 next[4] = next[3] + 1 = 2
在这里插入图片描述

  • 情况二:此时两个深色位置不相等,也就是 t[i] != t[next[i-1]]

直观理解就是这里的新的 next[i+1] 不能再顺着 next[i] 匹配的成果下去了,比如下图:
在这里插入图片描述
这里的蓝色部分是 next[9] 匹配好的,但是对于 next[10] 来说因为 a!= c 无法接下去了;

则我这里的目标找到哪里可以继续匹配我 t[9] 位置的这个 a,且匹配上的位置尽量靠后(则可以保证 k0 尽量长),那也就是 t[9] 这个 a 前面的 abab 尽量也匹配上,则此时问题转移为蓝色的部分的倒数 k1 个能否和 t 开头的 k1 个匹配上,注意到了嘛,这个 t0 就存在 next[4] = next[next[i]],也就是关注蓝色部分的匹配情况的那个 next 存储着这里我要的 k1

则我一开始是在 next[9] 的基础上继续算 next[10],发现匹配不上,则我下一个就在 next[4] 的基础上继续匹配,也就是下图:
在这里插入图片描述
这下匹配上了!则 next[10] 也就是 next[4] + 1

当然也会有很刁钻的情况,就是匹配不上,比如:
在这里插入图片描述
这个 z 哪里都没有啊,则我还是不断考虑蓝色部分的最大的 k1,也就是 next[next[4]] = next[2],此时变成 0

则此时变成 t[9] 和 开头 t[0] 匹配,还是不行,直接返回 0


3.2.2. 整理

综上,计算 next[i] 按照下述步骤:

  • 初始化,next[0] = 0, next[1] = 1
  • 对于 next[i+1]
    • prek0 = next[i-1] 标记上面蓝色部分的末尾
    • 先看 next[i] == t[prek0]
    • 如果成立,则 next[i] = prek0 + 1
    • 如果不成立,prek0 = next[prek0],继续检查
    • 直到这里的 prek0 = 0,但是还是 next[i] != t[0],则 next[i] = 1

一个基础的 python 代码

        nxt = [0]*(len(t)+1)  # 这里多计算一位,在某些任务有用(比如 lc 214)
        nxt[0], nxt[1] = -1, 0
        i = 2
        prek0 = nxt[1]
        while i < len(t)+1:
            if t[prek0] == t[i-1]:    nxt[i] = prek0 + 1; prek0 += 1; i += 1
                # 表示跟着 next[i-1] 匹配上了
            elif prek0 <= 0:    nxt[i] = 0; prek0 = 0; i+=1
                # 表示 prek0 已经为 0 了但是还是匹配不上;则 nxt 为 0,下一个
            else:   prek0 = nxt[prek0]
                # 表示当前的 i 还有救,不断缩短蓝色部分尝试匹配它

3.3 整体代码

综上,此时匹配的思路为:

  • 先预处理得到 next
  • 开始匹配,第一轮 start = 0,将 t 和 s 的 start 位置对齐尝试匹配
    • 匹配成功,返回 start 就是匹配成功的起始位置
    • 匹配到 t[k] 的时候失败,
      • 则取出 next[k],这里的 start = start + k-next[k]
      • 主串 s 的指针:i = max(start, i)
      • 模式串 t 的指针:j = max(next[k], 0)
  • 如果当前从 s 的 start 位置开始剩下的部分不足 len(t),匹配失败,此时 s 中不存在 t 子串
class Solution:
    def strStr(self, s: str, t: str) -> int:
        # 预处理 计算 next
        nxt = [0] * (len(t) + 1)
        nxt[0], nxt[1] = -1, 0
        i = 2; prek0 = 0
        while i < len(t)+1:
            if t[i-1] == t[prek0]:  nxt[i] = prek0 + 1; prek0 += 1; i += 1
            elif prek0 == 0:    nxt[i] = 0; i += 1
            else:   prek0 = nxt[prek0]
        # 进行匹配
        start = 0
        i,j = 0,0
        while start + len(t) <= len(s):
            while s[i] == t[j]: 
                i+=1; j+=1
                if j == len(t): return start  # 匹配成功,返回起始位置
            k = j 
            start += k - nxt[k]
            i = max(i, start)  # 注意处理一个都匹配不上,start 直接 + 1 超过了 i 的情况
            j = max(nxt[k], 0)  # 小心 k = 0 的情况
        return -1  # 匹配失败




整理仓促,存在错误 / 不足欢迎指出!期待进一步讨论~
转载请注明出处。知识见解与想法理应自由共享交流,禁止任何商用行为!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值