面试-字符串的处理总结

【串和序列处理 1】PAT Tree 子串匹配结构

Patricia Tree  简称PAT tree。 它是 trie 结构的一种特殊形式。是目前信息检索领域应用十分成功的索引方法,它是1992年由Connel根据《PATRICIA——Patrical Algorithm to Retrieve Information Coded in Alphanumeric》算法发展起来的。

 

PAT tree 在字符串子串匹配 上有这非常优异的表现,这使得它经常成为一种高效的全文检索算法,在自然语言处理领域也有广泛的应用。其算法中最突出的特点就是采用半无限长字串(semi-infinite string 简称 sistring) 作为字符串的查找结构。

 

采用半无限长字串(sistring): 一种特殊的子串信息存储方式。

比如一个字符串CUHK。它的子串有C、CU、CUH、CUHK、U、UH、UHK、H、HK、K十种。如果有n个字符的串,就会有n(n+1)/2种子串,其中最长的子串长度为n。因此我们不得不开辟 n(n+1)/2个长度为n的数组来存储它们,那么存储的空间复杂度将达到惊人的O(n^3)级别。

 

但是我们发现这样一个特点:

            CUHK ——  完全可以表示 C、CU、CUH、CUHK

            UHK   ——  完全可以表示 U、UH、UHK

            HK     ——  完全可以表示 H、HK、

            K       ——  完全可以表示 K

这样我们就得到了4个sistring: CUHK、UHK、HK和K。

 

PAT tree的存储结构

如果直接用单个字符作为存储结点,势必构造出一棵多叉树(如果是中文字符的话,那就完蛋了)。检索起来将会相当不便。事实上,PAT tree是一棵压缩存储的二叉树结构。现在我们用“CUHK”来构造出这样一棵PAT tree 。

 

开始先介绍一下PAT tree的结点结构(看了后面的过程就再来理解这些概念)

* 内部结点:用椭圆形表示,用来存储不同的bit位在整个完整bit sequence中的位置。

* 外部节点(叶子结点): 用方形表示,用来记录sistring的首字符在完整sistring中的开始位置(字符索引)和sistring出现的频次。

* 左指针:如果 待存储的sistring在 内部结点所存储的bit位置上的数据 是0,则将这个sistring存储在该结点的左子树中。

* 右指针:若数据是1,则存储在右子树中。

 

(1) 将所有sistring的字符转化成1 bytes的ASCII码值,用二进制位来表示。形成一个bit sequence pattern(没有的空字符我们用0来填充)。

 

                         sistring                           bit sequence

 完整sistring  ->   CUHK        01000011   01010101   01001000   01001011   <- 完整bit sequence

                          UHK0         01010101   01001000   01001011   00000000                          

                            HK00         01001000   01001011   00000000   00000000

                            K000         01001011   00000000   00000000   00000000

 (2) 从第一个bit开始我们发现所有sistring的前3个bit位都相同010,那么相同的这些0/1串对于匹配来说就毫无意义了,因此我们接下来发现第4个bit开始有所不同了。UHK 的第4个bit是1,而CUHK、HK、K的第4个bit是0。则先构造一个内部结点iNode.bitSize=4(第4个bit),然后将UHK的字符索引 cIndex=2(UHK的开始字符U在完整的CUHK的第2位置上)构造成叶子结点插入到iNode的左孩子上,而CUHK、HK、K放在iNode右子树中。(如下图2)

 

(3) 递归执行第2步,将CUHK、HK、K进一步插入到PAT tree中。流程如下图所示。所有sistring都插入以后结束。

注意:既然PAT tree 是二叉查找树,那么一定要满足二叉查找树的特点。所以,内部结点中的bit 位就需要满足,左孩子的bit位< 结点bit 位< 右孩子的bit 位。

 

 

 

PAT tree的检索过程

 

利用PAT tree可以实现对语料的快速检索,检索过程就是根据查询字串在PAT tree中从根结点寻找路径的过程。当比较完查询字串所有位置后,搜索路径达到PAT tree的某一结点。

 

      若该结点为叶子结点,则判断查询字串是否为叶子结点所指的半无限长字串的前缀,如果判断为真,则查询字串在语料中出现的频次即为叶子结点中记录的频次;否则,该查询字串在语料中不存在。

 

      若该结点为内部结点,则判断查询字串是否为该结点所辖子树中任一叶子结点所指的半无限长字串的前缀。如果判断为真,该子树中所有叶子结点记录的频次之和即为查询字串的出现频次。否则,查询字串在语料中不存在。

 

      这样,通过PAT tree可以检索原文中任意长度的字串及其出现频次,所以,PAT tree也是可变长统计语言模型优良的检索结构。

 

 

例如:要查找string= “CU ”(bit sequence=010 00 0 1 1 01010101) 是不是在CUHK 中。

(1)   根据“CUHK ”的PAT tree 结构( 如上图) ,根结点r 的bit position=4 ,那么查找bit sequence 的第4 个bit=0 。然后查找R 的左孩子rc 。

(2)    rc 的bit position=5 ,在bit sequence 的第5 个bit=0 。则查找rc 的左孩子rcc 。

(3)   rcc= ” CUHK ” 已经是叶子结点了,则确定一下CU 是不是CUHK 的前缀即可。

 

PAT tree 的效率

 

      特点:PAT tree查找的时间复杂度和树的深度有关,由于树的构造取决于不同bit位上0,1的分布。因此PAT tree有点像二叉查找树 ,最坏情况下是单支树(如上图例子),此时的时间复杂度是O(n-1),n为字符串的长度。最好情况下是平衡二叉树 结构,时间复杂度是O(log2(N))。另外,作为压缩的二叉查找树,其存储的空间代价大大减少了。

 

PAT tree的实际应用

 

       PAT tree在子串匹配上有很好的效率,这一点和Suffix Tree(后缀树),KMP算法的优点相同。因此PAT tree在信息检索和自然语言处理领域是非常常用的工具。比如:关键字提取,新词发现等NLP领域经常使用这种结构。

 

 

【串和序列处理 2】字符串编辑距离算法

http://hxraid.iteye.com/blog/615469

我们来看一个实际应用。现代搜索技术的发展很多以提供优质、高效的服务作为目标。比如说:baidu、google、sousou等知名全文搜索系统。当我们输入一个错误的query="Jave" 的时候,返回中有大量包含正确的拼写 "Java"的网页。当然这里面用到的技术绝对不会是我们今天讲的怎么简单。但我想说的是:字符串的相似度计算也是做到这一点的方法之一。

 

字符串编辑距离: 是一种字符串之间相似度计算的方法。给定两个字符串S、T,将S转换成T所需要的删除,插入,替换操作的数量就叫做S到T的编辑路径。而最短的编辑路径就叫做字符串S和T的编辑距离。

 

举个例子:S=“eeba”   T="abac"   我们可以按照这样的步骤转变:(1) 将S中的第一个e变成a;(2) 删除S中的第二个e;(3)在S中最后添加一个c; 那么S到T的编辑路径就等于3。当然,这种变换并不是唯一的,但如果3是所有变换中最小值的话。那么我们就可以说S和T的编辑距离等于3了。

 

动态规划解决编辑距离

动态规划(dynamic programming)是一种解决复杂问题最优解的策略。它的基本思路就是:将一个复杂的最优解问题分解成一系列较为简单的最优解问题,再将较为简单的的最优解问题进一步分解,直到可以一眼看出最优解为止。

 

动态规划算法是解决复杂问题最优解的重要算法。其算法的难度并不在于算法本身的递归难以实现,而主要是编程者对问题本身的认识是否符合动态规划的思想。现在我们就来看看动态规划是如何解决编辑距离的。

 

还是这个例子:S=“eeba”   T="abac" 。我们发现当S只有一个字符e、T只有一个字符a的时候,我们马上就能得到S和T的编辑距离edit(0,0)=1(将e替换成a)。那么如果S中有1个字符e、T中有两个字符ab的时候,我们是不是可以这样分解:edit(0,1)=edit(0,0)+1(将e替换成a后,在添加一个b)。如果S中有两个字符ee,T中有两个字符ab的时候,我们是不是可以分解成:edit(1,1)=min(edit(0,1)+1, edit(1,0)+1, edit(0,0)+f(1,1)). 这样我们可以得到这样一些动态规划公式:      

        如果i=0且j=0        edit(0, 0)=1

        如果i=0且j>0        edit(0, j )=edit(0, j-1)+1

        如果i>0且j=0        edit( i, 0 )=edit(i-1, 0)+1

        如果i>0且j>0        edit(i, j)=min(edit(i-1, j)+1, edit(i,j-1)+1, edit(i-1,j-1)+f(i , j) )

 

小注:edit(i,j)表示S中[0.... i]的子串 si 到T中[0....j]的子串t1的编辑距离。f(i,j)表示S中第i个字符s(i)转换到T中第j个字符s(j)所需要的操作次数,如果s(i)==s(j),则不需要任何操作f(i, j)=0; 否则,需要替换操作,f(i, j)=1 。

 

这就是将长字符串间的编辑距离问题一步一步转换成短字符串间的编辑距离问题,直至只有1个字符的串间编辑距离为1。

 

编辑距离的实际应用

       在信息检索领域的应用我们在文章开始的时候就提到了。另外,编辑距离在自然语言文本处理领域(NLP)中是计算字符串相似度的重要方法。一般而言,对于中文语句的相似度处理,我们很多时候都是将词作为一个基本操作单位,而不是字(字符)。

 

【串和序列处理 3】Trie Tree 串集合查找

http://hxraid.iteye.com/blog/618962

Trie 树, 又称字典树,单词查找树。它来源于retrieval(检索)中取中间四个字符构成(读音同try)。用于存储大量的字符串以便支持快速模式匹配。主要应用在信息检索领域。

 

Trie 有三种结构: 标准trie (standard trie)、压缩trie、后缀trie(suffix trie) 。 最后一种将在《字符串处理4:后缀树》中详细讲,这里只将前两种。

 

1. 标准Trie (standard trie)

标准 Trie树的结构 : 所有含有公共前缀的字符串将挂在树中同一个结点下。实际上trie简明的存储了存在于串集合中的所有公共前缀。 假如有这样一个字符串集合X{bear,bell,bid,bull,buy,sell,stock,stop}。它的标准Trie树如下图:

 

 

      上图(蓝色圆形结点为内部结点,红色方形结点为外部结点),我们可以很清楚的看到字符串集合X构造的Trie树结构。其中从根结点到红色方框叶子节点所经历的所有字符组成的串就是字符串集合X中的一个串。

 

      注意这里有一个问题: 如果X集合中有一个串是另一个串的前缀呢? 比如,X集合中加入串bi。那么上图的Trie树在绿色箭头所指的内部结点i 就应该也标记成红色方形结点。这样话,一棵树的枝干上将出现两个连续的叶子结点(这是不合常理的)。

 

      也就是说字符串集合X中不存在一个串是另外一个串的前缀 。如何满足这个要求呢?我们可以在X中的每个串后面加入一个特殊字符$(这个字符将不会出现在字母表中)。这样,集合X{bear$、bell$、.... bi$、bid$}一定会满足这个要求。

 

      总结:一个存储长度为n,来自大小为d的字母表中s个串的集合X的标准trie具有性质如下:

      (1) 树中每个内部结点至多有d个子结点。

      (2) 树有s个外部结点。

      (3) 树的高度等于X中最长串的长度。

      (4) 树中的结点数为O(n)。

 

标准 Trie树的查找

       对于英文单词的查找,我们完全可以在内部结点中建立26个元素组成的指针数组。如果要查找a,只需要在内部节点的指针数组中找第0个指针即可(b=第1个指针,随机定位)。时间复杂度为O(1)。

 

      查找过程:假如我们要在上面那棵Trie中查找字符串bull (b-u-l-l)。

      (1) 在root结点中查找第('b'-'a'=1)号孩子指针,发现该指针不为空,则定位到第1号孩子结点处——b结点。

      (2) 在b结点中查找第('u'-'a'=20)号孩子指针,发现该指针不为空,则定位到第20号孩子结点处——u结点。

      (3) ... 一直查找到叶子结点出现特殊字符'$'位置,表示找到了bull字符串

      如果在查找过程中终止于内部结点,则表示没有找到待查找字符串。

 

      效率:对于有n个英文字母的串来说,在内部结点中定位指针所需要花费O(d)时间,d为字母表的大小,英文为26。由于在上面的算法中内部结点指针定位使用了数组随机存储方式,因此时间复杂度降为了O(1)。但是如果是中文字,下面在实际应用中会提到。因此我们在这里还是用O(d)。 查找成功的时候恰好走了一条从根结点到叶子结点的路径。因此时间复杂度为O(d*n)。

      但是,当查找集合X中所有字符串两两都不共享前缀时,trie中出现最坏情况。除根之外,所有内部结点都自由一个子结点。此时的查找时间复杂度蜕化为O(d*(n^2))

 

中文词语的 标准 Trie树

      由于中文的字远比英文的26个字母多的多。因此对于trie树的内部结点,不可能用一个26的数组来存储指针。如果每个结点都开辟几万个中国字的指针空间。估计内存要爆了,就连磁盘也消耗很大。

 

      一般我们采取这样种措施:

     (1) 以词语中相同的第一个字为根组成一棵树。这样的话,一个中文词汇的集合就可以构成一片Trie森林。这篇森林都存储在磁盘上。森林的root中的字和root所在磁盘的位置都记录在一张以Unicode码值排序的有序字表中。字表可以存放在内存里。

    (2) 内部结点的指针用可变长数组存储。

 

     特点:由于中文词语很少操作4个字的,因此Trie树的高度不长。查找的时间主要耗费在内部结点指针的查找。因此将这项指向字的指针按照字的Unicode码值排序,然后加载进内存以后通过二分查找能够提高效率。

 

