KMP算法原理与Golang实现 | 面试高频

931 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情

引言

KMP 算法是 D.E.Knuth、J.H.Morris和V.R.Pratt 发明的。它要解决的问题就是定位子串substr在字符串str中第一次出现的位置,不存在则返回-1。

这个问题,在各种语言中都有相应的库函数,比如java中可以用str.indexOf(substr);在Golang中可以用strings.Index(str,substr)

在面试中,我们如果没有明确要求需要用KMP算法实现,一般直接用库函数就行了。

老生常谈——暴力匹配

如果不用KMP算法,我们需要对主串从左往右开始遍历,对于每个位置,需要重复遍历子串来判断是否能够匹配。

代码写下来就是这样的:

func getIndex(str, substr string) int {
    if len(str)==0 || len(substr)==0 || len(str)<len(substr) {
        return -1
    }

    for i:=0;i<len(str)-len(substr);i++{
        for j:=0;j<len(substr);{
            if str[i+j] == substr[j] {
                j++
            }else{
                break
            }
        }

        if j == len(substr) {
            return i
        }
    }
    return -1
}

假设主串长度n,子串长度m,那么其时间复杂度为O(m*n)

最差的情况是:

主串str = "AAAAAAAAAAAAAAAAAAS"

子串substr = "AAAAAAAAB"

需要比较到最后一个才知道不匹配。

KMP—前缀与后缀最长匹配长度

为了理解KMP算法,我们先来理解一个概念——前缀与后缀最长匹配长度,用它来构造出next数组,而next数组能帮助我们减少无效的遍历。

假设我们需要求字符串substr="abcabcx"中最后一个字符x前面的字符串的前缀与后缀最长匹配长度:

x前面的字符串为"abcabc",那么它可以构造出的前缀和后缀有以下5种情况:

KMP.png

因此,前缀与后缀最长匹配长度为3,即x位置求得的next数组值为3。

KMP—next数组 快速求法

对于任何子串,next数组都是从-1,0开始。即next[0] = -1; next[1] = 0。

求子串索引i位置的next值,我们需要用i-1位置的字符与next[i-1]位置的字符进行比较:

KMP-next.png

  1. 如果 s[i-1] == s[next[i-1]],那么next[i] = next[i-1] + 1

  2. 如果 s[i-1]s[next[i-1]]不相等,那么继续用s[i-1]s[next[next[i-1]]]比较 ,即用next[i-1]位置的next位置的字符与s[i-1]进行比较,即更新了与s[i-1]比较的值,我们不妨把与s[i-1]比较的值记作变量cn,它在代码中会不断更新。

  3. 直到cn更新至0位置,此时next[i]=0

具体代码如下:

func getNextArr(s string) []int {
    if len(s) == 1 {
        return []int{-1}
    }

    next := make([]int, len(s))
    next[0] = -1
    next[1] = 0
    i := 2 //目前在i位置求next数组的值
    cn := 0 // 用cn位置的值与s[i-1]比较
    for i < len(s) {
        if s[i-1] == s[cn] { //如果匹配上了
            cn++
            next[i] = cn
            i++
        }else if cn > 0 { //如果没匹配上,但cn位置值不为0,还可以继续往前更新
            count = next[cn]
        }else{  //没匹配上,但是cn已经跳到了0位置
            next[i] = 0
            i++
        }
    }
    return next
}

在这个过程中,i一直在增加,只有一次遍历,时间复杂度为O(m)

KMP算法主要逻辑

有了next数组,我们可以一次遍历解决子串定位问题。

比如下面的场景,我们通过未匹配的字符下标5,可以找到next[5]=3,即最长可匹配前缀的下一个位置为3,那么我们就可以直接跳过主串5位置与子串0、1、2位置的比较,主串5位置直接与子串3位置的比较:

image.png

实现代码如下:

func getIndex(str, substr string) int {
    if len(str)==0 || len(substr)==0 || len(str)<len(substr) {
        return -1
    }
    next := getNextArr(substr) //求next数组

    i,j := 0,0
    for i<=len(str)-len(substr) && j<len(substr) {
        if str[i] == substr[j] { //匹配上了,两个指针往后移继续比较
            i++
            j++
        }else if next[j] == -1 { //没有匹配,且j等于0,更新i位置
            i++
        }else{  //没有匹配,且j不等于0,通过next值更新j位置
            j = next[j]
        }
    }
    if j == len(substr) { //跳出循环,要么j==len(substr)匹配成功了
        return i-j
    }
    return -1   //跳出循环,要么i已经达到最大值,不能匹配子串
}

在这个过程中,i一直在增加,且没有回退的情况,时间复杂度为O(n)

总结

至此,KMP算法已经实现了,KMP算法有很多应用,比如求一篇文档中的关键词位置AC自动机就是从KMP演变过来的,KMP之所以如此重要,就是它产生了一种启发式的思想,面试高频。