[譯] Swift 演算法學院 - KMP 字串搜尋演算法

KeithSummer發表於2018-04-02

本篇是來自 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]Pi 位置結束的最長子字串就是 P 的一個字首。(譯者注:字首指除了最後一個字元以外,一個字串的全部頭部組合;字尾指除了第一個字元以外,一個字串的全部尾部組合。字首和字尾的最長的共有元素的長度就是 suffixPrefix 要存的值)。比如 P = "abadfryaabsabadffg",則 suffixPrefix[4] = 0subffixPrefix[9] = 2subffixPrefix[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 位置,發現 GA 不匹配。像上面那樣,繼續根據 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),這裡 nP 的模式串長度。完成後,在查詢階段複雜度也不會超出文字 T 長度(設為 m )。可以證明查詢階段的比較次數邊界為 2 * m。所以 KMP 演算法複雜度為 O(n + m)

注意:如果你要執行 KnuthMorrisPratt.swift 需要拷貝 Z-Algorithm 資料夾下的 ZAlgorithm.swiftKnuthMorrisPratt.playground 已經包含 Zeta 函式。

宣告:這段程式碼是基於 1997年 CUP Dan Gusfield 的《Algorithm on String, Trees and Sequences: Computer Science and Computational Biology》 手冊。

作者 Matteo Dunnhofer,譯者 KeithMorning

譯者注:由於本文原文在分析 KMP 演算法上面明顯不夠用(雖然加了好多註釋,?),關鍵的 Next 陣列演算法又沒說明白,想繼續挖坑的同學,推薦以下三篇文章,絕對夠用。

相關文章