【資料結構與演算法】字串匹配

XenonHelium發表於2020-10-23

20201023 更新:
之前發新的部落格把原來的覆蓋掉了,重新發一遍,把連結部分補上去了。

字串匹配

text:需要檢索子串的長字串

pattern:作為被檢索物件的短字串

應用情景:

  • 檢索操作:特別是text很長,pattern很短的情況。

  • 網際網路上的資訊監視

  • 爬蟲時的資訊檢索

0 Brute-force substring search (naïve)

讓pattern和text的每個位置進行比對,直到某一次比對完全對應。可以用一個二重迴圈完成。或者讓i直接減j(i在這裡是回溯的,但在KSP當中不是),然後j清零即可,避免了二重迴圈。

時間複雜度: O ( m n ) O(mn) O(mn)。最壞情況例如:aaaaaabaab

問題:

  • linear-time algorithm?
  • avoid backup(本地的備份)?

1 KMP演算法

DFA版本

如果當前的匹配失敗了,並且pattern已經匹配上 n + 1 n+1 n+1個字元,我們考慮藉助pattern的特徵,減少匹配的次數。

對於KMP演算法,我們考慮其DFA,其中state的編號是當前匹配的pattern的長度。我們可以用DFA的矩陣來描述這個pattern,通過一定的程式求解,用求得的DFA來檢查text即可。

求解DFA的過程:

  • 如果在state[j](前面有j個字元匹配),並且當前字元恰好為p[j],我們前往state[j+1]
  • 否則,我們給已經求解的DFA輸入 p 1 ⋯ p j p_1 \cdots p_j p1pj,少輸入一個開頭的 p 0 p_0 p0。這個序列最多跳轉到當前節點的前一個,正好可以放入我們已經求好的DFA,就可以遞迴地求出在state[j]根據 p j p_j pj跳轉的方式。

虛擬碼:

string pat;
int M = pat.length();
int dfa[R][M];  // pattern長度為M,字符集大小為R
dfa[0][0] = 1;
for (int X = 0, j = 1; j < M; j++) {  // X表示輸入p1...pX到當前DFA
    for (int c = 0; c < R; c++)
        dfa[c][j] = dfa[c][X];  // 所有不匹配的情況都可以複製
    dfa[pat[j]][j] = j + 1;  // 匹配的情況轉移到下一個結點
    X = dfa[pat[j]][X];  // 根據當前DFA更新X
} // 雖然內部有個迴圈,但R是給定常數!

預處理時間和空間複雜度: O ( R M ) O(RM) O(RM)

用DFA處理,如果R很大,比如Unicode,其實也不合適。不過在檢索過程中複雜度是線性的,就還行。

特徵向量版本

改進版本是用一維陣列處理,就是課上講的。

如果有n位匹配,而第n+1位不匹配,我們有: s m ⋯ s m + n = p 0 ⋯ p n − 1 s_m \cdots s_{m+n} = p_0 \cdots p_{n-1} smsm+n=p0pn1。這時候我們考慮最小的 j j j,使得 p 0 ⋯ p n − 1 − j = p j ⋯ p n − 1 p_0 \cdots p_{n-1-j} = p_j \cdots p_{n-1} p0pn1j=pjpn1,也就是找到 p 0 ⋯ p n − 1 p_0 \cdots p_{n-1} p0pn1的一個最長的相同prefix和suffix。為什麼呢?我考慮左手和右手都拿著一個pattern字串,它倆一開始是上下對齊的,這時候我左手不動,右手往右移動,此時兩個字串重合的地方就是相同長度的prefix和suffix。直到重合的部分相等,這時候我就停下來。在停下來之前,重合的部分都不相等,也就是說,當前這段prefix和 s m ⋯ s m + n s_m \cdots s_{m+n} smsm+n的等長suffix是不相等的,我沒有必要做這個檢驗!我只需要等到停下來之後,prefix和suffix相等了,這時候我再開始做檢驗。這樣,i根本不會回退,雖然j有時回退,但是ji總是同時++,因此檢驗的時間只取決於text的長度,是線性的。

注意,最長相等字首字尾必須是真子串!否則會原地不動。

全體j = N[n]構成了pattern字串的特徵向量N。特別地,令N[0]=-1,它的優點會在程式碼中看到。

整理成程式碼長這樣:

