本篇是來自 Swift 演算法學院的翻譯的系列文章,Swift 演算法學院 致力於使用 Swift 實現各種演算法,對想學習演算法或者複習演算法的同學非常有幫助,講解思路非常清楚,每一篇都有詳細的例子解釋。 更多翻譯的文章還可以檢視這裡。
Knuth-Morris-Pratt 字串搜尋演算法
目標:用 Swift 寫一個線性的字串搜尋演算法,返回模式串匹配到所有索引值。
換句話說就是,實現一個 String
的擴充套件方法 indexesOf(Pattern:String)
,函式返回 [Int]
表示模式串搜尋到的所有索引值,如果沒有匹配到,返回 nil
。
舉例如下:
let dna = "ACCCGGTTTTAAAGAACCACCATAAGATATAGACAGATATAGGACAGATATAGAGACAAAACCCCATACCCCAATATTTTTTTGGGGAGAAAAACACCACAGATAGATACACAGACTACACGAGATACGACATACAGCAGCATAACGACAACAGCAGATAGACGATCATAACAGCAATCAGACCGAGCGCAGCAGCTTTTAAGCACCAGCCCCACAAAAAACGACAATFATCATCATATACAGACGACGACACGACATATCACACGACAGCATA"
dna.indexesOf(ptnr: "CATA") // Output: [20, 64, 130, 140, 166, 234, 255, 270]
let concert = "?????????????"
concert.indexesOf(ptnr: "??") // Output: [6]
複製程式碼
Knuth-Morris-Pratt 演算法被公認是字串匹配查詢的最好演算法之一。雖然 Boyer-Moore 簡單,也同樣只需要線性的時間複雜度。
這個演算法後的思想和原來的 暴力字串搜尋演算法 沒什麼不同,KMP 和它同樣將字串從左到右依次比較,但是與之不同的是不會在字串不匹配時移動一個字元,而是用了更聰明的方式移動模式串。實際上這個演算法對模式串特徵做了預處理,使得它獲得足夠的資訊能跳過不必要的比較,所以可以移動更多的距離。
預處理後得到一個整型陣列(程式碼中命名為 suffixPrefix
),陣列每個元素 suffixPrefix[i]
記錄的是 P[0...i]
( P
是模式串 )最長的的字尾等於其字首的長度。換句話說,suffixPrefix[i]
是 P
以 i
位置結束的最長子字串就是 P
的一個字首。(譯者注:字首指除了最後一個字元以外,一個字串的全部頭部組合;字尾指除了第一個字元以外,一個字串的全部尾部組合。字首和字尾的最長的共有元素的長度就是 suffixPrefix
要存的值)。比如 P = "abadfryaabsabadffg"
,則 suffixPrefix[4] = 0
,subffixPrefix[9] = 2
,subffixPrefix[14] = 4
。(譯者注:以 suffixPrefix[9]
為例,計運算元字串 abadfryaab
, 其字首集合為 a, ab,aba,abad,abadf,abadfr,abadfry,abadfrya,abadfryaa
和字尾集合為 b,ab,aab,yaab,ryaab,fryaab,dfryaab,adfryaab,badfryaab
,相同的有 ab
,因為匹配的只有一個,也就是最長值了,其長度為 2 ,因此 subffixPrefix[9] = 2
。)計算這個並不複雜,可以使用如下的程式碼實現:
for patternIndex in (1 ..< patternLength).reversed() {
textIndex = patternIndex + zeta![patternIndex] - 1
suffixPrefix[textIndex] = zeta![patternIndex]
}
複製程式碼
簡單計算一下以索引值結束,以 i
開始的子字串與 P
字首是否匹配。把(匹配上的最長的)字串長度賦值給suffixPrefix
陣列的 Index 值 。
完成 suffixPrefix
偏移陣列後,演算法第一步就是嘗試與模式串各個字元比較,如果比較成功,繼續比較下一個,如果全部匹配,則直接移向下一段文字,否則需要將模式串右移,右移的位數根據 suffixPrefix
,它能夠保證字首 P[0…suffixPrefix[i]]
能夠與對應的字元(字尾)相匹配(譯者注:實際就是把字尾的位置替換為相同的字首的位置)。通過這種方式可以大大減少匹配的次數,可以加快很多。
如下為 KMP 演算法實現:
extension String {
func indexesOf(ptnr: String) -> [Int]? {
let text = Array(self.characters)
let pattern = Array(ptnr.characters)
let textLength: Int = text.count
let patternLength: Int = pattern.count
guard patternLength > 0 else {
return nil
}
var suffixPrefix: [Int] = [Int](repeating: 0, count: patternLength)
var textIndex: Int = 0
var patternIndex: Int = 0
var indexes: [Int] = [Int]()
/* 預處理程式碼: 通過 Z-Algorithm 演算法計算移動用的表*/
let zeta = ZetaAlgorithm(ptnr: ptnr)
for patternIndex in (1 ..< patternLength).reversed() {
textIndex = patternIndex + zeta![patternIndex] - 1
suffixPrefix[textIndex] = zeta![patternIndex]
}
/* 查詢程式碼:查詢模式串匹配值 */
textIndex = 0
patternIndex = 0
while textIndex + (patternLength - patternIndex - 1) < textLength {
while patternIndex < patternLength && text[textIndex] == pattern[patternIndex] {
textIndex = textIndex + 1
patternIndex = patternIndex + 1
}
if patternIndex == patternLength {
indexes.append(textIndex - patternIndex)
}
if patternIndex == 0 {
textIndex = textIndex + 1
} else {
patternIndex = suffixPrefix[patternIndex - 1]
}
}
guard !indexes.isEmpty else {
return nil
}
return indexes
}
}
複製程式碼
下面讓我們解釋一下上面的程式碼。如果 P = "ACTGACTA"
,suffixPrefix
的結果為 [0, 0, 0, 0, 0, 0, 3, 1]
,文字為 "GCACTGACTGACTGACTAG"
。演算法開始的比較過程如下,先比較 T[0]
和 P[0]
。
1
0123456789012345678
text: GCACTGACTGACTGACTAG
textIndex: ^
pattern: ACTGACTA
patternIndex: ^
x
suffixPrefix: 00000031
複製程式碼
比較後發現不匹配,下一步比較 T[1]
和 P[0]
,不幸的是要檢查模式串不一致,因此需要繼續向右移動模式串,移動多少需要查詢 suffixPrefix[1 - 1]
。如果值是 0
,需要再比較 T[1]
和 P[0]
。但還是不匹配,所以我們繼續比較 T[2]
和 P[0]
。
1
0123456789012345678
text: GCACTGACTGACTGACTAG
textIndex: ^
pattern: ACTGACTA
patternIndex: ^
suffixPrefix: 00000031
複製程式碼
這次有相同的字元了,但也是至相同到第 8
位置,不幸的是匹配的長度與模式串長度並不相同,因此不能認為是相同的,但還是有辦法的,我們可以用 suffixPrefix
陣列存的值,匹配的長度是 7
, 檢視 suffixPrefix[7-1]
的值是 3
。這個資訊告訴我們 P
的字首與 T[0...8]
的子字串是有匹配。suffixPrefix
陣列保證我們模式串有兩個子字串是與之匹配的,因此不用再進行比較,我們可以直接大幅向右移動模式串!
從 T[9]
和 P[3]
重新比較。
1
0123456789012345678
text: GCACTGACTGACTGACTAG
textIndex: ^
pattern: ACTGACTA
patternIndex: ^
suffixPrefix: 00000031
複製程式碼
繼續比較直到第 13 位置,發現 G
和 A
不匹配。像上面那樣,繼續根據 suffixPrefix
陣列進行右移。
1
0123456789012345678
text: GCACTGACTGACTGACTAG
textIndex: ^
pattern: ACTGACTA
patternIndex: ^
suffixPrefix: 00000031
複製程式碼
再次進行比較,這次我們終於找到一個,從位置 17 - 7 = 10
。
1
0123456789012345678
text: GCACTGACTGACTGACTAG
textIndex: ^
pattern: ACTGACTA
patternIndex: ^
suffixPrefix: 00000031
複製程式碼
演算法再繼續比較 T[18]
和 P[1]
,(因為 suffixPrefix[8 - 1] = 1),但是並不相同,在下次迴圈後也就停止計算了。
預處理階段只涉及到模式串,執行 Z-Algorithm 演算法是線性的,只需要 o(n)
,這裡 n
是 P
的模式串長度。完成後,在查詢階段複雜度也不會超出文字 T
長度(設為 m
)。可以證明查詢階段的比較次數邊界為 2 * m
。所以 KMP 演算法複雜度為 O(n + m)
。
注意:如果你要執行 KnuthMorrisPratt.swift 需要拷貝 Z-Algorithm 資料夾下的 ZAlgorithm.swift。 KnuthMorrisPratt.playground 已經包含
Zeta
函式。
宣告:這段程式碼是基於 1997年 CUP Dan Gusfield 的《Algorithm on String, Trees and Sequences: Computer Science and Computational Biology》 手冊。
作者 Matteo Dunnhofer,譯者 KeithMorning
譯者注:由於本文原文在分析 KMP 演算法上面明顯不夠用(雖然加了好多註釋,?),關鍵的 Next
陣列演算法又沒說明白,想繼續挖坑的同學,推薦以下三篇文章,絕對夠用。