码迷,mamicode.com
首页 > 其他好文 > 详细

第十一章·串

时间:2016-05-15 08:19:53      阅读:253      评论:0      收藏:0      [点我收藏+]

标签:

ADT

定义:字符串,是指来自于某个字母表的字符组成的有限序列。
数据结构:可以由向量或者列表来实现。
特点:相对于一般的线性序列,串具有更鲜明的特征:其组成字符很少,串的长度却高出几个数量级。
技术分享
几个术语:
技术分享

  1. 空串是任何串的子串、前缀和后缀
  2. 任何串是其自身的子串、前缀和后缀
  3. 长度严格小于原串的子串、前缀和后缀也称为真子串、真前缀和真后缀

作为一个ADT,其标准接口如下
技术分享

  1. length用于获取串S的长度
  2. charAt(i)用于获取指定字符在串S中的位置
  3. substr(i,k)用于获取串S中从i到k的子串
  4. prefix(k)用于获取长度为k的前缀
  5. suffix(k)用于获取长度为k的后缀
  6. concat(T)用于连接另一个串T
  7. equal(T)判断串S与另一个串T是否相等
  8. indexOf(P):串匹配,equal接口的推广,获取另一个串P是否与串S的某一个子串相等

indexOf的高效实现是本章重点讨论的内容

串匹配

技术分享

  • 什么是串匹配
    用于判断一个串(模式串P,串长m)是否是另外一个串(文本串T,串长n)的子串。
  • 串匹配的功能是什么

    1. 判断m是否在n中出现
    2. m在n中首次出现的位置
    3. m在n中出现过几次
    4. 枚举:m在n中各次出现的位置
  • 怎么评测性能
    由于串匹配的特殊性,其匹配成功率极低,采用一般的随机概率评估性能不太合适。而应该对匹配成功和失败两种情况分别评测。
    成功:在T中,随机取出长度为m的子串作为P;分析平均复杂度
    失败:采用随机P,统计平均复杂度

暴力匹配

从文本串第一个位置开始比对模式串,比对失败时移动模式串到文本串下一个位置,直到比对成功。
技术分享
此种算法的代码实现如下:
技术分享
复杂度分析:

  • 最好情况下,首轮比对即成功,复杂度为O(m)。
  • 最坏情况下,要一直到最后一次比对,且每次都比对到模式串最后一个字符,复杂度为O(m*(n-m+1)),由于n>>m,也可以视为O(n*m)。
    字母表大小越小或模式串长度越长时,最坏情况出现的可能性越高。
  • 当字母表较大时的平均情况下,暴力匹配可以达到O(n)的效率。

KMP算法

我们先回到蛮力算法,看看其低效的原因
技术分享
上图是蛮力算法一次典型的运行过程。蛮力算法需要从首字符开始进行一系列的比对直至在某个位置发生失配。在此之前每一次迭代所花费的时间都正比于这个前缀。
文本串中的某一特定字符在最坏情况下会与模式串中的每一个字符都比较一次,一共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表的强大之处。这个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算法版本一中,我们在匹配成功的判断语句中加了一句:j < 0 ,即表示哨兵匹配成功的情况,从而使文本串和模式串都往后移一个字符。
  • 这里实际上是一种匹配失败的情况,但通过添加“哨兵”,在逻辑上变更为匹配成功的情况,但处理的结果完全相同。

由此可见,巧妙地引入和设置哨兵在程序和算法设计过程中是一种非常高明的处理手法。KMP就是这方面的一个典型范例。概括而言,这种手法的高明之处主要体现在两个方面:在代码实现上,可以使得算法的描述更为简洁。其次,通过相应地建立一种假想的模型也可以使得我们对算法的理解更为统一和深入。包括伽利略在内的许多著名物理学家都擅长于在头脑中进行所谓的虚拟实验。实际上,计算机科学中的这种假象模式与物理学中的虚拟实验有着异曲同工之妙。

next表的构造