int KMPStringMatching(string text, string pattern)
    int n = text.length();
    int m = pattern.length();
    int next[m];  // pattern的特徵向量,假設已知
    int i = 0, j = 0;  // 兩個“指標”
    while (i < n && j < m) {
        if (j == -1 || text[i] == pattern[j]) {  // 匹配的情況,或者j=-1時跳過這個i
            i++;
            j++;
        }
        else {  // 失配的情況
            j = next[j];  // 讓pattern向右移動,表現為指標即這種跳轉
        }
    }
    if (j == m)
        return i - j;  // 找到一開始的匹配位置
    else 
    	return -1;  // 失配
}

求特徵向量的思想是動態規劃:

  • 對於一個匹配的最長字首字尾 p 0 ⋯ p k − 1 p_0 \cdots p_{k-1} p0pk1 p j − k ⋯ p j − 1 p_{j-k} \cdots p_{j-1} pjkpj1,也就是next[j] == k,如果 p k = p j p_k = p_j pk=pj,那麼next[j+1] = k+1
  • 否則,如果 p k ≠ p j p_k \neq p_j pk=pj,令k = next[j],則 p 0 ⋯ p k − 1 p_0 \cdots p_{k-1} p0pk1 p j − k ⋯ p j − 1 p_{j-k} \cdots p_{j-1} pjkpj1仍然相等,這時候繼續討論,直到最小的k都不行,那麼next[j] = 0,表示這樣的k不存在。
int* findNext(string P) {
    int j, k;
    int m = P.length();
    assert (m > 0);
    int *next = new int[m];
    assert (next != 0);
    next[0] = -1; // 注意賦初值!這個時候就是相容的了
    j = 0; k = -1;
    while (j < m - 1) {
        while (k >= 0 && P[k] != P[j])
            k = next[k];
        j++;
        k++;
        next[j] = k;  // next[j+1] = k+1
    }
    return next;
}

在KMP匹配過程中,i永遠不會回退,但是j會根據N[j]回退。

KMP演算法的效率分析:

預處理是 O ( m ) O(m) O(m)的,匹配時間效率是 O ( n ) O(n) O(n)的。

j = N[j]最多執行N次:j增加只有j++,而j = N[j]至少讓j減1。如果執行超過N次,那麼j會得到負數,這肯定不可能。同樣,求特徵向量同理。

特徵向量有兩種求法:一個是求N[j]時不包含p[j],也就是上面的那種方法;一個是包含的。這個不多講了,比較繁瑣

KMP演算法優化

在原來的KMP演算法中,當 t i ≠ p j t_i \neq p_j ti=pj,我們就讓j = next[j],使得pattern串向右移動。令k = next[j],那麼此時和 t i t_i ti對應的是 p k p_k pk。如果本來 p j = p k p_j = p_k pj=pk,那麼 t i = p k t_i = p_k ti=pk,這一步判斷是冗餘的。因此在求next的時候,我們加一行:

while (j < m - 1) {
    while (k >= 0 && P[k] != P[j])
        k = next[k];
    j++;
    k++;
    if (P[j] == P[k]) // 加入的行,也就壓榨了一點效能
        next[j] = next[k];
    else  // 無論條件是否成立,不影響下一步的k的值
    	next[j] = k;
}

事實上這就保證了優化之後對於任何j,都有p[j] != p[next[j]]。用歸納法就可以了。設優化之前,k = next[j] < j, l = next[k] < k,p[l] != p[k] == p[j]。如果k已經是0了,那next[j] = l = -1 ,上面那個表示式都會報錯……

靈活的字串運算

求最長重複字串的下標位置,暴力列舉是 O ( n 4 ) O(n^4) O(n4)。KMP可以在這裡優化嗎?

遍歷所有的子串s[i]~s[n],求每一個子串的特徵向量,然後最好的值是所有特徵向量在這一位的值的最大值。這個是 O ( n 2 ) O(n^2) O(n2)

2 Boyer-Moore演算法

【參考博文】

http://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html

https://blog.csdn.net/sealyao/article/details/4568167

與KMP演算法不同,BM演算法中j是反著走的。i不保證一直往右走,每次pattern右移後,必須從右向左檢查pattern和text的對齊情況。BM演算法不走回頭路是指:pattern始終在往右移動,而不是text的i始終往右移動。

壞字元規則:

對於失配的s[i],我們稱其為壞字元。

  • 如果該字元不存在於p,則pattern右移m位。
  • 否則,s[i]與p中從後往前最近的一個s[i]對齊匹配(也就是s[i]P中最後一次出現的座標;如果沒出現就是-1,也就是上面的情況)。

這個演算法效率的分析其實不太穩定,取決於pattern向右跳多少。最好的情況下,每次都跳pattern的長度,是 O ( n / m ) O(n/m) O(n/m)的;最壞的情況下,每次跳一步,每跳一步都要遍歷整個pattern,是 O ( m n ) O(mn) O(mn)的。

