标签:
定义:字符串,是指来自于某个字母表的字符组成的有限序列。
数据结构:可以由向量或者列表来实现。
特点:相对于一般的线性序列,串具有更鲜明的特征:其组成字符很少,串的长度却高出几个数量级。
几个术语:
作为一个ADT,其标准接口如下
indexOf的高效实现是本章重点讨论的内容
串匹配的功能是什么
怎么评测性能
由于串匹配的特殊性,其匹配成功率极低,采用一般的随机概率评估性能不太合适。而应该对匹配成功和失败两种情况分别评测。
成功:在T中,随机取出长度为m的子串作为P;分析平均复杂度
失败:采用随机P,统计平均复杂度
从文本串第一个位置开始比对模式串,比对失败时移动模式串到文本串下一个位置,直到比对成功。
此种算法的代码实现如下:
复杂度分析:
我们先回到蛮力算法,看看其低效的原因
上图是蛮力算法一次典型的运行过程。蛮力算法需要从首字符开始进行一系列的比对直至在某个位置发生失配。在此之前每一次迭代所花费的时间都正比于这个前缀。
文本串中的某一特定字符在最坏情况下会与模式串中的每一个字符都比较一次,一共m次比较。
在前面对蛮力算法最坏情况的分析可以看到每一步迭代都需要比较m次,才能够在最后一个位置发现失配。因此可以这么理解:
在模式串中存在大量与文本串能够局部匹配的前缀,而蛮力算法的计算成本主要消耗于这些前缀中
然而这些局部匹配前缀所涉及的比对绝大多数都是不必进行的,至少不必反复进行
既然这些字符在前一轮迭代中已经接受过比对并且成功,我们也就掌握了他们的所有信息。我们可以充分利用这些信息,提高匹配效率。
用T[i]和P[j]分别表示当前接受比对的一对字符。
当本轮比对进行到最后一对字符发现失配后,蛮力算法会令两个字符指针同步回退:令i = i - j +1 ; j = 0。然后再从这一位置继续比对。然而这一过程是完全不必要的。
经过前一轮的比对,我们已经很清楚:子串T[i-j,i)已经成功匹配,因此下一轮比对中,前j-1次比对也会匹配成功。因此,可以直接让i保持不变,令j = j -1,再继续比对。
如此,下一轮只需1次比对,共减少 j -1 次比对
这一过程可理解为:“令P相对于T右移一个单元,然后从前一失配位置继续比对”
这样,利用以往成功的比对所提供的信息(记忆),不仅可避免文本串字符指针的回退,而且可使模式串尽可能大跨度地右移(经验)。
再来看上图右边的例子。本轮比对发现T[i]和P[4]失配后,在保持i不变的同时,应该将P右移几个单位呢?
若在此局部能够实现匹配,则至少紧邻于T[i]左侧的若干字符均得到匹配
在这个例子中,我们发现右移1个或2个都是徒劳的,只有右移3个时,T[i]左侧的字符才都得到匹配。因此 i - 1 是能得到如此结果的最左侧位置。这样,我们便可直接将P右移3个单元(等效于i保持不变,同时令 j = 1 ),然后再继续比对。
那么,怎么确定这个右移距离呢?
首先要明确一点的是:这个右移距离(或者说拿来与失配的T[i]进行下一轮比对的字符)仅与模式串有关,而与文本串无关。
如图,文本串分为四个部分:前缀,已经匹配成功的子串、失配字符、后缀。显然前缀和后缀对于右移距离没有影响,匹配成功的子串虽然有影响,但这个子串和模式串的子串是完全一致的,因此可以等同于是模式串的子串。
另一方面,顶替字符与其说取决于模式串,不如说取决于此前失配的P[j]。在长度为m的模式串中,P[j]最多有m种可能。
KMP算法的核心在于:将所有这m种情况事先处理并且归纳整理为一张查询表。一旦在某一位置P[j]处发生失配,只需简单地从查询表中取出对应的拿来顶替P[j]的字符即可。由此可见,这种策略与其说是在借助强大的记忆力,不如说是在事先已经为各种情况准备好了充分的预案。
根据前面的分析得出KMP算法(版本1):
可以看到,此算法与蛮力算法大体相同,区别仅在于else分支,即失配情况下的处理:KMP采用查询表中直接获取下一个模式串的字符,并将其与文本串失配的T[i]进行又一轮比对。另外一个不同在于比对条件多了一个“ j < 0 “。这留在后面分析。
通过前面的分析可以看到利用next表的强大之处。这个next表是怎样产生的呢?其原理又是什么?
我们先来看看模式串可以右移的“必要条件”
还是以一个典型失配场景为例
文本串和模式串在T[i]、P[j]失配,我们将模式串向右移动,使得P[t]与T[i]继续比对。那么新的模式串的子串P[0,t)必然与T[i-t,i)(亦即P[j-t,j))匹配。而P[0,t)、P[j-t,j)其实都是模式串P[0,j)的真子串(一个真前缀,一个真后缀)。
也就是说,这个“必要条件”是:模式串的子串P[0,j)中存在P[0,t) == P[j-t,j)。所有满足这样一个关系的t组成一个集合N(P,j)。
那么,一旦出现失配,我们可以直接从N[P,j]中取出一个t,继续比对。而取出的t,是集合中所有t的最大值。之前在“记忆·经验·预知力”中提到过
在这个例子中,我们发现右移1个或2个都是徒劳的,只有右移3个时,T[i]左侧的字符才都得到匹配。因此 i - 1 是能得到如此结果的最左侧位置
这个最左侧位置就是对应于最大的t,也即最小的右移距离。这么做的原因是为了保证不错失任何一种比对成功的可能。另一方面,也说明KMP舍弃的所有比对,都是被证明无法匹配成功的。
在前面的匹配集合有可能是空集吗?
事实上,只要j>0,那么集合N至少包含一个t= =0的元素。如果j= =0,那么子串P[0,j)不存在,也就是模式串在第一个字符即匹配失败,这个时候集合N为空。
为了解决这个问题,我们引入一个“通配哨兵”。我们把next表的第一项统一设为 -1 。这个哨兵等效于在模式串的左端(即秩为-1的位置)添加一个虚拟的字符,这个字符与所有字符都匹配。
由此可见,巧妙地引入和设置哨兵在程序和算法设计过程中是一种非常高明的处理手法。KMP就是这方面的一个典型范例。概括而言,这种手法的高明之处主要体现在两个方面:在代码实现上,可以使得算法的描述更为简洁。其次,通过相应地建立一种假想的模型也可以使得我们对算法的理解更为统一和深入。包括伽利略在内的许多著名物理学家都擅长于在头脑中进行所谓的虚拟实验。实际上,计算机科学中的这种假象模式与物理学中的虚拟实验有着异曲同工之妙。
next表的构造过程相当于是模式子串的自匹配,其实现只需要对KMP算法稍作修改即可。
这里使用一种“观察变量”的方法。在KMP算法的循环代码中,加入一个变量k,这个k随着迭代次数的增加同步增加。因此,只要确定了K的上界,我们就能确定迭代步数的上界,也就知道了复杂度的上界。
而k的范围为O(n),因此可确定KMP算法的复杂度也为O(n)。另外,KMP next表的构造算法和KMP算法一样,其复杂度为O(m)。因此对于长度为n的文本串和长度为m的模式串,KMP算法的复杂度为O(n+m)。
尽管KMP算法在性能上可以达到O(n)的效率,但是在某些情况下,仍然存在明显的瑕疵。
在这种情况下,模式串与文本串比对失败了四次。除了第一次是必要的之外,其他三次其实都是不必要的。因为既然第一次比对发现0和1不匹配,那么其之前的三个字符0都不与1匹配。我们已经掌握了模式串的信息(next表),就应该利用这个信息节省不必要的比对。为了解决这个问题,应该修改next表。
next表既是算法策略的体现者,也是每一个模式串中所蕴涵信息的具体承载者。它简明地刻画了每一个模式串的本质特征。
修改next表构造逻辑也很简单:我们在确定next表下一项位置时,除了判断其自相似特征,也要判断其“不相似特征”:即新的比对字符不应该和原字符相同。
如果说自相似特征是模式串利用前一次比对的“经验”做出的优化,那么不相似则是利用前一次比对得到的“教训”。
由此得到next表构建算法的改进版:
在此前匹配成功的分支加了条件判断,如果P[j]和P[t]相同,则将N[j]赋值为N[t]。
最后,我们回到蛮力算法和KMP算法的比较上。蛮力算法虽然低效,但是在字符表足够大时,蛮力算法的平均效率也接近O(n)。也就是说,这种情况下KMP的优势并不高。事实上,KMP通常用于二进制串的比较(字符表长度为2)。
串匹配是由若干个字符在局部组成的一个片段而言,由多个字符对多个字符匹配,当且仅当每一对字符彼此相等时匹配成功。反过来,一旦发现有一对字符不等我们就立即可以判断串失配。由此可见,从计算成本的角度来看判定一对串是否相等与判定它们是否不等并不是完全一样的。而BM算法就充分地利用了这一性质,从而使得串匹配的效率得以进一步地提高。这一算法采用两种策略:坏字符和好后缀。
KMP会聪明地排除掉大量的对齐位置从而大大地节省计算的成本。然而就排除某个对齐位置而言,相应的那些成功的比对并不重要,而在这个意义上起实质作用的,反而是那些失败的比对。我们应该更加期盼这种失败的比对出现得更早。比如,按照这种思路的一种极端情况就是我们或许可能只做这些失败的比对,就足以排除掉相应的对齐位置。
我们的目标与其说是要加速匹配,不如说是要加速失败。
每一次匹配失败都给我们一个“教训”。然而,不同位置的字符失配带来的价值是不同的。显然,越靠后的字符失配价值更大。因为我们可以借此排除更多的对齐位置。
先来看这种匹配策略的一个例子:
我们发现,在与“道”失配时,我们应该检查模式串中“道”的位置,并移动模式串使之与文本串对齐。如果不存在,则直接将模式串跳过。在上图中,我们总共经过了8次比对,比文本串的长度(12)还少。
对于BM算法的一个典型过程,我们需要找出模式串前缀中最靠后的字符’X’,并将‘X’移动至失配位置j。那么这个过程的位移量只与失配位置j和‘X’在模式串的秩有关。借此我们可以构建bc表,事先将所有位移量计算出来。与KMP算法一样,我们也会在模式串第一个字符前增加一个“通配哨兵”,用于处理字符X在模式串中不存在的情况。另外,如果’X’在模式串中的秩位于j靠后的位置,位移量为负,显然我们不需要将模式串左移,而是将模式串右移一位处理。
KMP算法的核心在于next表,类似的,BM算法的核心是bc表和gs表。
那么如何构建bc表呢?
构建算法很简单,只是找出模式串所有字符对应的秩。如果存在多个相同字符,则保存最后的秩。其空间复杂度为bc表长度,即字母表长度O(s)。时间复杂度为O(s+m)。事实上第一个for循环可以省略,时间复杂度为O(m)。
利用bc表,BM算法在最好情况下可以达到O(n/m)的效率,但最坏情况下的效率却为O(n*m)。
坏字符策略很好的利用了失配的“教训”,但要充分提升效能,还需要利用比对提供的“经验”。而这个“经验”与KMP算法的思路非常相似。
当模式串在j位置失配时,j-m的子串已经匹配成功。那么在模式串P后移之后,应该保证P[k,k+m-j)的子串仍然与文本串后缀匹配,且新的比对位置k的字符至少不能等于’Y’(KMP改进算法)。这个过程也仅与j,P有关,可以预先构造gs表。
先介绍两个概念:MS表和ss表
MS[j]表示在以j为末字符的所有子串中,与模式串P的后缀匹配的最长子串。比如图中j为8时,最长子串MS[8]因为“RICE”。j为3时,MS[3]为“ICE”。而ss表示MS对应的子串的长度。可以知道,通过构建ss表即相当于构建除了gs表。
构造算法如下:
构造gs表算法虽然包含双重循环,但总体时间复杂度为O(m),因为内循环的累计执行次数不超过变量lo(i)的变化范围。(证明过程详见邓俊辉,数据结构习题解析(C++语言版), 清华大学出版社, 2013年9月, ISBN: 7-302-33065-3 第215页)
图中竖轴由低到高表示n/m、n、n*m,Pr表示字母表大小的反比,即1/s。可以看到,字母表越大,蛮力匹配(BF)效率越接近线性。而字母表越小,越接近n*m。而KMP算法不管字母表规模,始终维持在线性水平。利用BC策略的BM算法在字母表规模大时充分发挥优势,但字母表规模小时劣势也很明显。最后综合BC策略和GS策略的BM算法则保持了最好效率,同时最坏效率不超过线性水平。
由此可见,BM算法非常适用于字母表较大的串匹配。
Karp-Rabin算法是一种比较“另类”的串匹配算法,通过将串转换为数字,直接对数字进行比较。由于数字比较为常数时间,因此总体效率可以达到O(n)。
All things are numbers –Pythagoras(毕达哥拉斯)
数字是自然的本源,这是很多人所秉持的一种信念。其中最坚定的信奉者同时也是最高明的实践者,莫过于哥德尔。为了证明伟大的不完备定理,他发明了一种简洁而强大的编号方式,来对逻辑系统中几乎所有的组成部分统一以自然数来做标识。
扩展阅读: 探访莱布尼茨:与大师穿越时空的碰撞
我们可以证明,任意一个自然数向量,都唯一对应一个自然数(指纹)。且这个转换过程是可逆的。
那么对于字符串,我们可以对字符表中的每一个字符进行数字编号,那么每一个字符串都可以由s进制的自然数表示。比如每一个英文单词都对应于一个26进制的自然数。
但是当字符表较大,且模式串较长时,模式串P对应的指纹将很大,其字长可能超过64位导致难以存储。另外,指纹过大时,其计算和比对也不能视为常数时间了。
为此需要的解决方法是使用散列(散列的介绍)将指纹进行压缩。
另一个问题是hash()计算每次需要O(|p|)的时间,如此一来Karp-Rabin算法仍然需要O(n*m)的时间,显然是不行的。这里我们采用快速指纹计算的方法:相邻的指纹之间,存在一种相关性,利用这种相关性,可以在O(1)时间内,由上一个指纹得到下一个指纹。证明过程详见邓俊辉,数据结构(C++语言版), 第三版, 清华大学出版社, 2013年9月, ISBN: 7-302-33064-6 第330页
注:
文中提到的数据结构电子书好像缺页看不到相关内容了
补充如下:
复杂度分析:
http://7xt4i9.com1.z0.glb.clouddn.com/16-5-15/3095954.jpg
快速指纹更新算法
http://7xt4i9.com1.z0.glb.clouddn.com/16-5-15/98400059.jpg
http://7xt4i9.com1.z0.glb.clouddn.com/16-5-15/84329949.jpg
标签:
原文地址:http://blog.csdn.net/xiang_freedom/article/details/51415687