关于利用有限自动机进行字符串匹配

文章目录

  • 有限自动机的字符串匹配
  • 一种针对 $\Sigma^*P\Sigma^*$ 模式的正则表达式的DFA直接构造方法
    • 转移函数$\delta$的定义
    • 如何计算转移函数???
  • 与KMP算法的联系与区别
    • KMP算法的核心:理解辅助数组aux[1,...,m]。
    • 辅助函数aux[1, ..., m]的计算
    • 辅助函数aux[1, ..., m]的使用

注:需要读者学习过基础的形式语言与自动机的相关知识,至少是了解正则表达式,DFA,NFA的关系。
注2:本文描述的字符串的前缀的时候,不包含字符串最后一个字母;字符串的后缀,不包含字符串第一个字母。
注3:KMP算法的理解,千万千万不能纠结于标准代码中的那些索引下标。关键是准确透彻的理解辅助数组的含义,以及求解辅助函数的过程,理解了这两点,自己在写代码的过程中才可以根据具体的情况来分析其中的下标与索引的更新。

在算法导论32.3节中,介绍了一种利用有限自动机进行字符串匹配的算法,看上去非常复杂,在本文中我尝试从另一个角度来理解这个算法。在介绍了这个算法后,再介绍KMP算法,并且分析这两个算法之间的关系。

有限自动机的字符串匹配

问题描述:
需要匹配的字符串是T,模式是P[1,…,m]。我们可以很容易的就能够写出一个正则表达式,能够匹配所有的包含模式P的字符串:
Σ ∗ P Σ ∗ \Sigma^*P\Sigma^* ΣPΣ
显然该正则表达式能够严密而且完善的匹配所有包含P的字符串,也就是说如果T包含P,那么其能够与该正则表达式匹配,否则不能匹配。

综上我们构造出该正则表达式对应的DFA,显然匹配过程就非常简单了。按照常见的正则表达式的DFA构造过程,应该是这样的:

正则表达式 —> NFA —> DFA

学习过形式语言与自动机的读者,应该都知道这两步转换过程都是有非常固定的套路的,显然这么做是可以的。可是问题在于两步过程非常繁琐,而且需要经过一个NFA做为桥梁,利用代码来定义NFA也是比较麻烦的。

在形式语言与自动机的学习中,我们有时候对于简单,或者说有些有固定模式的正则表达式也会直接构造其对应的DFA,跳过NFA的构造。

算法导论32.3的本质就是介绍了一种直接构造 Σ ∗ P Σ ∗ \Sigma^*P\Sigma^* ΣPΣ这种模式的正则表达式的DFA的方法。

一种针对 Σ ∗ P Σ ∗ \Sigma^*P\Sigma^* ΣPΣ 模式的正则表达式的DFA直接构造方法

M = ( Q , q 0 , A , Σ , δ ) Q = { 0 , 1 , . . . , m } q 0 = 0 A = { m } Σ δ ? ? ? ? ? \begin{aligned} M = & (Q, q_0, A, \Sigma, \delta) \\ & Q = \{0, 1, ..., m\} \\ & q_0 = 0 \\ & A = \{m\} \\ & \Sigma \\ & \delta????? \end{aligned} M=(Q,q0,A,Σ,δ)Q={0,1,...,m}q0=0A={m}Σδ?????
P [ 1 , . . . , m ] P[1,...,m] P[1,...,m]是正则表达式中的模式, M M M是我们将要构造的DFA。

从上述定义可以看出:

规定DFA M M M 的状态集合为 Q = { 0 , 1 , . . . , m } Q = \{0, 1, ..., m\} Q={0,1,...,m} 共m+1个状态;
DFA M M M 的初始状态 q 0 = 0 q_0 = 0 q0=0
DFA M M M 的结束状态集合为 A = { m } A = \{m\} A={m} ,仅包含一个状态 m m m
至于字母表,则是与原始题目相关的;
所以DFA M M M 的构造的最关键就是转移函数 δ \delta δ 的定义了。

转移函数 δ \delta δ的定义

δ ( q , a ) = σ ( P q a ) for any state  q ∈ Q  and for any character  a ∈ Σ \delta(q, a) = \sigma(P_qa) \newline \text{for any state } q \in Q \text{ and for any character } a \in \Sigma δ(q,a)=σ(Pqa)for any state qQ and for any character aΣ
σ ( x ) = max { k } where   P k  is a prefix of  P  and  P k  is also a suffix of  x \sigma(x) = \text{max}\{k\} \newline \text{\textbf{where} } P_k\ \text{is a prefix of } P \text{ and } P_k \text{ is also a suffix of } x σ(x)=max{k}where Pk is a prefix of P and Pk is also a suffix of x
P k = P [ 1 , . . . , k ] P_k = P[1, ..., k] Pk=P[1,...,k]

