KMP算法(二):另一种求解思路(确定有限状态自动机、动态规划)

一、简介

在上一篇KMP算法中已经介绍了KMP使用next数组进行求解的方法(https://blog.csdn.net/not_say/article/details/105291946),这一篇将讲述另外一种求解思路--利用确定有限状态自动机和动态规划的思路进行求解。

主要是参考了知乎一个专栏的一篇文章,内容非常详细,配有动态图,建议大家去看这篇文章,我自己写的这篇基本来源于它,然后是为了帮助自己理解,所以用博客的形式写出来,同时加入自己的理解,可能没有那么详细。文章链接:https://zhuanlan.zhihu.com/p/83334559

 

二、如何理解这里的状态机

同样沿用上一篇的字符串命名,父字符串名称为father,子字符串名称为son。这里的状态机和动态规划的功能与上一篇的next数组相似,都是为了确定子串回退的距离,减少不必要的对比。

为什么说 KMP 算法和状态机有关呢?是这样的,我们可以认为 son 的匹配就是状态的转移。比如当 son = "ABABC":

preview

如上图,圆圈内的数字就是状态,状态 0 是起始状态,状态 5(son.length)是终止状态。开始匹配时 son 处于起始状态,一旦转移到终止状态,就说明在 father 中找到了 son。比如说当前处于状态 2,就说明字符 "AB" 被匹配:

preview

另外,处于不同状态时,son 状态转移的行为也不同。比如说假设现在匹配到了状态 4,如果遇到字符 A 就应该转移到状态 3,遇到字符 C 就应该转移到状态 5,如果遇到字符 B 就应该转移到状态 0:

好了,内容引用到这里,按照我自己的理解来描述一下这个状态机的流转情况:

1、将子串的匹配程度理解为状态的转移,开始的时候或者一个都没匹配的时候状态是0.

2、当匹配到1个、2个、3个、4个的时候,状态分别为1、2、3、4。

3、当匹配5个的时候状态是5,数字也就是子串的长度。这时候就表示father中匹配到了son,起始下标就是此时A的位置。也就是i - son.length() + 1

4、这里是将子串自身后面的字符作为参照,假想匹配father时会遇到的字符,从而形成一个二维坐标图。例如下图就是“ABABC的”状态流转二维图:

二维图是用一个二维数组表示,用ASCII码0-255表示接下来可能遇到的字符,数值表示遇到此字符时的下一个状态。当依次遇到A、B、A、B、C时,状态才会流转到5,表示匹配成功。

5、匹配顺利前进的情况就如上面所说,匹配不成功则需要回退,但是具体回退到什么位置,则需要借助一个辅助状态,“labuladong”将其称为“影子状态”。

 

三、影子状态

所谓影子状态,就是和当前状态具有相同的前缀,用变量X表示,比如下图:

preview

当前状态 j = 4,其影子状态为 X = 2,它们都有相同的前缀 "AB"。因为状态 X 和状态 j 存在相同的前缀,所以当状态 j 准备进行状态回退的时候(遇到的字符 c 和 son[j] 不匹配),可以通过 X 的状态转移图来获得最近的重启位置。

那为什么可以这样呢?

原文的回答是这样的:

为什么这样可以呢?因为:既然 j 这边已经确定字符 "A" 无法推进状态,只能回退,而且 KMP 就是要尽可能少的回退,以免多余的计算。那么 j 就可以去问问和自己具有相同前缀的 X,如果 X 遇见 "A" 可以进行「状态推进」,那就转移过去,因为这样回退最少。

你也许会问,这个 X 怎么知道遇到字符 "B" 要回退到状态 0 呢?因为 X 永远跟在 j 的身后,状态 X 如何转移,在之前就已经算出来了。动态规划算法不就是利用过去的结果解决现在的问题吗?

其实换句话来说,就是用 X 来跟随 j 的脚步,当 j 遇到新的字符时,如果与预期的值一致,则状态加 1 ,否则回退到影子。而同时,影子从 0 开始,当遇到一个字符的时候,X = dp[X][son.charAt(j), 意思是等于X此时位置如果遇到 j 当前遇到的字符时将要转移的状态。这个状态是 j 之前所经历过的。如果这句话还是不能很好理解的话,那就再使用上一篇说的next数组的方式来帮助理解,通过debug发现,X的状态值,其实就是包含当前字符的最长相同前缀后缀(注意,这里理解为当前)。X的值其实就是为了记录当 j 的字符不匹配时应该回退到的位置。

四、代码

public class KMP {

    private int[][] dp;
    private String son;

    /**
     * 使用二维数组,利用有限状态机的思想--确定有限状态自动机、动态规划
     */
    public KMP(String son) {
        this.son = son;
        //son子串长度,也是状态的最大值
        int M = son.length();
        //dp[状态][字符--ASCII码] = 下个状态
        dp = new int[M][256];
        //遇到第一个字符则推进一步,否则其他的都是0
        dp[0][son.charAt(0)] = 1;
        //影子状态,初始为0 --所谓影子状态,就是和当前状态具有相同的前缀
        int X = 0;
        // 当前状态j从1开始
        for (int j = 1; j < M; j++) {
            //c代表此时要遇到的字符,父串的字符, ASCII从0-256
            for (int c = 0; c < 256; c++) {
                if (son.charAt(j) == c) {
                    //遇到的字符跟此时子串的字符抑制,则推进一步
                    dp[j][c] = j + 1;
                } else {
                    //不是的话则重启,回退到影子状态
                    dp[j][c] = dp[X][c];
                }
            }
            //更新影子状态
            X = dp[X][son.charAt(j)];
        }


    }

    public int search(String father) {
        int M = son.length();
        int N = father.length();
        // son 的初始态为 0
        int j = 0;
        for (int i = 0; i < N; i++) {
            // 当前是状态 j,遇到字符 father[i],
            // son 应该转移到哪个状态?
            j = dp[j][father.charAt(i)];
            // 如果达到终止态,返回匹配开头的索引
            if (j == M) {
                return i - M + 1;
            }
        }
        // 没到达终止态,匹配失败
        return -1;
    }

    public static void main(String[] args) {
        String son = "ABABCC";
        String father = "ABABEABABCABABA";
        KMP kmp = new KMP(son);
        int[][] dp = kmp.dp;
        System.out.println(kmp.search(father));
    }
}

五、自述

这篇文章到此结束了,大家可以去看我参考的那篇文章(https://zhuanlan.zhihu.com/p/83334559),自此我也只是大概了解了算法的思路,自我感觉并没有完全理解透测。意思就是仅限于跟着思路和代码来了解,但是并不能学以致用,遇到这个算法的变体或者另类的描述,我可能无法利用这个思路来写出合格的代码。因为后续会找时间去找相关的题目做一下。

有问题的朋友可以通过留言来进行讨论,描述错误或者不严谨的地方也请指出~

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值