数据结构 | 第11章 串 - KMP算法

54 阅读5分钟

11.3 KMP算法

11.3.1 构思

蛮力算法中存在大量的局部匹配:每一轮的m次比对中,仅最后一次可能失配。而一旦发现失配,文本串、模式串的字符指针都要回退,并从头开始下一轮尝试。实际上,这类重复的字符串比对操作没有必要。

  • 简单示例

    image.png

    图11.1,事实上,这种情况指针i完全不必回退。

  • 记忆 = 经验 = 预知力

    利用以往的成功比对所提供的信息(记忆),不仅可避免文本串字符指针的回退,而且可使模式串尽可能大跨度地右移(经验)。

  • 一般实例

    如图11.5所示,可直接将P右移4 - 1 = 3单元,然后继续比对。(等效于 i 保持不变,同时令j = 1)

11.3.2 next表

按以上构想,指针i不必回退,而是将T[i]与P[t]对齐并开始下一轮比对,那么t准确地应该取多少呢?

image.png

  • 由图可见,经过此前一轮的比对,已经确定匹配的范围为:P[0, j) = T[i - j, i)
  • 若模式串P经适当右移之后,能够与T的某一(包括T[i])子串完全匹配,则一项必要条件就是:P[0, t) = T[i - t, i) = P[j - t, j)
  • t 必来自集合:N(P, j) = { 0 ≤ t < j | P[0, t) = P[j - t, j) }
  • 一般地,该集可能包含多个这样的t。但需特别注意的是,其中具体有哪些t值构成,仅取决于模式串P以及前一轮比对的首个失配位置P[j],而与文本串T无关。
  • 为保证指针i绝不倒退,同时又不致遗漏任何可能的匹配,应在集合N(P, j)中挑选最大的t。
  • 令next[j] = max( N(P, j) ),则一旦发现P[j]与T[i]失配,即可转而将P[ next[j] ]与T[i]彼此对准,并从这一位置开始继续下一轮比对。
  • 于是,对于任一模式串P,不妨通过预处理提前计算出所有位置所对应的next[j]值,并整理为表格以便此后反复查询——亦即,将“记忆力”转化为“预知力”。

11.3.3 KMP算法

KMP主算法(待改进版):

 0001 int match ( char* P, char* T ) {  //KMP算法
 0002    int* next = buildNext ( P ); //构造next表
 0003    int n = ( int ) strlen ( T ), i = 0; //文本串指针
 0004    int m = ( int ) strlen ( P ), j = 0; //模式串指针
 0005    while ( j < m  && i < n ) //自左向右逐个比对字符
 0006       if ( 0 > j || T[i] == P[j] ) //若匹配,或P已移出最左侧(两个判断的次序不可交换)
 0007          { i ++;  j ++; } //则转到下一字符
 0008       else //否则
 0009          j = next[j]; //模式串右移(注意:文本串不用回退)
 0010    delete [] next; //释放next表
 0011    return i - j;
 0012 }

对照代码11.1的蛮力算法,只是在else分支对失配情况的处理手法有所不同,这也是KMP算法的精髓所在。

注:文本串不用回退。

11.3.4 next[0] = -1

不难看出,只要j > 0则必有0 ∈ N(P, j)。此时N(P, j)非空,从而可以保证“在其中取最大值”这一操作的确可行。但反过来,若j = 0,则即便集合N(P, j)可以定义,也必是空集。此种情况下,又该如何定义next[ j = 0 ]呢?

image.png

反观串匹配的过程。若在某一轮比对中首对字符即失配,则应将P直接右移一个字符,然后启动下一轮比对。

假想在P[0]的左侧“附加”一个P[-1],且该字符与任何字符都是匹配的。就实际效果而言,这一处理方法完全等同于“令next[0] = -1”。

ebo

11.3.5 next[j + 1]

那么,若已知next[0, j],如何才能递推地计算出next[j + 1] ? 是否有高效方法?

image.png

若next[j] = t,则意味着在P[0, j)中,自匹配的真前缀和真后缀的最大长度为t,故必有next[j + 1] ≤ next[j] + 1——而且特别地,当且仅当P[j] = P[t]如图11.7取等号。

要么一般地,若P[j] ≠ P[t],又该如何得到next[j + 1]?

image.png

只需反复用next[t]替换t(即令t = next[t]),即可按优先次序遍历以上候选者;一旦发现P[j]与P[t]匹配(含与P[t = -1]的通配),即可令next[j + 1] = next[t] + 1。

11.3.6 构造next表

 0001 int* buildNext ( char* P ) { //构造模式串P的next表
 0002    size_t m = strlen ( P ), j = 0; //“主”串指针
 0003    int* N = new int[m]; //next表
 0004    int t = N[0] = -1; //模式串指针
 0005    while ( j < m - 1 )
 0006       if ( 0 > t || P[j] == P[t] ) { //匹配
 0007          j ++; t ++;
 0008          N[j] = t; //此句可改进...
 0009       } else //失配
 0010          t = N[t];
 0011    return N;
 0012 }

可见next表的构造算法与KMP算法几乎完全一致。实际上按照以上分析,这一构造过程完全等效于模式串的自我匹配,因此两个算法在形式上的近似亦不足为怪。

11.3.7 性能分析

即便在最坏情况下,KMP算法也只需运行线性的时间。

若不计next表的构造所需时间,KMP算法本身的运行时间不超过O(n)。

next表构造算法的流程与KMP算法并无实质区别,故仿照上述分析可知,next表的构造仅需O(m)时间。综上可知,KMP算法的总体运行时间为O(n + m)

image.png

image.png

11.3.8 继续改进

尽管以上KMP算法已可保证线性的运行时间,但在某些情况下仍有进一步改进的余地。

image.png

image.png

  • 记忆 = 教训 = 预知力

    就算法策略而言,11.3.2节引入next表的实质作用,在于帮助我们利用以往成功比对所提供的“经验”,将记忆力转化为预知力。然而实际上,此前已进行过的比对还远不止这些,确切地说还包括那些失败的比对——作为“教训”,它们同样有益,但可惜此前一直被忽略了。

  • 改进

    为把这类“负面”信息引入next表,只需将11.3.2节中集合N(P, j)的定义修改为: N(P, j) = { 0 ≤ t ≤ j | P[0, t) = P[j - t, j) 且 P[t] ≠ P[j] }

     0001 int* buildNext ( char* P ) { //构造模式串P的next表(改进版本)
     0002    size_t m = strlen ( P ), j = 0; //“主”串指针
     0003    int* N = new int[m]; //next表
     0004    int t = N[0] = -1; //模式串指针
     0005    while ( j < m - 1 )
     0006       if ( 0 > t || P[j] == P[t] ) { //匹配
     0007          N[j] = ( P[++j] != P[++t] ? t : N[t] ); //注意此句与未改进之前的区别
     0008       } else //失配
     0009          t = N[t];
     0010    return N;
     0011 }
    

    改进后的next表只需O(m)时间。

  • 实例

image.png

改进后就其效果而言,等同于聪明且安全地跳过了三个不必要的对齐位置。

总结:

next查询表:

  • 之前的next查询表:一个自相似的条件(相似就不用去比对)(经验)
  • 现在的next查询表:新增补了一个条件(要求新字符与此前字符不一样)(教训)

PS:只有在字符集规模很小时,KMP相对于BF在性能上的优势才能充分得以展示。(如二进制串)

“开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 19 天,点击查看活动详情