标准Trie树的应用和优缺点

     (1) 全字匹配:确定待查字串是否与集合的一个单词完全匹配。如上代码fullMatch()。

     (2) 前缀匹配:查找集合中与以s为前缀的所有串。

 

     注意:Trie树的结构并不适合用来查找子串。这一点和前面提到的PAT Tree以及后面专门要提到的Suffix Tree的作用有很大不同。

 

      优点: 查找效率比与集合中的每一个字符串做匹配的效率要高很多。在o(m)时间内搜索一个长度为m的字符串s是否在字典里。

      缺点:标准Trie的空间利用率不高,可能存在大量结点中只有一个子结点,这样的结点绝对是一种浪费。正是这个原因,才迅速推动了下面所讲的压缩trie的开发。

 

2. 压缩Trie (compressed trie)

      压缩Trie类似于标准Trie,但它能保证trie中的每个内部结点至少有两个子节点(根结点除外)。通过把单子结点链压缩进叶子节点来执行这个规则。

 

压缩Trie的定义

      冗余结点(redundant node):如果T的一个非根内部结点v只有一个子结点,那么我们称v是冗余的。

      冗余链(redundant link):如上标准Trie图中,内部结点e只有一个内部子结点l,而l也只有一个叶子结点。那么e-l-l就构成了一条冗余链。

      压缩(compressed):对于冗余链 v1- v2- v3- ... -vn,我们可以用单边v1-vn来替代。

 

      对上面标准Trie的图压缩之后,形成了Compressed Trie的字符表示图如下:

压缩Trie的性质和优势:

     与标准Trie比较,压缩Trie的结点数与串的个数成正比了,而不是与串的总长度成正比。一棵存储来自大小为d的字母表中的s个串的结合T的压缩trie具有如下性质:

 

     (1) T中的每个内部结点至少有两个子结点,至多有d个子结点。

     (2) T有s个外部结点。

     (3) T中的结点数为O(s)

 

     存储空间从标准Trie的O(n)降低到压缩后的O(s),其中n为集合T中总字符串长度,s为T中的字符串个数。

 

压缩Trie的压缩表示

     上面的图是压缩Trie的字符串表示。相比标准Trie而言,确实少了不少结点。但是细心的读者会发现,叶子结点中的字符数量增加了,比如结点ell,那么这种压缩空间的效率当然会打折扣了。那么有什么好办法呢,这里我们介绍一种压缩表示方法。即把所有结点中的字符串用三元组的形式表示如下图:

 

      其中三元组(i,j,k)表示S[i]的从第j个位置到第k个位置间的子串。比如(5,1,3,)表示S[5][1...3]="ell"。

 

      这种压缩表示的一个巨大的优点就是:无论结点需要存储多长的字串,全部都可以用一个三元组表示,而且三元组所占的空间是固定有限的。但是为了做到这一点,必须有一张辅助索引结构(如上图右侧s0—s7所示)。


 

【串和序列处理 4】Suffix Trie 子串匹配结构

Suffix Trie : 又称后缀Trie或后缀树。它与Trie树的最大不同在于,后缀Trie的字符串集合是由指定字符串的后缀子串构成的。比如、完整字符串"minimize"的后缀子串组成的集合S分别如下:

 

         s1=minimize

         s2=inimize

         s3=nimize

         s4=imize

         s5=mize

         s6=ize

         s7=ze

         s8=e

 

      然后把这些子串的公共前缀作为内部结点构成一棵"minimize"的后缀树,如图所示,其中上图是Trie树的字符表示,下图是压缩表示(详细见《Trie树 》)。可见Suffic Trie是一种很适合操作字符串子串的数据结构。 它和PAT tree在这一点上类似。

 

 

Suffix Trie的创建  

 

      标准Tire树的每一个内部结点只有一个字符,也就是说公共前缀每一次只找一个。而Suffix Trie的公共前缀可以是多个字符,因此在创建Suffix Trie的时候,每插入一个后缀子串,就可能对内部结点造成一次分类。下面我们我们看一种后缀树构造算法。以"minimize"为例:

 

      当插入子串时,发现叶子结点中的关键字与子串有公共前缀,则需要将该叶子结点分裂。如上图第3到4步。否则,重新创建一个叶子结点来存放后缀,如上图第1到2步

 

