1. 程式人生 > >字串匹配演算法的分析【轉】

字串匹配演算法的分析【轉】

轉自:https://www.cnblogs.com/adinosaur/p/6002978.html

問題描述

字串匹配問題可以歸納為如下的問題:
在長度為n的文字T[1...n]中,查詢一個長度為m的模式P[1...m]。並且假設T,P中的元素都來自一個有限字母集合Ʃ。如果存在位移s,其中0≤s≤n-m,使得T[s+1..s+m] = P[1..m]。則可以認為模式P在T中出現過。

1. 樸素演算法

最簡單的字串匹配演算法是樸素演算法。該演算法最直觀,通過遍歷文字T,對每一個可能的位移s都比較T[s+1..s+m]於P[1..m]是否匹配。

程式碼實現

程式碼用python寫的:

def naive_string_match(T, P):
    n = len(T)
    m = len(P)

    for s in range(0, n-m+1): k = 0 for i in range(0, m): if T[s+i] != P[i]: break else: k += 1 if k == m: print s

演算法分析

最壞情況下,對每一個s都需要做m次(模式P的長度為m)的比較。則演算法的上屆是O((n-m+1)*m)。到後面我們會看到樸素演算法之所以慢,是因為它只是關心有效的位移,而忽略其它無效的位移。當一次位移s被驗證是無效的之後,它只是向右位移1位,然後從頭開始繼續下一次的比較。這樣做完全沒有利用到之前已經匹配的資訊,而這些資訊有時候會很有用。

2. Rabin-Karp演算法

對樸素演算法的一個簡單的改進就是Rabin-Karp演算法。Rabin-Karp演算法的思路是將字串的比較轉換成數字的比較。比較兩個長度為m的字串是否相等需要O(m)的時間,而比較兩個數字是否相等通常可以是Ɵ(1)。為了將字串對映到對應的數字,我們需要用到雜湊函式。我們都知道開放定址法的雜湊函式(open addressing)是可能遇到衝突的。對於這個問題來說衝突意味著雖然兩個字串的雜湊值是一樣的,但是這兩個字串實際上是不一樣的。解決的辦法是當遇到雜湊值相同時,再做m次(模式P的長度為m)遍歷,近一步判斷這兩個字串是否相等。既是說,雜湊值是第一步地判斷,如果兩個字串不相等那麼他們的雜湊值也肯定不相等。通過第一步的篩選後,再做近一步更可靠的篩選。運氣好的話,大部分不匹配的字串會在第一步(通過雜湊值)被篩選掉,僅留有少量的字串需要近一步的審查。

程式碼實現

繼續附上Python程式碼:

def rabin_karp_matcher(T, P):
    n = len(T)
    m = len(P)
    h1 = hash(P)
    for s in range(0, n-m+1): h2 = hash(T[s:s+m]) if h1 != h2: continue else: k = 0 for i in range(0, m): if T[s+i] != P[i]: break else: k += 1 if k == m: print s

演算法分析

從程式碼上來看Rabin-Karp演算法與樸素演算法十分近似,最壞情況下,每一個雜湊值都衝突,而且對每個衝突都進行了m次的比較。在這種情況下,該演算法的時間複雜度與樸素演算法相同,如果算上雜湊演算法的開銷,時間複雜度還要高出樸素演算法(通常一個字串進行雜湊的演算法的時間複雜度是Ɵ(1))。當然這是最壞情況下的分析,對於平均情況下Rabin-Karp演算法的效果要好得多。根據數學推斷,Rabin-Karp演算法的平均情況下的時間複雜度是O(n+m)。

詳細分析

以下這一段分析Rabin-Karp的平均複雜度。如果不關心O(n+m)具體是如何得來的可以跳過這一段。
我們稱兩個字串雜湊值相同為一次命中,如果這兩個字串實際上是不同的則這次命中是一個偽命中。我們期望偽命中的次數要少一些,因為越少的偽命中意味著演算法的效率越高。偽命中問題實際上是雜湊演算法的衝突問題,因此具體衝突的次數與具體的雜湊演算法相關。

演算法導論中給出的雜湊演算法是:
t[s+1] = (d * (t[s]-T[s+1]) * h) + T[s+m+1]) mod q

該演算法是將字串的每一個位的字元轉換成對應的數字,再根據一定的權重相乘得到一個數值,最後對q取模對映到[0, q-1]空間的一個值。有n個數字待對映到[0, q-1]這q個值中。如果一個雜湊函式把一個數字隨機地對映到q個數中的任意一個,理論上來說衝突的個數O(n/q)。假設正確命中的個數是v,由前面討論偽命中的個數是n/q。那麼Rabin-Karp演算法的期望執行時間是:O(n)+O(m(v+n/q))。如果有效命中v=O(1)並且q≥n,那麼Rabin-Karp演算法的時間複雜度是O(n+m)。

Rabin-Karp演算法優勢

Rabin-Karp演算法的優勢是可以多維度或者多模式的匹配字串。以多模式匹配為例,如果你需要在文字T中找出模式集合P=[P1, P2, ...Pk]中所有出現的模式。對於這個問題,Rabin-Karp演算法的威力就能發揮出來了,Rabin-Karp演算法能通過簡單地擴充套件便能夠支援多模式的匹配。

