快速理解kmp算法

112 阅读9分钟

快速理解kmp算法

假设有这样一个问题:有一个文本串 haystack,和一个模式串 needle ,现在要查找 needle 在 haystack 中的位置,怎么查找呢?

haystack = "hello,world" # 文本串
needle = "ll " # 模式串

如果用暴力匹配的思路,并假设现在文本串haystack匹配到i位置,模式串needle匹配到j位置,则有:

  • 如果当前字母匹配成功,haystack[i]== needle[j],则I++,j++,继续匹配下一个字符;
  • 如果匹配失败,haystack[i]!= needle[j],即I = I -j +1,j = 0。相当于每次匹配失败时,I回溯,j被置为0。

明白了暴力匹配算法的流程及内在的逻辑,就可以实现代码了:

haystack = "hello,world" # 文本串
needle = "ll " # 模式串

i = 0 # 定义haystack的下标
j = 0 # 定义needle的下标

# 首先,haystack的长度大于haystack的下标i并且needle的长度大于needle的下标j,循环数组判断每一个haystack是否和needle相等,如果相等就i++和j++,否则i(i-j + 1)就退回,j从第一个下标开始.
# 如果needle的下标等于needle的长度,就把当前i减去j并且返回,没有就返回-1.

while i < len(haystack) and j < len(needle):
    if haystack[i] == needle[j]:
        i+=1
        j+=1
    else:
        i = i -(j -1) # 如果不匹配,就回退,从第一次匹配的下一个开始,
        j = 0

    if j == len(needle):
        return i - j

return -1

以上述例子为准,现在拿模式串needle去跟文本串haystack匹配,整个过程如下所示:

  1. haystack[0]为h,needle[0]为l,不匹配,执行第二条指令:”如果不匹配haystack[i]!= needle[j]”,令I = I - (j - 1),j = 0,haystack[1]跟needle[0]匹配成功,相当于模式串要往往往右移动一位i=1,j=0

EBC88B36-8FA0-4088-848F-C457C9D25239.png

  1. haystack[1]跟needle[0]还是不匹配,继续执行第二条指令:haystack[i]!= needle[j],令I = I - j - 1,j = 0, haystack[2]跟needle[0]匹配,从而模式串不断的向右移动一位(不断的执行I = I - (j - 1),j = 0,i从1变到2,j一直为0)

AE979E0D-2046-4482-A838-91E8EA4A259C.png

  1. 直到haystack[2]跟needle[0]匹配成功(I=2,j = 0),此时按照上面的暴力匹配算法的思路,转而执行第一条指令:”如果当前字符匹配成功haystack[i]== needle[j],则I++,j++”,可得haystack[I]为haystack[3], needle[j]为needle[1],接下来haystack[3]跟needle[1]匹配(I=3,j=1)

4933272E-31AD-426A-9434-290AED6D9952.png

  1. haystack[3]跟needle[1]匹配成功,继续执行第一条指令:”如果当前字符匹配成功haystack[i]== needle[j],则I++,j++”,得到haystack[4]跟needle[2]匹配(I=4,j=2)

DE9A7D7C-06BF-41A4-B690-44DAB9DCC577.png

  1. 直到haystack[4]字符,needle[3]为空字符 (I=4,j=3),因为不匹配,重新执行第二条指令:“如果失配haystack[i]!= needle[j],命I = I - (j - 1),j = 0”,相当于haystack[2]跟needle[0]匹配(I=2,j=0)

2981F2F9-3FD8-4912-886F-DA8C6412E7D9.png

  1. 如果按照暴力匹配算法的思路,尽管之前文本串和模式串已经匹配到了haystack[3], needle[2],但因为haystack[4]跟needle[3]不匹配,所以文本串回溯到haystack[2],模式串回溯到needle[0],从而让haystack[2]跟needle[0]匹配。

2179E20B-AEBD-4F9A-9CD1-F303BF98EE92.png

因为回溯过去必然会导致失配,那有没有一种算法,让 i 不往回退,只需要移动 j 即可呢?

KMP 算法


定义


Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP 算法”,常用于在一个文本串 S 内查找一个模式串 P 的出现位置,这个算法由 Donald Knuth、Vaughan Pratt、James H. Morris 三人于 1977 年联合发表,故取这三人的姓氏命名此算法。

Kmp的算法流程


  • 假设现在文本串haystack匹配到i位置,模式串needle匹配到j位置;
  • 如果j = -1,或者当前字符匹配成功,haystack[I] == needle[j],都令I++,j++,j继续匹配下一个字符;
  • 如果j !=-1,且当前字符匹配失败,haystack[I] != needle[j],都令I不变,j = next[j]。此举意味着失配时,模式串p相对于文本串haystack向右移动了j - next[j]位;
  • 当匹配失败时,模式串向右移动的位数为:移动的实际位数为:j - next[j],且此值大于等于1。