转移函数的解释:

δ ( q , a ) \delta(q, a) δ(q,a) 说明对于任意一个状态 q q q ,以及一个字母 a a a ,其下一个状态是 σ ( P q a ) \sigma(P_qa) σ(Pqa)

σ ( P q a ) = k \sigma(P_qa)=k σ(Pqa)=k 表示找一个P的最长的前缀 P k P_k Pk 也是 P q a P_qa Pqa 的后缀。或者说,找 P q a P_qa Pqa的后缀,也是 P P P的前缀,其中最长的一个。

通俗的说,处于状态 q q q 表示,对于某个字符串使用DFA的匹配过程中已经匹配了模式 P P P 的前 q q q 个字符,现在匹配下一个字符 a a a 的时候(即 P q a P_qa Pqa )发现其后缀最多只能匹配 k k k 个字符了,所以又进入状态 k k k (即 P q a P_qa Pqa 的符合要求的后缀最长只能是 P k P_k Pk )。

更通俗的说,使用DFA来匹配的时候,在状态 q q q 的含义就是我当前位置已经成功匹配了模式的前 q q q 个字符,下一待匹配字符是 a a a 。我们观察 P q a P_qa Pqa字符串,发现现在能够匹配模式P的前 k k k 个字符,所以进入状态k。

如何计算转移函数???

理解了转移函数的含义后,接下来的关键就是,如何计算转移函数了。
计算转移函数 δ : Q × Σ → Q \delta: Q×\Sigma \rightarrow Q δ:Q×ΣQ本质就是计算一张 m ∗ ∣ Σ ∣ m*|\Sigma| mΣ大小的表。

对于其中一项 δ ( q , a ) \delta(q, a) δ(q,a)就是寻找字符串 P [ 1 , . . . , q ] a P[1,...,q]a P[1,...,q]a的一个后缀,同时也是模式 P [ 1 , . . . , m ] P[1,...,m] P[1,...,m]的前缀,找其中最长的一个。

computeTransitionFunction(P[1,...,m], E)
	delta[0,...,m][|E|]; //保存转移函数
	for q = 0->m
		for each a in E //计算delta(q, a)
			delta(q,a) = 0; //如果找不到前缀的话,就是0,即转移到状态0
			for l = 1->q+1 //检查P[1,...,q]a的后缀P[l,...,q]a 是不是P的前缀
								//l=q+1的时候,表示检查a是不是P的前缀
				if(P[l,...,q]a is prefix of P)
					delta(p,a) = q-l+1+1; //前缀的长度
					break;
	return delta

注:上述转移函数的计算与算法导论中略有不同

与KMP算法的联系与区别

KMP算法根据模式计算出一个辅助数组,从某种意义上说————可以根据辅助数组“即时”的计算出转移函数。

  1. 从辅助数组aux使用的角度来理解aux的含义;
  2. 根据DP的自底向上的角度来理解,aux的计算过程。

KMP算法的核心:理解辅助数组aux[1,…,m]。

从aux[q]的使用角度来理解,当使用到aux[q]的时候,表明匹配T[j]与P[q]的时候失败,那么说明:

  1. 已经成功匹配了q-1个字符了(T[j-q+1, …, j-1] == P[1, …, q-1])
  2. 第q个字符匹配失败了(T[j] != P[q])
    换句话说,在q位置匹配失败,意味着我们知道T[j-q-1, …, j-1]的内容是什么。所以我们就来研究这部分的串,即P[1, …, q-1]。

如果我知道,P[1, …, q-1]的一个最长的既是前缀又是后缀的子串Pk的话,那么我在P[q]位置匹配失败的时候,直接将这个Pk移动到P[q-k, …, q-1](即T[j-k, …, j-1])与这个对齐,也就是说,这k个位置我们不需要重新匹配了,我知道肯定是匹配的。

整理一下:

当我们在q位置匹配失败的时候,我知道P[q-k, …, q-1]可以直接与T[j-k, …, j-1] (即P[1,…,k])匹配的,所以我们在aux[p]中记录下一个值k+1。下一次匹配的时候,直接使用P[k+1] 与T[j]匹配(之前匹配失败的地方)。因为我们已经知道T[j-k, …, j-1]与P[1, …, k]一定是匹配的。

