【算法学习笔记】KMP算法之构造回溯表(backtrack table)的思路和技巧

      【备注】本文为KMP算法的学习笔记,重点说明KMP算法中最关键的回溯表的构造思路。若对KMP算法涉及到的基础概念(如串的proper suffix, proper prefix等名词)不熟悉,建议先参照阅读本文最后部分给出的参考文献。

        Knuth–Morris–Pratt string searching algorithm (简称KMP算法)是一种高效的子串查找算法,

        提到子串查找,一种最简单易实现的方法是从源串第1个字符开始,与目标串进行逐次匹配,匹配思路如下所示(串S为长度为n的源串,W为长度为k的目标串,我们要实现的是在S中查找W,若查找成功,则返回其首次匹配成功的子串的首字符位置): 

for(i = 0; S[i] != '\0'; ++i) 
{
    for(j = 0; S[i+j] != '\0' && W[j] != '\0' && S[i+j] == W[j]; j++);
    if (W[j] == '\0') { 
        // found a match
        return i;
    } 
    else {
        // match failed
        return -1;
    }
}
          上述方法优点是实现起来简单,缺点是时间复杂度高,最坏情况下的复杂度为O(nm)。

       不过在实际使用中,通常在逐次匹配(上述代码第2个for循环所示的过程)的早期就会出现dismatch,从而可以及时回溯进行下次匹配,故这种实现方法效果通常也不会很差,因此较为常用,c的库函数strstr()就是采用这种思路实现的(strstr()的一种实现方法)。

       为方便描述,本文约定:S[0, n-1]表示长度为n的源串,W[0, k-1]表示长度为k的目标串。

       假设串W的第j个字符W[j-1]与串S的第(i+j-1)个字符S[i+j-1]不相等引起本次匹配失败,这意味着在匹配失败的这个字符之前,串S与串W的对应字符均相等,即S[i, i+j-2]与W[0, j-2]逐字匹配成功。每一次的匹配过程均可用得到类似的、比较隐晦的“历史信息”。

       上面的代码中,每次出现dismatch后,外循环的index+1,内循环的index清零,重新开始匹配。可见,该方法并没有充分利用上次匹配失败过程中得到”历史信息“,这是导致其复杂度较高的根本原因。

       KMP查找算法正是充分利用了这些匹配失败时的“额外信息”,对回溯过程进行了优化,从而将最坏情况下的时间复杂度降低到了O(k+n),其中k为目标串W的长度,n为源串S的长度。若k远小于n,则KMP算法几乎是在线性时间内完成查找,效率提高的相当明显。

       为尽快引入本文的重点(回溯表的构造方法),我们将wikipedia中给出的KMP算法伪码(文末参考文献里有链接)摘出如下:

