swift-algorithm-club-cn

Swift 数据结构与算法学院!翻译自https://github.com/raywenderlich/swift-algorithm-club

View project on GitHub

Knuth-Morris-Pratt(KMP) 字符串搜索算法

目标:用 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 数组算法又没说明白,想继续挖坑的同学,推荐以下三篇文章,绝对够用。