next表的构造过程相当于是模式子串的自匹配,其实现只需要对KMP算法稍作修改即可。
技术分享

KMP算法的性能

这里使用一种“观察变量”的方法。在KMP算法的循环代码中,加入一个变量k,这个k随着迭代次数的增加同步增加。因此,只要确定了K的上界,我们就能确定迭代步数的上界,也就知道了复杂度的上界。

技术分享

而k的范围为O(n),因此可确定KMP算法的复杂度也为O(n)。另外,KMP next表的构造算法和KMP算法一样,其复杂度为O(m)。因此对于长度为n的文本串和长度为m的模式串,KMP算法的复杂度为O(n+m)。

KMP算法的改进

尽管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算法

串匹配是由若干个字符在局部组成的一个片段而言,由多个字符对多个字符匹配,当且仅当每一对字符彼此相等时匹配成功。反过来,一旦发现有一对字符不等我们就立即可以判断串失配。由此可见,从计算成本的角度来看判定一对串是否相等与判定它们是否不等并不是完全一样的。而BM算法就充分地利用了这一性质,从而使得串匹配的效率得以进一步地提高。这一算法采用两种策略:坏字符和好后缀。

坏字符策略

KMP会聪明地排除掉大量的对齐位置从而大大地节省计算的成本。然而就排除某个对齐位置而言,相应的那些成功的比对并不重要,而在这个意义上起实质作用的,反而是那些失败的比对。我们应该更加期盼这种失败的比对出现得更早。比如,按照这种思路的一种极端情况就是我们或许可能只做这些失败的比对,就足以排除掉相应的对齐位置。

我们的目标与其说是要加速匹配,不如说是要加速失败。

每一次匹配失败都给我们一个“教训”。然而,不同位置的字符失配带来的价值是不同的。显然,越靠后的字符失配价值更大。因为我们可以借此排除更多的对齐位置。

先来看这种匹配策略的一个例子:
技术分享
我们发现,在与“道”失配时,我们应该检查模式串中“道”的位置,并移动模式串使之与文本串对齐。如果不存在,则直接将模式串跳过。在上图中,我们总共经过了8次比对,比文本串的长度(12)还少。

bc表

技术分享
对于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表。

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页

性能分析与比较

  • BM算法的空间复杂度为O(s+m) (bc表+gs表)
  • bc、gs表构建时间为O(s+m)。
  • 查找最好情况为O(n/m),最坏为O(n+m)(与KMP类似)

串匹配算法综合比较

技术分享
图中竖轴由低到高表示n/m、n、n*m,Pr表示字母表大小的反比,即1/s。可以看到,字母表越大,蛮力匹配(BF)效率越接近线性。而字母表越小,越接近n*m。而KMP算法不管字母表规模,始终维持在线性水平。利用BC策略的BM算法在字母表规模大时充分发挥优势,但字母表规模小时劣势也很明显。最后综合BC策略和GS策略的BM算法则保持了最好效率,同时最坏效率不超过线性水平。

由此可见,BM算法非常适用于字母表较大的串匹配。

Karp-Rabin算法

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页

总结

  1. 首先介绍了串这一数据结构的实现方式和特点,定义了抽象数据接口,并介绍了几个相关概念。因为串结构相对简单,因此重点介绍串匹配算法。
  2. 介绍蛮力匹配算法的实现思路,并分析其效率。在字符表较小的情况下,效率较低。
  3. 通过分析蛮力匹配低效率的原因,针对性的提出KMP算法。利用next表充分节省比对次数,使匹配效率达到线性。
  4. KMP算法仍然不是最高效的。在字符表较大的情况下,蛮力匹配也可以达到线性。而根据字符表规模大导致匹配失败概率激增这一特点设计的BM算法,对比对失败”教训“进行优化,可以达到O(n/m)的效率。
  5. 介绍了以另一种思路实现匹配的Karp-Rabin算法,也可以达到线性效率。

注:
文中提到的数据结构电子书好像缺页看不到相关内容了
补充如下:
复杂度分析:
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

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!