KMP算法核心理论讲解(多年困惑一朝解)

如何理解dfa最重要的构造过程

在一切开始之前,我希望你已经掌握下面几点基础知识,也是为了方便你跟着我的思路理解KMP。

  1. 本文基于《算法 第四版》的KMP的教程。所以对于dfa的定义你一定要清楚,如果还不清楚dfa的定义,建议先看看书
  2. 先看一下这篇文章https://zhuanlan.zhihu.com/p/83334559,它讲解了KMP的一些基础知识,而且对于最长公共前缀的讲解也不错。你一定要理解最长公共前缀在KMP中有什么意义,是干嘛的,否则,接下来的讲解你都看不懂。
  3. 还是推荐先看一下这篇文章,理解什么是重启状态
  4. 最好知道字符串查找的暴力算法
参考文章:ttps://zhuanlan.zhihu.com/p/83334559

《算法》里面对dfa的定义和我刚才推荐的文章的定义是有点相反的,注意一下。

重启状态的定义

当C匹配文本字符失败后,会继续使用重启位置处的模式字符和该文本进行匹配

好了,让我们开始吧。

本文的讲解基于该算法

    public KMP(char[] pattern, int R) {
        this.R = R;
        this.pattern = new char[pattern.length];
        for (int j = 0; j < pattern.length; j++) {
            this.pattern[j] = pattern[j];
        }
        int m = pattern.length;
        dfa = new int[R][m];
        // 定义一个最基础的dfa
        dfa[pattern[0]][0] = 1;
        int X = 0;// 初始化重启状态
        for (int j = 1; j < m; j++) {
            for (int c = 0; c < R; c++) {
                dfa[c][j] = dfa[c][X];// 把重启状态的dfa的值赋给j
            }
            dfa[pattern[j]][j] = j + 1;// 匹配成功,+1
            X = dfa[pattern[j]][X];// 更新重启状态
        }
    }

而重心则在于讲解X = dfa[pattern[j]][X];这一句。前面的逻辑只要你看了上面推荐的那篇文章应该就能看懂,但是我相信这句代码的逻辑是很多人苦思冥想都不明白的。

X = dfa[pattern[j]][X];// 更新重启状态

0. 场景模拟

  ABABB         文本
  ABABAC        模式

如上所示,我们在匹配 A4 时失败了,那么该模式字符会进入重启状态,并且会继续使用重启位置处的模式字符和B进行匹配

而重启状态其实就是和 A4 拥有最长公共前缀的那个字符。

什么,这一点你不知道吗?还不去看看那篇推荐的文章!或者接着看也行,因为我也有做解释。

1. 为什么重启状态就是拥有最长公共前缀的那个字符?

我们的目的是找到重启状态,其实,就是为了寻找拥有最长公共前缀的那个字符

为什么呢?

原因可以用暴力回退算法来模拟得知:

回退处理与重启状态的关系

在暴力算法中,如果某个模式字符匹配失败,整个模式字符串都要回退。

回退之后,当匹配完前面几个字符,下一个要去匹配文本的字符就是重启状态的字符

我们通过一个暴力算法的例子来了解其中关系

ABABB         文本
ABABAC        模式

比如说,在上面的匹配中, A4 没有匹配上B,在回退算法中,下一步就要从这种状态开始执行算法

ABABB         文本
 ABABAC       模式

在经历了几次回退算法之后,文本和模式之间的关系会变成这样

  ABABB         文本
    ABABAC      模式

这是不是相当于,在第一次匹配失败之后,就直接尝试用 A2 去匹配B呢?

所以 A2 就是在 A4 匹配失败之后的重启位置!

我们的目的就是找到像 A2 这样的字符,和 A4 具有重合的最长公共前缀

而且:对于相邻两个字符B和C而言,C在模式中的最长公共前缀一定包含B的最长公共前缀!

2. 通过什么方式计算重启状态?

我们通过前一个模式字符 B3 的重启状态 B2 来匹配上一个文本字符B,通过dfa可以计算出匹配结果,这个匹配结果就是 A4 重启状态。

换句话说,下一个字符的重启状态可以使用 X = dfa[B][B*] 来计算

重启状态公式的正确写法

上面我写着 X = dfa[B][B2] ,严格来说,重启状态的表达式是:X = dfa[pattern[j]][X];,在算法中也是这样表达的。

因为在上面的例子中,pattern[j]就是B,所以就写成了 X = dfa[B][B2] 。

还有,《算法》里面对dfa的定义和我刚才推荐的文章的定义是有点相反的,注意一下。

3. 为什么可以通过这种方式计算重启状态?

我们已知的条件有:

  1. B的重启位置是 B2 , B2 对应的模式字符不一定等于B

  2. 模式字符C匹配失败的那个文本的上一个文本字符,一定是B

在C匹配失败后,会用重启状态 C2 处的模式字符来重新匹配那个文本字符。但是,我们不知道这个重启状态的索引,好在我们知道上一个模式字符B的重启状态的索引。

假如说 C2 要去重新匹配那个文本字符,那么在此之前, B2 也一定要与上一个文本字符B匹配成功。

模式与文本匹配成功,通过dfa可以计算出下一个与文本匹配的模式的下标,即 B2 +1,就是 C2 的位置!!

这个结果与通过暴力回退算法模拟的情况也是一致的。

但是,匹配的过程并不是会直接成功,可能 B2 也会进入重启状态,直到最后匹配成功(具体过程看下一点。或者所有的重启状态可能永远匹配上,这种情况就代表,在模式字符串中,C字符没有最长公共前缀)。

