一、简介
在上一篇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":
如上图,圆圈内的数字就是状态,状态 0 是起始状态,状态 5(son.length)是终止状态。开始匹配时 son 处于起始状态,一旦转移到终止状态,就说明在 father 中找到了 son。比如说当前处于状态 2,就说明字符 "AB" 被匹配:
另外,处于不同状态时,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表示,比如下图:
当前状态 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),自此我也只是大概了解了算法的思路,自我感觉并没有完全理解透测。意思就是仅限于跟着思路和代码来了解,但是并不能学以致用,遇到这个算法的变体或者另类的描述,我可能无法利用这个思路来写出合格的代码。因为后续会找时间去找相关的题目做一下。
有问题的朋友可以通过留言来进行讨论,描述错误或者不严谨的地方也请指出~