[譯] Swift 演算法學院 – Z-Algorithm 字串搜尋

KeithSummer發表於2019-03-03

本篇是來自 Swift 演算法學院的翻譯的系列文章,Swift 演算法學院 致力於使用 Swift 實現各種演算法,對想學習演算法或者複習演算法的同學非常有幫助,講解思路非常清楚,每一篇都有詳細的例子解釋。 更多翻譯的文章還可以檢視這裡

前言

最近幾篇演算法都是關於字串查詢的,而此次的演算法 Z-Algorithm 是一個很有趣來看看。

Z-Algorithm 字串搜尋

目標:給定一個模式串,用 Swift 寫一個線性時間複雜度的匹配演算法,返回在字串中出現的位置。

換而言之,我們需要實現一個 String 的擴充套件方法 indexsOf(pattern:String), 能夠返回一個 [Int] 陣列代表所有模式串出現的索引位置,或者返回 nil 如果沒有在字串中找到。

舉個例子:

let str = "Hello, playground!"
str.indexesOf(pattern: "ground")   // Output: [11]

let traffic = "??????????????????????"
traffic.indexesOf(pattern: "?") // Output: [4, 21]
複製程式碼

許多字串搜尋演算法都會有一個預處理函式計算一個表用在隨後的計算過程中。這個表可以可以節省模式串匹配階段的一些時間,因為可以避免一些不必要的字串比較。Z-Algorithm 就是眾多預處理函式的一種。儘管它生為預處理函式(在 KMP 演算法和其他演算法中就承擔了一個這樣的角色),但是本文將介紹如何將它作為字串搜尋演算法使用。

Z-Algorithm 模式串的字首

正如本文所說,Z-Algorithm 是演算法開始部分通過處理模式串計算出一個跳過非必要比較的表。Z-Algorithm 計算模式串後得到一個整數陣列(文獻中稱之為 Z)每個元素稱作 Z[i], 表示模式串 P 的以 i 開始的最長子字串的字首與 P 的字首相匹配的長度。簡而言之就是 Z[i] 記錄了 P[i...|P|] 最長的與 P 字首相同的字首。舉個例子,假設 P = "ffgtrhghhffgtggfredg"。那麼 z[5] =0 (f...h...)z[9] = 4 (ffgtr...ffgtg...)z[15] = 1 (ff..fr..)。(譯者注:好吧,這個例子其實很難看,相信你可能數的眼都花了,這裡 z[5] = hghhffgtggfredg 與原字串比較字首一個都沒有所以結果為0,而 z[9] = ffgtggfredg 與原字串比較一下結果為 ffgt 相同,結果為 4。)

但是我們如何計算 Z? 在介紹這個演算法之前,我們需要先介紹一個下 Z-box 這個概念。 一個 Z-Box 含有 (left,right) 一對值,用來在計算過程中記錄子字串與 P 字首相同的長度。leftright 這兩個索引值各自代表子字串的左邊界和右邊界索引。Z-Algorithm 定義比較感性,它從 k-1 開始,計算了模式串中每個位置 k。演算法背後的思想是之前計算的值可以加快 Z[k + 1] 的演算,避免重複已經比較過的。思考一下:如果迭代到 k = 100, 分析模式串 100 位置如何計算。所有的 Z[1]Z[99] 已經計算過並且 left = 70, right = 120。這意味著子字串長度為 51 且是從 70 開始到 120結束,而且還是與模式串字首相匹配的。推理一下後可以認為從 100 開始,長度為 21 的字元與模式串中從 30 開始長度為 21 的子字串相匹配(因為我們是在一個與模式串字首相匹配的子字串中)。因此我們可以避免額外的比較直接用 Z[30] 來計算 Z[100]

這是這個演算法背後的簡單思想。無法通過之前計算的值直接進行處理的情況很少,但還是有一些比較需要處理一下。

下面是計算 Z-array 的程式碼:

func ZetaAlgorithm(ptrn: String) -> [Int]? {

    let pattern = Array(ptrn.characters)
    let patternLength: Int = pattern.count

    guard patternLength > 0 else {
        return nil
    }

    var zeta: [Int] = [Int](repeating: 0, count: patternLength)

    var left: Int = 0
    var right: Int = 0
    var k_1: Int = 0
    var betaLength: Int = 0
    var textIndex: Int = 0
    var patternIndex: Int = 0

    for k in 1 ..< patternLength {
        if k > right {  //在 Z-box 之外: 比較字串直到不匹配
            patternIndex = 0

            while k + patternIndex < patternLength  &&
                pattern[k + patternIndex] == pattern[patternIndex] {
                patternIndex = patternIndex + 1
            }

            zeta[k] = patternIndex

            if zeta[k] > 0 {
                left = k
                right = k + zeta[k] - 1
            }
        } else {  // 在 Z-box 中
            k_1 = k - left + 1
            betaLength = right - k + 1

            if zeta[k_1 - 1] < betaLength { // 全部在 Z-box 中: 可以使用之前計算過的
                zeta[k] = zeta[k_1 - 1]
            } else if zeta[k_1 - 1] >= betaLength { // 不全在 Z-box 中: 必須處理一些比較
                textIndex = betaLength
                patternIndex = right + 1

                while patternIndex < patternLength && pattern[textIndex] == pattern[patternIndex] {
                    textIndex = textIndex + 1
                    patternIndex = patternIndex + 1
                }

                zeta[k] = patternIndex - k
                left = k
                right = patternIndex - 1
            }
        }
    }
    return zeta
}
複製程式碼

