文章目录
- 1.快速移动
- 2.避免回溯
- 3.通配哨兵
1.快速移动
在接下来这节,就让我们从严格的意义上来理解 next 表的具体含义及其原理。
我们已经切实地看到, KMP 算法的优化效果首先体现在它可以使模式串得以快速地后移,而不是如蛮力算法那样只能亦步亦趋。反过来我们也可以认为 KMP 可以聪明地排除掉很多不必要的对齐位置。 而这些位置之所以被排除掉,是因为 KMP 发现它们不具备某种必要条件,正如我们马上就要看到的,这种必要条件就具体体现为模式串自身的某种匹配性。
依然回到这样一个串匹配的典型场景。我们在 T[i] 与 P[j] 之间发现了一次失配,接下来 KMP 会去查询 next 表,取出对应的表项 t,并用 P[t] 来取代此前的 P[j],使之继续以此前的 T[i] 相对齐,并从这个位置出发,继续后续的比对。
我们的问题是, KMP 在这种场合为何会选定这样一个特定的 t 呢?或者说,这样的 t 又具备哪些必要条件呢?答案就藏在上图中。
- 我们来考察 t 所对应的这个前缀( P[0,t) ),在KMP算法中,这个前缀将不再会重复地接受比对。我们已经看到,之所以能够这样,是因为 KMP 已经预先判定,这个前缀必然会与主串中对应的这个子串( P[ j - t, j) ) 完全匹配。在这里,我们需要回过头来考察,此前 P[j] 所对应的这个前缀,同样地,这个前缀在当年也应该和这个子串(i 所对应的行)是完全匹配的,因此,相对于它,文本串中这个长度为 t 的子串( P[ j - t, j) ) 就是一个后缀。
- 另一方面,既然这个新的前缀( P[0,t) ) 是由此前的前缀(j 所对应的行)经过右移之后而得到的,所以同样相对于此前的这个前缀,它依然是一个长度为 t 的前缀。
由此,我们也就得出了关于 t 的一个至关重要的必要条件:也就是说,在此前的这个前缀中,必须有一个长度为 t 的前缀与长度为 t 的后缀彼此相等。也就是说在相对于 P[j] 而言的这个前缀中,其首部和尾部必须具有一定的相似性。 这个必要条件,可以形式化地表示为上图中的等式。其左侧是一个真前缀,而右侧则是一个真后缀。
就任何一个特定的模式串 P 而言,对于区间内的任何一个整数 j,如果将满足上述条件的 t 筛选出来,就可以得到这样一个候选集合。
根据刚才的分析,既然这些 t 都满足上述必要条件,那么一旦在 T[i] 和 P[j] 处发生一次失配,只有来自于这个集合中的 t, 才有资格作为下一轮的对齐位置。
2.避免回溯
当然,你应该记得在这种情况下,KMP 并不会去逐一尝试所有的 t。事实上,在 next 表中,针对于每一项只会给出唯一的一种选择。那么 KMP 所选取的,究竟是其中的哪一个呢?
为此,我们需要考察这里的位移量( j - t)。是的,如果是用 t 来取代原来的 j,那么对应的位移量就应该是 j 和 t 之差。在这里 j 是相对固定的,因此 t 越小,对应的位移就越大。反之,t 越大,相应的位移也就越小。而位移量更小,也就意味着某种意义上的更加安全。什么意义呢?避免回溯。
是的,KMP 在所有候选者中最终所选取的的确就是这个集合中最大的那个 t。如此可以保证对应的位移量是安全的。实际上,这种原则也暗藏了另一个不变性,也就是说,由 KMP 所舍弃的那些对齐位置,的确都是不值得对齐的。
3.通配哨兵
至此,严谨的你或许会有一个疑问,这里的候选者集合 N(P, j) 一定是非空的吗?因为对于空集而言,无论是选取最大元,还是选取任何一个元素,都是无从谈起的。然而实际上,这种担心是不必要的。在这里,我们注意到只要 j 是正数,那么这个集合就必然包含0。
然而遗憾的是,j 可能恰好就是 0。当然,此时 P[ j ] 所对应的前缀 P[0 ,j) 也自然是空串。而我们知道对于空串而言,所谓的真前缀和真后缀都是不可能存在的,也就是说,此时的候选集合的确是一个空集。
为了修补这一漏洞,KMP算法所给出的建议是,统一将 next 表中的第0项视为-1。那么如何来理解这个-1呢?
一种形象而有效的理解方式就是,为每一个模式串在首字符的前端,也就是等效于秩为 -1 的位置增设一个哨兵。当然,与所有假想的哨兵一样,这个哨兵并不需要真实的存在,但是在逻辑上,这个哨兵却等效于一个与所有字符都通配的字符。
你应该还记得,我们在介绍 KMP 主算法时所搁置起来的一个问题。也就是其中那个 if 语句所对应的条件式,你应该记得,除了常规的字符比对逻辑,KMP 还增设了另一个并列的逻辑,也就是 j 是否小于0。现在你应该恍然大悟了吧?
是的,在正常的情况下,所谓的 j < 0 无非就是一种情况。也就说,在刚刚过去的那一轮比对中,我们失败于首次符 P[0]。于是,按照我们刚才所建议的那种理解方式,KMP 将用这个假想的通配符,去与文本串中适配的那个 T[i] 继续比对。既然是通配符,所以接下来的第一次比对必然会成功。也正因为此,我们可以将 j < 0 的条件,在语义上与字符的成功比对等效起来。也就是说,这个合成的逻辑判断式,在语义上完全等效于一次成功的比对。
由此可见,巧妙地引入和设置哨兵,在程序和算法设计过程中是一种非常高明的处理手法,KMP 就是这方面的一个典型范例。概括而言,这种手法的高明之处主要体现在两个方面。
- 首先,在代码实现上可以使得算法的描述更为简洁。
- 其次,通过相应的建立一种假想的模型,反过来,也可以使得我们对算法的理解更为统一和深入。
我们知道,包括伽利略在内的许多著名物理学家,都擅长于在头脑中进行所谓的虚拟实验。而实际上,计算机科学中的这种假想模式与物理学中的虚拟实验有着异曲同工之妙。
至此,我们已经对 KMP 所使用的那个 next 查询表有了足够深刻的认识。那么接下来一个问题自然就是,从计算的角度来看,这样一份查询表又当如何构造出来呢?为此,我们又需要花费多少成本呢?