1. 程式人生 > >字串匹配:MP,KMP,暴力搜尋等

字串匹配:MP,KMP,暴力搜尋等

看到一篇很好的部落格 

轉一波~

MP/KMP 演算法詳解

By If

Contents

目錄

4  MP 演算法

4.1  原理

9  PS

1  Prologue

本篇文章主要針對的是對字串匹配有興趣的生物以及被某版本資料結構與演算法教材中的 KMP 演算法講解弄得不知所云但與此同時卻還難能可貴地保持著旺盛求知慾的不幸生在了錯誤年代的可憐童鞋,其他生物閱讀本文前請慎重考慮因為它 可能對您的大腦(如果有)、小腦甚至包括脊髓都造成嚴重且不可恢復的創傷 。

2  Notations

下文可能會提到 “模式串”、“文字串”、“視窗” 這些詞,它們的定義如下,如果這些文字使你頭暈,請及時做好救治準備。

模式串、文字串

所謂 模式串 ,是指你想要找到(或者得到位置 &etc.)的字串;而 文字串 ,則是指搜尋的目標字串。

比如說你要在 "lucky dog" 中尋找 "dog" ,那麼 "dog" 是模式串, "lucky dog" 則是文字串;

而你若要在 "If is a lucky dog" 中尋找 "lucky dog" ,那麼 "lucky dog" 便成了模式串, "If is a lucky dog" 則是文字串。

Understand?

視窗

無論用什麼樣的搜尋演算法,在搜尋的過程中,總是需要將模式串與文字串進行比較,它們對齊的那部分割槽域,也就是們關心的那塊區域,咱稱為 視窗 。

另外,為了避免讓已經適應 C/C++/C#/D/Java/JavaScript/Python/Go/... 語言思維的童鞋多繞一個彎,本文用到的陣列下標都以 0 開始 —— 甚至包括費波拉契數列也如此。

3  Main Idea

MP/KMP 字串搜尋演算法的思想精華在於利用已經匹配的部分包含的資訊,加速搜尋的過程。

嗯——已經匹配的部分包含什麼資訊?

它已經匹配了!

舉例說,在某個字串中查詢子串 A B C D A B D A C 時,如果遇到 A B C D A B ,而緊跟其後的不是 D ,這時候我們可以將視窗右移四位(而不是一位),因為既然 A B C D A B 已經匹配了, 那麼移動當前視窗之後 已經匹配過的地方

 肯定需要保證 依然匹配 ,這裡最好的做法即讓 A B 相互對齊:

. . A B C D A B ? . . . . . .
    A B C D A B D A C

=>

. . A B C D A B ? . . . . . .
            A B C D A B D A C

因為,看呀,如果只右移一位,那麼:

. . . . . . . . . . . . . . . // 先不用管這個字串
    A B C D A B . . .         // 已經匹配的部分
=>    A B C D A B D A C
      |
    糟糕!

如上面所示, A B C D A B 匹配了,那麼移動一位之後,第一個字元 A 就肯定會對著 B ,絕對不可能在這個地方找到匹配

右移兩位、三位或者四位時發生的狀況可以依此類推;而右移四位時就不同了:

. . . . . . . . . . . . . . . // 暫時還不用管這個字串
    A B C D A B . . .         // 已經匹配的部分
=>          A B C D A B D A C

這個時候才 可能 成功匹配。

4  MP 演算法

4.1  原理

MP 演算法基於這樣一種觀察:

Note

注意了,這裡以及下面所說的 字首 和 字尾 都是指 不包括自身 的“真”字首或字尾 ( proper prefix/suffix )

發生了不匹配之後,移動視窗時,定要保證 將模式串已匹配部分的一個字首和一個相同的字尾對齊,並使這個字首儘可能長 。

什麼意思?

首先讓我們列出模式串 A B C D A B 的所有字首:

0: /*Empty*/
1: A
2: A B
3: A B C
4: A B C D
5: A B C D A

我們再列出它所有的字尾:

0: /*Empty*/
1: B
2: A B
3: D A B
4: C D A B
5: B C D A B

發現字首 A B == 字尾 A B ,將它們對齊(即,接下來直接從第 3 位開始比較),完美了,前兩位不必重複比較了。

原理上說也不難理解: 從左向右移動視窗的過程就是用字首去匹配字尾的過程 ,而第一次匹配成功的肯定是最長的相同字首/字尾 —— 在上例中,兩個空字串也相等,可是如果將它們對齊的話那可就“移過頭了”。

這麼看,我們發現,在模式串的每一個位置上,匹配失敗之後能最大限度的將視窗移動多少位 —— 即,與什麼位置對齊, 只與模式串在該位置前方的子串有關 ,與文字串無關,與模式串在該點之後的字元也無關。

