1. 程式人生 > 實用技巧 >字串匹配問題

字串匹配問題

本文僅僅是為了學習記錄知識,對於其中橙色部分尚不太理解,望能者指教,謝謝!

問題定義

輸入:

  • 文字 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取最大素數,有幾種優化的說法,不過我不太理解其含義。

  1. 字母表的字母數目為d,那麼選取一個d值,使得dq在一個計算機字長內,那麼就用單精度算術運算執行必需的操作。
  2. 如果 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 = { 0,1,2,...,m }
  • 開始狀態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,而第6個字元匹配失敗,則這次偏移量為s的字串匹配失敗。而可以看到的是,偏移量為s+1的字串不可能匹配成功,偏移量為s+2的字串已經匹配成功3個字元。如果,我們可以依據已經匹配成功的字元資訊可以得出上面的偏移量為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 Pk of Pq that is also a suffix of Pq.

顯然,這與我們所推得k:the longest proper prefix Pk of Pq 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 = {k ∈ π*[q-1],且P[k+1] = P[q] }
  • 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}。

  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}。

  2. 由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)。
  1. 其中,第7~8行的while迴圈,將遍歷π*[q-1]中的所有元素,直到P[k+1] = P[q]或者k = 0。
  2. 此迴圈一定會終止。 這是因為,由於之前的迴圈迭代,π[1..q-1]的所有值都已滿足P的字首函式的定義,而由該函式的定義可知π(q) < q,那麼k的每一次迴圈都會減小,當k減小到1時,π(1) = 0,迴圈終止。當然了,如果能夠使得P[k+1] = P[q],迴圈提前終止,那再好不過了。
  3. 如果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 。
  4. 如果while迴圈終止由於k = 0,即遍歷完成整個迴圈,都無法找到P[k+1] = P[q]的值,這樣的情況對應於Eq-1 = null的情況,第9~10行判斷失敗,而之後自然π(q) = 0。
  5. 這裡當然存在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]; 

演算法的正確性證明。

對於該演算法的正確性證明,將採取一種較為特別的方式,並不會直接證明該演算法的正確性,而是妄圖證明該演算法等價於前面的有窮自動機識別字符串的演算法。一旦分析得到該演算法等價於之前的有窮自動機識別字符串的演算法,那麼,該演算法也就是正確的。

為了方便,這裡重新貼一下有窮自動機識別字符串的演算法。

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)。

  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。

  2. 當σ(Ti)= σ(Ti-1)+1時,此種情況的出現說明,P[σ(Ti-1) + 1] = T[i]。對於KMP演算法而言,第7行的迴圈將直接跳過,而第8行的判斷成功,q = σ(Ti-1) +1。所以,當演算法進行到第10行時,q滿足條件。

  3. 當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)。