KMP模式匹配

KMP 模式匹配

\(\text{Update 2021.7.2}\):用 \(\rightarrow\) 代替了 \(\LaTeX\) 公式中的 \ to\ ,更新了代码中一句不必要的边界判断。

KMP 算法得名的缘由:由三位计算机科学家 \(\text{Knuth}\)\(\text{Morris}\)\(\text{Pratt}\) 共同设计。不是什么看毛片啦

Part 1 KMP算法可以做什么

KMP 算法,又称模式匹配算法,能够在线性时间内判定字符串 \(A_1\rightarrow A_N\) 是否为字符串 \(B_1\rightarrow B_M\) 的子串,并且求出字符串 \(A\) 在字符串 \(B\) 中各次出现的位置。

通常的 \(O(NM)\) 做法是,尝试枚举字符串 \(B\) 中的每个位置 \(i\) ,把字符串 \(A\) 与 字符串 \(B\) 的后缀 \(B_i\rightarrow B_M\) 对齐,向后扫描注意比较 \(A_1\)\(B_i\)\(A_2\)\(B_{i+1}\) ... 是否相等。我们把这种比较过程成为 \(A\)\(B\) 尝试进行“匹配”。

Part 2 KMP算法原理

KMP 算法分为两步:

  1. 对字符串 \(A\) 进行自我“匹配”,同时求出一个数组 \(next\) ,其中 \(next_i\) 表示 “ \(A\) 中以 \(i\) 为结尾的非前缀子串”与 “ \(A\) 的前缀”能够匹配的最长长度,即:

    \[next_i=max\{j\},j<i,A_{i-j+1}\rightarrow A_i=A_1\rightarrow A_j \]

    特别地,当 \(j\) 不存在时,令 \(next_i=0\)

    叙述比较难懂,如图:

    简单来说,在下面的 \(A\) 中,找到一个最大的 \(j\) ,把 \(i,j\) 对齐后使得下面的 \(A\) 串从开头到 \(j\) 和上面的 \(A\) 串从 \(i-j+1\)\(i\) 完全一样。此时,三段标为红色的字符串都应该完全相同。

  2. 对字符串 \(A\)\(B\) 进行匹配,求出一个数组 \(f\) ,其中 \(f_i\) 表示“ \(B\) 中以 \(i\) 结尾的子串”与“ \(A\) 的前缀”能够匹配的最长长度,即:

    \[f_i=max\{j\},j\leq i ,B_{i-j+1}\ to\ B_i=A_1\rightarrow A_j \]

    步骤 2 和步骤 1 十分相似,只是把上面的字符串换成了 \(B\) 串而已,不再多做解释。

由于 \(f\) 数组和 \(next\) 数组的相似性,下面只讨论 \(next\) 数组的求法,而 \(f\) 数组可以根据相似的方法求出。根据定义,\(next_1=0\) 。接下来我们按照 \(i=2\rightarrow N\) 的顺序依次计算 \(next_i\)

假设 \(next_1...next_{i-1}\) 已经计算完毕,当计算 \(next_i\) 时,根据定义,我们需要找到所有满足 \(j<i\)\(A_{i-j+1}\rightarrow A_i=A_1\rightarrow A_j\) 的整数 \(j\) 并取最大值。为了叙述方便,我们称满足这两个条件的 \(j\)\(next_i\) 的“候选项”。

使用朴素算法计算 next 数组

朴素的做法是枚举 \(j\in [1,i-1]\) 并检查 \(A_{i-j+1}\rightarrow A_i\)\(A_1\rightarrow A_j\) 是否相等。

这种算法对于每个 \(i\) 枚举了 \(i-1\) 个非前缀子串,时间复杂度约为 \(O(N^2)\) 。能否更快的求出 \(next_i\) 呢?

引理

\(j_0\)\(next_i\) 的一个“候选项”,即 \(j_0<i\)\(A_{i-j+1}\rightarrow A_i=A_1\rightarrow A_j\) ,则小于 \(j_0\) 的最大的 \(next_i\) 的“候选项”是 \(next_{j_0}\) 。换言之,\(next_{j_0}+1\rightarrow j_0-1\) 之间一定没有 \(next_i\) 的候选项。

证明:

反证法。假设存在 \(next_{j_0}<j_1<j_0\) 使得 \(j_1\)\(next_i\) 的“候选项”,即 \(A_{i-j_1+1}\rightarrow A_i=A_1\rightarrow A_{j_1}\)

