11.3 KMP算法
11.3.1 构思
蛮力算法中存在大量的局部匹配:每一轮的m次比对中,仅最后一次可能失配。而一旦发现失配,文本串、模式串的字符指针都要回退,并从头开始下一轮尝试。实际上,这类重复的字符串比对操作没有必要。
-
简单示例
图11.1,事实上,这种情况指针i完全不必回退。
-
记忆 = 经验 = 预知力
利用以往的成功比对所提供的信息(记忆),不仅可避免文本串字符指针的回退,而且可使模式串尽可能大跨度地右移(经验)。
-
一般实例
如图11.5所示,可直接将P右移4 - 1 = 3单元,然后继续比对。(等效于 i 保持不变,同时令j = 1)
11.3.2 next表
按以上构想,指针i不必回退,而是将T[i]与P[t]对齐并开始下一轮比对,那么t准确地应该取多少呢?
- 由图可见,经过此前一轮的比对,已经确定匹配的范围为: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 ]呢?
反观串匹配的过程。若在某一轮比对中首对字符即失配,则应将P直接右移一个字符,然后启动下一轮比对。
假想在P[0]的左侧“附加”一个P[-1],且该字符与任何字符都是匹配的。就实际效果而言,这一处理方法完全等同于“令next[0] = -1”。
ebo
11.3.5 next[j + 1]
那么,若已知next[0, j],如何才能递推地计算出next[j + 1] ? 是否有高效方法?
若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]?
只需反复用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) 。
11.3.8 继续改进
尽管以上KMP算法已可保证线性的运行时间,但在某些情况下仍有进一步改进的余地。
-
记忆 = 教训 = 预知力
就算法策略而言,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)时间。
-
实例
改进后就其效果而言,等同于聪明且安全地跳过了三个不必要的对齐位置。
总结:
next查询表:
- 之前的next查询表:一个自相似的条件(相似就不用去比对)(经验)
- 现在的next查询表:新增补了一个条件(要求新字符与此前字符不一样)(教训)
PS:只有在字符集规模很小时,KMP相对于BF在性能上的优势才能充分得以展示。(如二进制串)
“开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 19 天,点击查看活动详情”