3. 利用有限狀態自動機進行字串匹配

有限狀態自動機是一個處理資訊的機器,通過對文字T進行掃描,找出模式P的所有出現的位置。在建立有限狀態自動機後只需要對T一次掃描便可以完成所有匹配工作(即匹配時間是O(n))。但是如果字符集Ʃ很大時,建立自動機的時間消費很大,這是這種方法的缺點。雖然一開始可能會被這個有限狀態自動機的名字嚇到(我就是這樣),因為看上去好像很高大上,但相信我,當你細看之後會發現並沒有想象的那麼難。

有限狀態自動機定義

先給出定義,有限狀態自動機M是一個5元組(Q,q0,A,Ʃ,δ),其中:

  1. Q是狀態集合。
  2. q0屬於集合Q,q0是初始狀態。
  3. A是可接收的狀態集合(A是Q的子集合)。
  4. Ʃ是字符集。
  5. δ是一個Q×Ʃ到Q的函式,稱為狀態轉移函式。

有限狀態自動機演算法工作流程

對應到我們的字串匹配問題中,有限狀態自動機的工作流程如下:開始於狀態q0,每次讀入輸入字串的一個字元a,則它狀態從q變為狀態δ(q,a)。每當其當前q> 屬於A時,自動機M就接受起勁為止所讀入的所有字串。

構造自動機

為了能構造一個字串配對的自動機,我們還需要4個定義,以方便我們後續的表達和計算。

  • 定義1:對於模式P,Pq表示P的前q個字元組成的子串。
  • 定義2:字串P的字首是{ Pi | 0≤i≤P.length }。例如“ababa”的字首為 { “a”,”ab”,”aba”,”abab”,”ababa” }。字串字尾定義與字首的定義相似。
  • 定義3:設字首函式ơ(x)是x的字尾中在模式P中的最長字首的個數。例如P=ab, 則ơ(ccaca)=1,ơ(ccab)=2。
  • 定義4:字串A、B,則AB意味著字串A與B的連結。例如A=“aba”, B=”c”,則AB=”abac”。

好了,有了上述的定義我們就可以得到我們的字串匹配自動機的定義。(又是定義,掩面偷笑)根據給定的字串模式P[1...m],其字串匹配自動機的定義如下:
狀態集合Q={0,1,2...,m}。其中q0是狀態0,狀態m是唯一被接受的狀態。對任意的狀態q和字元a,轉移函式δ(q,a)=ơ(Pqa)(注意:這裡Pqa表示字串Pq和字串a的連結)。

來看一個例子,上圖是模式P=“ababaca”的字串匹配自動機。可以的話根據狀態轉移函式自己推一下上面的表,這樣會對自動機方法理解得更好。

自動機演算法原理

通過狀態轉移函式,可以理解了有限自動機比樸素演算法快在哪了。對於樸素演算法,若字元不匹配則直接右移一位開始下一輪的m(模式P)個字元的比較。但是對於有限> 自動機來說,如果當前的字元不匹配(不能理想地進入下一個狀態),自動機將根據轉移函式δ回滾到之前已經匹配的某一個狀態。這樣的話即使字元不匹配也利用到了之前已經匹配的字元資訊。例如對於上述的模式P=“ababaca”,如果已經有一個位移匹配到了前5個字元”ababa”,當下一個讀入的字元是“c”則順利地進入狀態6。如果讀入的字元是“b”,雖然不匹配(不是理想的“c”)但是根據狀態轉移函式我們只需回退到狀態4。因為此時雖然不能湊齊6個字元匹配成功,但是我們任然能夠湊齊4個字元匹配成功(“abab”)。如果讀入的字元是”a”的話,那隻能回退到狀態1,既是隻有1個字元匹配成功(“a”)。

程式碼實現

知道了如何計算狀態轉移函式(實際上就是知道如何根據一個字串構造它的有限狀態自動機),然後就可以通過掃描T找出所有匹配P的字串了。具體看程式碼:

####根據狀態轉移函式ơ掃描T匹配字串:
def finite_auto_matcher(T, f, m): n = len(T) q = 0 for i in range(0, n): q = f[(q, T[i])] if q == m: print i+1-m ####構造狀態轉義函式: def compute_transition_function(P, charSet): f = dict() m = len(P) for q in range(0, m): for a in charSet: k = min(m, q+1) while not ispostfix(P[:q]+a, P[:k]): k -= 1 f[(q, a)] = k for a in charSet: f[(m, a)] = 0 return f def ispostfix(s1, s2): n = len(s1) m = len(s2) for i in range(0, m): if s1[n-1-i] != s2[m-1-i]: return False else: return True

演算法實現複雜度

構造狀態轉義函式的時間複雜度是O(m^3 * | Ʃ |)。第14行迴圈m次,第15行迴圈| Ʃ |次,第18行最多執行m+1次,第17行的ispostfix函式最多m次比較。因此總共是m * m * m * | Ʃ | 。還有更好的演算法可以使計算轉移函式的時間降到O(m * | Ʃ |),因此對於有限自動機總的時間複雜度為O(n + m * | Ʃ |)。