分别取 \(A_{i-j_0+1}\ to\ A_i\)\(A_1\ to\ A_{j_0}\) 的后 \(j_1\) 个字符,显然也相等,即\(A_{i-j_1+1}\rightarrow A_i=A_{j_0-j_1+1}\rightarrow A_{j_0}\) 。从而 \(A_{j_0-j_1+1}\rightarrow A_{j_0}=A_1\rightarrow A_{j_1}\) ,这与 \(next_{j_0}\) 最大的性质矛盾,故假设不成立。

另一方面,\(next_{j_0}\) 显然是 \(next_i\) 的候选项。

证毕。

证明看懂需要花时间,这里放一张我自己画的图帮助理解(原谅画图能力有限):

把 4 个指针都对齐,使得 \(j_0\)\(next_i\) 的候选项,根据 \(next\) 数组的定义,图中划紫色斜线的部位一定相同,也就有了证明中最后一句的“另一方面,\(next_{j_0}\) 显然是 \(next_i\) 的候选项”。

现在假设出现 \(j_1\) ,且 \(next_{j_0}<j_1<j_0\) (如图),如果 \(j_1\)\(next_i\) 的候选项,根据 \(next\) 数组的定义,那么图中划粉色斜线的部位一定相等,那么 \(next_{j_0}\) 的值应该为 \(j_1\) ,而不是 \(next_{j_0}\) (粉色显然长度比紫色长),这与 \(next\) 数组的“最长”定义相违背,所以 \(j_1\) 一定不存在。

Part 3 使用以上定理优化求解 next 数组

根据引理,当 \(next_{i-1}\) 计算完毕时,我们可以得知 \(next_{i-1}\) 的所有候选项,从大到小依次为:(这里用 LaTeX 不太方便,于是用代码块给出)

next[i-1],next[next[i-1]],next[next[next[i-1]]]...

另外,如果一个整数 \(j\)\(next_i\) 的候选项,那么很显然 \(j-1\) 也应该是 \(next_{i-1}\) 的候选项(整体前移一位)。因此,在计算 \(next_i\) 的时候,候选项从大到小依次为:

next[i-1]+1,next[next[i-1]]+1,next[next[next[i-1]]]+1...

像下面,就是求解 \(next_8\) 的全过程:

    1 2 3 4 5 6 7 8 9
                  i
A = a b a b a b a|a c              
        a b a b a|b a a c                 Fail(next[7]=5)
            a b a|b a b a a c             Fail(next[5]=3)
                a|b a b a b a a c         Fail(next[3]=1)
                 |a b a b a b a a c       next[1]=0,A[1]==A[8],next[8]=1
                j j+1

可以看到,我们从长到短尝试把 8 前面的字符匹配上( | 符号之前的元素),但是 8 这一位始终不能匹配。于是到最后,只能找到 \(A_8=A_1\) ,故 \(next_8=1\)

当然,如果在找完 next[i-1],next[next[i-1]],next[next[next[i-1]]]... 之前已经匹配上了,那么 \(next_i\) 就是匹配上的那个 \(next\)\(+1\) ,这里不再赘述。

这里因为 \(j\) 始终非负,所以 \(j\) 的变化次数不会超过模式串的长度,故总复杂度 \(O(N+M)\)

Part 4 KMP 代码实现

#include<cstdio>
#include<cstring>
#include<algorithm>
#include<iostream>

//using namespace std;

const int maxn=1000005;

int next[maxn],f[maxn];
char A[maxn],B[maxn];//A为原串,B为匹配串
int n,m;

inline void Init(){
  scanf("%s %s",A+1,B+1);
  n=std::strlen(A+1),m=std::strlen(B+1);
}

inline void KMP(){
  next[1]=0;//定义,next[1]=0
  for(int i=2,j=0;i<=m;++i){//j表示next[i-1]
    while(j>0 && B[i]!=B[j+1]) j=next[j];//next[i-1]+1先尝试匹配,然后依次变为next[next[i-1]]...
    if(B[i]==B[j+1]) j++;//下一个字符相等,匹配长度++
    next[i]=j;
  }
  for(int i=1,j=0;i<=n;++i){
    while(j>0 && A[i]!=B[j+1]) j=next[j];//同next数组求解f
    if(A[i]==B[j+1]) j++;
    f[i]=j;
    if(f[i]==m) printf("%d\n",i-m+1);//这说明在A串中找到了B串,输出开头位置
  }
}

int main(){
  Init();
  KMP();
  return 0;
}

本博客部分证明参考《算法竞赛进阶指南》,作者李煜东,特此注明。

posted @ 2021-07-01 16:41  ZTer  阅读(240)  评论(1编辑  收藏  举报