「字符串匹配算法 2/3」有限自动机匹配字符串算法

有限自动机匹配字符串算法需要一定的数论知识,而且也不是很好玩。

本文不会展开说其数学属性,因为要说清楚这点需要读者有一定的离散数学基础,不然就得先解释清楚一些概念。所以如果你不懂自动机、状态机等概念,对集合、关系等概念不熟悉,也不想搞懂,那么理解下面的代码就行了,概念上我会进行一些解释,毕竟也是个记录。

如果你想搞懂背后的数学属性,首先学一下离散数学,重点是数论。有基础之后,就可以看一下《算法导论》和芝加哥大学的String Matching with Finite Automata,后者示例更好一些,本文也参考了一些。

请添加图片描述

自动机干嘛的

自动机(automata)是一种数学模型,包含了一系列状态,输入不同,会转换成不同的状态。一般这种xx机都是用来实现一些语言或者机器的(比如大名鼎鼎的图灵机),但是也可以使用编程语言模拟出来。有限机一般使用状态转移表来进行模拟和实现,就像各种图经常是用矩阵表达的。

下图是《算法导论》中给出的状态转移表格,这个表格怎么来的?要怎么看呢?

这个表格表示了有限自动机的状态是如何根据输入改变的:

  • 这个表格中的input三列表示输入的可能性有三种:abc。(输入d就不能匹配了,所以这个表格的宽度经常等于 ASCII 码的大小)
  • P下面是匹配字符串。
  • 每一行表示一种状态。第一行是初始状态。
  • 每一个格子表示当前状态和输入的情况下,前往哪一个状态。
    请添加图片描述
    如果你没搞明白怎么看这个表格,跟着下面的步骤走一下(我特地让二者能同时显示,不用上下划):
  1. 从第一行开始:如果当前输入的字符是a(也就是检查的字符串中的字符),与P的字符相同,那么前往下一状态1;如果是其他的,还呆在状态0
  2. 假设输入了a,前往了状态1
    • 此时如果输入的依旧是a,那么还在当前行(相当于从状态0又来了一次);
    • 如果输入的是b,那么前往状态2
    • 如果是输入的字符是c,那么返回状态0(因为下次匹配字符串要从头开始)。
  3. 以此类推,直到完全匹配成功一次。这时候在状态7,然后根据下次输入的字符,返回012(为了检查自己有没有真的搞明白,可以想想最后一个状态为什么输入b的时候返回的是状态2)。

算法逻辑

从上面的过程可知,只要有一种输入能前往状态7,那么这个输入就包含匹配的字符串。所以这个算法的逻辑大致如下:

  1. 生成这样一个状态表格(这部分只和匹配字符串有关);
  2. 用字符串在这个表格上“前进”,如果到最后状态了,那么就说明存在一个匹配部分。

生成状态转移表格

生成状态表格简单中又包含着不简单:

  • 简单的是:将可能的输入字符(行元素)等于匹配字符串的字符(列元素)的位置等于下一行号。
  • 不简单的是:如果当前值不匹配,返回哪一状态呢?从上面的过程中我们看到,如果直接返回初始状态0的话,有些时候是会出错的。实现方法就是从当前状态往前倒,不过倒到哪里合适呢?

首先是找到一个离当前状态最近,并且和当前输入字符相同的状态。因为重复意味着有可能只用返回到这个位置后的一个就行了。还记得前面那个过程中的状态 1 吗?输入a之后返回的还是1,而不是回到0了。

然后逐步从头检查匹配字符串的子字符串和含当前状态对应的子字符串是否相等,如果相等就不用回到开头了。

比如,在后面列出的完整代码中,会发现最后在处理“往前倒”的时候,有一段这样的代码:

for (int i = 1; i < state; i++) {
    if (partern[state-i] == in) {
        int j=0;
        for (j = 0; j < state-i; j++) {
            if (partern[j] != partern[i+j]) {
                break;
            }
        }

        if (j == state-i) {
            FA[state][in] = state-(i-1);
            break;
        }
    }
}

