字串搜尋經常出現在日常生活中,因此優化後的字串搜尋演算法必然是大家所追求的目標,而KMP演算法(Knuth–Morris–Pratt algorithm)便是其中之一。在此整理網路上關於KMP的資料。
符號定義
字串搜尋便是在文字材料(text)中找到一些字詞(pattern)。因此定義T
代表要搜索的文章(字串),P
則代表使用者要找的字詞。
本文的所有字串都是由零開始的陣列。若P = "ALGORITHM"
,則P[1]
是L
。i
和j
分別代表T
和P
的索引。
P[0 ... j]
表示包含0
但不包含j
的字元,在數學上用區間表示即是[0,j)
。若P = "ALGORITHM"
,則P[2 ... 5] = "GOR"
。
暴力法
T
和P
頭對頭(i = 0
、j = 0
),一次取一字一一比對。
- 若
T[i] == P[j]
,則i++
、j++
- 若
T[i] != P[j]
,則i = i - j + 1
、j = 0
時間複雜度O(mn),空間複雜度O(1)。
|
|
改良
暴力法的精神便是逐一比對,但事實上不必如此。觀察上方第二至五步,若能一次性將P
移動至第五步的位置便能減少挪動次數而增加比對效率,尤其T
為長字串時效果更為顯著。
第一步比對失敗時,j = 5
。我們發現,可以藉由預先分析P[0 ... 5] = "ABCDA"
(從P[0]
到第一步比對失敗前的子字串)來決定P
要挪動多少。
接下來介紹實現此想法所需的前置作業。
Failure Function
中文簡稱F函數,又稱prefix function、border function。給定一字串,它便會輸出「次長相同前綴後綴(longest proper prefix-suffix)」的長度。一個字串可能有不只一個「相同前綴後綴(prefix-suffix)」,而「最長相同前綴後綴」肯定是字串本身;「次長相同前綴後綴」便可顧名思義。
字串 | 相同前綴後綴 | 次長相同前綴後綴 | F函數 |
---|---|---|---|
ABCDE | ∅ 、ABCDE | ∅ | 0 |
ABCDA | ∅ 、A 、ABCDA | A | 1 |
ABCAB | ∅ 、AB 、ABCAB | AB | 2 |
ABCBA | ∅ 、A 、ABCBA | A | 1 |
AAAAA | ∅ 、A 、AA 、AAA 、AAAA 、AAAAA | AAAA | 4 |
部分匹配表
我們為P
建立部分匹配表(Partial match table)來決定比對失敗時所要挪動的幅度。在此以P = "ABCDABD"
為例。
j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
P[j] | A | B | C | D | A | B | D |
next[j] |
當P[0]
比對失敗後,必須將P
右移才能繼續比對,因此設next[j] = -1
。
j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
P[j] | A | B | C | D | A | B | D |
next[j] | -1 |
在j > 0
的情況下,next[j] = F(P[0 ... j])
。例如:
next[1]
=F(P[0 ... 1])
=F("A")
=len("")
= 0next[5]
=F(P[0 ... 5])
=F("ABCDA")
=len("A")
= 1next[6]
=F(P[0 ... 6])
=F("ABCDAB")
=len("AB")
= 2
j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
P[j] | A | B | C | D | A | B | D |
next[j] | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
其原理便是:當比對失敗時,部分匹配表使你可以直接從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)。
|
|
此演算法衍生的另一個優點便是,不用每次都從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
必須再移動一次。
|
|
newNext
為next
的優化版,以實現「避免連續挪移」。
j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
P[j] | A | B | C | D | A | B | D |
next[j] | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
newNext[j] |
定義newNext[0] = -1
,原因見上一節。若P[0 ... j]
的「次長相同前綴後綴」的前綴和後綴沒有相同的「下一個字元」,則newNext[j] = next[j]
。
j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
P[j] | A | B | C | D | A | B | D |
next[j] | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
newNext[j] | -1 | 0 | 0 | 0 | 2 |
若有相同的下一個字元,則藉遞迴法使其移動時一次到位,即newNext[j] = newNext[next[j]]
。在本例中:
j | 4 | 5 |
---|---|---|
P[0 ... j] | ABCD | ABCDA |
次長相同前綴後綴的前綴 | P[0 ... 0] | P[0 ... 1] |
次長相同前綴後綴的後綴 | P[4 ... 4] | P[4 ... 5] |
相同的下一個字元 | A | B |
newNext[j] | -1 | 0 |
註:
P[0 ... 4]
的次長前綴後綴為空,但其仍有相同的下一個字元。
將newNext[j]
填完:
j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
P[j] | A | B | C | D | A | B | D |
next[j] | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
newNext[j] | -1 | 0 | 0 | 0 | -1 | 0 | 2 |
接著便利用newNext[j]
來搜尋字串,規則同上方的改良版:
- 若
T[i] == P[j]
,則i++
、j++
- 若
T[i] != P[j]
,則j = newNext[j]
- 若
j == -1
,則i++
、j++
- 若
時間複雜度O(m+n),空間複雜度O(n)。
|
|