algorithm kmp_search:
    input:
        an array of characters, S (the text to be searched)
        an array of characters, W (the word sought)
    output:
        an integer (the zero-based position in S at which W is found)

    define variables:
        an integer, m ← 0 (the beginning of the current match in S)
        an integer, i ← 0 (the position of the current character in W)
        an array of integers, T (the table, computed elsewhere)

    while m+i is less than the length of S, do:
        if W[i] = S[m + i],
            if i equals the (length of W)-1,
                return m
            let i ← i + 1
        otherwise, let m ← m + i - T[i], if T[i] is greater than -1, let i ← T[i] else let i ← 0
            
    (if we reach here, we have searched all of S unsuccessfully)
    return the length of S
        上述伪码中,标红且加粗的部分是KMP算法的关键,其对匹配失败时的回溯过程做了优化:不是简单的将外循环变量m+1且对内循环变量清零( m = m + 1, i = 0,这是传统查找过程的做法),而是借助回溯表T,将外循环变量m更新为(m + i - T[i]),其中m+i为引起dismatch的字符在源串S中的index,同时,根据T[i]的值对内循环变量i做更新

       对这两个循环变量的优化是KMP算法的精髓,优化过程中用到的回溯表T则是关键中的关键。

       假设目标串W长度为k,则回溯表T是由k个元素构成的数组,其中T[i]决定了源串S的第(m+i)个字符与目标串W的第i个字符匹配失败时,两个串的回溯步骤(从伪码中两个循环变量m, i的更新均用到T[i]可以得到这一结论)。

       我们从很多介绍性文章中都可以看到类似的描述:只要给出目标串W,就可以构造出回溯表T,与源串S无关。

       相信不少初学KMP算法的童鞋会有疑问:既然T的构造与S无关,那为啥S可以根据T做回溯?为神马这么神奇?反正我当时就有这样的疑问。。。

       谜底隐藏在T的构造过程中,这个过程充分利用了本文前面曾提到的上次匹配失败时得到的“额外信息”或“历史信息”,为了突出这些额外信息的重要性,本文在这里再次说明:

       假设串W的第j个字符W[j-1]与串S的第(i+j-1)个字符S[i+j-1]不相等引起本次匹配失败,这意味着在匹配失败的这个字符之前,串S与串W的对应字符均相等,即S[i, i+j-2]与W[0, j-2]逐字匹配成功。每一次的匹配过程均可用得到类似的、比较隐晦的“历史信息”。

       对上述说明做进一步解释:若W[j-1]与源串S的对应字符匹配失败,则暗示了子串W[0, j-2]与S串的相应子串相等,因此,该子串已然隐含了源串S的”历史信息“,正因如此,才有”T的构造过程跟源串S无关,但S却可以根据T进行回溯“这种看似神奇的东东。

        由上面的说明,在理解string的proper suffix和proper prefix等基本概念的基础上(这些概念可参考文末给出的参考文献),我们可以得到回溯表T的构造过程

        首先定义辅助函数overlap(x, y)为:既是串x的前缀,又是串y的后缀的串集合中的最长的串的length,为避免歧义,给出overlap(x, y)的英文定义(摘自本文末某篇参考文献):Define the overlap of two strings x and y to be the longest word that's a suffix of x and a prefix of y.

        借助辅助函数,对于回溯表的每个元素T[j],其值是这样确定的:

            T[j] = overlap(W[0, j - 1], W[0, k])

        其中:j表明目标串W在第j个字符上与源串S相应字符匹配失败;W[0, j - 1]为第j个字符dismatch前,匹配成功的子串(该子串包含了源串S的历史信息,这一点务必要理解);W[0, k]则表示目标串W本身。

        至此,只要真正理解overlap(x, y)的含义,我们就可以快速构造给定目标串W的回溯表T。

        作为练习,假设目标串W为:PARTICIPATEIN PARACHUTE,串长为24,求其回溯表T。

  构造T的详细过程如下:

    0)对于i = 0,为特殊情况(special case),表明首字符就dismatch,此时没有回溯,故在KMP算法中,固定T[0] = -1

    1)对于i = 1,表明W[1] dismatch,暗示W[0]匹配成功,但W[0]只含1个字符,不存在proper suffix,故T[1] = 0

    2)对于i = 2,表明W[2] dismatch,暗示W[0, 1]匹配成功,其最长proper suffix为W[1],本例为'A',与串W所有前缀均不匹配(因W前缀以P开头),故T[2] = 0

   3)对于i = 3,表明W[3] dismatch,暗示W[0, 1, 2]匹配成功,其最长proper suffix为W[1, 2],本例为'AR',与串W所有前缀均不匹配,故T[3] = 0

   4)对于i = 4,表明W[4] dismatch,暗示W[0, ..., 3]匹配成功,其最长proper suffix为W[1, 2, 3],本例为'ART',与串W所有前缀均不匹配,故T[4] = 0

   5)对于i = 5,表明W[5] dismatch,暗示W[0, ..., 4]匹配成功,其最长proper suffix为W[1, ..., 4],本例为'ARTI',与串W所有前缀均不匹配,故T[5] = 0

   6)对于i = 6,表明W[6] dismatch,暗示W[0, ..., 5]匹配成功,其最长proper suffix为W[1, ..., 5],本例为'ARTIC',与串W所有前缀均不匹配,故T[6] = 0

   7)对于i = 7,表明W[7] dismatch,暗示W[0, ..., 6]匹配成功,其最长proper suffix为W[1, ..., 6],本例为'ARTICI',与串W所有前缀均不匹配,故T[7] = 0

   8)对于i = 8,表明W[8] dismatch,暗示W[0, ..., 7]匹配成功,其最长proper suffix为W[1, ..., 7],本例为'ARTICIP',与串W前缀'P'匹配,故T[8] = 1

   9)对于i = 9,表明W[9] dismatch,暗示W[0, ..., 8]匹配成功,其最长proper suffix为W[1, ..., 8],本例为'ARTICIPA',与串W前缀'PA'匹配,故T[9] = 2

 10)对于i = 10,表明W[10] dismatch,暗示W[0, ..., 9]匹配成功,其最长proper suffix为W[1, ..., 9],本例为'ARTICIPAT',与串W所有前缀均不匹配,故T[10] = 0

 11)对于i = 11,表明W[11] dismatch,暗示W[0, ..., 10]匹配成功,其最长proper suffix为W[1, ..., 10],本例为'ARTICIPATE',与串W所有前缀均不匹配,故T[11] = 0

 12)对于i = 12,表明W[12] dismatch,暗示W[0, ..., 11]匹配成功,其最长proper suffix为W[1, ..., 11],本例为'ARTICIPATE ',与串W所有前缀均不匹配,故T[12] = 0

 13)对于i = 13,表明W[13] dismatch,暗示W[0, ..., 12]匹配成功,其最长proper suffix为W[1, ..., 12],本例为'ARTICIPATE I',与串W所有前缀均不匹配,故T[13] = 0

 14)对于i = 14,表明W[14] dismatch,暗示W[0, ..., 13]匹配成功,其最长proper suffix为W[1, ..., 13],本例为'ARTICIPATE IN',与串W所有前缀均不匹配,故T[14] = 0

 15)对于i = 15,表明W[15] dismatch,暗示W[0, ..., 14]匹配成功,其最长proper suffix为W[1, ..., 14],本例为'ARTICIPATE IN ',与串W所有前缀均不匹配,故T[15] = 0

 16)对于i = 16,表明W[16] dismatch,暗示W[0, ..., 15]匹配成功,其最长proper suffix为W[1, ..., 15],本例为'ARTICIPATE IN P',与串W前缀'P'匹配,故T[16] = 1

 17)对于i = 17,表明W[17] dismatch,暗示W[0, ..., 16]匹配成功,其最长proper suffix为W[1, ..., 16],本例为'ARTICIPATE IN PA',与串W前缀'PA'匹配,故T[17] = 2

 18)对于i = 18,表明W[18] dismatch,暗示W[0, ..., 17]匹配成功,其最长proper suffix为W[1, ..., 17],本例为'ARTICIPATE IN PAR',与串W前缀'PAR'匹配,故T[18] = 3

   ... 

   以此推理,对于i = 19 至 23,匹配成功的string的proper suffix均与串W的所有前缀不匹配,故T[19] 至 T[23]均为0。

           综上,我们构造的回溯表T[ ] = {-1, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 0, 0, 0, 0, 0, 1, 2, 3, 0, 0, 0, 0, 0},与wikipedia给出的例子对比可知,该表是正确的(这也是我们与wikipedia选同一个目标串的目的,可以检验我们的答案,哈哈)

        至此,我们已经掌握了构造T的思路和方法,下面摘出wikipedia提供的构造过程伪码(由伪码可以很容易地写出可运行的代码,本文不再赘述):

