有限自动机匹配字符串算法需要一定的数论知识,而且也不是很好玩。
本文不会展开说其数学属性,因为要说清楚这点需要读者有一定的离散数学基础,不然就得先解释清楚一些概念。所以如果你不懂自动机、状态机等概念,对集合、关系等概念不熟悉,也不想搞懂,那么理解下面的代码就行了,概念上我会进行一些解释,毕竟也是个记录。
如果你想搞懂背后的数学属性,首先学一下离散数学,重点是数论。有基础之后,就可以看一下《算法导论》和芝加哥大学的String Matching with Finite Automata,后者示例更好一些,本文也参考了一些。
自动机干嘛的
自动机(automata)是一种数学模型,包含了一系列状态,输入不同,会转换成不同的状态。一般这种xx机都是用来实现一些语言或者机器的(比如大名鼎鼎的图灵机),但是也可以使用编程语言模拟出来。有限机一般使用状态转移表来进行模拟和实现,就像各种图经常是用矩阵表达的。
下图是《算法导论》中给出的状态转移表格,这个表格怎么来的?要怎么看呢?
这个表格表示了有限自动机的状态是如何根据输入改变的:
- 这个表格中的
input
三列表示输入的可能性有三种:a
、b
、c
。(输入d
就不能匹配了,所以这个表格的宽度经常等于 ASCII 码的大小) P
下面是匹配字符串。- 每一行表示一种状态。第一行是初始状态。
- 每一个格子表示当前状态和输入的情况下,前往哪一个状态。
如果你没搞明白怎么看这个表格,跟着下面的步骤走一下(我特地让二者能同时显示,不用上下划):
- 从第一行开始:如果当前输入的字符是
a
(也就是检查的字符串中的字符),与P
的字符相同,那么前往下一状态1
;如果是其他的,还呆在状态0
。 - 假设输入了
a
,前往了状态1
。- 此时如果输入的依旧是
a
,那么还在当前行(相当于从状态0
又来了一次); - 如果输入的是
b
,那么前往状态2
; - 如果是输入的字符是
c
,那么返回状态0
(因为下次匹配字符串要从头开始)。
- 此时如果输入的依旧是
- 以此类推,直到完全匹配成功一次。这时候在状态
7
,然后根据下次输入的字符,返回0
、1
、2
(为了检查自己有没有真的搞明白,可以想想最后一个状态为什么输入b
的时候返回的是状态2
)。
算法逻辑
从上面的过程可知,只要有一种输入能前往状态7
,那么这个输入就包含匹配的字符串。所以这个算法的逻辑大致如下:
- 生成这样一个状态表格(这部分只和匹配字符串有关);
- 用字符串在这个表格上“前进”,如果到最后状态了,那么就说明存在一个匹配部分。
生成表格
生成状态表格简单中又包含着不简单:
- 简单的是:将可能的输入字符(行元素)等于匹配字符串的字符(列元素)的位置等于下一行号。
- 不简单的是:如果当前值不匹配,返回哪一状态呢?从上面的过程中我们看到,如果直接返回初始状态
0
的话,有些时候是会出错的。实现方法就是从当前状态往前倒,不过倒到哪里合适呢?
首先是找到一个离当前状态最近,并且和当前输入字符相同的状态。因为重复意味着有可能只用返回到这个位置后的一个就行了。还记得前面那个过程中的状态 1 吗?输入a
之后返回的还是1
,而不是回到0
了。
然后逐步从头检查匹配字符串的子字符串和含当前状态对应的子字符串是否相等,如果相等就不用回到开头了。
完整代码
写这个代码的时候参考了一下别人的,因为我一开始看《算法导论》没搞懂这个算法在干嘛。然后一堆人在
如果不匹配,确定要返回哪个状态
用的是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遍历;
然后发现直到最后都相同,就返回这个状态编号。
*/
int i = 0;
for (i = 0; i < state; i++) {
//如果输入等于之前的某一个状态,那么说明有可能可以返回到这个状态
if (partern[state-i-1] == in) {
int j=0;
for (j = 0; j < state-i-1; j++) {
//如果不等于就下一次循环,如果最后都没有,那么直接就是0,回到初始状态
if (partern[j] != partern[i+j+1]) {
break;
}
}
//如果匹配字符串前面的部分等于当前状态前面的部分,那么返回上一状态就行了
if (j == state-i-1) {
FA[state][in] = state-i;
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[]="bccdccdcccd";
char partern[]="ccd";
int lenstr=sizeof(str)/sizeof(char)-1;
int lenpart=sizeof(partern)/sizeof(char)-1;
automataMatch(str, partern, lenstr, lenpart);
return 0;
}
这里的字符串与前面不同,是因为这里要展示一下多个匹配是什么样的。结果如下:
从1开始匹配
从4开始匹配
从8开始匹配
下一篇是关于 KMP(The Knuth-Morris-Pratt algorithm)算法的。
希望能帮到有需要的人~