Suffix Trie的子串查询

 

     如果在后缀树T中查找子串P,我们需要这样的过程:

     (1) 从根结点root出发,遍历所有的根的孩子结点:N1,N2,N3....

     (2) 如果所有孩子结点中的关键字的第一个字符都和P的第一个字符不匹配,则没有这个子串,查找结束。

     (3) 假如N3结点的关键字K3第一个字符与P的相同,则匹配K3和P。

          若 K3.length>=P.length  并且K3.subString(0,P.length-1)=P,则匹配成功,否则匹配失败。

          若 K3.length<=P.length  并且K3=P.subString(0, K3.length-1),则将子串P1=P.subString(K3.length, P.length); 即取出P中排除K3之后的子串。然后P1以N3为根结点继续重复(1)~(3)的步骤。直到匹配完P1的所有字符,则匹配成功。否则匹配失败。

 

      查询效率:很显然,在上面的算法中。匹配成功正好比较了P.length次字符。而定位结点的孩子指针,和Trie情况类似,假如字母表数量为d。则查询效率为O(d*m),实际上,d是固定常数,如果使用Hash表直接定位,则d=1.

      因此,后缀树查询子串P的时间复杂度为O(m),其中m为P的长度。

 

Suffix Trie的应用

 

      标准Trie树只适合前缀匹配和全字匹配,并不适合后缀和子串匹配。而后缀树在这方面则非常合适。

 

      另外后缀树也可以进行前缀匹配。 如果模式串P是字符串S的前缀的话,那么从根结点出发遍历后缀树,一定能够寻找到一条路径完全匹配完P。比如上图: 模式串P=“mini”,主串S="minimize"。P从根节点出发,首先匹配到结点mi,然后再匹配孩子结点nimize。直到P中所有的字符都找到为止。所以P是S的前缀。

 

【串和序列处理 5】KMP子串匹配算法

http://hxraid.iteye.com/category/83702

模式匹配: 在字符串S中,子串P的定位操作通常称做串的模式匹配。说白了,就是在一个字符串中寻找子串。在Suffix Trie和PAT tree中我们已经讨论过匹配子串的方法了。这里我们讨论一种线性匹配算法来寻找子串。

 

例: 我们要在S="ababcabcacbab"中查找子串P="abcac"。下图左侧是一种很普通的模式匹配算法

这种普通的模式匹配算法很简单,但时间复杂度是O(n*m)。其中n=S.length,m=T.length.  代价很高。难道真的要像第三趟到第四趟那样:好不容易匹配到S中的第7个位置,但由于不相同,则有回溯到第4个位置重新开始。

 

其实,每一次匹配过后,主串S中被匹配过的位置其实不用再匹配了。也就是上一趟匹配结果对下一趟有指导作用。我们用一个证明来说明这一点(假如主串S=s1 s2 .... sn ,模式串P=p1 p2 ... pm  )。

 

证明:当一趟匹配完成之后,我们发现si !=pj 。那么至少说明了模式串p1... p(j-1)是匹配成功的。也就是得到了: p1  p2  ...  p(j-1) = s(i-j+1)   s(i-j+2) .....  s(i-1) 。 比如第三趟 s7!=p5 => s2...s6=p1...p4 = "abca".

         如果像上图左侧算法那样,从第三趟i=7回溯到第四趟i=4是有必要的话,那么也说第四趟可能完全匹配到了模式串P。好,我们现在就假设si != sj之后,i 回溯主串(i-j+1)的下一个位置上可以匹配成功模式串P,那么我们可以得到 p1 p2 ... p(j-1) =s(i-j+2)   s(i-j+3) ... s(i)。

         合并下标蓝色的两个式子,我们可以得到:

                   s(i-j+1)   s(i-j+2) .....  s(i-1)= s(i-j+2)   s(i-j+3) ...  s(i)

         也就是:  s(i-j+1)= s(i-j+2)= s(i-j+3)= .... =s(i-1)=s(i)

         我靠,主串S中全部的字符都一样,这种情况下才必须每一次都回到主串的下一个位置上重新开始。而只要有字符不同,就完全没有这个必要了。好了,基于这个证明成果,我们开始介绍KMP算法。

 

 

KMP 模式匹配 —— D.E.Knuth   V.R.Pratt和J.H.Morris同时发现的。因此人们称它为克努特-莫里斯-莫拉特操作(简称KMP算法)。KMP的优势在于当每一趟匹配结果中出现了不等情况时,主串并不需要回溯位置i (上面已经证明),而只要 回溯模式串P即可 。也就是说,只需要在主串S的位置i 处重新与模式串的位置k进行比较(如上图右侧过程)。 那么这个 重新 需要定位的模式串k位置有什么要求呢,或者说我们怎么确定这个k呢。我们再用一个小小的证明来揭示( 还是假如主串S=s1 s2 .... sn ,模式串P=p1 p2 ... pm  ):

 