Skip的規則可以預處理,得到一個table。只需要遍歷一遍p即可,時間複雜度 O ( m ) O(m) O(m)

好字尾規則:

我們稱檢查過程中能夠與text匹配的pattern字尾為好字尾。好字尾的位置以最後一個為準。如果好字尾在pattern中只出現一次,那麼上一次出現的位置為-1

失配時,pattern後移至上一個好字尾在pattern中出現的位置。

  • 如果最長的好字尾在pattern中存在,則將pattern向右移動到好字尾的位置。
  • 否則,則從好字尾中找到pattern的最長相等字首字尾。(如果這樣的字首字尾存在,那麼直接將pattern右移m位是不安全的,可能漏解)

兩個規則放在一起,取最大的跳

我們來簡單地分析一下這個演算法。當遇到壞字元的時候,我們根據壞字元規則右移到上一個壞字元與之匹配,因為僅憑一個字元我們就可以斷言:任何更短步長的位移都會失配。而這兩個字元匹配上之後,我們還是要從後往前重新檢查,因此BM演算法只是單純地減少了不必要的操作。當遇到好字尾的時候,如果上一個好字尾在pattern串內部是比較好討論的,和壞字元類似;如果不在,採用了移動最長相等字首字尾的情形,仍然是安全的。

補充: Sunday演算法

【參考部落格】

https://blog.csdn.net/q547550831/article/details/51860017

和BM演算法類似,Sunday演算法考慮text和pattern對齊之後,text參加匹配的最後一個字元的後面一個字元。把它作為壞字元進行移位。不同的地方在於,Sunday演算法從前往後遍歷pattern串。

求偏移表的方式和BM演算法一樣。

3 shift-or algorithm

【參考部落格】

https://blog.csdn.net/weixin_30443813/article/details/99766066

演算法的原理很簡單:維持這樣一個字串集合 S S S,集合中的元素是text的字尾,又是pattern的字首。我們讓pattern不斷右移,更新 S S S,直到pattern ∈ S \in S S

集合可以通過bitset表示:

bitset D[m], D[j] = 1當且僅當 p 0 ⋯ p j = t j − i ⋯ t i p_0 \cdots p_j = t_{j-i} \cdots t_i p0pj=tjiti

初始條件:d[0] = (S[m-1] == T[0])

終止條件:d[m-1] == 1

更新操作:d[j] = 1,當且僅當d[j-1] == 1 && s[i]==p[j]

s[i]==p[j]可以提前求出來,因為它只依賴於pattern中的字元。對於pattern中每一個字元c,都對應一個特徵bitset B[m],其中B[i] == (c == p[i])。因此,更新操作可以寫作D = (D << 1) | 1 & B,其中Bs[i]的特徵bitset。或一個1,保證最低位不恆為0。

上面這種寫法是shift-and,shift-or就是把所有的0和1互換,與和或互換。這樣更新操作不用或那個1,因為右移出來的總是0;但是,需要記得提前把D初始化為1。

4 Karp-Rabin演算法

【參考部落格】

https://blog.csdn.net/Shine__Wong/article/details/102095474

假如有很多模式,和一個需要匹配的串。用一個window來檢索需要匹配的串,對它做hash。因為hash是對輸入敏感的,如果window裡面的東西的hash值和某個pattern的hash值一樣,那我們就匹配上了。這個更方便。

計算hash:設字符集大小為R,那麼每個長度為m的字串都可以看做一個R進位制數(在判斷過程中不考慮終止字元),它有一個多項式表示。由於R是有限大小的,它有唯一確定的指紋fingerprint。

問題在於:

  • 求指紋的時間是 O ( m ) O(m) O(m)
  • R很大時不好表示

解決方案:

  • 相鄰兩個字串的hash值是高度關聯的,體現在多項式表示中。因此,hash(i+1) = (hash(i) * m + p[i+1]
  • 對上述結果再mod M,其中M是個常數,它充分大使得發生衝突的概率很小,但又保證每個串的指紋是唯一的。解決了儲存空間的問題。

5 有窮自動機

它可以定義一套字串的pattern。

用矩陣表示,矩陣中每個元素表示:結點state遇到字元時轉移到哪個結點。用圖表示,是一個有窮個結點的單向圖,每個結點都有編號。從同一個節點出發的邊都有字元表中的字元作為編號,且字元相同時邊相同。

如果一個字串符合有窮自動機deterministic finite state automation (DFA),那麼這個字串匹配當前模式。

在網課當中講到了用DFA定義的KMP演算法,可以參考普林斯頓的演算法網課。

相關文章