文本编辑过程中的查找,替换功能,在一段DNA中查找特定序列等,都要用到字符串匹配。简单地说,就是在一大段字符里(称为主串T)找一小段目标字串(称为模式串P,是否存在,存在多少次)。
A B A C A B A C C A
A B A A B A
模式串ABA出现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种状态,类似一种通过问题的答案跳到指定题目的游戏。模式串遇到的下一个字符就是这个“答案”,每个问题就是每种状态,只有连续答对问题才能跳到最后一种状态,称为接受状态(其它状态为拒绝状态)。另外,这些状态代表的含义是模式串的前端