这段代码的partern[j] != partern[(i+1)+j是在干什么呢?其实不管外循环是递增还是递减,一开始都有点难懂。

有些博客、教材使用的是nextState--,而不是i++state-i,我更偏向后者。为什么呢,看完下面的解释你就懂了。

这里其实是在找匹配字符串中和开头重复的子字符串。

比如ABABAC,在C处输入B匹配失败,那么找到这之前的一个B后:

匹配字符串:	A B A B A C(B)
第一步:		      |				 (这一步找到与输入相同的B)
第二步:			  |__2__|	     (两个B的距离是2,也就是说如果从开头有字符串和包含这个B的字符串相同,那么开头的字符串相当于右移了2)
第三步:		|   |				 (从开头右移2开始对比,第一个相同)
第四步:		  |   |				 (两个下标+1,第二个相同)
第四步:		    |   |			 (两个下标+1,第三个相同)
第四步:							 (由于最开始进入这个循环就是因为判断了两个B相等,所以不用比这一位)
第四步:		|_____|				 (也就是说),这两个子字符串相等,这里状态转移表存储的下一状态就是往前倒2
				|_____|

也就是说:

  1. 先假设存在这样一个重复的子字符串,那么这个子字符串与开头那个相比,右移对应的位数。
  2. 由于是假设,所以要验证一下是不是。然后对两个子字符串以此对比。如果两个子字符串相同,那么就可以返回到这个重复的地方的下一个(所以代码中,状态转移表存储的值是state-(i-1),而不是state-i)。

而这就是上面的代码的逻辑。

按照状态转移表往前走

这个就很简单了,一步步将字符串“输入”表格,最后就能告诉你哪里是匹配好的。不过怎么输入呢?

由于实现状态转移表的时候,每一行的一个元素就相当于一个输入,然后按照值跳转。如下:

for (int i = 0, state = 0; i < lenstr; i++) {
    state = FA[state][str[i]];
    //如果状态机表格中的值等于匹配部分的长度,那么说明匹配好了
    if (state == lenpart) {
        printf ("从%d开始匹配\n", i-lenpart+1);
    }
}

完整代码

写这个代码的时候参考了一下别人的,因为我一开始看《算法导论》没搞懂这个算法在干嘛。然后一堆人在如果不匹配,确定要返回哪个状态用的是for递减来计算,我看了半天才反应过来在干嘛,我不喜欢这个表达方式,然后就改递增,但是中间有个地方修改后忘了,最后查了一个小时才找到问题所在,真的是犯蠢了。

完整代码如下:

#include<stdio.h>

//这里的长度是ASCII的长度,因为使用的是ASCII码
#define NumInput 256

void automataMatch(char str[], char partern[], int lenstr, int lenpart) {
    //保证FA的初始值是0
    int FA[lenpart+1][NumInput];
    for (int i=0; i<(lenpart+1); i++) {
        for (int j=0; j<NumInput; j++) {
            FA[i][j] = 0;
        }
    }
 
    //构建状态转移表格
    for (int state = 0; state <= lenpart; state++) {
        for (int in = 0; in < NumInput; in++) {
            //如果当前状态匹配的字符partern[state]等于输入的元素x
            if (state < lenpart && in == partern[state]) {
                FA[state][in] = state + 1;
            } else {
                //如果不匹配,确定要返回哪个状态
                /*  比如表格中给出的ababaca,
                    如果在c的位置输入了b,那么就往前倒,找和开头相同的部分,此时state=6;
                    先找为b的部分,可以看到是第4个位置,也就是说,此时preState=state-2=4;
                    表示判断从a(状态2)开始判断,发现和开头是一样的,就开始使用i遍历;
                    然后发现直到最后都相同,就返回这个状态编号。
                 */
                for (int i = 1; i < state; i++) {
                    if (partern[state-i] == in) {
                        int j=0;
                        for (j = 0; j < state-i; j++) {
                            if (partern[j] != partern[i+j]) {
                                break;
                            }
                        }

                        if (j == state-i) {
                            FA[state][in] = state-(i-1);
                            break;
                        }
                    }
                }
            }
        }
    }
    
    //开始匹配
    for (int i = 0, state = 0; i < lenstr; i++) {
        state = FA[state][str[i]];
        //如果状态机表格中的值等于匹配部分的长度,那么说明匹配好了
        if (state == lenpart) {
            printf ("从%d开始匹配\n", i-lenpart+1);
        }
    }
    
}

int main(void)
{
    char str[]="ABABAC";
    char partern[]="ABA";

    int lenstr=sizeof(str)/sizeof(char)-1;
    int lenpart=sizeof(partern)/sizeof(char)-1;
    
    automataMatch(str, partern, lenstr, lenpart);
    
    return 0;
}

这里的字符串与前面不同,是因为这里要展示一下多个匹配是什么样的。结果如下:

从0开始匹配
从2开始匹配

下一篇是关于 KMP(The Knuth-Morris-Pratt algorithm)算法的。

希望能帮到有需要的人~

  • 10
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值