辅助函数aux[1, …, m]的计算

理解了aux的含义后,关键就是aux的计算了。

从aux的使用角度来理解。aux[q]记录的是:在模式的P[q]位置匹配失败的时候,应该将q更新为aux[q]位置。

aux[q]的本质就是去计算P[1,…,q-1]的一个最长的既是前缀又是后缀的Pk,然后aux[q]=k+1;

aux[q]的计算就转换为了找P[1,…,q-1]的一个最长的既是前缀又是后缀的Pk;

怎么找?????? 找P[1, …, q-1]的一个最长的既是前缀又是后缀的Pk,当然是使用模式P[1,…, q-2] 与 文本P[2, …, q-1]进行比较了。

从上面这句话就可以看出来一点有意思的地方了,整理一下:

  1. 计算aux[q]就是找P[1,…,q-1]的一个最长的既是前缀又是后缀的Pk
  1. 找Pk本质就是:模式P[1,…,q-2] 与文本 P[2,…,q-1]的匹配

所以又转换为了一个 模式与文本匹配 的问题:我们的确可以使用朴素的暴力算法来匹配的。但是且慢,我们冷静的思考一下:
我们从DP的自底向上的角度来安排aux[1,…,m]的计算过程,依次计算aux[1], aux[2], …, aux[m]。这样我们计算aux[q]的时候,其实已经知道了aux[1], aux[2], …, aux[q-1]的值了。

我们在回顾一下aux[q]的计算,本质就是匹配模式P1[1, …, q-2]与文本P2[2, …, q-1]的过程,即文本P2匹配到最后一个字符P[q-1]的时候,此时模式P1的指针k就是我们要的Pk,那么在这个过程中如果失败的话,只会在模式的P[1], P[2], …, P[q-2]的位置,神奇的是,这些位置的辅助数组都已经计算出来了,我们是可以直接使用的。也就是说如果在P[i]位置失败了(1<=i<=q-2),我们直接使用aux[i]来更新i,继续匹配。

所以aux的高效计算方法如下:

computePrefixFunc(P[1, ..., m])
	aux[1] = 0+1;  //回顾aux[i]的含义
	aux[2] = 0+1; //回顾aux[i]的含义
	int k = 1; //首先计算aux[3]即,需要匹配模式P[1] 与文本P[2], 所以k初始化为1。 k是模式串的索引 
	for i = 3 -> m //计算aux[i], 即模式P[1, ..., i-2]   文本P[2, ..., i-1]的匹配过程。i同时还是文本串的索引。
		while(k > 1 && P[k] != P[i-1]) //在匹配的过程中,发现P[k]位置匹配失败, 11,如果是k>0的话,会陷入死循环
		if(P[k] == P[i-1]) //由于k是由aux[k]更新来的,结合aux[k]的含义,我们知道此时有P[1, ..., k] == P[i-k, ..., i-1] 
			k++;					//即Pk是我们需要找寻的P[1, ..., i-1]的最长的前缀,同时也是后缀
		aux[i] = k; 		

/*
最后分析一下for循环两次迭代之间的关系:
上一次迭代计算aux[i]结束的时候(注意k++了),有
	P[1, ..., k-1] == P[i-k+1, ..., i-1]
下一次迭代需要计算aux[i+1],也就是找P[1, ..., k'] == P[i+1-k', ..., i]。注意在上一次迭代结束的时候k最后的值,会发现:
	P[1, ..., k-1] == P[i-k+1, ..., i-1]
所以,如果P[k] == P[i]的话,我们就可以直接更新aux[i+1] = k+1了,
而如果不匹配的话,那么显然就需要我们已经计算的aux数组,来帮助我们调整模式串的匹配位置,即while循环。
*/

辅助函数aux[1, …, m]的使用

KMP-match(T[1,...,n], P[1, ..., m])
	int q = 1;
	for j = 1->n
		while(q > 1 && P[q] != T[j])
			q = aux[q];
		if(P[q] == T[j])
			q++;
		if(q == m+1) //匹配结束了,此处体现了本文中的索引q与算法导论中的索引q的含义略有不同
			print success;
			break;

注:本文中的辅助函数与算法导论中的略微有差别,但核心思想相同。另外本文默认文本与模式都是从1开始的,如果从0开始也需要注意索引的范围。

你可能感兴趣的:(算法导论,字符串匹配,形式语言与自动机,算法与数据结构)