[演算法] KMP演算法簡介

優化的字串搜尋演算法

字串搜尋經常出現在日常生活中,因此優化後的字串搜尋演算法必然是大家所追求的目標,而KMP演算法(Knuth–Morris–Pratt algorithm)便是其中之一。在此整理網路上關於KMP的資料。

符號定義

字串搜尋便是在文字材料(text)中找到一些字詞(pattern)。因此定義T代表要搜索的文章(字串),P則代表使用者要找的字詞。

本文的所有字串都是由零開始的陣列。若P = "ALGORITHM",則P[1]Lij分別代表TP的索引。

P[0 ... j]表示包含0但不包含j的字元,在數學上用區間表示即是[0,j)。若P = "ALGORITHM",則P[2 ... 5] = "GOR"

暴力法

TP頭對頭(i = 0j = 0),一次取一字一一比對。

  • T[i] == P[j],則i++j++
  • T[i] != P[j],則i = i - j + 1j = 0

時間複雜度O(mn),空間複雜度O(1)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
T = ABCDAABCDABD
P = ABCDABD
----------------
1.
T = ABCDAABCDABD
P = ABCDABD
    ^^^^^X

2.
T = ABCDAABCDABD
P =  ABCDABD
     X

3.
T = ABCDAABCDABD
P =   ABCDABD
      X

4.
T = ABCDAABCDABD
P =    ABCDABD
       X

5.
T = ABCDAABCDABD
P =     ABCDABD
        ^X

6.
T = ABCDAABCDABD
P =      ABCDABD
         ^^^^^^#

改良

暴力法的精神便是逐一比對,但事實上不必如此。觀察上方第二至五步,若能一次性將P移動至第五步的位置便能減少挪動次數而增加比對效率,尤其T為長字串時效果更為顯著。

第一步比對失敗時,j = 5。我們發現,可以藉由預先分析P[0 ... 5] = "ABCDA"(從P[0]到第一步比對失敗的子字串)來決定P要挪動多少。

接下來介紹實現此想法所需的前置作業。

Failure Function

中文簡稱F函數,又稱prefix functionborder function。給定一字串,它便會輸出「次長相同前綴後綴(longest proper prefix-suffix)」的長度。一個字串可能有不只一個「相同前綴後綴(prefix-suffix)」,而「最長相同前綴後綴」肯定是字串本身;「次長相同前綴後綴」便可顧名思義。

字串相同前綴後綴次長相同前綴後綴F函數
ABCDEABCDE0
ABCDAAABCDAA1
ABCABABABCABAB2
ABCBAAABCBAA1
AAAAAAAAAAAAAAAAAAAAAAAA4

部分匹配表

我們為P建立部分匹配表(Partial match table)來決定比對失敗時所要挪動的幅度。在此以P = "ABCDABD"為例。

j0123456
P[j]ABCDABD
next[j]

P[0]比對失敗後,必須將P右移才能繼續比對,因此設next[j] = -1

j0123456
P[j]ABCDABD
next[j]-1

j > 0的情況下,next[j] = F(P[0 ... j])。例如:

  • next[1] = F(P[0 ... 1]) = F("A") = len("") = 0
  • next[5] = F(P[0 ... 5]) = F("ABCDA") = len("A") = 1
  • next[6] = F(P[0 ... 6]) = F("ABCDAB") = len("AB") = 2
j0123456
P[j]ABCDABD
next[j]-1000012

其原理便是:當比對失敗時,部分匹配表使你可以直接從next[j]開始繼續搜尋而避免「挪動、比對、失敗」循環(暴力法第二到四步),便達成減少挪動字數的目的。

接著便利用next[j]來搜尋字串:

  • T[i] == P[j],則i++j++
  • T[i] != P[j],則j = next[j]
    • j == -1,則i++j++

時間複雜度O(m+n),空間複雜度O(n)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
T = ABCDAABCDABD
P = ABCDABD
----------------
1.
T = ABCDAABCDABD
P = ABCDABD
    ^^^^^X

2.
T = ABCDAABCDABD
P =     ABCDABD
         X

3.
T = ABCDAABCDABD
P =      ABCDABD
         ^^^^^^#

此演算法衍生的另一個優點便是,不用每次都從P[0]開始比較,因為F函數已經保證移動後「次長相同前綴後綴」的前綴部分和T的部分相同,如上方的步驟二。

有文獻稱這種演算法為MP演算法(Morris–Pratt algorithm)。

KMP演算法

觀察上面的演算法,在第一步遇到不匹配的字元P便右移,第二步時P[1]又不等於T[5],須再右移一次。為了使演算法更優化,這時便要藉由優化next避免連續挪移

再以P = "ABCDABD"為例並找出連續挪移的條件。P[0 ... 5] = "ABCDA"的「次長相同前綴後綴」為P[0 ... 1] = P[4 ... 5] = "A",若P[0 .. 1]P[4 ... 5]下一個字元相同(都是'B'),則第一次移動後所比較的字元也會相同('A' != 'B'),因此P必須再移動一次。

1
2
3
4
5
6
P          = ABCDABD
P[0 ... 5] = ABCDA
             !*  !*
--------------------
'!' => 次長相同前綴後綴
'*' => 次長相同前綴後綴的下一個字元

newNextnext的優化版,以實現「避免連續挪移」。

j0123456
P[j]ABCDABD
next[j]-1000012
newNext[j]

定義newNext[0] = -1,原因見上一節。若P[0 ... j]的「次長相同前綴後綴」的前綴和後綴沒有相同的「下一個字元」,則newNext[j] = next[j]

j0123456
P[j]ABCDABD
next[j]-1000012
newNext[j]-10002

若有相同的下一個字元,則藉遞迴法使其移動時一次到位,即newNext[j] = newNext[next[j]]。在本例中:

j45
P[0 ... j]ABCDABCDA
次長相同前綴後綴的前綴P[0 ... 0]P[0 ... 1]
次長相同前綴後綴的後綴P[4 ... 4]P[4 ... 5]
相同的下一個字元AB
newNext[j]-10

註:P[0 ... 4]的次長前綴後綴為空,但其仍有相同的下一個字元。

newNext[j]填完:

j0123456
P[j]ABCDABD
next[j]-1000012
newNext[j]-1000-102

接著便利用newNext[j]來搜尋字串,規則同上方的改良版:

  • T[i] == P[j],則i++j++
  • T[i] != P[j],則j = newNext[j]
    • j == -1,則i++j++

時間複雜度O(m+n),空間複雜度O(n)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
T = ABCDAABCDABD
P = ABCDABD
----------------
1.
T = ABCDAABCDABD
P = ABCDABD
    ^^^^^X

2.
T = ABCDAABCDABD
P =      ABCDABD
         ^^^^^^#

參考資料

updatedupdated2020-10-042020-10-04