4. kmp演算法

相比於有限狀態自動機,Kmp演算法的優勢在於它只需要O(m)的與處理時間,而有限狀態自動機最快也需要O(m * | Ʃ |)。Kmp演算法的主要思路跟字串自動機很像,在預處理階段建立一個字首函式,然後順序掃描文字T,即可找出所有與模式P相匹配的字串。字首函式與字串自動機中的轉移函式功能相同,都是當遇到匹配失敗時能根據字首函式(或者轉移函式),利用之前匹配的資訊,能夠找出下一個應該匹配的位置,避免類似樸素演算法做過多的無用功。

圖片來自《演算法導論》。看圖(a),文字T和模式P一直匹配成功前5個字元,但是第6個字元不匹配。但觀察可以發現此時已經匹配的5個字元中的後三個是模式P的前三個字元,因此我們可以退而求其次地將已經匹配的字元數減少一點,看能否匹配新的字元。如圖(b),此時比較新的字元是否匹配P的第四個字元,這樣的比較實際上是把P左移了2位。這個2是這樣得來的,原來已經匹配了5位,這5位的字尾中在P的最長字首(“aba”)的長度是3,5-3=2由此得出應該左移2位。

程式碼實現

我們先假設已經能夠得到字首函式,即是說先不去管字首函式是如何計算出的。那當我們已經有方法得到字首函式後,如何匹配模式P?看下面這段python程式碼,注意為了方便理解,我在字串T、P前面都加上了一個空格字元,效果是模擬字串下標從1開始而不是從0開始。

def kmp_matcher(T, P):
    T = ' ' + T P = ' ' + P n = len(T) - 1 m = len(P) - 1 t = KMP.longest_prefix_suffix(P) q = 0 for i in range(1, n+1): while q > 0 and P[q+1] != T[i]: q = t[q] if P[q+1] == T[i]: q += 1 if q == m: print i-m+1 q = 0

程式碼分析

第6行呼叫函式longest_prefix_suffix,計算出模式P的字首函式。第8行開始順序掃描文字T,注意變數q記錄此刻與模式P成功匹配的字元的個數。當下一個字元匹配失敗時(P[q+1]!=T[i]),q的值根據字首函式重新計算出,如9、10兩行程式碼。當匹配成功時q的值只需簡單加1。最後13行,當q的值(已經匹配的字元數)與模式P的長度相等時,我們便找到了一個匹配的字串。
是時候來到最難理解的部分了(至少是我認為是最難理解的部分),計算字首函式。其實如果不嫌慢的話可以暴力解法,但是時間複雜度是O(m^3),太差了。而書本給出的演算法是O(m),對比產生美!

首先再說一下字首函式的意思,字首函式t[q]的物理意義是模式P的子串P[1..q]的字尾字串中,是模式P的最大字首的長度。

def longest_prefix_suffix(P):
    if P[0] != ' ': P = ' ' + P m = len(P) - 1 t = [0] * (m+1) k = 0 match = 0 for q in range(2, m+1): while k > 0 and P[k+1] != P[q]: k = t[k] if P[k+1] == P[q]: k += 1 t[q] = k return t

一些理解

從程式碼上來看,計算字首函式和匹配很相似,其實可以把計算字首函式看作是和自己匹配的過程(書上這麼說)。還是一樣,為了陣列下標從1開始,我把字串下標0> 的位置放了一個空格。這段程式碼中變數k記錄著當前匹配成功的字元的個數。11、12行程式碼是好理解的,當下一個字元匹配成功時,簡單地把k加1。13行說的是,最後只需在下標為q的位置記錄者子串P[1..q]的最長字首數(k的值)。
對我來說,最難的部分在於理解9、10兩行的程式碼,為什麼當不匹配時只需不斷的迭代(迴圈k = t[k]),便能找到適合的k值?

首先,發現對k不斷地迭代(即k = t[k]),k的值會越來越小。回憶一下字首函式的定義,t[q]表示P[1..q]的字尾,同時也是P的字首的最大長度,所以其值顯然要比q小。

所以有不等式:k > t[k] > t[t[k]] > ...

《演算法導論》中有句話,通過對字首函式的不斷進行迭代,就能列舉出P[1..q]的真字尾中的所有字首P[1..k]。如果真如其所說的話,那麼9、10兩行程式碼就好理解了,當匹配失敗時,從大至小地列舉出其所有字首,找到一個能使下一個字元匹配成功的字首即可。而從大到小地列舉出所有字首只需要迴圈地迭代其字首函式即可。因為我們已經得知前了,(1)綴函式不斷迭代其值越來越小,(2)而且如書所說可以通過迭代來列舉出所有可能的字首。
好了,現在我們知道還剩哪裡不懂了。就是為什麼不斷地迭代字首函式能夠列舉出所有可能的字首?現在整個KMP就只剩下這一部分的問題了,如果你不關心為什麼你可以只是簡單的記住這個結論。但是如果你想要具體瞭解為什麼會得出這個結論,那你還得接著往下看,我們可以通過數學證明這個結論!