字符串匹配问题
本文仅仅是为了学习记录知识,对于其中橙色部分尚不太理解,望能者指教,谢谢!
问题定义
输入:
- 文本 T[1...n],文本的长度为n
- 模式 P[1...m],模式的长度为m
输出:
- 令S = { 非负整数s | 满足T[s... s+m-1] = P[0 .. m–1] }。
- if S != null,then 输出 min(S);否则,输出 -1。
暴力搜索算法
算法思想
- 直接检查起始元素从0 到 n-m 的所有长度为m的字符串,共n-m+1个。
- 该算法的不足之处在于,当处理其他无法命中的字符串时,仅仅关心是否匹配,而完全忽略了检测无效时可以获得的信息。本文的其他算法都充分利用了这部分信息,以此来降低时间复杂度。
算法设计
由于穷举较为简单,直接给出伪代码。在该算法中s记作偏移量。
1. Naive-Search(T[1...n],P[1...m])
2. for s = 0 to n – m
3. j = 0
4. // check if T[s+1..s+m] = P[1..m]
5. while T[s+j] == P[j] do
6. j = j + 1
7. if j = m
8. print “Pattern occurs with shift” s
9. return –1
算法分析
最坏情况分析,
- 第2行循环进行n-m+1次,第5行的循环进行m次,那么总计(n-m+1)m = O((n-m+1)m)。
- 最坏情况为,每一次都需要进行内层循环的比较,而且内层循环比较只有在比较P的最后一个字符时才会失败,也可以匹配成功,目标为找出所有匹配P的字符串。例如,T=am,P=an
全部随机的文本和模式的情况分析。
- 假定字母表中共有k个字符
- 对于内层循环使用概率分析,尽可能降低上界,当第4行隐含的循环执行i次时,其概率P为:
- P = 1/Ki-1 * (1-1/k), if i < m
- P = 1/Km-1 * (1-1/k) + 1/Km , if i = m
可以计算每次for循环迭代时,第4行的循环的平均迭代次数为:
[1(1-1/k)+2(1/K)(1-1/k)+3(1/k2)(1-1/k)+…+(m-1)(1-km-2)(1-1/k) +m(1/km-1)(1-1/k) + m*(1/km)]
= 1 - 1/k + 2/k - 2/k2 + 3/k2 - 3/k3 +...+ m/km-1 - m/km + m/km
= 1 + 1/k + 1/k2 +...+ 1/km-1
= (1 - 1/Km) / (1 - 1/k)
≤ 2
- 所以,可知,第4行循环的总迭代次数为:(n-m+1) * [(1-1/Km) / (1-1/k)] ≤ 2 (n-m+1)所以算法复杂度为O(n-m-1)。
Rabin-Karp算法
算法思想
构造函数F:字符串→某个可以比较的量。该函数具有以下性质:
- 在O(m)计算一个模式P的F(P),P.length = m。
- 在O(n-m+1)计算所有的Ti的F(Ti),Ti = T[i..i+m-1],而i∈{1,2,..,n-m+1}。而要达到这一点,可以通过在O(m)的时间内计算F(T1),而后依据F(Ti)和F(Ti+1)之间的关系在O(1)时间内计算其他值。
- 可以通过比较F(P)和F(Ti),来进行比较P和T[i..i+m-1],至少达到if F(P) != F( T[s .. s+m–1] ),那么P和T[s .. s+m–1]匹配不成功。
显然,函数F对字符串进行包装化,其有效利用了检测无用字符串时获得的信息,利用方式为其上第3点,依据F(T[s .. s+m–1]) 计算 F(T[s+1.. s+m])。
算法设计
对于该问题而言,其核心就是寻找尽可能满足此问题的函数F。
对于该问题而言,Rabin-Karp算法采用了许多方法来降低这些操作的时间复杂度,以达到算法的性能最佳,其中的方法有:
-
F(P) = P[m-1] + d( P[m-2] + d( P[m-3]+ … + d( P[1] + dP[0] )…) ) mod q,其中d等于字符数。这里采用霍纳法则以及取余运算,保证计算一个长度为m的字符串P的F(P)的时间复杂度为O(m)。霍纳法则降低了运算次数,取余运算降低了运算的数据规模。
-
令F(Ti+1) =( F(Ti)– T[s] * dm-1 mod q)*d + T[s+m]) mod q。如果预先将dm-1 mod q计算而出(计算dm-1 mod q的时间复杂度为O(m)),那么这里的执行算数操作的次数将为常数,运算时间为O(1),那么计算所有的F(Ti)(这里将不处理i=1以及计算dm-1 mod q的情况,而将其划归到预处理范畴当中去)的时间复杂度为O(n-m+1) ,其中n-m+1个O(1)。
-
值得注意的一点是,F(T) = F(P)并不能说明T = P,而是需要进行字符串的具体比较。
-
对于q取最大素数,有几种优化的说法,不过我不太理解其含义。
- 字母表的字母数目为d,那么选取一个d值,使得dq在一个计算机字长内,那么就用单精度算术运算执行必需的操作。
- 如果 q 是素数, 取余操作将会使m位字符串在q个值中均匀分配 ,因此,仅有s个轮换中的每第q次才需要具体匹配字符串 (匹配需要比较O(m)次)
算法伪代码
1. Rabin-Karp-Search(T[1...n],P[1...m],d)
2. n = T.length;
3. m = P.length;
4. q = a prime larger than m;
5. c = d^(m-1) mod q; // run a loop multiplying by 10 mod q
6. fp = 0;
7. ft = 0;
8. for i = 1 to m // preprocessing
9. fp = (d*fp + P[i]) mod q;
10. ft = (d*ft + T[i]) mod q;
11. for s = 0 to n – m // matching
12. if fp = ft // run a loop to compare strings
13. then
14. if P[1..m] = T[s+1..s+m]
15. print “Pattern occurs with shift” s
16. if s < n-m
17. ft = ((ft – T[s]*c)*d + T[s + m + 1]) mod q;
算法分析
-
预处理(preprocessing)阶段包括计算F(P),F(T1),dm-1/ q,三者的时间复杂度均为O(m),故预处理阶段总时间复杂度为O(m)。
-
而对于字符串匹配部分,在最坏情况情况下,第9行循环进行n-m+1次,第5行的循环进行m次,那么总计(n-m+1)m = O((n-m+1)m)。
最坏情况下的字符串为,每一次都需要进行内层循环的比较,即每一次的字符串F函数值都命中,而且内层循环比较只有在比较P的最后一个字符时才会失败,也可以比较成功,因为需要找到所有的匹配P的子字符串,例如,T=am,P=an。 -
对于原文中有更一般的算法复杂度分析,不过我未看懂
使用有穷自动机进行字符串匹配
定义P的后缀函数σ
定义1, P的后缀函数σ:maps ∑* to{0,1,...,m},σ(X)可以表示如下,两者意思相同:
- σ(X)表示在既是X的后缀,又是P的前缀的字符串集合中,取长度最长的字符串的长度。
- σ(X)表示匹配X的后缀的P的最长前缀的长度。
其形式化定义对应如下:
- σ(X) = max{ |s| | s是X的后缀,也是P的前缀}。
- σ(X) = max{ k:Pk是X的后缀 },其中,Pk表示P[1...k]。
定义2, 终态函数Φ:maps ∑* to Q,Φ(X)表示,一个有穷自动机在读入字符串X后所处的位置。Q表示该有穷自动机的状态集合
可以递归地定义为:
- Φ(ε) = q0.
- Φ(wa) = δ(Φ(w),a),for w ∈ ∑*,a ∈ ∑.
可以完成字符串匹配工作的有穷自动机定义
- 状态集合Q =
- 开始状态q0 = 0
- 接收状态集合为{m}。
- 状态转移函数为:δ(q,a) = σ(Pqa)。
对于这个自动机而言,我们期望状态q可以保存的信息是:当q=Φ(Ti)时,q = σ(Ti),也就是q = 匹配Ti的后缀的P的最长前缀的长度,其中Ti = T[1...i]。 如果我们的期望被满足,那么,当录入某个字符时,自动机的状态q = m,Pm就是此时Ti的后缀,那么匹配成功,有效偏移s = i - m + 1,这达到了我们构造此有穷自动机的目的,即:解决字符串匹配问题。
正确性证明
要证这个自动机的正确性,只需证,对于任意的i而言,Φ(Ti) = σ(Ti)。可以采用数学归纳法进行证明Φ(Ti) = σ(Ti)。但在证明该定理之前,我们将先行证明两个辅助定理。
定理1, 对于任意的字符串x以及字符a,σ(xa) ≤ σ(x) + 1。
证明1,
- 令r = σ(xa),那么Pr是xa的后缀。
- 如果r = 0,那么σ(xa) ≤ σ(x) + 1是显然的,σ(xa)非负。
- 如果r > 0,那么Pr-1是x的后缀,而依据σ(x)的定义,可知r - 1 ≤ σ(x),r ≤ σ(x) + 1。
- 其实,讲道理,这也是显然的,因为当多匹配一个字符,至多使得最长前缀加1。
定理2, if q =σ(x), then σ(xa) = σ(Pqa)。
证明2,
-
由q =σ(x)得,Pq是x的后缀,如果在Pq和x后同时加一字符a,则,Pqa是xa的后缀,依据σ的定义可知,σ(xa) ≥ σ(Pqa)。
-
令r = σ(xa),由定理1可知,σ(xa) ≤ σ(x) + 1,即, r ≤ q + 1,那么|Pr| = r ≤ q + 1 = |Pqa|,即,|Pr| ≤ |Pqa|。Pr是xa的后缀,Pqa也是xa的后缀,而且 |Pr| ≤ |Pqa|,那么,Pr一定是Pqa的后缀,r ≤ σ(Pqa)。即σ(xa) ≤ σ(Pqa)。
-
由σ(xa) ≥ σ(Pqa) 以及 σ(xa)≤ σ(Pqa),可知,σ(xa) = σ(Pqa)。
定理3, 对于任意的i∈{1,2,...,n}而言,Φ(Ti) = σ(Ti)。
证明3, 使用数学归纳法,施归纳于i
-
当i = 0时,Ti =T0 = ε,而σ(T0) = 0,Φ(T0) = 0。
-
假设,当i = k时,Φ(Ti) = σ(Ti),即Φ(Tk) = σ(Tk),且令q = Φ(Tk)。
-
那么,
Φ(Tk+1)
= Φ(Tka)------------------Tk+1 = Tka
= δ(Φ(Tk),a)--------------Φ的定义
= δ(q,a)------------------依据假设:q = Φ(Tk)
= σ(Pqa)------------------δ的定义
= σ(Tka)------------------定理2
= σ(Tk+1)-----------------Tka = Tk+1
主算法伪代码以及复杂度分析
下面给出该有穷自动机的伪代码:
1. FINITE-AUTOMATON-MATCHER(T[1..n],δ,m)
2. n = T.length
3. q = 0
4. for i = 1 to n
5. q = δ(q,T[i])
6. if q == m
7. print "Pattern occurs with shift" i-m
对于这样的有穷自动机而言,一旦该有穷自动机被构建完成,其识别长度为n的字符串的时间复杂度为O(n),因为其只需要扫描一遍字符串就可以。
预处理算法以及复杂性算法分析
O(m3|Σ|)的算法
而构建这样的有穷自动机,其实就是计算所有可能的转移函数σ(Pqa)(算法中σ用A的二维数组表示)。下面给出了一个直观但效率较低的算法。
1. COMPUTE-TRANSITION-FUNCTION.
2. m = P:length
3. for q = 0 to m
4. for each character a ∈ ∑*
5. k = min{m+1,q+2};
6. repeat
7. k = k - 1;
8. until P(k) 是 P(q)a 的后缀;
9. σ[q,a] = k;
10. return σ;
需要说明的是, 在这个算法中,之所以取q+2,是因为Pqa的长度为q+1,而下面的循环会先执行一次,那么会先将q+2减去1。
在最坏情况下,该算法的时间复杂度是O(m3|Σ|),之所以为m3|,是因为第三行循环执行m次,第6~8行循环至多执行m次,而第8行的判断也会执行m次比较。
O(m|Σ|)的算法
而我们可以构造令一种算法来执行上述功能,虽然不如上述算法直观,但其最坏情况下时间复杂度为O(m|Σ|)。
最坏情况下时间复杂度为O(m|Σ|)的算法伪代码如下:
1. π= COMPUTE-PREFIX-FUNCTION(P)
2. for a∈Σ* do
3. δ(0,a) = 0
4. end for
5. δ(0,P[1]) = 1
6. for a∈Σ* do
7. for q= 1 to m do
8. if q==m or P[q+ 1] != a then
9. δ(q,a) =δ(π[q],a)
10. else
11. δ(q,a) =q+ 1
12. end if
13. end for
14. end for
对于此算法中的π[q]的计算,可以采用KMP中的COMPUTE-PREFIX-FUNCTION(P)进行解决,此算法的时间复杂度为O(n),因而,并不会对该算法的时间复杂度造成影响。
可以看到时间复杂度为:O(|∑|) + O(1) + O(|∑|m) = O(|∑|m).
对于该算法的正确性分析。(对于这一部分的分析,建议看完全文再返回观看)
第2~5行完成对于δ(0,a)的赋值,任意的a∈Σ*。它的赋值是正确的,这里不做过多解释。
第6行,第7行的循环分别遍历字符表以及状态集,对于第8行的判断,需要分情况进行讨论,
-
当判断为false时,即P[q+1] == a的情况,即q = q + 1,这是易于理解的。
-
对于判断为true时,且q == m的情况而言,可以参见本文KMP主算法的细节补充:关于δ(m,a) = δ(π(m),a)的证明。(在KMP算法最后方)
-
对于判断为true时,且q!=m && P[q+ 1] != a的情况而言,这一部分的证明,其实也完全可以采用δ(m,a) = δ(π(m),a)的证明,只需要将m更改为q即可。因为在此证明中,并没有Pm = P的特性,所以对于一般情况也是成立的。而下面将给出个人对于这一情况的一种理解。
当q != m && P[q+1] != a时,可以确定的是此时的P[q+ 1] != a,从算法意义上讲,就需要寻找Pq的一个最长前缀Pk,使得P[k+ 1] == a,只有这样才会得到一个合适的偏移以及合适的比较。我们所需要证明的就是δ(π[q],a)能够完成我们的预期,根据KMP算法中关于π[q]以及π*[q]的定义,会了解到π[q]将是之前可以与P的后缀匹配的次长的前缀的长度,而该算法之前的运行结果将帮助我们进行判断P[k+ 1] == a,它将要指向的值将会是我们所需要的。如果此时P[k+ 1] == a,那么它必然等于π[q]+1,否则,会继续遍历δ(π(2)[q],a),直到,找到P[k+ 1] == a或者0。
那么总而言之,该算法的预处理过程的时间复杂度为O(m|Σ|),运行过程的时间复杂度为O(n)。
KMP算法
模式P的前缀函数π
对于这一部分的阅读,可以参见《算法导论》中的有关内容,这里将不在进行大范围地摘抄或者翻译,当然也摘抄了不少:),而是尽可能地叙述其中的逻辑。下面将分析定义前缀函数的逻辑,即解决“为什么这样定义前缀函数”。本文力求对于所有问题都能大到这样的效果。
模式P的前缀函数π的定义:
- π:{1,2,...,n} → {0,1,...,m-1}。
- π(q) = max{k: k < q && Pk是Pq的后缀}。
显然,前缀函数π记录了这样的信息:how the pattern matches against shifts of itself. 或者 k:the longest proper prefix Pk of Pq that is also a suffix of Pq.(这里感觉难以翻译,要么过于冗杂,要么难以把握主旨,下面采用英文同样是这个原因)。
下面将解释,为什么如此定义前缀函数?
定义前缀函数的目的:是为了改善暴力搜索算法,当匹配失败时,记录需要的信息,以避免未来的不必要偏移量以及不必要比较。
为了更好说明这一问题,将举出实例,来说明在解决问题中确实会遇到这样的情况。
如下图,当偏移量为s时,进行字符串匹配,此时已成功匹配5个字符,即ababa,此时的q = 5,q中保存的信息就是当前已经匹配成功的字符数目,而第6个字符匹配失败,则这次偏移量为s的字符串匹配失败。而可以看到的是,偏移量为s+1的字符串不可能匹配成功,偏移量为s+2的字符串已经匹配成功3个字符。如果,我们可以依据已经匹配成功的字符信息q可以得出上面的偏移量为s+1,s+2的信息,就可以避免不必要的偏移量计算以及某些必要偏移量的某些字符的比较。
可以将上面的例子一般化,并抽象出输入以及输出,即可以定义问题为:
- 输入,当偏移量为s时,字符串已匹配q个字符,即T[s+1...s+q] == P[1...q],但T[s+q+1] != P[q+1]。
- 输出,k = max{ m | m < q && P[1..m]是T[1..s+q]的后缀}。
我们可以计算k所对应的偏移量s',即T[s'+1..s'+k] = T[s+q-k+1...s+q],显然,s'+ k = s+q,显然,当偏移量为s'时,已经有k个字符完成了匹配。那么,可以直接判断P[k+1]和T[s'+k+1]。
至于在该问题中限定k < q,本质上定义该问题的目的就是计算将来的有效s',如果k ≧ q,那么最终s' ≤ s,这不符合我们的期望。
其实,如果我们可以证明s到s'中间的偏移量的匹配都将失败,那么s'就是我们所期望的预期偏移量。其避免未来的不必要偏移量以及不必要比较。
证明 :s~s'的所有偏移量的匹配都将失败。
假设存在一个偏移量s",满足:s < s" < s',可以成功匹配P,即T[s"+1...s"+m] = P[1...m]。
- 由T[s"+1...s"+m] = P[1...m]可知:T[s"+1...s+q] == P[1..s+q-s"],那么,s+q-s" ∈ { m | P[1..m]是T[1..s+m]的后缀 },则,s+q-s" ≦ k。
- 由于s < s" < s',那么 而k = s+q-s'。那么k < s+q-s"。
- s+q-s" ≦ k 与 k < s+q-s" 矛盾,假设不成立。
抽象出该问题的本质: 计算 k:the longest proper prefix Pk of P that is also a suffix of Ts+q. 只要k值被计算出,目标即被达成。
对该问题的本质稍加分析,便可知晓,由于k < q,所以仅仅需要分析Ts+q中的T[s+1..s+q]部分,不然的话,k可能会超出q,这部分k值会被舍弃,所以没有比要进行计算。而,依据题意,可知T[s+1..s+q] = P[1...q]部分,那么原问题的本质将可以更改为:计算k:the longest proper prefix P that is also a suffix of Pq.
显然,这与我们所推得k:the longest proper prefix P that is also a suffix of Pq 的问题本质是相符的,所以,模式P的前缀函数π完成了我们所预期的目的:其保存的信息,可以在暴力搜索算法中,当匹配失败时,避免不必要的偏移量以及比较。
为了不混淆概念,这里,我们将比较模式P的前缀函数π以及P的后缀函数σ的定义 其实,σ(Pq) = π(q),由σ(X) = max{ k:Pk是X的后缀 }可知,σ(Pq) = max{ k:Pk是Pq的后缀 },而既然Pk是Pq的后缀,那么k < q。
计算模式P的前缀函数π的算法
下面给出计算π[1...m]的算法伪代码,其中,m = |P|.
1. COMPUTE-PREFIX-FUNCTION(P)
2. m = P.length;
3. let π(1:m) be a new array;
4. π(1) = 0;
5. k = 0;
6. For q = 2 to m
7. while k > 0 and P[k+1] != P[q]
8. k = π[k];
9. IF P[k+1] == P[q]
10. k++;
11. π[q] = k;
12. return π;
COMPUTE-PREFIX-FUNCTION算法的正确性证明。
定义,π*[q]: (这一定义可以说是kmp算法中最核心的东西,也可以说,kmp降低时间复杂度是因为它)
- π*[q] = {π(1)(q),π(2)(q),π(3)(q),...π(t)(q)}。
- 一般而言,π*[q]中元素的迭代终止于π(t)(q) = 0.
其中,递归地定义π(i)(q}。
- π(i)(q} = π( π(i-1)(q) ),if i ≥ 1.
- π(0)(q) = q,if i = 0.
- 其实,在该算法的正确性证明中,根本不会遇到π(0)(q)。
为了下面证明的需要,π*[q]中,π(i)(q) > π(i+1)(q),i ∈ { 0,1,2,...,t},证明依据π(q)的定义即可得到。
本质上,我们定义π*[q]的目的是表示:all the k of the prefixes Pk that are proper suffixes of Pq.
下面将使用定理4来证明π*[q]是可以完成此目的。
定理4,令P的前缀函数为π,且P.length = m,那么,对于任意的q∈{1,2,3,...,n},π*[q] = {k:k < q && Pk是Pq的后缀}。
证明:
先证: π*[q] 是 {k:k < q && Pk是Pq的后缀}的子集。
要证: π*[q] 是 {k:k < q && Pk是Pq的后缀}的子集,只须证: 对于任意的i∈π[q],i < q 以及 Pi是Pq的后缀。而对于任意的i ∈ π[q],一定存在u > 0,使得 i = π[u](q),将使用数学归纳法 ,施归纳于u,来证明 i<q 以及 Pi 是Pq 的后缀。
- 当u = 1时,i = π(q),根据P的前缀函数π的定义可知:Pπ(q) 是Pq的后缀,而且i < q,则,i<q 以及 Pi是Pq的后缀。满足条件。
- 假设当u = k时 ,i = π[k](q),则,π[k](q) < q 以及 Pπ[k](q)是Pq的后缀。
- 那么,当u = k+1时,i = π[k+1](q),已知,π[k+1](q) = π(π[k](q)),由前缀函数的定义可知,π[k+1](q) < π[k](q),且Pπ[k+1](q) 是Pπ[k](q)的后缀,而由第二步假设可知π[k](q) < q 以及 Pπ[k](q)是Pq的后缀。由< 以及前缀的传递性,可知π[k+1](q)<q 以及 Pπ[k](q)是Pq的后缀。
证毕: π*[q] 是 {k:k < q && Pk是Pq的后缀}的子集。
后证:{k:k < q && Pk是Pq的后缀} 是 π*[q]的子集。
我们将使用反证法来证明这一结论。
- 假设 {k:k < q && Pk是Pq的后缀} - π*[q] 不是空集。
- 那么在 {k:k < q && Pk是Pq的后缀} - π[q] 中,一定存在一个最大元素j,即,j = max{ {k:k < q && Pk是Pq的后缀} - π*[q] }。*
- 在π*[q]中选取元素m,使得m = min{ k:k∈π*[q] 且 k > j }。 下面将证明m一定存在,已知π(q)是 {k:k < q && Pk是Pq的后缀}以及π[q]中最大的元素,自然,j < π(q)。而 j ∈ {k:k < q && Pk是Pq的后缀} - π[q] },π(q) !∈ {k:k < q && Pk是Pq的后缀} - π*[q] },则,j不可能等于π(q)。那么,如果{ k|k∈π*[q] 且 k > j }/{π(q)} = null,m至少可以选择为π(q)。所以,m一定存在。
- m∈π[q],由该证明的前半部分可知:π[q] 是 {k:k < q && Pk是Pq的后缀}的子集,那么m∈ {k:k < q && Pk是Pq的后缀},可知Pm是Pq的后缀,而Pj也是Pq的后缀,而m > j,所以Pj是Pm的后缀。
- j是在Pm的后缀中,可以与P的前缀匹配的字符串的最长长度。 而j 取自 {k:k < q && Pk是Pq的后缀} - π*[q]的最大值,那么,如果存在某个值n大于j,Pn是Pm的前缀,n必然属于π*[q],而我们选取m实在整个π*[q]集合中选取的元素,m是大于j中最小的元素,那么,n一定小于j。那么,π[m] = j。
- 由π[m] = j可知,j∈π*[q],这与 j ∈ {k:k < q && Pk是Pq的后缀} - π*[q]矛盾。假设不成立。
证毕: π*[q] 是 {k:k < q && Pk是Pq的后缀}的子集。
定理5,令P的前缀函数为π,且P.length = m,那么,当任意的q∈{1,2,3,...,n},如果π(q) > 0,那么π(q) -1 ∈ π*[q-1]。
证明,令 r = π(q) > 0,依据π(q)定义可知,r < q,且Pr是Pq的后缀。r - 1< q - 1,且Pr-1是Pq-1的后缀(两个串同时去掉最后一个字符,字符串长度r > 0)。可知,r-1 ∈{k | k < q-1 && Pk是Pq-1的后缀},由定理4,r-1 ∈π*[q-1]。得证。
定义,Eq-1是π*[q-1]的子集合,q∈{2,3,...,m},且Eq-1满足以下公式:
- Eq-1 =
- Eq-1 = {k < q-1,且,Pk是Pq-1的后缀,且,P[k+1] = P[q] }
- Eq-1 = {k < q-1,且,Pk+1是Pq的后缀 }
对于此定义来将,Eq-1 consists of those values k ∈ π*[q-1] such that we can extend Pk to Pk+1 and get a proper suffix of Pq.
其实,在某种程度上,π*[q] = {k | k-1 ∈ Eq-1,P[k] = P[q]}.。
而定义Eq-1的目的是建立π*[q]与π[q]的关系,下面的定理*将说明这种关系。
定理6,令P的前缀函数为π,且P.length = m,那么,当任意的q∈{1,2,3,...,n},有如下结果:
- π(q) = 0,if Eq-1 = null.
- π(q) = 1 + max{k ∈ Eq-1},if Eq-1 != null.
证明,
-
if Eq-1 = null,即,不存在k ∈ π[q-1] ,可以将Pk进行扩展到Pk+1,使得Pk+1与Pq的某个后缀匹配。假设存在k > 0,使得Pk是Pq的后缀,那么Pk-1也是Pq-1,且k - 1 ≧ 0,k-1 ∈ π[q-1],那么Eq-1 != null,矛盾,所以不存在k > 0,使得Pk是Pq的后缀,那么π(q) = 0。
-
if Eq-1 != null,即,存在k ∈ π*[q-1] ,可以将Pk进行扩展到Pk+1,使得Pk+1与Pq的某个后缀匹配。对于这一部分的证明,将分别证明π(q) ≥ 1 + max{k∈Eq-1},以及π(q) ≤ 1 + max{k∈Eq-1}。
-
对于任意的k∈Eq-1,依据Eq-1的定义,可知Pk+1是Pq的后缀且k < q-1。那么,k+1 ∈ {m:m < q && Pm是Pq的后缀},由定理4,k+1 ∈ π[q]。而π(q)是π[q]中最大的元素,所以π(q) ≥ 1 + max{ k∈ Eq-1}。
-
由1中可知π(q) ≥ 1 + max{ k∈ Eq-1}。而max{ k∈ Eq-1} ≧ 0,那么π(q) > 0。由定理5可知,π(q) -1 ∈ π*[q-1]。而依据π(q)的定义,我们可知:P[r+1] = P[q]。由Eq-1的定义,我们可以知道:π(q) -1 ∈ Eq-1 ,那么π(q) -1 ≤ max{k∈Eq-1},则π(q) ≤ 1 + max{k∈Eq-1}。
上述证明的核心是定理4以及定理6,它们都将在下面的算法正确性证明中用到。
正式证明算法正确性
为了观看方便,这里将重新贴一次计算π[1...m]的算法伪代码。
1. COMPUTE-PREFIX-FUNCTION(P)
2. m = P.length;
3. let π(1:m) be a new array;
4. π(1) = 0;
5. k = 0;
6. For q = 2 to m
7. while k > 0 and P[k+1] != P[q]
8. k = π[k];
9. IF P[k+1] == P[q]
10. THEN k++;
11. π[q] = k;
12. return π;
这个算法保持了循环不变量:在第6~11的for循环的每次迭代开始前,k = π(q-1)。
- 在循环第一次开始前,第4~5行的赋值保证满足该循环不变量。
- 如果在某一次循环迭代前,循环不变量为真,那么在下一次循环迭代前,循环不变量为真。而伪代码的第7~10行将调整k,使得其是π(q)。
- 其中,第7~8行的while循环,将遍历π*[q-1]中的所有元素,直到P[k+1] = P[q]或者k = 0。
- 此循环一定会终止。 这是因为,由于之前的循环迭代,π[1..q-1]的所有值都已满足P的前缀函数的定义,而由该函数的定义可知π(q) < q,那么k的每一次循环都会减小,当k减小到1时,π(1) = 0,循环终止。当然了,如果能够使得P[k+1] = P[q],循环提前终止,那再好不过了。
- 如果while循环终止由于P[k+1] = P[q],这样的情况对应于Eq-1 != null的情况,迭代终止时所取出的k将会是π[q-1]中满足P[k+1] = P[q]中最大的元素,因为π(q)的定义,π(q) < q,所以π(q) > π(π(q)),所以,迭代将递减的遍历π(q)。所以当迭代终止时,k = max{k ∈ Eq-1},而第9~10行则判断成功,k = k+1,那么,根据定理6的内容,得到π(q) = k+1 =max{k ∈ Eq-1}+1 。
- 如果while循环终止由于k = 0,即遍历完成整个循环,都无法找到P[k+1] = P[q]的值,这样的情况对应于Eq-1 = null的情况,第9~10行判断失败,而之后自然π(q) = 0。
- 这里当然存在k = 0以及P[k+1] = P[q]同时出现的情况,对于这种情况,应将其归类到Eq-1 != null的情况。而第9~10行的加一操作使得它也是正确的。
- 那么,当循环q = m + 1终止时,π[1...m]即为我们所需要的值。
此处,重复一下算法正确性证明逻辑 ,定理6 和 Eq-1连接了π*(q-1)和π(q),而π*(q-1)和π(q)的关系实际上就是π(q)与π(i)的关系,i ∈ {1,2,..,q-1}。
COMPUTE-PREFIX-FUNCTION(P)算法的时间复杂度分析。
为了观看方便,这里将重新贴一次计算π[1...m]的算法伪代码。
1. COMPUTE-PREFIX-FUNCTION(P)
2. m = P.length;
3. let π(1:m) be a new array;
4. π(1) = 0;
5. k = 0;
6. For q = 2 to m
7. while k > 0 and P[k+1] != P[q]
8. k = π[k];
9. IF P[k+1] == P[q]
10. THEN k++;
11. π[q] = k;
12. return π;
这里将采用摊还分析的聚集方法来确定该算法的时间复杂度。可以参见:关于摊还分析的有关知识。不看貌似也可以。
我们将对该7~8行的while循环的k值进行做出分析。
- 在第6行的for循环开始迭代前,k=0,而在第6行的for循环中,仅仅只有第9~10行的if语句可以使得k = k+1,也就是说,k在for循环的每一次迭代中,至多加1。k值至多为m-1。
- 在第6行的for循环开始迭代前,k<q,由于k在for循环的每一次迭代中,至多加1,那么,恒有,k<q,又由于第4行以及第11行,可以判断的是,恒有,π(q) < q,则,第7~8行的while循环将会减小k值。
- k值非负。
- 可以得出的结论是k值至多下降m-1次,即在5-10行的for循环的全部迭代中,第7~8行的while循环至多执行m-1次。
那么该算法的时间复杂度为O(m-1) + O(m-1) = O(m)。
KMP主算法
下面将先给出KMP主算法的伪代码:
1. KMP-Matcher(T,P)
2. n = T.length;
3. m = P.length;
4. π = COMPUTE-PREFIX-FUNTION(p);
5. q = 0;
6. For i = 1 to n /*scan the text from left to right*/
7. while q > 0 and P[q+1] != T[i]
8. q = π[q];
9. IF P[q+1] == T[i]
10. q++;
11. IF q == m
12. print "Pattern occurs with shift" i-m;
13. q = π[q];
算法的正确性证明。
从KMP算法主代码可以看出转换函数δ是被π以及P[q+1] != T[i]进行取代的。
对于该算法的正确性证明,将采取一种较为特别的方式,并不会直接证明该算法的正确性,而是妄图证明该算法等价于前面的有穷自动机识别字符串的算法。一旦分析得到该算法等价于之前的有穷自动机识别字符串的算法,那么,该算法也就是正确的。
为了方便,这里重新贴一下有穷自动机识别字符串的算法。
1. FINITE-AUTOMATON-MATCHER(T,δ,m)
2. n = T.length
3. q = 0
4. for i = 1 to n
5. q = δ(q,T[i])
6. if q == m
7. print "Pattern occurs with shift" i-m
根据有穷自动机部分的叙述,可知,我们希望,在FINITE-AUTOMATON-MATCHER算法中的第4行的for循环处理T[i]的迭代中,当算法执行到第6行时,q = σ(Ti),其中,i∈{1,2,3...n}。
那么,只要可以证明,在KMP-Matcher算法中的第5行的for循环处理T[i]的迭代中,在算法执行到第10行时,q = σ(Ti),其中,i∈{1,2,3...n}。那么我们就可以说这两个算法等价。
证明:
将使用归纳法进行证明:在KMP-Matcher算法中的第5行的for循环处理T[i]的迭代中,在算法执行到第11行时,q = σ(Ti),其中,i∈{1,2,3...n}。该归纳法是归纳于i,即循环迭代到第i次。
-
当i = 1时,此时的q = 0,此时算法将并不会执行第7~8行的循环,直接执行第9行的判断,如果T[1] == P[1]时,q = 1,否则q = 0。这明显与σ(Ti) = σ(T1) 等价,所以q = σ(Ti) ,满足条件。
-
假设:当i = k时,在KMP-Matcher算法中的第5行的for循环迭代处理T[k]的迭代中,当算法执行到第10行时,q = σ(Ti)。
-
往证,当i = k+1时,在KMP-Matcher算法中的第5行的for循环处理T[k]的迭代中,当算法执行到第10行时,q = σ(Ti)。可以根据σ(Ti)与σ(Ti-1)的取值,将σ(Ti)分为三种情况:0 && σ(Ti-1)+1 && 0 < σ(Ti) < σ(Ti-1)。
-
当σ(Ti)= 0时。σ(Ti)= 0表示:P0 = ε 是the only prefix of P that is a suffix of Ti。那么,也就是说,对于任意的k ∈ π[σ(Ti-1)],P[k + 1] != T[i]。对于KMP算法而言,第7行的循环将遍历整个π[σ(Ti-1)],但不会产生满足P[q+1] != T[i]的q,因而最终,q = 0,而且,第8行的判断失败,即,当算法进行到第10行时,q = 0。
-
当σ(Ti)= σ(Ti-1)+1时,此种情况的出现说明,P[σ(Ti-1) + 1] = T[i]。对于KMP算法而言,第7行的循环将直接跳过,而第8行的判断成功,q = σ(Ti-1) +1。所以,当算法进行到第10行时,q满足条件。
-
当0 < σ(Ti) ≤ σ(Ti-1)时,σ(Ti) = max{ k:Pk是Ti的后缀}。而0 < σ(Ti) < σ(Ti-1),这说明:P[σ(Ti - 1) + 1] != T[i],存在k∈π[σ(Ti-1)],使得,P[k+1] = T[i]。对于这种情况而言,KMP算法中的第7行的while循环将至少执行一次,且递减的遍历π[σ(Ti-1)],π*[σ(Ti-1)] = {k|k<σ(Ti-1) && Pk是Pσ(Ti-1)的后缀},得到q,使得q是:Pσ(Ti-1) 的最长后缀Pq,使得P[q + 1] = T[i]。而Pσ(Ti-1)是Ti-1的最长后缀,又由于Pq是Pσ(Ti-1)使得P[q + 1] = T[i] 的最长后缀,,那么Pq就是Ti的最长后缀。如果不是,存在比么Pq的Ti的最长后缀Pq,那么q'- 1 > σ(Ti-1),因为在小于σ(Ti-1)范围内,q就是符合条件最大的。而Pq'- 1 一定是Ti-1的后缀,而这与Pσ(Ti-1)是Ti-1的最长后缀矛盾,所以此时的q满足:Pq就是Ti的最长后缀,那么满足条件。
到此为止,算法的主体正确性已经证明完毕,下面完善关于KMP主算法的第13行的细节问题。
KMP主算法的第13行之所以被需要,是因为该问题的结果是:S = { 非负整数s | 满足T[s... s+m-1] = P[0 .. m–1] }。这样的结果要求我们必须找出所有的有效偏移,而不是找到一个有效偏移即可。如果第13行的代码被去掉,可能会访问到P[m+1],造成数组越界。
但必须证明对于q所做出的处理并不会影响结果,实际上即证明:δ(m,a) = δ(π(m),a) 。对于原因,我们仅仅在该循环的最后一步将q = π(q),那么只要δ(m,a) = δ(π(m),a),那么,这将并不会影响下一次迭代的结果。
定理7,令δ是匹配字符串P的有穷自动机,其具体定义符合上文中所述的字符串匹配有穷自动机,P.length = m,则,δ(m,a) = δ(π(m),a) 。
证明,
- δ(m,a) = σ(Pma),且,δ(π(m),a) = σ(Pπ(m)a)。
- 由π(q) = max{k: k < q && Pk是Pq的后缀}的定义可知,Pπ(m)是Pm的后缀,那么,Pπ(m)a是Pma的后缀,由σ(x) = max{ k:Pk是x的后缀 }的定义可知,一个字符串的σ绝不会小于该字符串后缀的σ,可知:σ(Pma) ≥ σ(Pπ(m)a)。
- 因为σ(Pma) = max{ k:Pk是Pma的后缀 },σ(Pma) - 1∈ { k:Pk是Pm的后缀 },而π(m) = max{k: k < m && Pk是Pm的后缀}。所以,π(m) ≥ σ(Pma) - 1。而P[σ(Pma)] = a,那么,σ(Pπ(m)a) ≥ σ(Pma) 。这是因为已知Pσ(Pma) - 1是Pπ(m)的前缀,而P[σ(Pma)] = a,也就是说,σ(Pπ(m)a) ∈ { k:Pk是Pπ(m)a的后缀 },所以,σ(Pπ(m)a) ≥ σ(Pma) 。
- 由σ(Pπ(m)a) ≥ σ(Pma)以及σ(Pma) ≥ σ(Pπ(m)a)可知,δ(m,a) = δ(π(m),a) 。
KMP-Matcher(T,P)的时间复杂度分析
为观看方便,重新贴一次伪代码。
1. KMP-Matcher(T,P)
2. n = T.length;
3. m = P.length;
4. π = COMPUTE-PREFIX-FUNTION(p);
5. q = 0;
6. For i = 1 to n /*scan the text from left to right*/
7. while q > 0 and P[q+1] != T[i]
8. q = π[q];
9. IF P[q+1] == T[i]
10. q++;
11. IF q == m
12. print "Pattern occurs with shift" i-m;
13. q = π[q];
这里的分析极其类似于COMPUTE-PREFIX-FUNCTION(P)算法的时间复杂度分析。可以对应了解。
这里将采用摊还分析的聚集方法来确定该算法的时间复杂度。可以参见:关于摊还分析的有关知识。
我们将对该循环的q值进行做出分析。
- 在第6行的for循环开始迭代前,q=0,而在第6行的for循环中,仅仅只有第9~10行的if语句可以使得q = q+1,也就是说,q在for循环的每一次迭代中,至多加1。q值至多为n。
- COMPUTE-PREFIX-FUNCTION(P)算法中,不管从代码角度(算法时间复杂度证明),或者从语义角度(算法正确性证明),都可以得出 π(q) < q,则,第7~8行的while循环将会减小q值,第13行的赋值也会降低q值。
- 而k值非负。可以得出的结论是q值至多下降n次,即在5-10行的for循环的全部迭代中,第7~8行的while循环至多执行n次。
那么该算法的时间复杂度为O(n) + O(n) = O(n)。