[譯] Swift 算法學院 - Z-Algorithm 字符串搜索

本篇是來自 Swift 算法學院的翻譯的系列文章,Swift 算法學院 致力於使用 Swift 實現各類算法,對想學習算法或者複習算法的同窗很是有幫助,講解思路很是清楚,每一篇都有詳細的例子解釋。 更多翻譯的文章還能夠查看這裏html

前言

最近幾篇算法都是關於字符串查找的,而這次的算法 Z-Algorithm 是一個頗有趣來看看。git

Z-Algorithm 字符串搜索

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

換而言之,咱們須要實現一個 String 的擴展方法 indexsOf(pattern:String), 可以返回一個 [Int] 數組表明全部模式串出現的索引位置,或者返回 nil 若是沒有在字符串中找到。算法

舉個例子:swift

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。)app

可是咱們如何計算 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 的代碼:ui

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

相關文章
相關標籤/搜索