字符串匹配——KMP 算法详解

175 阅读3分钟

字符串匹配是leetcode刷题和笔试时经常出现的题型,一般的要求就是给定一个主串S,求模式串P是否为主串的连续子串或者在主串中的位置。

对于这类题其实思路是很确定的 ——— 逐一对比两个字符串的元素,看是否相等。

image.png 下面是Go版本暴力遍历的代码思路(没编译过可能有错,只是描述下大概思路)

target := "abcabcd"

pattern := "abcd"

i,j := 0,0
for ;i<len(target) && j<len(pattern) ;{
   
    if target[i] == pattern[j]{
        i++
        j++
    }else{
        //不相同,对比下一个子串
        i = i-j+1
        j = 0
    }
    
}
if j == len(pattern){
    //找到子串
}else{
    //没找找到子串
}

从实质上讲,对比字符串只能使用这种逐一对比的方式,但是,对于太长的子串,这种匹配方式是非常耗时的O(nmn*m)

如果我们在对比的过程中,可以省略掉一些重复的过程,提前找到并跳过一些一定匹配失败的步骤,提高匹配速度。

比如下图中,对于index为2,3,4,5等元素的对比,其实是可以跳过的,很显然可以看出这几个匹配是会失败的 image.png

KMP算法就是找到了这样的规律,跳过一些匹配失败的步骤,来优化匹配过程。

下面来分析一下KMP算法

KMP可行的基础

如果我们用人眼来匹配字符串,当当前元素匹配失败后,我们并不会把子串的起点移动到B处,而是移动到下一个A处。通过这种方式来减少匹配的次数

image.png

kmp算法要做的就是把这种方式,抽象成数学公式

  • 我们首先假设主串是T,当前对比元素的下标是i;模式串是P,当前对比元素的下表是j
  • 由于已经对比到了下标i和j,所以前面的子串必然是相等的。即P[0:j-1] == T[i-j:i-1]
  • 如果此时模式串的P[0:j-1]中,存在一个k(有多个则取最大的k),满足P[0:k-1] == P[j-k:j-1]
  • 那么 T[i-k:i-1]==P[0:k-1]
  • 那么,我们是不是可以直接以主串的i-k位置为起点,i位置开始继续匹配,从而跳过中间的元素

直接讲公式可能不太好理解,结合下面的图理解

image.png

根据上图,此时i=5,j=5;P[0:4] = T[0:4] => 即前面的abcab这个子串是两边共有的

同时我们发现 P[0:1] = P[3:4],那么我们可以跳过一些匹配过程,从下图开始匹配,此时 i=5,j=2

image.png

这就是KMP的理论基础。

ps.如果前缀的子串完全一致(如aaaab,aaaac那么则k=j-1,即挪一个位置进行比较,和暴力法一样)

如何实现

那么,在我们匹配的过程中,当前元素不匹配了,如何确定要从哪个位置开始重新匹配呢。

其实就是找到这个共同子串最长的 前缀=后缀 的位置

还是这幅图,共同子串abcab

  • 前缀:a,ab,abc,abca,abacb
  • 后缀:b,ab,cab,bcab,abcab

所以最长相同前后缀为ab,使用k=2

image.png

如果我们提前对模式串做下处理,得到他每一个位置的最长前后缀长度,不就可以很快的知道要从什么位置开始匹配了。这就是KMP算法中next数组的作用

Next数组

我们在匹配字符串之前,先对模式串做一次遍历,计算他的最长相同前后缀(实质上类似自己和自己做了一次匹配)

  • 我们定义now为当前前缀最后一个元素的位置,x为后缀最后一个元素的位置
  • next数组中,next[x]为模式串P[0:x]中最长相同前后缀的长度
  • 遍历过程中,如果当前元素相等,最长前后缀长度可以加1,即next[x]=now+1

image.png

  • 如果元素不相同,需要缩短最长前后缀,now = next[now-1]进行递归,直到相等或值now=0,

image.png

用下面的图来理解不想等的时候,(now =k,x=j) image.png 当P[j] != P[k]时,最长前后缀就得缩短,相当于把前缀后移,如下图,直到找到P[j] == P[k],如果找不到,最长前后缀就是0 image.png

pattern := "patternString"
next := make([]int,len(pattern))
now := 0 
x := 1
next[0] = 0
for ;x<len(pattern);{
    if ( pattern[now]==pattern[x])
    {	
        now ++
        next[x]=now
        x++

     }elif now {
         // 不想等且now !=0
         now = next[now-1]
     }else{
         next[x]=0
         x++
     }
}

有了这个最长前后缀数组,就可以进行字符串匹配了

匹配

target := "abcabcd"

pattern := "abcd"

i,j := 0,0
for ;i<len(target) && j<len(pattern) ;{
   
    if target[i] == pattern[j]{
        
        i++
        j++
    }else if j==0{
        i++
    }else{   
        j = next[j] // 前面建立好的前后缀数组
    }
    
}
if j == len(pattern){
    //找到子串
}else{
    //没找找到子串
}