很快,你也会意识到 next 数组各值的含义:代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如,如果 next [j] = k,代表 j 之前的字符串中有最大长度为 k 的相同前缀后缀。 此也意味着在某个字符失配时,该字符对应的 next 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到next [j] 的位置)。如果 next [j] 等于 0 或 -1,则跳到模式串的开头字符,若 next [j] = k 且 k > 0,代表下次匹配跳到 j 之前的某个字符,而不是跳到开头,且具体跳过了 k 个字符。

明白了KMP 算法的流程及内在的逻辑,就可以实现代码了:


def getNext(p):
    n = len(p)
    next=[-1]*n
    j,k=0,-1
    while j<n-1:
        if (k>=0 and p[j]==p[k]) or k==-1:
            k += 1
            j += 1
            if p[j]==p[k]:
                next[j]=next[k]
            else:
                next[j]=k
        else:
            k=next[k]
    return next

# kpm算法实现
def main01():
    haystack = "hello,world" # 文本串
    needle = "ll " # 模式串
    next = getNext(needle)
    i = 0 # 定义haystack的下标
    j = 0 # 定义needle的下标

    while i < len(haystack) and j < len(needle):
        if j == -1 or  haystack[i] == needle[j]:
            i+=1
            j+=1
        else:
            # 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令i不变,j = next[j]
            j = next[j] # 获取下一个

        if j == len(needle):
            return i - j

    return -1

if __name__=="__main__":
   main01()

继续拿之前的例子来说,当haystack[4]跟needle[3]匹配失败时,kpm不是跟暴力匹配那样简单的把模式串右移一位,而是执行第二条指令:j != -1,且当前字符匹配失败haystack[i] != needle[j],则令i不变,j=next[j]”, 即 j 从 4 变到 2,所以相当于模式串向右移动的位数为 j - next[j](j - next[j] )。

73D8388F-E333-4D24-9724-64F1CF8C2453.png

向右移动 2 位后,haystack[5] 跟 needle[0] 继续匹配。为什么要向右移动 4 位呢,因为移动 4 位后,模式串中又有个“ll”可以继续跟 haystack[2] haystack[3] 对应着,从而不用让 i 回溯。

分析


步骤


  • ① 寻找前缀后缀最长公共元素长度

对于 P = p0 p1 …pj-1 pj,寻找模式串 P 中长度最大且相等的前缀和后缀。如果存在 p0 p1 …pk-1 pk = pj- k pj-k+1…pj-1 pj,那么在包含 pj 的模式串中有最大长度为 k+1 的相同前缀后缀。举个例子,如果给定的模式串为“abab”,那么它的各个子串的前缀后缀的公共元素的最大长度如下表格所示:

321.jpg

比如对于字符串 aba 来说,它有长度为 1 的相同前缀后缀 a;而对于字符串 abab 来说,它有长度为 2 的相同前缀后缀ab(相同前缀后缀的长度为 k + 1,k + 1 = 2)。

  • ② 求 next 数组 Next 数组考虑的是除当前字符外的最长相同前缀后缀,所以通过第 ① 步骤求得各个前缀后缀的公共元素的最大长度后,只要稍作变形即可:将第 ① 步骤中求得的值整体右移一位,然后初值赋为 -1,如下表格所示:

322.jpg

比如对于 aba 来说,第 3 个字符 a 之前的字符串 ab 中有长度为 0 的相同前缀后缀,所以第 3 个字符 a 对应的 next 值为 0;而对于 abab 来说,第 4 个字符 b 之前的字符串 aba 中有长度为 1 的相同前缀后缀 a,所以第 4 个字符 b 对应的 next 值为 1(相同前缀后缀的长度为 k,k = 1)。

  • ③ 根据 next 数组进行匹配

匹配失配,j = next [j],模式串向右移动的位数为:j - next[j]。换言之,当模式串的后缀 pj-k pj-k+1, …, pj-1 跟文本串 si-k si-k+1, …, si-1 匹配成功,但 pj 跟 si 匹配失败时,因为 next[j] = k,相当于在不包含 pj 的模式串中有最大长度为 k 的相同前缀后缀,即 p0 p1 …pk-1 = pj-k pj-k+1…pj-1,故令 j = next[j],从而让模式串右移 j - next[j] 位,使得模式串的前缀 p0 p1, …, pk-1 对应着文本串 si-k si-k+1, …, si-1,而后让 pk 跟 si 继续匹配。如下图所示:

323.jpg

综上,KMP 的 next 数组相当于告诉我们:当模式串中的某个字符跟文本串中的某个字符匹配失配时,模式串下一步应该跳到哪个位置。如模式串中在j 处的字符跟文本串在 i 处的字符匹配失配时,下一步用 next [j] 处的字符继续跟文本串 i 处的字符匹配,相当于模式串向右移动 j - next[j] 位。

知识扩展


BM 算法


