0x13 KMP
1. 模式匹配问题
模式匹配问题,也叫子字符串搜索问题(substring search):给定一个长度为N的字符串,称其为text
;和一个长度为M的字符串,称其为pattern
;找出在text
中pattern
出现的位置。
那怎么找出来呢?老规矩,还是朴素算法走起。对text
中所有可能出现pattern
字符串的位置,都搜索一遍,就能找出pattern
在text
中的所有位置了。而text
中的每个字符后(包括其自身)都有可能出现pattern
,除非不够pattern
的长度了。(bda
中肯定不可能出现lambda
) 那么就写一个二重循环来搜索所有的可能。
这个算法的时间复杂度上界为O(n*m)
。外循环最差需要循环N次,内循环最差每次都要匹配到最后一位。为了更好的引入KMP算法,可以将上面的算法理解视角稍作转换。本来是对text
中的每个位置可能出现的pattern
长度的字符串进行匹配,现在理解为对text
的每个字符和pattern
中的每个字符进行匹配,一旦匹配失败,就意味着我们之前的匹配付诸东流,需要进行回退,从下一个字符开始匹配。可以将这个角度的理解写成另一个等价的算法。
虽然这样写看起来比较粘合和恶心,但是它的思想揭示了字符串匹配的一个过程:匹配-回退。
2. 优化重复
现在来观察这个匹配-回退的过程中,我们有哪些重复工作,可以优化掉哪些步骤。虽然比较晦涩,但是在我们匹配失败text
前一个字符的位置时,在最后一个字符匹配失败前,我们已经匹配成功了一些字符,而这些字符就是我们要回退回去的起点或更未来要回退的起点。
以上述代码为例,在下标5的位置(值为'9')匹配失败,我们其实没有必要回退到下标1,因为我们已经知道它和pattern
中的5匹配了,而pattern
的开头是4,因此肯定不匹配;同理,一直到下标5都没有必要再回退回去,因为我们已经知道了它不可能与pattern
匹配。但是如果盲目的不回退,就有可能错过本来能匹配到的字符串。
那到底怎么样知道什么时候该回退什么时候可以安全地跳过呢,可以发现跟pattern
自身的性质有关系,能回退的都是因为已匹配的字符串可以重新作为pattern
中前部分重新匹配('1112'中的'111'虽然匹配失败,但是后两个'11'可以重新匹配)。而已匹配成功的部分为pattern
的前部分,因此如果pattern
字符串具备这样的性质,就需要回退:\(pattern[0:i] == pattern[j-i:j],\ i < j\)。其中,j
是本次匹配过程中的最后一位下标,也就是pattern
中有一部分和自身的前缀字串匹配。那对于这样的情况,就说明已匹配的部分中存在一部分可能重新匹配能够匹配成功的可能性。
因此,每次匹配失败时,只需要查看当前匹配失败前的最后一位下标,是否满足上述的式子的性质。如果满足,那么就说明有必要回退重新匹配(但是不需要真的回退,因为已经知道后半部分匹配成功了,直接从匹配成功的后一位开始匹配);否则对下一位字符重新开始匹配。这样我们的搜索下标就永远不会回退,在搜索阶段是O(n)
的。
但是想要知道每个j
满足不满足性质还需要计算量,或许我们可以使用一个数组\(back[j] = max(i), where\ i<j\ and \ pattern[0:i]==pattern[j-i:j]\),记录了对于每个j
是否需要回退,如果能回退,最多回退到多少。朴素的计算这个数组需要计算M
个j
的位置,对于每个j
的位置,都有M - j - 1
种可能的i
的位置,因此最终的时间复杂度可能是O(M^2)
的。
3. KMP算法
以上就是KMP算法的主要思路,两部分构成,首先求back数组,其次根据back数组进行优化后的匹配过程。但是单单一个back数组的求值过程就有O(M^3)
的时间复杂度了,比之前还慢。我们需要对其进行一些优化。
依旧是观察back数组的性质,根据back数据的定义:
这里假设back[j - 1]
存在,那么对于j
来说,有两种可能:1. pattern[j] == pattern[back[j - 1] + 1]
; 2. pattern[j] != pattern[back[j - 1] + 1]
. 对于第一种情况而言,已知pattern[0:i] == pattern[j-i-1 : j-1]
,而back[j-1]
就是此处的i
,那么也就是pattern[j] == pattern[i + 1]
,那么进而可以推出pattern[0:i+1] == pattern[j-i, j]
,此时要求的back[j]=max(i)
就是i+1
。
对于第二种,不相等的情况。我们就需要重新找到back[j] = max(i), where pattern[0:i] == pattern[j - i:j]
。如果重新找又要浪费时间了,最好能够利用已有的信息。这里首先定义清楚名词,最大前缀串顾名思义,就是对于j
的位置来说能够匹配到的最大前缀字符串,即back[j]
的定义;同字符最大前缀串,即与j
位置不相同,但是字符相同的最大前缀串max(k), where pattern[k] == pattern[j], k < j, pattern[0:back[k]] == pattern[k - back[k], k]
。既然对于当前j
的上一位来说的最大前缀串匹配失败了,那我们不妨匹配前一位即j-1
的同字符最大前缀串。这样在上一位的最大前缀串匹配失败的情况下,如果同字符最大前缀串的后一位与当前位匹配成功,那么这对于当前位来说就是最大前缀串。问题在于如何找到同字符最大前缀串。
这里可以用反证法证明出对于pattern[i]
的同字符最大前缀串就是back[back[i]]
,具体证明可以参考李煜东大佬的算法竞赛进阶指南。因此对于back[j]
的每个j
而言,我们只需要不断比对pattern[j - 1]
也就是前一个字符能匹配到的最大前缀串的后一位: pattern[back[j-1] + 1
与当前位:pattern[j]
。如果匹配失败,那么我们就寻找次大的前缀串:pattern[back[back[j-1]]]
,直到比对到-1,说明对于当前位没有匹配的前缀串。
这个计算的过程主要取决于外部的for循环与内部的while循环,也就是这M次循环中,内部while训练的总循环次数。可以发现,while循环的次数取决于j的回退,而j的回退不会超过j的增加量,而j每次最多增加1。在这M次循环中,j最多增加M,因此j最多回退M。因此while循环的总循环次数不会超过j的总增加次数,即M。故最终的时间复杂度是O(2M)=O(M)
的。
而求出了back数组,就可以进行txt
与ptn
的匹配过程了,这个过程与求back
数组的过程类似。
搜索部分的时间复杂度与求back数组部分类似,都是外层for循环与内层的while循环控制。while循环与求back数组一样,j
每次最多增长1,N次循环下最多增长N,因此j
最多回退N次。因此时间复杂度为O(2N)=O(N)
。整个KMP的时间复杂度为O(N) + O(M) = O(N + M)
。