假如重启状态 Bn 匹配成功之后,按照模式的使用规则,和下一个文本字符匹配的索引就是Dfa[B][Bn],也就是 Bn 的下一个,同时,这就是 C2 的索引了!

由上面的条件可知,在B处就可以计算出C的重启位置Dfa[B][ B2 ]=Dfa[B][Bn]= Bn + 1

4. 如何通过这种方式计算重启状态呢

  • C的最长公共前缀里面一定包括B(包括它的的前几个(可能是一个,也可能是两三个,具体的数量不清楚,而且这不重要)模式字符)

  • 上一个文本字符肯定是B,模式字符也是B。

设B的重启状态为 B2

如果 B2 匹配文本字符B成功了,说明 B2 (包括 B2 的前面几个字符)等于C的最长公共前缀,那么 B2 的下一个字符就是C的重启状态。

如果 B2 匹配文本字符B失败了,那么 B2 也会进入重启状态, B2 的重启状态就是和 B2 拥有最长公共前缀的那个模式字符(在算法中,由于X始终小于j,所以当我们在计算C的重启状态时,dfa[B][ B2 ]肯定已知了。这个值是多少我们在最后一点会讲),我们会用该重启状态去再次匹配文本B。

如果 B2 的重启状态 B3 还是没有匹配上该字符,那么会去寻找 B3 的重启状态 B4 来尝试匹配,进入一个递归过程,直到终于匹配上,或者永远匹配不上。

  • 如果最终匹配上,那么匹配上的那个重启状态 Bn 的下一个字符就是C的重启状态(等于dfa[B][Bn]。由于匹配成功,所以 Bn 处的字符也是B,表达式dfa[B][Bn]的值就是 Bn 的下一个)。可能这时候最长公共前缀会变得很短,但是, Bn 和它的前几个(可能是一个,也可能是两三个,具体的数量不清楚,而且这不重要)模式字符一定在C的最长公共前缀中。
  • 如果永远匹配不上,说明在模式中,没有和C拥有最长公共前缀的字符,所以C的重启状态就是0

5. 由论述到表达式

由上述推导过程可以得到最后的重启状态表达式是dfa[B][Bn]。

但是我们在算法中,计算的X = dfa[pattern[j]][X] = dfa[B][模式B的重启位置],这二者在表达式上会相同吗?

dfa[B][B2] = dfa[B][Bn]???

相同的!

因为 Bn 也是B的重启位置之一,而且我们在算法的循环过程中使dfa[c][j] = dfa[c][X];每一个模式字符的dfa值和重启状态都是一致的。

    for (int c = 0; c < R; c++) {
        dfa[c][j] = dfa[c][X];// 把重启状态的dfa的值赋给j
    }

大家可能有一个困惑,就是 dfa[B][Bn] 的值究竟是多少呢?

我们继续往下

6. 从重启状态的角度去理解dfa的本质

dfa[c][j]的概念

c是文本字符,j是模式的下标

对于每个字符c,在比较了c和pat.charAt(j)之后,dfa[c][j]表示的是应该和下个文本字符比较的模式字符的位置。

我们可以***用上面的重启状态的角度***拓展一下通过dfa[c][j]得到下一个和文本字符匹配的模式字符下标的过程

  • 如果c和pat.charAt(j)匹配成功的话,那么无需重启,直接用下一个模式字符匹配下一个文本字符
  • 如果匹配失败了,那么pat.charAt(j)进入重启状态,并用重启状态的字符来匹配c,成功的话用下一个模式字符匹配下一个文本字符,否则,再次进入重启状态,直到匹配成功。

但是,为什么一个简单的dfa[c][j]表达式的值会是上面这么复杂的过程的结果呢?而且为什么dfa[c][j]的值会是和下一个文本字符匹配的模式下标?

我们看一个推导过程就明白了

如果模式字符C去匹配文本字符M,那么和下一个文本字符匹配的模式的下标就是dfs[M][C]

而dfs[M][C]=dfs[M][C2],根据值传递关系(dfa[c][j] = dfa[c][X]),dfs[M][C2]=dfs[M][C3]…

这个值传递的过程最终得到的值是多少呢?

是 dfs[M][Cn] = dfs[M][M] = M+1 。

在不断的值传递过程中,终会有一个模式字符的重启状态对应的字符= M ,而这个字符的dfa值在算法中根据dfa[pattern[j]][j] = j + 1;算了出来,这个j+1很明显就是M的下一个字符,而且,就是和下一个文本字符匹配的模式下标

这个值传递的过程就是一个不断使用重启状态的过程。

结束了,终于写完了这个推导过程。
如果帮到了大家,希望在评论区给我一个鼓励!

关键代码实现如下

public KMP(char[] pattern, int R) {
    this.R = R;
    this.pattern = new char[pattern.length];
    for (int j = 0; j < pattern.length; j++) {
        this.pattern[j] = pattern[j];
    }
    int m = pattern.length;
    dfa = new int[R][m];
    // 定义一个最基础的dfa
    dfa[pattern[0]][0] = 1;
    int X = 0;// 初始化重启状态
    for (int j = 1; j < m; j++) {
        for (int c = 0; c < R; c++) {
            dfa[c][j] = dfa[c][X];// 把重启状态的dfa的值赋给j
        }
        dfa[pattern[j]][j] = j + 1;// 匹配成功,+1
        X = dfa[pattern[j]][X];// 更新重启状态
    }
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值