文本编辑过程中的查找,替换功能,在一段DNA中查找特定序列等,都要用到字符串匹配。简单地说,就是在一大段字符里(称为主串T)找一小段目标字串(称为模式串P,是否存在,存在多少次)。
A B A C A B A C C A
A B A A B A
模式串ABC出现2次,位置0(或1,如果把第一个位置看作1),或位置4(或5,如果把第一个位置看作1)。我们的目的是把所有ABA出现的地方找出来。如果待查模式串是ABCD,应该返回“没有出现”。无论哪种解法,都要带着模式串(已知的小字符串)在待查找的主串里前进,所以算法的时间一定是Ω(n)的(n是待查找的主串长度。
字符串匹配常用算法有几种:暴力解法,RK算法(Rabin-Karp),字符串匹配自动机(Finite automaton)以及KMP算法 (Knuth-Morris-Pratt,最快)。后三种算法都对模式串进行了预处理,其中RK算法使用哈希函数对文本进行转化,比较转化后的值,自动机和KMP算法类似,都是利用了模式串本身的性质缩短查找时间。
暴力解法用几个嵌套循环就可以了。如果主串长度为n,模式串长度为m,在主串里前n-m+1个位置上每次都进行模式串的匹配。每次匹配需要检查m对字符,一共匹配n-m+1次,时间复杂度为O(m(n-m+1))。这种算法效率低是因为进行了很多次重复的检查:因为模式串是已知的,每次会对m个字符进行匹配,同时会获取主串里下次匹配的前m-1个字符,如果对这些信息进行处理能够提高效率。
Rabin-Kart算法的求解思路如下:把模式串看作一段数字,数字的位数与原长度相同(m),是多少进制的呢?可以想象十进制数使用了0-9十个数字,满10进1,二进制数使用了0,1两个数字,满2进1,八进制数使用了8个数字,满8进1,等等。所以模式串以及主串的进制是文本的字符库的大小,如果所有字符串都来自A-Z 26个字母,则是26进制,如果使用了字母,数字36个不同的字符,就是36进制。这里字符库的大小一般用d表示,则主串是n位的d进制数,子串是m位的d进制数。
前面说过,包括RK算法在内的后三种算法会对子串进行预处理,子串是已知的,能得到的信息很多。RK算法的预处理方式就是将子串哈希成一个值,类似于将一个数字数组[2][3][4][5][6][7], 转化成一个数字234567,前者要处理数组的每一个位置,后者只需处理一个数字。如果数组长度为m,一般的转化方式需要Θ(m2)的时间,使用Horner法则(秦九韶算法 )则只需要Θ(m)的时间。
同样地,母串也需要转化。第一次比较需要使用同样的方式将母串里前m位转化成一个值,使用Θ(m)的时间。但是第二次转化只需要常数时间,为什么呢?比如母串里第一次转化后的值,称为t0,为1234567,第二次转发时发现下一位是8,不需要再对2345678进行转化,只需要将第一个数的首位去掉,乘以10,再加上下一位8,就变成2345678了。这样后面只需要执行n-m次常数时间的转化,如果把对母串前m位的转化看作预处理的一部分,那么预处理使用Θ(m)的时间,后面的匹配使用Θ(n-m+1)的时间。这里是Θ(n-m+1)而不是Θ(n-m),因为当n=m时,匹配时间应该是Θ(1),表示常数时间,而不是Θ(0),用Θ(n-m+1)可以避免Θ(0)的出现。
一个可能的问题是子串的长度很长,即m很大,可能产生溢出错误,对它的计算和处理也更加费力。m特别大的时候,前面的常数时间就不成立了。这类问题一般用对素数取余来处理,而且这个素数越大冲突越小,只要这个素数乘以d不会产生溢出错误,就是安全的。
取余以后可能出现一个问题,不一样的数可能取余以后得到的数字是一样的,也就是哈希过程发生冲突了,所以需要素数尽量大,这样发生冲突的可能性小。解决方法很简单,就是每次匹配成功,就把字串和母串的匹配成功区域用Θ(m)的时间逐一比较。所以RK算法适用于匹配区域远远小于母串长度,其实也是实际应用中的大部分情况。
字符串匹配自动机
所谓“自动机”,其实就是提前计算好的一个函数,这个函数可以根据模式串本身以及遇到的下一个字符,推断模式串需要前进几步,就不需要一个一个的比较了。因为主串里的每个字符只比较一次,因此匹配时间为(n),预处理是根据模式串写出这个函数,称为转换函数。如果模式串的长度是m,则转换函数有0到m-1,共m种状态,类似一种通过问题的答案跳到指定题目的游戏。模式串遇到的下一个字符就是这个“答案”,每个问题就是每种状态,只有连续答对问题才能跳到最后一种状态,称为接受状态(其它状态为拒绝状态)。另外,这些状态代表的含义是模式串的前端与主串相应区间后端的最多匹配字符数量,比如状态q=3,代表当前模式串前端与主串区域后端最多有3个字符是相同的。
先看一下转换函数,假设转换函数用一个表格,或者二维数组的形式储存,列数是字符库的大小d,行数是0到m的状态,共m+1行,转换函数需要计算出表格或数组里的每一个值。计算函数时,在第q行,第a列(a是字符库里的某一个字符),取前q个字符加a,我们称前q个字符的字符串为Pq,加a以后的字符串称为Pqa,如模式串是abacabcdab,q=3,则Pq是aba,如果主串里下一个字符是d,Pqa是abad。这一小段字符串其实是主串里上次匹配区域后端匹配上的一段q个字符,加上这次在主串中遇到的新字符。遇到的新字符不同,状态也会改变。如果新的字符能够继续匹配,状态加一进入下一状态,否则会向以前的状态退回,甚至回到完全不匹配的初始状态0。还是以模式串abacabcdab,q=3,Pq是aba为例,如果新的字符是c,则Pqa是abac,状态由3到4,如果新字符是b,Pqa是abab,就要往回退了,至于退到哪里,要看P前端最多有几个字符能够“套”到Pqa的尾巴上,也就是P前端最多有几个字符和Pqa的后面几个字符相同,可以看到P前端2个字符ab,刚好和abab的后面两个字符相同,所以回到状态2,也就是说下次判断前默认P的前两个字符已经匹配了,可以直接从P的第三个字符开始看了。
转换函数的表格或数组大小为md,要进行md次计算。每次计算时,先取P的前q+1个字符,与Pqa比较,如果有不同,再取P的前q个字符,与Pqa的后q个字符比较,如果还不匹配,q=q-1再进行比较,最多可进行q+1次比较,q最多为m-1,所以最多可进行m次比较,每次比较的字符数最多为m,所以每次计算的时间为O(m2),总时间为O(m2d)。
有了转换函数,自动机的算法实现就很简单了,在主串里向前遍历的时候,每次遇到一个新字符就通过转换函数计算下一个状态,每次达到状态m(接受状态),就可以确定是成功匹配了。
在字符串匹配中,KMP算法是很常用的算法,其实现方式与自动机类似,只是用了一个π函数代替转换函数,但是速度更快,匹配时间是Θ(n),预处理(包括π函数的计算)是Θ(m)。与自动机的表格不同或二维数组不同,KMP算法的π函数保存在长度为m的一维数组中,避免了字符库长度d对预处理时间的影响。
π函数是根据P自身的性质计算出来的,是看P的前端和末尾的重复程度。如果以q代表子串P从前往后第i位(从1开始),π[q]则代表P[1]到P[q](包括P[1]和P[q])这一段字符串的末尾,从后往前连续最长有多少字符和P的前端相同。当i等于1时,设π[1]=0。如果把P[1]到P[q](包括P[1]和P[q])这一段字符串看作前文的Pq,π[i]和转换函数里遇到a以前的状态q是相同的。
首先看π函数的计算方法:π[1]=0,当q大于等于2,P的前q位后端能匹配上P的前面多少位,可以看P[q-1],如果P的前q-1位完全不能和P的前端匹配,若P[q]与P[1]相同,则π[q]=1,若P[q]与P[1]不同,则π[q]=0。如果P的前q-1位后端有k个字符能和P的前端匹配,若P[q]与P[k+1]相同,则π[q]=k+1,若P[q]与P[k+1]不同,就不等于0了,要看k个匹配的字符末端有多少位可以和P的前端匹配。这个值就是π[k],所以当P[q]与P[k+1]不同时,令k=π[k],再比较P[q]与P[k+1],若不相同再令k=π[k],这里可以用while循环实现,一直到k=0,此时π[q]=0。
π的函数定义如下:π[q]=max{k:k 注意k 计算π函数的时间如何呢?可以使用平摊分析里的聚集分析(aggregate method)。可以看到while循环里没有其它的循环,因此只要知道while循环多少次,如O(m)次,π函数的时间就是O(m)。每次while循环,k=π[k]会令k减小,但是后面的if语句又令k增加。T有n个字符,进行n次匹配,每次匹配要么先减小k,再令k+1,要么减小k到0,k不增加,要么不减小k,k直接增加。但是k一定是不大于m的,所以k增加的次数不超过m,因为k>0,k减小的次数也不能超过m,所以while循环的次数不超过m,π函数的时间是O(m)。 KMP匹配的主函数与π函数的计算过程类似,不同的是π函数是P自身与自身相比较,匹配函数是主串T与模式串P相比较。对P预处理计算出π的数组以后,对主串从头到尾逐个字符遍历,当遇到部分字符匹配,看下一个字符是否匹配,如果下一个字符不匹配,找到匹配区间长度q的π函数值,即匹配区间后端再取更小的和P前端相同的部分,或者说P的前端更小的和匹配区间后端相同的部分,也就是把字串P向前移动一些,看能不能有一小段从头开始的字符让下一个字符也相符,如果找到了就免去再次从P最前端开始匹配了,如果找不到q会一直减小到0。这个q可以看作P前端已默认匹配免去查找的长度,若q减小到0的话就要从头开始查了。 KMP匹配过程的时间分析与算π函数的时间的时间分析类似,可以使用同样的聚集分析方法。
#include