讓我們舉例說明上面程式碼。假設 P = “abababbb” 。演算法從 k = 1 開始, left = right = 0 。因此沒有“啟用” Z-box ,又因為 k > right 開始比較 P[1]p[0]

   01234567
k:  x
   abababbb
   x
Z: 00000000
left:  0
right: 0
複製程式碼

由於第一次比較後以 P[1] 開始的字串與 P 字首不匹配,因此 z[1] = 0leftright 也沒動。開始繼續下去 k = 2,我們有 2 > 0 ,因此繼續比較 P[2]P[0]。這次字元匹配了,繼續比較直到不匹配。在第 6 的位置發生不匹配,相匹配的字元共有 4 個,因此 Z[2] = 4left = k = 2 , right = k + Z[k] - 1 = 5。現在有了第一個 Z-box, 字串為 "abab"(注意與 P 的字首相匹配) ,以 left = 2 開始。

   01234567
k:   x
   abababbb
   x
Z: 00400000
left:  2
right: 5
複製程式碼

開始處理 k = 3。因此 3 < = 5 ,因此在之前計算的 Z-box 中並也是 P 的字首的一部分。因此看一下之前計算的結果, k_1 = k - left = 1 是前面 與P[k] 相同的字元,Z[1] = 0 並且 0 < (right - k + 1 = 3),所以一定是在 Z-box 範圍內,可以直接使用之前計算的值。令 Z[3] = Z[1] = 0leftright 保持不變。

計算 k = 4 會執行 外層 ifelse 邏輯分支。在內部 if 位置 k_1 = 2(Z[2] = 4) >= 5 - 4 + 1 。因此子字元 P[k...r]P 的前 right - k + 1 = 2 個字元匹配,但是後面的並不知道。所以必須繼續比較從 r + 1 = 6 位置字元和right - k + 1 = 2 位置的字元。 由於 P[6] != P[2], 所以結果為 Z[k] = 6 - 4 = 2, left = 4right = 5

   01234567
k:     x
   abababbb
   x
Z: 00402000
left:  4
right: 5
複製程式碼

迴圈到 k = 5,因為 k <= right(Z[k_1] = 0) < (right - k + 1 = 1), 結果 Z[k] = 0。 繼續迴圈 67,執行外層 if 的第一個分支,但是都是不匹配,但是 演算法得到的 Z-陣列 為 Z = [0, 0, 4, 0, 2, 0, 0, 0]

Z-Algorithm 演算法是線性時間複雜度,進一步說,Z-Algorithm 計算長度為 n 的字串 P 時間複雜度為 O(n)

字串預處理Z-Algorithm 演算法實現在 ZAlgorithm.swift 檔案中。

Z-Algorithm 字串搜尋演算法

上面討論的 Z-Algorithm 是最簡單的線性時間複雜度的字串匹配演算法。只需要將模式串 P 和 文字 T 連線到一個字元中 S = P$T, 這裡 $ 是一個不在 P 或者 T 中的字元。用上面的演算法計算 S 得到 Z 陣列。現在只需要遍歷一下 Z 找到等於 n (模式串長度)的元素。如果找到了就算找到了。

extension String {

    func indexesOf(pattern: String) -> [Int]? {
        let patternLength: Int = pattern.characters.count
        /* 用 Z-Algorithm 計算模式串和文字連線後的字串 */
        let zeta = ZetaAlgorithm(ptrn: pattern + "?" + self)

        guard zeta != nil else {
            return nil
        }

        var indexes: [Int] = [Int]()

        /* 遍歷 zeta 陣列嘗試找匹配的模式串 */
        for i in 0 ..< zeta!.count {
            if zeta![i] == patternLength {
                indexes.append(i - patternLength - 1)
            }
        }

        guard !indexes.isEmpty else {
            return nil
        }

        return indexes
    }
}
複製程式碼

舉個例子吧,令 P = “CATA”T = "GAGAACATACATGACCAT" 作為模式串和待查文字。把他們用 $ 連線起來,得到 S = "CATA$GAGAACATACATGACCAT"。用演算法計算後得到如下結果:

            1         2
  01234567890123456789012
  CATA$GAGAACATACATGACCAT
Z 00000000004000300001300
            ^
複製程式碼

遍歷 Z 陣列在 10 的位置我們找到 Z[10] = 4 = n。因此可以認為在文字 10 - n - 1 = 5 的位置找到了匹配的字串。

正如之前說的那樣,這個演算法複雜度是線性的。定義nm 作為模式串和文字的長度。最後的得到的複雜度為 O(n + m + 1) = O(n + m)

宣告:本程式碼基於1997年劍橋大學出版社 Dan Gusfield 編寫的 “Algorithm on String, Trees and Sequences: Computer Science and Computational Biology” 手冊。

作者 Matteo Dunnhofer ,譯者 KeithMorning

相關文章