於是,自然而然的就想到了,為什麼不把這麼個失敗後對齊的位置存放在一個數組中呢,這樣每次匹配失敗之後就按照它的指示進行跳轉。

令 F[i] == max{ j | pattern[0:j) == pattern[i-j+1:i+1) and 0 ,也就是當前位置上保證字首等於字尾的最大長度。

對於模式串 A B C D A B D A C , F 陣列如下:

Index 0 1 2 3 4 5 6 7 8
Pattern A B C D A B D A C
F 0 0 0 0 1 2 0 1 0

繼續扯,在位置 i 匹配失敗之後,可以將視窗繼續右移一位,並從 F[i-1] 位置開始繼續比較模式串與文字串(按照定義,pattern[ 0 : F[i-1] ) 已經保證匹配了),用程式碼表示的話就是這樣:

int MP(char const* pattern, char const* text)
{
    int i=0,j=0,m=strlen(pattern);
    int F[m];
    calcF (pattern, F); // 計算跳轉陣列
    for(;text[i];++i,++j) {
        while(j>=0 && pattern[j]!=text[i]) {
            // 第一個字元不匹配則右移視窗、從 0 開始比較
            if (j==0) {
                j=-1;
                break;
            }
            j = F[j-1]; // 對齊
        }
        if(j>=m-1) {    // 找到了!
            return (i+1-m);
        }
    }
    return -1;
}

—— 多和諧呀!

對了,內個 F 陣列怎麼算?

4.2 跳轉陣列的計算

觀察一下,希望求出擁有相同字尾的最長字首,這個過程不也是一個字串匹配的過程嗎:

1.  A B C D A B D A C
    A B C D A B D A C
    |
    0  // 定義為 0

2.    A B C D A B D A C
    A B C D A B D A C
      |
      0 // 匹配部分的長度

3.      A B C D A B D A C
    A B C D A B D A C
        |
        0

4.        A B C D A B D A C
    A B C D A B D A C
          |
          0

5.6.        A B C D A B D A C
    A B C D A B D A C
            | |
            1 2

7.              A B C D A B D A C
    A B C D A B D A C
                |
                0

8.                A B C D A B D A C
    A B C D A B D A C
                  |
                  1

9.                  A B C D A B D A C
    A B C D A B D A C
                    |
                    0

如上面所示,將模式串向右移動,並與自身做比較,在位置 i 上,pattern[i:] 與 pattern 自身相匹配的部分的長度就是 F[i] 。

注意第6步到第7步! 為什麼可以直接右移兩位呢?

—— 因為 F[1] 已經算出來了!於是我們可以將之前 MP 演算法中的思想用在這裡,聰明的你想到了沒有?

用程式碼來說就是這樣的,看不懂的話我會很傷心:

void calcF(char const* pattern, int* F)
{
    int i=0,j=0;
    for(;pattern[i];++i,++j) {
        while(j>0 && pattern[j-1]!=pattern[i]) {
            j = F[j-1];
        }
        F[i] = j;
    }
}

4.3  另一種表示

對了有沒有人覺得 MP 演算法中對於第一個字元不匹配時的特殊處理感覺到很生硬的?

嗯,其實呢,考慮到第一個字元失敗時的特殊情況其實也不怎麼特殊,不如干脆把這種情況也放到 F 陣列中去統一處理好了:

Index 0 1 2 3 4 5 6 7 8 9
Pattern A B C D A B D A C
F -1 0 0 0 0 1 2 0 1 0

這樣,MP 演算法表達起來更簡單了:

int MP(char const* pattern, char const* text)
{
    int i=0,j=0,m=strlen(pattern);
    int F[m+1];
    calcF (pattern, F); // 計算跳轉陣列
    for(;text[i];++i,++j) {
        while(j>=0 && pattern[j]!=text[i]) {
            j = F[j];   // 對齊
        }
        if(j>=m-1) {    // 找到了!
            return (i+1-m);
        }
    }
    return -1;
}

calcF 也並沒有因此變複雜:

void calcF(char const* pattern, int* F)
{
    int i=0,j=-1;
    F[0]=-1;
    for(;pattern[i];++i,++j) {
        while(j>=0 && pattern[j]!=pattern[i]) {
            j = F[j];
        }
        F[i+1] = j+1;
    }
}

理解之後就會覺得,這種表示方法比較 “省事”;下面咱就都用這種表示得了。

Nevertheless, 其實它們是一碼事。

4.4  可是...

我們再停下來看看 “暴力” 搜尋演算法 —— 有沒有可能暴力演算法比 MP 演算法還快呢?

答案是 “Yes!”

( 哈哈!你輸了吧! )

讓我們想象一下在一個字符集很大的串 —— 比如說 UTF-16 字串吧,中尋找一段模式串;而模式串的第一個字元出現在文字串中的頻率根本就不大,那麼看看第一次匹配失敗時它們兩者工作的流程吧:

MP 演算法 暴力演算法
  1. i>=n?
  2. j>=0? ( 此時 j == 0 )
  3. 比較 pattern[j] 與 text[i]
  4. j = F[j]
  5. j>=0? ( 此時 j == -1 )
  6. j = j+1
  7. j >= m?
  8. i = i+1
  9. 到第 1 步
  1. i>=n?
  2. j = 0
  3. j < m?
  4. 比較 pattern[j] 與 text[i]
  5. i = i+1
  6. 到第 1 步

嗯,所以說,這個地方是有優化空間的,Knuth、Morris、Pratt 的論文 [1] 中有提到,俺就不展開了 —— 因為真正牛B的優化在下面:

5  KMP演算法

其實 MP 演算法的效率還有提升的空間,不過從模式串 A B C D A B D A C 中看不明顯;我們試試這樣一個模式串: A B A B A B C 。

假設在 A B C A B C A B A B A B C A C 中查詢 A B A B A B C ,按照 MP 演算法的思想,先算出 F 陣列:

Pattern A B A B A B C
F -1 0 0 1 2 3 4 0

於是查詢的過程就是這樣的:

1.  A B C A B C A B A B A B C A C
    A B A . . . .

2.  A B C A B C A B A B A B C A C
        A . . . . . .

3.  A B C A B C A B A B A B C A C
          A B A . . . .

4.  A B C A B C A B A B A B C A C
              A . . . . . .

5.  A B C A B C A B A B A B C A C
                A B A B A B C

從第 1 步到第 2 步、從第 3 步到第 4 步,我們發現,字元 A 與 C 的不匹配導致了第一次失敗,然後緊接著又直接導致了第二次失敗。

如此,我們又驚喜的發現,在 A B 之後若是遇到不是 A 的字元,我們完全可以跳三步!因為跳兩步的話算是把 A 對齊了 —— 可是它們會被對齊到一個不是 A 的、將會導致匹配失敗的字元上面去。

這樣的規則有什麼規律呢?我直接放出程式碼吧:

// 這是我們計算 MP 演算法中的 F 陣列的函式:
void calcF(char const* pattern, int* F)
{
    int i=0,j=-1;
    F[0]=-1;
    for(;pattern[i];++i,++j) {
        while(j>=0 && pattern[j]!=pattern[i]) {
            j = F[j];
        }
        F[i+1] = j+1;
    }
}

睜大眼睛準備找茬嘍:

// 只需要改一句話:
void calcF(char const* pattern, int* F)
{
    int i=0,j=-1;
    F[0]=-1;
    for(;pattern[i];++i,++j) {
        while(j>=0 && pattern[j]!=pattern[i]) {
            j = F[j];
        }
        // !這裡!
        F[i+1] = pattern[j+1] == pattern[i+1]?
                   F[j+1]:
                   j+1;
    }
}

為什麼呢?因為對於同一個字元導致的失敗,失敗在前面應該跳到哪裡,到後面就還是應該跳到哪裡。

另外,這個時候咱似乎就比較喜歡把這個陣列稱作 next 陣列了 —— 其實還是同一回事。

那麼, A B A B A B C 的 next 陣列如下,請您欣賞:

Index 0 1 2 3 4 5 6
Pattern A B A B A B C
Next -1 0 -1 0 -1 0 4 0

6  複雜度分析

至於為什麼 KMP 演算法的複雜度是線性的,我們再回頭看看 另一種表示 一節中的演算法主體:

int MP(char const* pattern, char const* text)
{
    int i=0,j=0,m=strlen(pattern);
    int F[m+1];
    calcF (pattern, F);
    for(;text[i];++i,++j) { // j 增大,i 增大
        while(j>=0 && pattern[j]!=text[i]) {
            j = F[j];       // j 減小
        }
        if(j>=m-1) {
            return (i+1-m);
        }
    }
    return -1;
}

i 只有增大的份,所以 ++i 最多執行 n 次,這個很顯然。

j 初始值為 0,一共增加了 n 次,而 j>=-1 ,於是 j = F[j] 這一句最多也就執行了 n+1 次(否則就會出現 j<-1 的情況了)。

所以就是線性的了!

7  KMP 演算法的最長停頓

為了說明 KMP 演算法在文字串上的某一個字元上進行了很多次比較的極限情況(也就是所謂的停頓或者E文的 delay ),我們首先要介紹一下 “費波拉契串” —— 因為,很巧,它就是能使 KMP 演算法達到最糟糕狀況的模式串,一會兒我們會說到。

提到 “費波拉契” ,相信不少人會直接想到 1, 1, 2, 3, 5, 8, 13, 21, 34 ... ,是的,費波拉契串的定義也十分類似:

設 P 是個費波拉契串,那麼:

P[0] = b
P[1] = a
P[i] = P[i-1]P[i-2]

所以:

P[2] = ab
P[3] = aba
P[4] = abaab
P[5] = abaababa
P[6] = abaababaabaab
P[7] = abaababaabaababaababa
P[8] = abaababaabaababaababaabaababaabaab
...

計算出 P[7] 的 F 陣列和 Next 陣列如下,我們一會兒要用到,你也可以先把它當作找規律的題看看:

P a b a a b a b a a b a a b a b a a b a b a
n 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
F -1 0 0 1 1 2 3 2 3 4 5 6 4 5 6 7 8 9 10 11 7 8
Next -1 0 -1 1 0 -1 3 -1 1 0 -1 6 0 -1 3 -1 1 0 -1 11 -1 8

費波拉契串有幾個性質值得注意一下:

Note

文章前面已經假設了我的世界裡費波拉契數列下標是從 0 開始的,這裡再強調一遍。

  1. strlen(P[n]) == Fibonacci[n] (這個應該很容易理解吧)

  2. 設函式 c(t) 的作用是交換字串 t 的最後兩個字元,例如 c("abcdef") == "abcdfe",那麼當 n>=2 時 P[n-1]P[n-2] == c(P[n-2]P[n-1]) :

    n == 2 時這是顯然的;

    當 n>2 時可以用數學歸納法證明:

    c(P[n-2]P[n-1]) = P[n-2]c(P[n-1]) = P[n-2]P[n-3]P[n-2] = P[n-1]P[n-2]

  3. 由上面兩條性質我們又可以推匯出:

    Next[Fibonacci[n]-2] == F[Fibonacci[n]-2] == Fibonacci[n-1]-2, n>=2

    這是因為,可以把一個費波拉契串分解開:

    P[n] == P[n-1]P[n-2] == P[n-2]P[n-3]P[n-2] == c(P[n-3]P[n-2])P[n-2] == P[n-3]c(P[n-2])P[n-2] == ...

    具體以 P[7] 為例,

    P[7] == ab-ab-ba---ab------ba

    其中省略掉的部分根據 性質2 表現出的規律與前方相等,因此如果在 P[7] 的最後一個字元 b 處發生了不匹配,接下來應該在下列位置重新試著匹配:

    ab-a--b----a-------b-

    它們正好佔據著 Fibonacci[2]-2, Fibonacci[3]-2, .. , Fibonacci[7]-2, ... 的位置。

因此,如果在費波拉契串的第 n 位,n == Fibonacci[k]-2 上發生了不匹配,接下來則還需要 k-1 次比較;

又因為 Fibonacci[k-1] == (φk - (-1)kφ-k)/sqrt(5) == round( φk / sqrt(5) ), 於是可以解得 k ~ logφ(n),其中 φ 是黃金比例 (1+sqrt(5))/2 == 1.618...

—— k 便是文字串上的一個字元的最多比較次數。

8  為什麼費波拉契串那麼神奇

為了證明為何費波拉契串就是使停頓時間最長的模式串,我們再看看 MP 演算法的基本思想:將字串已匹配的一個字首和一個對應的字尾匹配。

假設字串 S 有且僅有一個相等的字首和字尾(設為 a ),那麼 S 可以表示為

S = aB = Ca

再假設 a 本身也有且僅有一個相等的字首和字尾(設為 e ),那麼 a 也可以表示為

a = eF = Ge

對應 MP 演算法,匹配 Ca 時若在 a 之後失敗,則會將 aB 的 a 與其對齊:

Cax
Cay

==>

Cax
 aBy

若在 B 的第一個字元處再次失敗,則下一次對齊是這樣的:

CGex
 GeBy

==>

CGex
  eFBy

KMP 演算法在這裡還要求 F 的第一個字元和 B 的第一個字元不等(否則會跳過這一段)

我們很容易可以證明想要 KMP 演算法在這個地方停留儘可能長的時間需要滿足 |S| <= |e| + |a| :因為若 |e| + |a| > |S|,那麼令 d = |e| + |a| - |S| 則 Ca = CGe = aB 算式中, a 和 e 將有長度為 d 的重疊,於是 B 的第一個字元等於 e[d];同理,在 aB = eFB = Ca 算式中,可以得到 F 的第一個字元為 a[d],由 a = eF 可以得到 a[d] = e[d],和 KMP 的要求不符。

於是 |S| == |e| + |a| 是使 KMP 演算法的停頓時間達到最長的極限情況——很容易發現,滿足這條件的便是費波拉契串了。

9  PS

對了,如果我要找出模式串在文字串中所有的出現怎麼辦?

提示一下:目前為止 F 陣列(或者 next 陣列)的最後一個元素我們還沒有用到過是不是?

10 References

[1] KNUTH D.E., MORRIS (Jr) J.H., PRATT V.R., 1977, Fast pattern matching in strings, SIAM Journal on Computing 6(1):323-350.