[译] 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

相关文章
相关标签/搜索