algorithm kmp_table:
    input:
        an array of characters, W (the word to be analyzed)
        an array of integers, T (the table to be filled)
    output:
        nothing (but during operation, it populates the table)

    define variables:
        an integer, pos ← 2 (the current position we are computing in T)
        an integer, cnd ← 0 (the zero-based index in W of the next character of the current candidate substring)

    (the first few values are fixed but different from what the algorithm might suggest)
    let T[0] ← -1, T[1] ← 0

    while pos is less than the length of W, do:
        (first case: the substring continues)
        if W[pos - 1] = W[cnd], 
          let cnd ← cnd + 1, T[pos] ← cnd, pos ← pos + 1

        (second case: it doesn't, but we can fall back)
        otherwise, if cnd > 0, let cnd ← T[cnd]

        (third case: we have run out of candidates.  Note cnd = 0)
        otherwise, let T[pos] ← 0, pos ← pos + 1
           上述伪码的实现过程跟我们前面用具体实例分析的回溯表构造过程非常类似,所以,此处不再解释,相信大家都能看明白:)

        构造好了回溯表T,至于为什么本文前面给出的kmp伪码中,m要回溯至 m + i - T[i], i要根据T[i]值清零或更新为T[i],只要在纸上画一下匹配过程的示意图就可以理解其中的原因,限于篇幅和本文的重点(构造T的思路),这里偷个懒,就不画了。


【参考文献】

    1. wikipedia KMP Algorithm:http://en.wikipedia.org/wiki/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm

   2. string prefix/suffix: http://en.wikipedia.org/wiki/Substring#Suffix

   3.  国外某大学关于KMP算法介绍的课件:  http://www.ics.uci.edu/~eppstein/161/960227.html


(不知不觉写到深夜,全文完)

你可能感兴趣的:(Algorithm)