KMP 的匹配是从模式串的开头开始匹配的,而 1977 年,德克萨斯大学的 Robert S. Boyer 教授和 J Strother Moore 教授发明了一种新的字符串匹配算法:Boyer-Moore 算法,简称 BM 算法。该算法从模式串的尾部开始匹配,且拥有在最坏情况下 O(N) 的时间复杂度。在实践中,比 KMP 算法的实际效能高。

BM 算法定义了两个规则:

  • 坏字符规则:当文本串中的某个字符跟模式串的某个字符不匹配时,我们称文本串中的这个失配字符为坏字符,此时模式串需要向右移动,移动的位数 = 坏字符在模式串中的位置 - 坏字符在模式串中最右出现的位置。此外,如果”坏字符”不包含在模式串之中,则最右出现位置为 -1。

  • 好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为 -1。

下面举例说明 BM 算法。例如,给定文本串“HERE IS A SIMPLE EXAMPLE”,和模式串“EXAMPLE”,现要查找模式串是否在文本串中,如果存在,返回模式串在文本串中的位置。

1.首先,”文本串”与”模式串”头部对齐,从尾部开始比较。”S”与”E”不匹配。这时,”S”就被称为”坏字符”(bad character),即不匹配的字符,它对应着模式串的第 6 位。且”S”不包含在模式串”EXAMPLE”之中(相当于最右出现位置是 -1),这意味着可以把模式串后移 6-(-1)=7 位,从而直接移到”S”的后一位。

41.png

2.依然从尾部开始比较,发现”P”与”E”不匹配,所以”P”是”坏字符”。但是,”P”包含在模式串”EXAMPLE”之中。因为“P”这个“坏字符”对应着模式串的第 6 位(从 0 开始编号),且在模式串中的最右出现位置为 4,所以,将模式串后移 6-4=2 位,两个”P”对齐。

42.png

3.依次比较,得到 “MPLE”匹配,称为”好后缀”(good suffix),即所有尾部匹配的字符串。注意,”MPLE”、”PLE”、”LE”、”E”都是好后缀。

44.png 4.发现“I”与“A”不匹配:“I”是坏字符。如果是根据坏字符规则,此时模式串应该后移 2-(-1)=3 位。问题是,有没有更优的移法?

45.png

5.更优的移法是利用好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串中上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为 -1。

所有的“好后缀”(MPLE、PLE、LE、E)之中,只有“E”在“EXAMPLE”的头部出现,所以后移 6-0=6 位。

可以看出,“坏字符规则”只能移3位,“好后缀规则”可以移 6 位。每次后移这两个规则之中的较大值。这两个规则的移动位数,只与模式串有关,与原文本串无关。

47.png

6.继续从尾部开始比较,“P”与“E”不匹配,因此“P”是“坏字符”,根据“坏字符规则”,后移 6 - 4 = 2 位。因为是最后一位就失配,尚未获得好后缀。

48.png

Sunday 算法


Sunday 算法由 Daniel M.Sunday 在 1990 年提出,它的思想跟 BM 算法很相似:

  • 只不过 Sunday 算法是从前往后匹配,在匹配失败时关注的是文本串中参加匹配的最末位字符的下一位字符。

  • 如果该字符没有在模式串中出现则直接跳过,即移动位数 = 匹配串长度 + 1;

  • 否则,其移动位数 = 模式串中最右端的该字符到末尾的距离 +1。

下面举个例子说明下 Sunday 算法。假定现在要在文本串”substring searching algorithm”中查找模式串”search”。

1.刚开始时,把模式串与文本串左边对齐:

51.png

2.结果发现在第 2 个字符处发现不匹配,不匹配时关注文本串中参加匹配的最末位字符的下一位字符,即标粗的字符 i,因为模式串 search 中并不存在 i,所以模式串直接跳过一大片,向右移动位数 = 匹配串长度 + 1 = 6 + 1 = 7,从 i 之后的那个字符(即字符 n)开始下一步的匹配,如下图:

52.png

3.结果第一个字符就不匹配,再看文本串中参加匹配的最末位字符的下一位字符,是’r’,它出现在模式串中的倒数第3位,于是把模式串向右移动 3 位(r 到模式串末尾的距离 + 1 = 2 + 1 =3),使两个’r’对齐,如下:

53.png

4.匹配成功。

回顾整个过程,我们只移动了两次模式串就找到了匹配位置,缘于 Sunday 算法每一步的移动量都比较大,效率很高.

参考资料 The Knuth-Morris-Pratt Algorithm in my own words - jBoxer www.ruanyifeng.com/blog/2013/0… 字符串匹配的Boyer-Moore算法 - 阮一峰的网络日志 Sunday算法原理与实现(模式匹配)-red_eyed_hare-ChinaUnix博客 wiki.jikexueyuan.com/project/kmp… wiki.jikexueyuan.com/project/kmp…

博客来源:雨夜的博客