文章目录
- 1. 递推
- 2. 算法
- 3. 实现
1. 递推
接下来的这节,我们就来讨论 next 查询表的构造算法。我们将会看到非常有意思是, next 表的构造过程与 KMP 主算法的流程在本质上是完全一样的。
在这里,我们不妨采用递推策略。我们只需回答这样一个一般性的问题即可,也就是说由低至高,如果我们已经构造到了 next 表的第 j 项,那么接下来又当如何进而构造出 j + 1 项?
在此,我们需要再次重温 next 表的定义。也就是说这个表中所谓的第 j 项,也就是在模式串长度为 j 的那个前缀中,自我匹配的真前缀与真后缀的最大程度。由此,我们自然就可得知,在数值上, next 表中的任何一项,相对于此前的那一项,至多只可能增长一个单位。通过反证法,这一点不难得到。
进一步地,这个不等式取等号的充量条件是,在模式串 P 中,编号为 j 的字符与它按照 next 表的继任者彼此相等。
比如在这幅图中, P[j] 就是这个字符 x,而它的继任者则为这个字符x。根据 next 表的定义,以这条线为界,P 的这个前缀与它的这个子串必然是完全匹配的。因此,如果 P[j] 与它的继承者也是相等的,这种自匹配的长度自然就会增加一个单位。
- 因此,在这种情况下,next 表中的第 j + 1 项,也自然地就应该在此前第 j 项的基础上再递增一个单位。这样我们也就证明了这个充要条件 “当” 的那个方向。
- 为了进而再证明"仅当",我们只需考察 P[j] 与它的替代者不相等的情况。比如后者为 y,此时在这个长度为 j + 1 的前缀中,任何一对自匹配的真前缀和真后缀,也必然蕴含着在此前长度为 j 的那个前缀中自匹配的一对真前缀和真后缀。而且新的那对真前缀和真后缀的长度,也必然相对于此前那对要增加一个单位。而由于 next 表中的各项都是对应于自匹配的最大长度,因此,新的自匹配长度绝对不可能超过此前的自匹配长度。
那么倘若 P[j] 果真与它的继任者不等,我们又该如何计算出 next 表中的下一项呢?
2. 算法
在这里,需要牢牢抓住的要领,依然是 next 表项的那个必要条件,也就是前缀的自相似性。 刚才为了估算出 next 表的第 j + 1项,我们曾经尝试过在第 j 项的基础上去加 1。因为根据刚才所建立的充要条件,只要 P[j] 与它的继任者是相等的,那么的确可以简明地通过加 1 得到下一项。那么即便 P[j] 与它的继任者不相等,这个必要条件依然可以适用,也就是说在这种情况下,为了估算出 next 表的第 j + 1项,下一个值得尝试的位置,依然需要满足自相似的必要条件。
那么,对应的这个前缀的长度,也自然就应该是在此前长度的基础上,再去取一次对应的 next 表项。也就是说,从前缀长度的变化趋势来看,如果此前是将 j 替换为 next[ j ],那么接下来,就应该将next[ j ]替换为 next[next[ j ]]。当然,如果仍有必要,我们还应该将next[next[ j ]]替换为next [next [next [ j ]]] 。这个过程可能持续多步,一旦遇到这样一个相等的替代者,就可以在它所对应的这个前缀长度的基础上,在累进一个单位,即可得到 next 表的下一项。概括而言,在估算 next 表下一项的过程中,我们应该按照这样一个序列依次尝试。
请注意,因为 next 表项对应的都是真前缀与真后缀的长度,所以,对于任何一个 j 而言,其对应的 next 表项都会严格地小于它自己。这就意味着上述这个序列必然是严格递减的,整个算法迟早会收敛并终止,不然最终的结局有可能是非常极端的,也就是说有可能会一直尝试到0号位置。
在上图中,也就相当于模式串经过多次的位移,最终居然越过了 i + 1 本身。按照通常的理解,此时会出现问题,因为接下来与 P[j] 进行比对的那个字符根本就无从谈起。而事实上,这正是我们的哨兵能够大显身手的又一个场合。应该记得这个假想的哨兵是一个通配的字符,所以作为假想的继任者,它必然在逻辑上也可等效为与 P[j] 相等。因此,即使整个计算过程到了这步田地,也必然会因为这次逻辑上的叛等通过而随即终止。
而且此时 next 表中对应的下一项,就应该是在-1的基础上再加 1,也就是取做0。
至此,只要纵观整个计算的过程,我们就不难发现,这实质上就是模式串自己与自己不断匹配的过程。因此,只需基于 KMP 主算法框架略做修改,也自然就可以导出 next 表的递推计算算法。二者的区别实际上无外乎一点,也就是,新的这个算法需要实时输出 next 表的下一项。
3. 实现
next表的构造算法可以具体实现如下:
正如我刚才所分析的, 其总体框架应该与 KMP 的主算法几乎一样。主要的差别有这么几点。
-
首先,入口参数只有模式串自己。这一点不难理解,因为我们刚才讲过,整个 next 表的构造过程就是它自己与自己的匹配。因此在这个场合,P 既是模式串,也是文本串。
-
另一点区别在于初始化。我们刚才已经分析过,next 表的首项,也就是第0项,数值必然固定为-1,因此我们不妨首先就完成这一设置。
-
接下来是我们已经非常熟悉的 KMP 循环,其中的 if 和 else 分支,分别对应于当前匹配与失配的两种情况。
按照我们刚才的分析,一旦发现一对新的匹配字符,我们就可以立即得出 next 表的下一项,而且它的数值就应该是在此前一项的基础上在累进一个单位。反过来,如果是失配,根据我们刚才的分析,也只需将当前的尝试位置 t 更新为它所对应的 next 表项。当然,根据刚才已指出的单调性,这个表项当前必然已经计算出来,所以你尽可放心。
这幅图也给出了该算法的一次典型运行过程。假设我们正需要递推地计算出下一项,此时,我们的 P[j] 是这个 x。首先尝试的是 next[j],如果对应的字符与 P[j] 不等,也就对应于循环中的 else 分支,于是我们会将 next [j] 进而替换为 next[next[j]],并且继续用对应的这个字符与 P[j] 进行比对,如果依然不等,我们就需要将 next[next[j]] 进一步地替换为 next[next[next[j]]],在任何一步迭代中,一旦当前的字符与 P[j] 相等,我们就可以立即将下一个 next 表项设置为在这个前缀的长度基础上,再累进一个单位。当然这个迭代的过程有可能会进行很多步,但正如我们刚才所分析的那样,充其量不过迭代到这样一种状态,也就是当假想的那个哨兵与 P[j] 对齐时,必然会随即终止。
至此,我们已经了解了 KMP 算法的基本原理以及相应的计算过程。那么接下来的一个问题自然就是,这个算法的总体时间复杂度是多少呢?是否如我们所期盼的那样,可以控制在线性的范围以内呢?