字符串匹配算法的分析


字符串匹配算法的分析

問題描述

字符串匹配問題可以歸納為如下的問題:
在長度為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就只剩下這一部分的問題了,如果你不關心為什么你可以只是簡單的記住這個結論。但是如果你想要具體了解為什么會得出這個結論,那你還得接着往下看,我們可以通過數學證明這個結論!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM