代码改变世界

Introduction to String Searching Algorithms--Rabin-Karp and Knuth-Morris-Pratt Algorithms [翻译]

2008-06-03 13:11  老博客哈  阅读(1902)  评论(3编辑  收藏  举报

    Introduction to String Searching Algorithms
Rabin-Karp and Knuth-Morris-Pratt Algorithms 
                【原文见: http://www.topcoder.com/tc?module=Static&d1=tutorials&d2=stringSearching
                                                                  作者:     By  TheLlama                                
                                                      Topcoder Member
                                         翻译:     农夫三拳@seu(drizzlecrj@gmail.com)

字符串搜索(匹配)算法的基本定义如下:给定两个字符串-文本串和模式串,判断模式串是否出现在文本串中。这个问题就是著名的“草垛里找针问题”。

直接的想法

想法很直接—对于文本串中的任何一个位置,判断从它开始是否和模式串匹配。

function brute_force(text[], pattern[]) 
{
  
// let n be the size of the text and m the size of the
  
// pattern

  
for(i = 0; i < n; i++{
    
for(j = 0; j < m && i + j < n; j++
      
if(text[i + j] != pattern[j]) break;
      
// mismatch found, break the inner loop
    if(j == m) // match found
  }

}

    这个直接的想法非常易懂也非常容易实现,但是它在某些情况下太慢。如果文本串的长度是n,模式串的长度是m,在最坏情况下将花费n * m次迭代来完成任务。

需要注意到的是,在实际使用中,如果处理的是自然语言,那么这个算法会非常快,因为内部的循环通常能够很快得到一次失配并且跳出。而当我们遇到诸如遗传密码的文本时,就会出问题了。 

Rabin-Karp 算法(RK)

这个实际上就是在前面的直接的算法上使用了一个很强大的编程技巧-hash函数。每一个长度为m的字符串数组s[],都可以看作一个B进制的数H(B>=字符串中所用到的字符集的大小)

H = s[0] * B(m - 1) + s[1] * B(m - 2) + … + s[m - 2] * B1 + s[m - 1] * B0 

的数,如果计算出模式串的hashH以及文本串的所有长度为m的子串,那么前面直接的算法中内部循环将会消失-不用逐个字母的去比较两个串,而只需要比较两个整数。

   mB非常大的时候,那么问题就产生了,由于H太大不能用标准的整数类型来存放。为了解决这个问题,我们可以用H对某个数M的余数来替代H。为了得到余数,我们不需要直接计算H。我们可以使用同余的一些性质:

A + B = C => (A % M + B % M) % M = C % M
A * B = C => ((A % M) * (B % M)) % M = C % M

我们得到:

H % M = (((s[0] % M) * (B(m - 1) % M)) % M + ((s[1] % M) * (B(m - 2) % M)) % M +…
…+ ((s[m - 2] % M) * (B1 % M)) % M + ((s[m - 1] % M) * (B0 % M)) % M) % M

这个方法的缺点在于对于两个不同的字符串,它们的结果值可能会一样(这叫做冲突)。冲突在M足够大并且BM是质数时很少发生。冲突的情况使得我们不能够完全的丢掉内层循环的直接做法。这样使得算法的易用性大打折扣,当两个字符串的hash值相同的时候,我们需要逐字的去比较“候选”串。

有一点很显然的是,如果我们对每一个长度为m的子串计算hash值的时候是从t文本串的每一个字母向后扫描m个字符计算的,那么整个算法将变得毫无意义。因为我们又要通过一个二重循环来进行计算:外层循环迭代所有可能的初始位置,内存循环-计算每一个开始位置的hash值。幸运的是,事实并非如此。让我们考虑一个字符串s[],假定我们将要计算的s[]的子串长度均为m=3,可以看到:

H0 = Hs[0]…s[2] = s[0] * B2 + s[1] * B + s[2]

H1 = Hs[1]..s[3] = s[1] * B2 + s[2] * B + s[3]

H1 = (H0 - s[0] * B2 ) * B + s[3] 

一般地:

Hi = ( Hi - 1 - s[i- 1] * Bm - 1 ) * B + s[i + m - 1] 

利用上面的同余性质,我们可以得到:

Hi % M = (((( Hi - 1 % M - ((s[i- 1] % M) * (Bm - 1 % M)) % M ) % M) * (B % M)) % M +
+ s[i + m - 1] % M) % M
 

显然(Hi - 1 - s[i - 1] * Bm - 1)的值有可能为负。我们同样可以利用同余的一个性质:

A - B = C => (A % M - B % M + k * M) % M = C % M 

因为Hi - 1 - s[i - 1] * Bm - 1)的绝对值在0(M-1)之间,我们可以用1来代替k

RK的伪代码如下:

// correctly calculates a mod b even if a < 0
function int_mod(int a, int b)
{
  
return (a % b + b) % b;
}


function Rabin_Karp(text[], pattern[])
{
  
// let n be the size of the text, m the size of the
  
// pattern, B - the base of the numeral system,
  
// and M - a big enough prime number

  
if(n < m) return// no match is possible

  
// calculate the hash value of the pattern
  hp = 0;
  
for(i = 0; i < m; i++
    hp 
= int_mod(hp * B + pattern[i], M);

  
// calculate the hash value of the first segment 
  
// of the text of length m
  ht = 0;
  
for(i = 0; i < m; i++
    ht 
= int_mod(ht * B + text[i], M);

  
if(ht == hp) check character by character if the first
               segment of the text matches the pattern;

  
// start the "rolling hash" - for every next character in
  
// the text calculate the hash value of the new segment
  
// of length m; E = (Bm-1) modulo M            
  for(i = m; i < n; i++{
    ht 
= int_mod(ht - int_mod(text[i - m] * E, M), M);
    ht 
= int_mod(ht * B, M);
    ht 
= int_mod(ht + text[i], M);

    
if(ht == hp) check character by character if the
                 current segment of the text matches
                 the pattern; 
  }

}

不幸的是,仍然存在一些情况我们需要运行“直接算法”的内层循环-例如,当在文本串“aaaaaaaaaaaaaaaaaaaaaaaaa”搜索模式串“aaa”。因此在最坏情况下,我们仍需要(n * m)次迭代过程。怎样来避免这种情况呢?

    让我们回到问题的基本思想上来-将两个串之间的逐字比较替换为两个整数之间的比较。为了使这些整数足够小,我们需要使用模运算。这带来的一个“副作用”- 字符串和整数之间的映射不是一一映射。因此当两个整数相等的时候,我们还需要通过逐字的对比来确认两个字符串是否一致,这将导致一个恶性循环

解决这个问题的方法叫做“理性博弈”或者叫“双散列”的技巧。我们“赌下”-如果两个字符串的hash值相同,我们就假定它们是相同的,并且不再逐字的比较它们。为了减少这种“错误”的发生,我们为每一个字符串基于不同的BM计算两个独立的hash值,我们假定如果两个hash值都一样,那么两个字符串是一样的。有些时候,也会用到“三散列”,不过从实用性角度来讲,这个很少用到。

草垛里找针问题”的问题的“原始”版本非常的直接,并且很少在编程比赛中遇到。尽管如此,RK中的“滚动hash”却是一个强有力的武器。它尤其适用于当我们需要在文本串中找出所有固定长度的子串的问题。例如,“最长公共子串问题”--给定两个字符串,找出它们的公共子串。在这种情况下,二分搜索(BS)和“滚动hash”可以很好的解决这个问题。可以使用BS的原因是对于给定的字符串,如果它们具有长度为n的公共子串,那么它们至少具有一个公共子串的长度为m,并且m小于n。如果两个串不具有长度为n的公共子串,那么它们肯定不具有长度为m(m>n)的公共子串。因此我们需要做的就是在待查找的字符串中对长度进行二分。对于第一个串的每一个固定长度的子串,我们使用一个hash值作为索引将另外一个hash值作为值(“双散列”)。对于第二个串的固定长度的子串,我们计算出相应的两个hash值并且在表中搜索在第一个串中是否已经出现了这个串。使用开散列的hash表非常适合这个任务。

    当然,在“现实生活”(真实的比赛)中,给定的字符串的个数要超过2,需要寻找的最长公共子串并非必须出现在所有的串中。但是解决这个问题的方法不变。

另外一个类型的问题是,在一个串中查找固定长度的出现次数最多的子串。因为长度已经固定,我们不需要BS算法,我们仅需要一个hash函数来跟踪频率即可。     

Knuth-Morris-Pratt 算法 (KMP)

 在某些情况下,“直接的”算法和扩展的RK算法都是符合人们“草垛里找针问题”的常规逻辑。KMP的基本思想稍稍有些不同。让我们假定我们能够在通过对文本串的一次遍历之后,能够得到所有模式串匹配中结束的位置。显然,这就能解决我们的问题了。因为我们知道了模式串的长度,如果可以很容易的得到匹配开始的位置。

这个方法可行吗?事实证明是可以的,如果我们应用自动机的思想。我们可以将自动机认为是一个抽象的对象,它包括一些有限数量的状态。在每一步中,都有一些信息与之对应。根据这个信息和当前的状态,按照内部的一些规则,自动机能够到达一个新的状态,。这些状态中有一个叫做“末状态”。每一次到达“末状态”,我们就能找到一次匹配结束的位置。

KMP中所使用到的自动机其实就是一些“指针”组成的数组(这个就是上面谈到的“内部规则”)和一个单独的某些位置的“外部”指针(代表“当前状态”)。当文本串中的下一个字符进入到自动机中,“当前状态”将会随着这个字符,当前位置和数组中一系列的“准则”到达一个新的状态。当到达一个“末状态”时,我们就找到了一个匹配。

自动机后的思想非常简单。我们考虑字符串:

A B A B A C

作为模式串,我们列出它的所有前缀

0 /the empty string/
1 A
2 A B
3 A B A
4 A B A B
5 A B A B A
6 A B A B A C
 

我们现在考虑上面列出的字符串(前缀)在模式串中能够得到的最长后缀(后缀要不同于自身)

0 /the empty string/
1 /the empty string/
2 /the empty string/
3 A
4 A B
5 A B A
6 /the empty string/
 

我们可以很容易的发现如果匹配到了(A B A B A),我们同样得到了部分匹配(A B A)(A),它们既是初始字符串的前缀也是当前匹配的后缀/前缀。根据下一个文本串中“到达的”字符,会出现以下三种情况:

1.              下一个字符是C。我们可以“扩展”到前缀(A B A B A)。在这个特殊的例子中,我们得到了一个匹配结果。

2.              下一个字符是B(A B A B A)的状态不能够得到“扩展”。我们能做的最好的方法是返回到上面找到的最长的部分匹配中--前缀(A B A),并继续尝试“扩展”。现在B“满足”了条件,我们可以继续处理下一个文本串中的字符,并且当前的“最佳部分匹配将变成前缀列表中的(A B A B)

3.              “到达的”字符是其他字符,例如,D。折回到(A B A)显然无法胜任“扩展”。在这种情况下我们需要到次长的部分匹配中(初始匹配中第二长的后缀,同时也是它的前缀)-也就是(A),最后返回到空串(例子中第三长的后缀)。既是使用空串,使用D也无法进行“扩展”,我们跳过D并处理文本串中的下一个字符。但是现在 “最佳”部分匹配将是空串。

为了建立KMP自动机(也叫做KMP“失效”函数)我们需要初始化一个整数数组F[]。索引(从0m-模式串的长度)代表了当前字符前面列出来的的前缀的大小。在每一个下标有一个“指针”--代表最长的后缀,同时也是给定文本串的一个前缀(或者换句话说F[i]是下一个最佳部分匹配的位置)。在我们的例子中(字符串 A B A  B A C),数组F[]如下:

F[0] = 0
F[1] = 0
F[2] = 0
F[3] = 1
F[4] = 2
F[5] = 3
F[6] = 0
 

注意在初始化以后,F[i]不仅包含下标i之前的最佳部分匹配,也包括自身的部分匹配。F[i]是第一个最佳的部分匹配的位置,F[F[i]]是第二个最佳的位置,F[F[F[i]]]是第三个,等等。

使用上面的信息,我们可以计算出F[i],如果我们知道了所有的F[k],k<i。字符串i的最佳部分匹配位置是最大i-1位置的最佳部分匹配,要求字符“扩展”到的下一个字符要与i出的字符相等。我们所需要做的是按照i-1的最佳匹配位置的逆序进行检查,并且查看在这层上最后一个字符是否能够“扩展”。F[i]就是这个最大的“扩展”部分匹配(扩展之后的位置)

F[](失效函数)的初始化伪代码如下:

// Pay attention! 
// the prefix under index i in the table above is 
// is the string from pattern[0] to pattern[i - 1] 
// inclusive, so the last character of the string under 
// index i is pattern[i - 1]   

function build_failure_function(pattern[])
{
  
// let m be the length of the pattern 

  F[
0= F[1= 0// always true
  
  
for(i = 2; i <= m; i++{
    
// j is the index of the largest next partial match 
    
// (the largest suffix/prefix) of the string under  
    
// index i - 1
    j = F[i - 1];
    
for( ; ; ) {
      
// check to see if the last character of string i - 
      
// - pattern[i - 1] "expands" the current "candidate"
      
// best partial match - the prefix under index j
      if(pattern[j] == pattern[i - 1]) 
        F[i] 
= j + 1break
      }

      
// if we cannot "expand" even the empty string
      if(j == 0{ F[i] = 0break; }
      
// else go to the next best "candidate" partial match
      j = F[j];
    }

  }
   
}

     自动机包括了初始化的数组F[](“内部规则”),和一个以当前的文本串中的位置结果(“当前状态”),并指向最佳匹配的指针。自动机的使用过程和我们建立的“失效函数是几乎一致的。我们从文本串中取出下一个字符并且“尝试”扩展到部分匹配。如果失败了,我们继续扩展到当前最佳匹配的部分最佳批评。根据伪代码中的索引,自动机中“当前状态”改变了。如果我们从空串也不能够“扩展”,那么我们跳过这个字符,并继续处理文本串中的下一个字符,“当前状态”变为0

function Knuth_Morris_Pratt(text[], pattern[])
{
  
// let n be the size of the text, m the 
  
// size of the pattern, and F[] - the
  
// "failure function"

  build_failure_function(pattern[]); 

  i 
= 0// the initial state of the automaton is
         
// the empty string

  j 
= 0// the first character of the text
  
  
for( ; ; ) {
    
if(j == n) break// we reached the end of the text

    
// if the current character of the text "expands" the
    
// current match 
    if(text[j] == pattern[i]) {
      i
++// change the state of the automaton
      j++// get the next character from the text
      if(i == m) // match found
    }


    
// if the current state is not zero (we have not
    
// reached the empty string yet) we try to
    
// "expand" the next best (largest) match
    else if(i > 0) i = F[i];

    
// if we reached the empty string and failed to
    
// "expand" even it; we go to the next 
    
// character from the text, the state of the
    
// automaton remains zero
    else j++;
  }

}

编程比赛中的许多问题都更加关注与KMP的“失效函数”的属性,而不是它在字符串匹配上的用途。例如:给定一个非常长的字符串,找出所有满足既是前缀也是后缀的子串。我们只需要计算给定串的“失效函数”,利用其中存储的信息打印出答案。

还有一个非常典型的问题是:给定一个字符串,找出最短的子串,满足串接一个或多个这样的子串能够得到原串。这个问题也可以规约到失效函数的属性。我们考虑字符串:

A B A B A B 

逆序顺序的后缀/前缀:

1 A B A B
2 A B
3 /the empty string/

这个问题每一个后缀/前缀唯一的定义了一个字符串,这个字符串插入在它们之前能够构成初始的串。在我们的例子中:

1 A B
2 A B A B
3 A B A B A B
 

这些“增加”的串都是一个潜在的“候选”。它们的几个拷贝的串接能够构成初始的串。它的原理在于,它不仅是原来的字符串的前缀,同样也是它增加的后缀/前缀的前缀。这个意思就是说当前的后缀/前缀串至少包括两份“增加”串的拷贝(因为它自身也是初始串的一个前缀)等等,当然前提是后缀/前缀要足够长。换句话说,一个成功的“增加”串的长度必须整除初始串的长度。

因此,我们可以逆序遍历初始串的所有后缀/前缀串。这正是“失效函数”的作用。我们遍历知道我们找到一个理想的“增加”字符串(它的长度能够整除初始串的长度)或者为空串,

在这种情况下满足题意的“增加”串就是它本身

Rabin-Karp and Knuth-Morris-Pratt at TopCoder

    上面提到的问题类型中,我们处理的都是RKKMP还有一些技巧的基本形式。在TopCoder SRM中很少能够碰到这样基本的算法,TopCoder中基本上都会将它融入到更加具有挑战性的问题中,在这些算法中,上面提到的算法只是作为多层复杂问题的一层。特定的输入大小决定了这种趋势,因为我们碰到的不是数百万字符的字符串,而只是一个“生成器”。而它通常能够展示出算法的一些性质。一个很好的例子是"InfiniteSoup," Division 1 - Level Three, SRM 286.