证明:当一趟匹配完成之后,我们发现si !=pj 。此时首先可以肯定的是k< j,因为模式串j 必须回溯。

        如果 s1 与 pk 有重新比较的必要,那么模式串P前k-1个字符必须满足下列关系式:

                            p1  p2  ...  p(k-1)=s(i-k+1)  s(i-k+2) ...  s(i-1)

        此外,由于经过额一趟的匹配之后,已经可以得到“部分匹配”结果,主串S中i 位置的前k个字符一定等于模式串P中j 位置上的前k个字符:

                           p(j-k+1)  p(j-k+2)  ...  p(j-1) = s(i-k+1)   s(i-k+2) .....  s(i-1) 。

        合并两个蓝色的式子:

                           p1  p2  ...  p(k-1) = p(j-k+1)   p(j-k+2)   ...   p(j-1)

        我们发现了,位置k的值取决于模式串P自己必须满足上面这个红色的式子。

 

 

失效函数: 当在模式串P的第j 个位置上发生匹配不成功时,需要将模式串回溯到位置 k处的这样一个f(j)=k 的函数,就叫做失效函数。其中j 和k 的值必须满足p1  p2  ...  p(k-1) = p(j-k+1)   p(j-k+2)   ...   p(j-1)。 也就是说 pj 的前k-1个字符必须等于pk 的前k-1 个字符。因此,失效函数f(j)的定义如下:

 

                                                 0       当j=1时

                                   f(j) =      Max{k|1

                                                 1       不满足上面的情况

 

比如模式串P=“abcac”的每一个位置的失效函数如下:

                                            j         1   2   3   4   5

                                           P         a   b   c   a    c

                                         k=f(j)     0   1   1   1   2

 

失效函数的算法

     假如f(j)=k,则表明 p1...p(k-1) = p(j-k+1)...p(j-1)。这说明 pj 的前k-1个字符必须等于pk 的前k-1 个字符。也就是说p1...pk一定是p1...pj的一个后缀子串。因此我们可以把模式串P与自身做KMP算法的匹配来求解这个K值。算法如下:

     (1) 若 pk=pj, 则p1...p(k-1)pk= p(j-k+1)...p(j-1) pj 。 表明f(j+1)=k+1

     (2) 若 pk!=pj , 则可以把求f(j+1)看成以P为主串和匹配串的模式匹配问题。即 pk!=pj 则比较pj与p(f(k)),如果pj==p(f(k)),则f(j+1)=f(k)+1。否则继续比较下去直到f(k)=0为止。

 

     失效函数算法的运行时间是o(m).

 

KMP算法效率

 

      对于长度为n的文本串和长度为m的模式串进行模式匹配的运行时间为O(n+m) . 很显然,因为文本串在KMP算法中并不需要回溯,因此与模式串的比较次数为O(n)。但模式串要建立失效函数,所付出的代价是O(m)。因此总体的时间复杂度是O(m+n)。实际中,m要远小于n,因此近似可以认为KMP效率为O(n)。

      但是KMP算法有种最坏的情况,当模式串P="aaaaa"时,即每一个字符都一样的时候。则失效函数为:

                                   j     1 2 3 4 5 

                                  P     a a a a a

                                 f(j)    0 1 2 3 4

      此时如果主串中的s[i]!=p[j]的时候,根据模式串P回溯j=f(j)的原则。s[i]需要从模式串P的最后一个字符一步一步回溯到第一个字符,每次都要比较一遍。这时的时间复杂度为O(m)。那么对于n个字符的S串而言,最差的时间复杂度就是O(n*m)了,退化成了蛮力匹配。

 

     KMP和后缀树都可以用来匹配子串。因此我们这里与后缀树做一个比较,虽然后缀树在查找的过程中只需要大概O(m)的时间复杂度。对长度n的文本串建立后缀树最好的算法需要O(n)时间复杂度,因此后缀树大致也需要O(n+m)。

 

【串和序列处理 6】LCS最长公共子序列

http://blog.csdn.net/yysdsyl/article/details/4226630

http://hxraid.iteye.com/blog/622462

LCS:又称 最长公共子序列。 其中子序列(subsequence)的概念不同于串的子串。它是一个不一定连续但按顺序取自字符串X中的字符序列。 例如:串"AAAG"就是串“CGATAATTGAGA”的一个子序列。

 

字符串的相似性问题可以通过求解两个串间的最长公共子序列(LCS)来得到。 当然如果使用穷举算法列出串的所有子序列,一共有2^n种,而每个子序列是否是另外一个串的子序列又需要O(m)的时间复杂度,因此这个穷举的方法时间复杂度是O(m*(2^n))指数级别,效率相当的可怕。我们采用动态规划算法来解决这个问题。

 

动态规划算法解决最长公共子序列

 

假如我们有两个字符串:X=[0,1,2....n]  Y=[0,1,2...m]。我们定义L(i, j)为X[0...i]与Y[0...j]之间的最长公共子序列的长度。通过动态规划思想(复杂问题的最优解是子问题的最优解和子问题的重叠性质决定的)。我们考虑这样两种情况:

 

(1)  当X[i]=Y[j]时, L(i, j)=L(i-1, j-1)+1 。证明很简单。

(2)  当X[i]!=Y[j]时, 说明此事X[0...i]和Y[0...j]的最长公共子序列中绝对不可能同时含有X[i]和Y[j]。那么公共子序列可能以X[i]结尾,可能以Y[j]结尾,可以末尾都不含有X[i]或Y[j]。因此

                               L(i, j)= MAX{L(i-1 , j), L(i, j-1)}

引进一个二维数组c[][],用c[i][j]记录X[i]与Y[j] 的LCS 的长度,b[i][j]记录c[i][j]是通过哪一个子问题的值求得的,以决定搜索的方向。
我们是自底向上进行递推计算,那么在计算c[i,j]之前,c[i-1][j-1],c[i-1][j]与c[i][j-1]均已计算出来。此时我们根据X[i] = Y[j]还是X[i] != Y[j],就可以计算出c[i][j]。

问题的递归式写成:

 回溯输出最长公共子序列过程:

为什么呢?因为通过表的回溯过程,从后向前重构了一个最长公共子序列。对于任何位置lcs[i][j],确定是否X[i]=Y[j]。如果是,那么X[i]必是最长公共子序列的一个字符。如果否,那么移动到lcs[i,j-1]和lcs[i-1, j]之间的较大者。

 

动态规划方法LCS效率:

 

动态规划方法构造最长公共子序列需要O(m*n)的代价,另外,如果想要得到最长公共子序列,又需要O(m+n)的时间来读取csl[][]数组。尽管如此,其时间复杂度仍然比蛮力穷举的指数级别要强的多。

 

问题拓展:设A,B,C是三个长为n的字符串,它们取自同一常数大小的字母表。设计一个找出三个串的最长公共子串的O(n^3)的时间算法。 (来自《Algorithm Design》(中文版:算法分析与设计) - Chapter9 - 文本处理 - 创新题C-9.18)

 

【串和序列处理 7】LIS 最长递增子序列

http://hxraid.iteye.com/blog/624858

LIS: 给定一个字符串序列S={x0,x1,x2,...,x(n-1)},找出其中的最长子序列,而且这个序列必须递增存在。

 

下面给出解决这个问题的几种方法:

 

(1) 转化为LCS问题

 

      思想: 将原序列S递增排序成序列T,然后利用动态规划算法取得S与T的公共最长子序列。具体算法详见《LCS最长公共子序列 》。

 

      效率: 这个方法排序最好的是时间复杂度是O(n*logn),动态规划解决LCS的时间复杂度是O(n^2)。因此总体时间复杂度是O(n*logn)+O(n^2)=O(n^2) 级别。

 

(2) 分治策略

 

      思想: 假设f(i)表示S中 x0 ... xi 子串的最长递增子序列的长度。则有如下递归:找到所有在xi之前,且值小于xi 的元素xj,即j

                                        f(i)=Max(f(j))+1.  其中{j | j

如果这样的j不存在,则xi自身构成一个长度为1的递增子序列。

效率: 算法时间复杂度为O(n^2)级别。

 

(3) 动态规划算法

 

      实际上这是一道很典型的动态规划问题。我们假设a[0]....a[i-1] 有一个最长递增子序列,其长度f(i-1)<=i, 且该最长递增子序列的最后一个元素为b。

      那么对于a[0].... a[i] 而言,如果b=a[i],那么f(i)=f(i-1)。

      上面的过程有一个难点:如果a[0]....a[i-1] 有多个最大长度为f(i-1)的递增子序列怎么办?需不需要所有长度等于f(i-1)的递增子序列的最后一个元素b0...bi全部存储起来,再一一和a[i]比较大小呢?如果是这样,那么整个算法与上面的分治策略将没有什么不同了?

      事实上,并不需要怎么做。我们举个例子: a[]={1、2、5、3、7}

      a[0] ... a[3] 的最大递增子序列有两个{1,2,5}和{1,2,3},当增加a[4]的时候,如果a[4]>5,则两个子序列都需要增加a[4];如果a[4]>3,则{1,2,3}+a[4]将必定成为新的最大子序列,而{1,2,5}不确定。因此我们看出,只要保存所有最大序列的最小的末尾元素即可。

 

      因此我们设计一个如下的算法:其中b[k]用来表示最大子序列长度为k时的最小末尾元素。

   该算法的时间复杂为O(N*logN)。

 

 

 

【串和序列处理 8】最长平台问题

http://hxraid.iteye.com/blog/655389

1、经典最长平台算法

 

已知一个已经从小到大排序的数组,这个数组中的一个平台(Plateau)就是连续的一串值相同的元素 ,并且这一串元素不能再延伸。例如,在 1,2,2,3,3,3,4,5,5,6中[1]、[2,2]、[3,3,3]、[4]、[5,5]、[6]都是平台。是编写一个程序,接受一个数组,把这个数组中最长的平台找出 来。在上面的例子中3,3,3就是该数组中最长的平台。
【说明】
这个程序十分简单,但是要编写好却不容易,因此在编写程序时应该考虑下面 几点:
(1) 使用的变量越少越好;
(2) 把数组的元素每一个都只查一次就得到结果;
(3) 程序语句也要越少越好。
这个问题曾经困扰过David Gries 这位知名的计算机科学家。本题与解答取自David Gries 编写的有关程序设计的专著。

这是一个时间复杂度为O(n) 的经典算法,其代码十分简练。

 

另外,我自己也写了一个时间复杂度为O(n)的算法,原理就是找出所有平台分界位置,后一个位置减前一个位置(平台长度)的最大值。

 

2、改进的最长平台算法

 

上面O(n)的时间复杂度级别已经很不错了,但是如果n值特别大,那么仍然要比较n次才可以出结果,我们能不能降低比较次数呢? 显然,这个问题是可以优化的。

 

我们再来回顾一下David Gries的经典算法(代码1的line: 9),不管当前最长平台的长度为多少,每一次比较都是i++。难道每一次比较都是必须的吗? 比如下面这个平台串:

                                              pArr[]:    1  1  1  2  2  2  2   2  3  3    4   4    5    5

                                              index:     0  1  2  3  4  5  6  7  8   9  10  11  12  13

分析: 当pArr[2]==pArr[0]的时候,最长平台长度已经增到了3。此时继续比较pArr[3]==pArr[0]发现不相等。那么说明pArr[3]已经开始了一个新的平台。依据经典算法,我们还要继续比较pArr[4]==pArr[1],pArr[5]==pArr[2]。显然,这两个比较是不必要的,因为pArr[3]开始了新的平台,位置3之前的所有数据都不会和3之后的所有数据相等了。

 

根据上面的分析,我们很容易的想到可以跳跃一定的次数进行比较,跳跃多少呢?最简单的想法就是跳跃一个当前的longest(最长平台长度)。因为如果当前pArr[index]==pArr[index+longest]的话,说明当前平台长度比上一次的longest还要长,如果pArr[index]!=pArr[index+longest]的话,那么目前的平台长度绝对不会超过longest,也就没有必要再去比较小于longest的平台长度是多少了。

 

问题并没有想象的那么简单,跳跃longest长度之后,为了下一次还能够跳跃longest长度,有的时候是需要回溯一段距离的。 我们来看看下面的详细算法分析。

 

还是上面的例子,我们来一步一步的研究这个改进的算法。

(1)  首先计算第一个平台 "1  1  1" 得到了当前最长平台长度为longest=3,当前串位置index=3。

(2)  这时我们比较pArr[index]==pArr[index+longest](即比较pArr[3]<->pArr[6])。显然相等,那么longest++(即longest=4)。然后继续循环比较pArr[index]==pArr[index+longest],直到不相等为止。此时index=8, longest=5.

(3)  这一步非常重要,当前的index=8已近开始了一个新的平台, 而当前的longest=5。继续比较pArr[index]==pArr[index+longest](即比较pArr[8]<->pArr[13]),发现不相等。此时我们能不能继续从pArr[13]开始向后跳跃longest=5的长度呢。显然不对,因为pArr[13]并不是平台5的开始位置,pArr[12]=5。如果跳跃longest长度,后面的计算结果将全部错误。 此时,我们必须从13开始回头遍历,直到找到平台的其实位置pArr[12],然后从12位置开始跳跃longest=5的长度才可以。

 

算法分析:时间复杂度仍然是O(n)级别 (注意:不要看到双重循环就认为是O(n^2)级别)。随然有的时候需要回溯到平台的起始位置,但改进之后的算法仍然降低了比较次数。 因为跳跃longest后最多需要回溯longest-1次(此时共比较longest次)。也就是最差情况下位置index每次跳跃之后都会回溯到index+1的位置上,因此最差情况下改进算法会蜕化成经典算法的比较次数n。

 

我们列举出一个最差情况的平台串:  1  1  2  2  3  3  4  4  5  5  6  6  7  7 ....

 

平均而言,1000个长度的随机初始化平台串,改进算法的比较次数在149次左右。而经典算法必须比较1000次。

 

 

你可能感兴趣的:(算法)