题目有点大了,如果是命题作文的话,绝对是吃力不讨好的活。说得有点形而上学,但正如爱情公寓中的经典台词:“话粗理不粗”… 那么引用滑稽剧团Python的开场词吧:请看一场前所未有的表演!
计算机科学中处处有模式匹配,不仅从计算理论以及到实际工程应用,模式匹配都是一项重要的研究点和有用技术。简单的来说模式匹配,是在目标串 中寻找 模式串的过程,因此也可以将模式匹配的问题转化成为搜索或者模拟问题去理解(其实程序执行的过程就可以理解成为一个模式匹配的问题)。同时,我们也将看到在搜索问题和模式匹配上有所共通点。
从模式匹配的两个对象来看,模式匹配技术存在两种方法论或者思维模式:
模式(1):模式串驱动:在当前关注:匹配进行到(某个)模式串的那个位置?
模式(2):目标串驱动:在当前关注:匹配进行到目标串的那个位置?
看上去这两种只不过是两种说法而已,何以成为两种方法论呢?这两种模式或者方法论确实是事物的对立面,充斥几乎整个模式匹配领域,包括字符串匹配、正则式匹配、语法解析(语法规则匹配)等等;从各个领域的算法中不难找出两种方法论的实践。
下面将漫步常见的模式匹配技术,从中发掘出它们的异同。
字符串匹配
字符串的精确匹配常用于入侵检测系统、杀毒软件等等场景,可以说应用非常广泛。众所周知,字符串匹配有暴力匹配、BM以及KMP等方案;一般工业上使用最多的是暴力匹配,如C/C++内置的库函数;BM算法,如snort入侵检测系统;KMP在学术界或者算法理论界受关注,但是工业上并没有受到青睐(也许在DNA序列匹配上有应用),甚至一些追求实用的算法书都不介绍KMP。其中原因我们将在后文进行详细解释。
字符串匹配的形式描述如下:
find_match(object ,pattern):
if pattern[0]== object[0]:
find_match(object[1:end] ,pattern[1:end])
从上面的形式描述中不难看出,暴力匹配可以理解成为一种递归实现;当然从另一面,我们也可以使用自动机(状态与转移)去描述它。其形式描述是:
new_state =trans(current_state ,input_char); 即
从计算理论的角度来看两者是等同的。上述就是字符串匹配的最朴素的两个模型,只要明白这两个基本模型,我们就可以了解字符串匹配的奥妙了。其中递归描述比较倾向于模式串驱动的方法论,而自动机更倾向于目标串驱动的方案。后文我们会逐步的展开说明为什么会有这种结论。
暴力匹配的原理想必不用解释,即逐字节查看m-前缀是否是模式串。这种方法最为朴素,大家都知道它的最坏情况下复杂度为O(m*n);其实,在工程实践中,病态的案例并不多见,所以并不需要过于吹毛求疵,一般场景使用是没问题的。算法实现简单正确,一目了然。这也是工业和学术界的不同点,学术界往往追求方法的精确性和普适性,而工业界往往愿意舍弃一部分精确性或者普适性,而追求在大部分场景的高效和可维护性,甚至愿意将不同的方法组合适用(当然学术界的不少人也乐衷此道,尤甚灌水)。
如果我们对效率有要求的话,必须对暴力匹配进行改进。最简单的改进是基于并行的,即并行技术,由此产生了shift-or等算法。回顾暴力匹配的过程,我们发现之所以造成暴力匹配做出很多冗余操作是因为目标串中的字符被反复的比较:一旦发现不匹配,暴力匹配就会退回开始的点步进一个字节再匹配。《柔性字符串匹配》一书就提到,暴力匹配的匹配模型是一个非确定自动机(NFA)的实现。因为我们总是为了保证不漏掉可能的匹配而反复的调回模式串的起始位置。如果有一种并行技术可以一起执行多路过程就可以加快速度了。shift-or即利用“位”标记来实现并行技术,“位”来记录每一条“执行路径”的状态,每一个“1”的位置表示某条路匹配到模式串的第几个位置了。因为字符串匹配并没有副作用,因此并行化不用担心干扰的问题。靠着和计算机本身位并行的特点,shift-or可以达到KMP两倍的执行速度;不过shift-or的位数目受制于机器字长,不能支持太多字符的模式串。
另外,顺便提一个简单的优化技巧: 利用机器字长一次可以比较多个字符,最简单的实现莫过于将4个字符(32个字长)做为一个unsigned处理了,这种简单的“位并行”以我的经验可以提高1/4的速度。当然,相比于以下的算法,都是雕虫小技了。
如果要再次提高暴力匹配的执行速度则需要考虑其他的思想了,BM和KMP由此诞生。回顾暴力匹配的之所以复杂度高,原因在于我们假设我们对模式串和目标串是一无所知的,造成很多冗余的操作。我们可以事先对模式串进行“学习”,了解其特征,并用一些额外的空间存储这些特征。对于目标串,因为我们使用的场景一般对目标串没有先验知识的,另外对目标串学习耗费的开销过多,鉴于这两个原因,我们不会对目标串进行预处理,而只对模式串进行学习。
BM算法:
BM算法可以理解成一种启发式算法,可以从递归模型去讲述它的思想。BM的优化思想在于利用启发式信息去节省匹配的开销。回想一下find_match函数:
find_match(object ,pattern):
if pattern[0]== object[0]:
find_match(object[1:end] ,pattern[1:end])
匹配真的是要逐字节的验证么?举个极端的例子如果我们发现当前检查的object中的字符不属于 pattern字符中的任何一个,我们就可以下结论,在这个char之前m的所有字符为起点的串均不可能是patter的匹配起点位置。简单的说,这是一个剪枝技术,如果场景中有很多可以跳跃的情况,那么BM能够跳跃很多object中的字节。BM的预处理过程就是构造剪枝用的表,从而跳过很多不可能的“执行路径”。
这里简单提一点,剪枝操作在实践中是非常有效的搜索(匹配)优化技术。事实证明,很多剪枝技术能够去除大部分的状态空间。不过据lda说,如果一个剪枝操作仅能将原搜索空间降低至10%,那么这种剪枝是非常低效的,往往还不如其他的优化技术,例如优化代码和内存使用等程序级技巧。
当然剪枝技术一般在算法书上是不会讲解的,它属于启发式方法,执行的效率依赖于问题,因此一般出现在《人工智能》的教材中。BM在典型情况下的复杂度为O(n/m),在病态案例的情况下仍然与暴力匹配的复杂度为O(m*n),这也是由于其是一种启发式方法造成的。
KMP算法:
KMP算法本质上是一种有限自动机(DFA)方案,《算法导论》就是先介绍完DFA技术后,再介绍KMP算法,并说明KMP是DFA的节省了内存空间的技巧实现,而复杂度级别是等同的:O(n+m)。
有限自动机方案则明显与上面讲述的方案有明显的区别,其将暴力匹配用到的NFA进行确定化(也可以将其理解成为一种压缩),将等价的状态进行合并而得到。经过确定化后,有一些等价的状态被合并,从而减少冗余的匹配。简单的来说,DFA之于NFA是一个空间换时间的技巧。考虑子集族的规模是大于原集合的规模的,DFA的空间开销为O(sigma*m),其中sigma是字符集的大小。
这里简单提一句,我们知道子集族规模应该是集合元素的指数级(幂集),为什么用于字符串匹配的DFA的空间开销只有O(sigma*m),而不是O(2^m)? 原因在于字符串是有序关系:子集族中有大量的共同后缀的情形,因此空间被节省(压缩)下来了。后缀数组之于后缀树也是相似的技术。这一点是字符串所特有的,正则式就没有这种序关系。
回想一下,暴力匹配的的最大优点就是:没有使用额外的内存(因为模式串本身就直接可以拿来当NFA使用)。另外,DFA处理的过程也需要一定的开销,如果是单次匹配的话,我们根本没必要去花这个预处理的时间。这两点也许就是为什么C语言库函数使用暴力匹配的原因了。
先在回过头来讲解KMP算法。KMP算法相对DFA的实现做了一个节省空间上的优化。我们知道模式串中的字符数目是有限的,往往远远少于字符集规模。因此我们根本没必要去构造一个O(sigma*m)空间复杂度的DFA,我们退而求其次,将匹配当前字符失败都统一合并成一种转移:失败(fail)指针。由于合并了本来是针对特定字符构成的转移,所以内存使用量下去了,但是在匹配的过程中,可能连续的在fail指针上进行跳跃,不过鉴于平摊分析,复杂度是与DFA一致的,均为O(n)。当然,虽然KMP与DFA复杂度一致,但是还是有一个常数的差别:KMP因为有失败转移,因此目标串中一个字符会反复比较,但是由于平摊效应,失败转移的次数不会超过n,所以准确的说,KMP的复杂度为O(2*n),而DFA每个字符都意味着一次确定的转移,因此复杂度是准确的O(n)。实践也证明,DFA方案要比KMP更快,这就是常数项的差别造成的。
如果KMP将DFA的一部分预处理操作转嫁到运行时根据情况处理的话,那么我们将在后面的正则式匹配中看到真正的运行时优化技巧。
KMP的预处理过程也可以理解为一个自匹配(即模式串被其本身的子串所匹配)的过程。KMP确实通过自匹配压缩了很多的等价状态。自匹配在压缩领域应用也非常广泛,例如LZ77、LZ78的压缩过程也是通过自匹配来完成字符串的压缩的。
RK算法:
RK算法与BM一样也是一种启发式方法,不过RK算法以hash值作为启发信息。依然回顾匹配过程,我们的剪枝操作可以利用字符串的hash值(即数据指纹)来作为启发式条件。即两个字符串的hash值不同,则两个串一定不同。所以我们首先计算出模式串的hash值,然后不断的在目标串上计算hash值,如果发现hash值相同,则再进行逐字节的比较验证。
由于在目标串上运行hash函数可以通过滚动hash技术来优化,所以复杂度为O(n)。当然RK算法的弱点在于hash值存在冲突,往往会造成很多次误匹配引发的逐字节验证。一个优化的技巧就是使用多个hash值来验证,如果多个hash值均相同,则我们就认为匹配成功了。这种技巧舍弃了正确性,而获得了效率。在一些不是事关紧要的场合是可以这样做的,例如暴雪游戏公司就采用类似的思想,使用3个hash值来验证。因为使用在游戏上,所以即使遇到小概率事件,也不会造成重大的损失。
很多书认为RK算法的应用有限,但其实RK算法的用途是非常的广泛,不亚于BM与KMP。很多混合算法会借鉴RK的思想,消减计算量。
回到主题,考虑现有的字符串匹配算法是否真的实践了开头所讲的两种模式:
从上文我们可以看出,不论是暴力匹配、shift-or(并行化执行的暴力匹配)、BM和RK(剪枝的暴力匹配)都关注当前我们匹配到模式串的那个位置了。我们也可以说它符合递归的形式表达、也可说它们本质上是一种NFA以及有技巧实现的NFA,但是不可忽视它们都是模式串驱动方案,执行的复杂度是依赖于模式串和目标串内容。它们的共同特点就是占用的空间较少,但是复杂度是不确定的,依赖于问题。
DFA和KMP算法本质上是一种目标串驱动(也可以叫做状态驱动)的方案,除却预处理的开销,其匹配的复杂度是不依赖与模式串和目标串的内容,而只与目标串的长度n有关:无论任何案例的复杂度均稳定在O(n),但是它们的问题在于内存开销。尽管由于字符串本身的序关系使得空间开销不是很大,但是我们将看到DFA方案在后面的正则式匹配领域遇到的困境。
一个算法的执行复杂度是否依赖于问题也是一个双刃剑。依赖于问题的优势在于我们可以针对问题作出优化,劣势也很明显:算法的执行复杂度是变化的,难免遇到病态案例,成为算法的克星。我们将在后面探讨如何解决这种矛盾。
多模式匹配
多模式匹配是字符串匹配的在多个模式串匹配上的延伸。其本身并没有很独特的创新,思想均来自字符串匹配。
WM算法:
WM算法非常高效,是入侵检测系统snort默认的字符串匹配算法。WM算法本质上是BM算法在多模式上的延伸和改进。我们知道BM算法之所以有效是因为剪枝用的表,但是在多模式匹配的情况下,依赖于单个字符构成的剪枝表的剪枝效果大大折扣,因此WM算法使用一块字符来构造剪枝表,当然这个字符块的合适大小要依赖于问题了。由于一块字符代表的值域范围要远大于单个字符的值域(0~255),因此WM算法使用一个hash表来作为剪枝表。WM算法依然继承了BM的传统,仍然是一种启发式方案,但是在典型场景下,其效率是非常的好,要优于AC自动机方案。
AC自动机:
AC自动机是KMP算法的延伸。不过其使用的数据结构要复杂一些,相对于KMP的自动机是带fail指针的序列结构,AC自动机使用了带fail指针的字典树(又称前缀树,即trie)。原因很简单,因为KMP算法应用的场景是单模式串的匹配,AC自动机用于互不相同的模式串匹配,为了节省内存开销,所以依据不同模式串的相同前缀关系,构造了trie结构。
如同KMP算法之于DFA一样,AC自动机存在高级AC自动机实现。即去除fail指针,形成完整的DFA,称之为高级AC自动机。虽然在平摊意义上,AC自动机与高级AC自动机的复杂度是一致的,但高级AC自动机因为减少fail指针会不断的跳转的情况,因此复杂度要少于AC自动机一个常数级别(即常数2,原因参见KMP和DFA的讲解),实践上也证明高级AC自动机是要比AC自动机要快。
高级AC自动机的问题仍然是内存问题,并且由于多模式的规模大于单模式而变得更加严峻。解决办法是在运行时动态的构建转移条件,将一些连续的fail指针操作确定化,称之为“动态确定化”。动态确定化技术在正则式匹配领域应用更为广泛。
如果内存空间受限,无法实现高级AC自动机,则有一种不完全的实现方案。方法就是测试在应用场景中那些失败转移的频度较高,而将这些失败转移进行确定化。这种实现可以是动态的也可以是静态的。
混合算法:
多模式匹配相对单模式匹配并没有本质上的创新,充其量多模式算法用到的数据更为复杂,包括字典树、hash表、甚至是后缀自动机(相当于增加了转移的后缀树,不过利用前面讲到的后缀关系进行了空间上的压缩)等等复杂的数据结构。值得一提的是,在多模式匹配中,不同算法互相借鉴,甚至互相融合,例如在工程上有实践表明AC自动机和BM算法结合使用可以比WM算法有更好的执行效率。
在工程上,有很多这种算法之间混搭的案例。因此能够灵活的应用这些算法思想去优化是一个非常重要的手段。
正则式匹配
正则式匹配引擎分成两种实现:NFA 和 DFA; 前者可以理解成 模式串驱动方案,后者是目标串驱动方案;前者灵活多变,可由用户调教,且内存使用量较小,但效率低于后者,不过一旦调教得当往往会超越后者;后者高效稳定,但内存开销大。
正如《精通正则表达式》一书的比喻:正则式的两种引擎:NFA与DFA相当于两种汽车,前者是手动档,后者是自动档。手动挡意味着执行的效率和复杂度要依赖于问题,或者说依赖于用户怎样调教。如果用户的正则式水平高,可以写出非常优秀高效的正则式,则NFA的引擎会运行的非常快速。
DFA引擎是自动档的汽车,其在任何情况下都有稳定的速度。但是问题在于其空间开销较大,而空间问题相对于字符串匹配要严峻的多。众所周知,正则式自身没有像字符串的序关系,除非正则式退化成为字符串,否则是没有一致的前缀和后缀关系的。因此用于正则式的DFA引擎的空间无法通过字符串那样进行压缩,是因此是指数级的复杂度。
DFA引擎的内存空间过大缺陷是致命的,而大部分的工程应用对内存要求都是苛刻的。如果内存使用量过大或者不可控,对方案的选取都是致命的。
现在的工程实践一般选用有技巧的NFA实现。回顾以上我们讲到的运行时优化技巧。我们可以通过运行时来讲NFA确定化从而模拟DFA的执行过程。NFA的确定化意味着,在匹配的过程中,我们会合并一些等价的状态并缓存它们,从而避免冗余的匹配。
NFA的动态确定化思路本质上与动态规划是相同的。动态规划重要的实现思路就是记忆化:记录曾经出现过的状态,从而减少重复的计算量。NFA的确定化思路也是一致的,通过运行时的额外内存开销(这部分内存开销是依赖于问题的,可以说是按需增长),回避了DFA的指数级内存开销。不过,在工程上,NFA的按需增长内存仍然会给实践造成风险,因此,一般工程实践会设置固定的内存作为状态缓存,按一定规则去更新缓存,保证固定的内存使用量(通俗的将就是选择性记忆化)。这种思路在搜索问题中也会出现,即不是完全的保存节点,而是缓存节点。上述的实现技巧也是典型的时间换空间思路,即不惜重复计算的开销而换取可控的内存使用量。
语法解析
语法解析是用来解析特定语言的。语法解析器处理的单元不再是字符,而是词素,语法解析的模式串是语法规则,而目标串是词素序列;另外,从计算理论的角度来看,语法解析是与上述的正则式匹配(字符串隶属于正则式)有本质的区别。语法解析的模型是下推自动机,在运行的过程中比正则式匹配的模型NFA与DFA(两种是等同的)多了“栈”这种功能强大的数据结构。之所以必须有栈,是一位语法规则是一种特殊的模式,这种模式可以嵌套其它的语法规则或者自引,一旦出现上述情形,栈就会发挥它的重要功能:记录现场,以便后期恢复上层语法规则的匹配。
语法解析是一个大的话题,不可能用很少的语言说个明白;在此,我仅从开篇的两种模式为角度来概述语法解析的典型方法:
LL解析方法:
LL解析方法的思路继承自NFA,适于人类理解,一般的手写解析器都是基于LL的。如NFA一样,LL是一种模式串驱动(语法规则)的解析方案,所以在解析(匹配)的过程中,我们可以直接可以说出当前正在展开那种语法表达式。
LL解析方法的优点在于直观,而缺陷与NFA相同,在于无法避免回溯。为了方便实现,一般解析对象(语言)语法规则有限而简单,可以依靠固定数量的词素来预测,因此简化了LL解析方法的实现。如果某种LL解析方法可以靠k个词素可以预测语法表达式,则称之为LL(K),当然最简单的实现莫过于LL(1)了,多数的教学语言或者手写编译器都是LL(1)的。
LL的问题在于简单直观,易被人类理解,容易手写实现。其实现有两种方案:隐式栈实现(递归下降方法)和显式栈实现(即通常教材上的LL)。两种本质相同,正如其名,递归下降是依赖递归(靠操作系统维护栈),而显式栈实现是显式的维护栈。
LL的缺陷思路通过限制解析对象这一手段解决了,但是回溯问题仍然如影随行,因为有一些语法有一些共同的“前缀”,如C++中的对象初始化和对象声明就是有一样的“前缀”。为了解决回溯的问题,LL解析器同样借鉴了动态规划(准确的说动态规划有两种实现方法:1)记忆化+递归; 2)递推)的思路:记忆化。采用记忆化的递归下降解析器称作林鼠解析器(packrat parser,参见《编程语言实现模式》)。
LR解析方法:
在编译原理教材中,LR一般在LL后面进行讲解。原因在于LR和DFA一样不用以被人类所理解(不过非常适用于机器执行)。LR一般无法通过手写完成,一般是由固定的算法根据固定的规则生成(称之为解析器的解析器),但是本质上与我们前面讲到的KMP思路并没有本质的区别:仍然是编译模式串,只不过此时的模式串是语法规则而已(这似乎不难理解为什么Knuth即发明了KMP又发明了LR解析器,道理都是互通的)。
LR的执行模型比较贴近计算理论的下推自动机的讲解方法(即移入规约,LL是预测和展开流程)。本质上LR是一种目标串驱动(或者说状态驱动)的方案。执行的过程就在于读入词素并作出状态转移以及维护栈,而状态并不代表具体一种展开式,往往栈中压入了大量的字符后,才做一次归约。
因为不用像LL对语法做太多的限制,LR解析方案的应用比LL更为广泛。但LR就像DFA一样,不再与模式串(语法规则)有明显而直观的联系,因此非常难以理解,所以一般是规定的算法根据语法规则生成的。
回顾LL和LR,是否可以讲两者理解成为基于栈的NFA和DFA呢?结论是否定的。计算理论讲到尽管NFA与DFA能力等同,但是基于栈的NFA和DFA的能力不是等同的,前者的能力更强。这里的能力与工程实践上的执行速度不是等同概念,这一点不再展开讨论,可参见计算理论。
总结
模式匹配的领域广泛,例如音频、图片、视频的匹配都是模式匹配问题,而由于上述应用一般带有人类的主观性,而采用近似匹配的技术(也就是模糊匹配)。虽然应用不同,但是思想有很多共同之处,例如google的图片搜索技术即通过类似于RK算法的数据指纹进行匹配的,只不过其数据指纹的计算技术比较特殊。
鉴于本人知识面有限,在此不再探讨。回顾前面所讲的内容,两种思路:1)模式串驱动方案;2)目标串驱动方案 贯穿整个模式匹配领域,其中简单探讨了计算机中程序的优化技术: 并行、记忆化、时空互换、缓存等。从算法的特征来看,我们可以发现 模式驱动方案的算法的复杂度是不确定的(这也就是NFA模型的问题吧),并依赖于问题,即模式串和目标串的内容;目标驱动方案典型特征是:复杂度确定,其匹配(不包括预处理开销)的复杂度只与目标串的长度有关,与模式串和目标串的内容无关。从思路上和运行的特征看,两者区别明显,但两者就像同一事物的两面一样,对立而统一。
就如现实中的时间与空间、数学中的自变量与其函数一样,计算机科学中也遍布对立面,模式匹配的两种思路就是典型的代表。其实从模式匹配出发还可以看到更多有意思的东西,例如程序语言的设计。
众所周知,程序语言的经典范式有:函数式编程和过程式编程,如Lisp之于C语言、Mathematica之于Matlab一样。前者利用递归实现语言的执行,而后者使用状态与转移实现语言的执行。它们也与我们的两种处理思路有相同之处,前者比较贴近人类的思维模式,直观简洁,但是执行效率较低(不过值得一提的是函数式利用惰性求值进行优化,原理也相当于运行时按需计算);后者执行速度快,但是不适合人类理解,在计算机行业最令人头疼的一大类问题就是后者引发的,如代码阅读问题(有些代码不运行调试否则难以理解:假如将DFA比作压缩后的NFA,想象一下你不运行解压程序,直接通过压缩文件能轻易看出原文件的内容么?)、程序副作用问题(多线程问题)等;原因在于状态转移实现没有了模式串驱动方案的直观性,而复杂的状态和转移只会令本已疲惫的程序员更加的疲于奔命。
另外,计算机的同步与异步也有着相似的关系。异步的优点在于模型与时间解耦,优点是按需调配; 而同步的优点在于实时性好,但与时间紧密耦合,同步代价高。这两种模拟的思路在计算机中的操作系统、图形界面设计等都有充分的体现。
选择合适的模式就像双目标优化问题一样,你永远无法到达一个完美的点,所谓鱼和熊掌不可兼得。不过正如我们前面探讨的混合方法一样,这两种本来互为矛盾的事物也在不断的融合,互相取长补短,不断的折中以达到tradeoff(即平衡,或者说适用某种场景的平衡)。Ruby、Python这类语言就是其中的典型。在此不再展开讨论。
计算机科学领域之大,已经难以用基本问题和基本处理方法去概述了,更何况它其实难以说成一门科学,而是各种学科的混合体呢。在此漫谈的目的在于管窥整个学科的基本问题和基本处理思路,当然只是一种抛砖引玉。
(完)
2012-6-4