图解KMP算法(Golang / Java实现)

应用场景

KMP算法用于加速字符串匹配。

字符串匹配:给定两个字符串,判断长的字符串(下文记为str1)中是否有子串与短的字符串(下文记为str2)匹配,返回匹配子串第一个字符的下标。

加速:对于字符串匹配问题,最简单暴力的做法是遍历str1中的每一个字符c,然后将str2的第一个字符与当前字符进行“对齐”,然后依次判断能不能每个字符都成功匹配上。

如果str2中的每个字符都与str1成功匹配,那么就认为成功在str1找到一串与str2匹配的子串,返回字符c所在的下标

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PETEbxka-1653730945959)(assets/暴力匹配字符串 - 成功.png)]

图1.1 暴力匹配字符串 - 成功

如果在str2中发现有一个字符与str1不匹配,那么str1的指针要移动到下一位,并从str2的第一个字符开始重新匹配

在这里插入图片描述

图1.2 暴力匹配字符串 - 匹配失败

在这里插入图片描述

图1.3 暴力匹配字符串-重新开始匹配

从图中可以看到,如果出现匹配失败的情况下,str1中的一些字符需要重新进行遍历(图中的’s’),这就是暴力匹配字符串效率低的根本原因,即每次出现匹配失败的时候,str1的指针总要往回跳。这就使得算法的时间复杂度变为O(N * M)N==str1.length, M == str2.length)。

KMP算法加速匹配的关键,就是如何让str1中的指针不往回跳,而是从头直接遍历到尾?让我们来一步步说明,首先需要介绍一个前置知识点 —— 字符串最长前后缀匹配的长度

前置知识点-最长前后缀匹配的长度

什么是前缀? 从字符串第一个字符开始,往右截取n个字符

什么是后缀? 从字符串最后一个字符开始,往左截取n个字符

在这里插入图片描述

图2.1 前后缀举例

严格地说,一个长度为n的字符串有n-1个前缀子串,如字符串"abb"有前缀"a", "ab",但"abb"不能算作"abb"的前缀,因为这没有研究价值。后缀也同理。

如何看一个字符串的最长前后缀匹配长度?

图2.1中展示的例子,两个红色的框选中的子串即为最长前后缀匹配的子串,长度为3

为什么不能更长呢?我们试着将红色框继续扩大:

在这里插入图片描述

图2.2 前后缀举例 - 匹配失败

可以看到,扩大之后前缀子串为"abba",后缀子串为"babb"。很明显,前后缀并不匹配,所以对于字符串"abbabb"来说,最长前后缀匹配的长度为3

清楚最长前后缀匹配的概念之后,我们就可以继续介绍 next 数组了。

next数组

在开始进行KMP算法之前,需要准备一个next数组。

next数组的长度与需要进行匹配的字符串(str2)的长度相同,它的含义是,对于str2中的每一个下标i,计算出str2的子串[0, i-1]的最长前后缀匹配长度,并保存在next数组的下标i中。

举个例子,现有字符串"abbabb",如何求出它的next数组:

在这里插入图片描述

图3.1 next数组举例

下面根据每个index来解释对应的next数值是什么

0:该位置处于字符串的第一个字符,其前面的子串为空字符串"",人为规定next[0]==-1。这里不必太过纠结,取值-1只是为了之后coding的时候更方便,逻辑更通用。对于任何的字符串,都有next[0] == -1

1:位于区间[0, 0]的子串为"a",上文说到,字符串本身不应该算作它自己的前缀或后缀,因为这样没有研究价值。所以next[1] == 0

2:位于区间[0, 1]的子串为"ab",只有一个字符的前缀"a"和后缀"b"已经不匹配了,所以next[2] == 0

3:位于区间[0, 2]的子串为"abb",很明显,仍然没有可以匹配的前后缀,next[3] == 0

4:位于区间[0, 3]的子串为"abba",此时有最长前缀"a"与最长后缀"a"相匹配,next[4] == 1

5:位于区间[0, 4]的子串为"abbab",此时有最长前缀"ab"与最长后缀"ab"相匹配,next[5] == 2

想必到了这里大家已经知道next数组的具体含义了,至于如何求出next数组暂且不说,下面我们开始来看KMP算法。

KMP执行过程

直接通过一个例子来说明:

在这里插入图片描述

图4.1 KMP举例 - 初始状态

这里假设str1很长,并且只是截取了str1中间的一小段,str2为要在str1中进行匹配的字符串

图中已经给出了str2对应的next数组,其中

next[6]:字符串"abbsta"的最长前后缀匹配为"a",故next[6] == 1

next[7]:字符串"abbstab"的最长前后缀匹配为"ab",故next[7] == 2

next[8]:字符串"abbstabb"的最长前后缀匹配为"abb",故next[8] == 3

接下来,我们按照程序的思维,来走一遍KMP算法的逻辑:

首先,定义两个指针i, j,分别指向str1str2的第一个字符(仅图中展示的部分)

在这里插入图片描述

图4.2 KMP举例 - 定义指针

既然是字符串匹配,那我们肯定是要判断字符是否相等了

显然,str1[i] == str2[j]str2中的第一个字符匹配成功,接下来让两个指针都往右移动一格

在这里插入图片描述

图4.3 KMP举例 - 字符匹配

当两边的字符都匹配时,我们不需要用到next数组,并且处理方式与暴力匹配是相同的,即往下继续匹配。

再接下来的7对字符中,str1[i] == str2[j]都是成立的,我们直接省略这个过程,并且i, j指针都来到了index == 8的位置

在这里插入图片描述

图4.4 KMP举例 - 字符匹配1

当来到index == 8的时候,此时str1[i] == str2[j]终于不成立了,next数组派上用场

接下来的操作是:j = next[j] = next[8] = 3i 不变,然后继续匹配

在这里插入图片描述

图4.5 KMP举例 - 字符不匹配

为什么这么做呢,别着急,现在我们将str2“往右推”,让i j重新对齐

在这里插入图片描述

图4.6 KMP举例 - 移动str2

神奇的事情发生了,str2的前3个字符居然又重新跟str1匹配上了!

这都要归功于next数组,我来回答一下大家到这里可能会有的疑问:

更新后的指针j和i对齐之后,j之前的字符串就一定与str1匹配吗?

答:是的,我们再回到str2“往右推”之前的样子

在这里插入图片描述

对于已经匹配上的方块(字符),我都标上了绿色,这就意味着str2j指针之前的每个字符与str1都是相同的,那j指针之前的前3个字符也必定跟str1是相同的

在这里插入图片描述

图4.7 KMP举例 - 局部匹配

我们再来回顾一下next数组的含义:对于str2中的每一个下标inext[i]表示str2的子串[0, i-1]的最长前后缀匹配长度

对于当前的j来说,next[j] == 3表示str2的子串"abbstabb"最长前后缀匹配长度为3,即前3个字符和后3个字符是相同的

在这里插入图片描述

图4.8 KMP举例 - 局部匹配1

可以很明显从图中看到,index在[5, 7]范围时,str1str2是匹配的,而index在[0, 2][5, 7]的范围上,根据next数组给出的最长前后缀匹配,也能得到str2中的相同前后缀。

经过以上的分析,结论就是str2的前3个字符,必定与str1中i指针的前3个字符是相等的,这下我们就可以放心地将str2“往右推”了

为什么i指针不需要移动?

在字符不匹配的时候,i指针不需要移动是KMP算法的精妙所在

在暴力匹配算法中,如果出现了字符不匹配的情况,i指针必须作“回滚”操作,在现在这个例子中,i指针就要回到第二个字符b,同时和str2的第一个字符重新开始匹配。

那么这里为什么i不需要“回滚”呢,我们反证法来说明

假设i指针回滚到index == 4的位置上,并且与str2从头开始匹配能够至少匹配4个以上的字符(存在比当前决策(3)更优的解)

在这里插入图片描述

图4.8 KMP举例 - 反例证明

因为是假设,所以我们就暂且认定图中的绿色方块的字符对都是能够匹配上的

还有一个前提条件不能忽略,在上文匹配的时候,两个字符串在index区间[0, 7]都是能够匹配上的(图4.4)

也就是说对于图4.4的情形来说,两个字符串在index区间[4, 7]也都是匹配上的

很明显,我们的假设与这一前提矛盾了,如果图4.8的绿色方块字符对能匹配上的话,就意味着字符串str2的子串"abbstabb"有一个长度为4的前缀,与一个长度为4的后缀相同,这是不可能的,因为next数组已经给出子串的最长前后缀匹配长度。

好了,经过上面的分析,我们通过反证法得出当从index == 0的位置开始进行两个字符串匹配,到了index == 8的时候,两个字符串出现字符不匹配的情况,i指针不需要回滚到index区间[1, 7]任何一个位置上

为什么可以通过j = next[j]得到j指针的下一个位置?

next数组传递的信息不仅仅只有最长前后缀匹配长度,它的数值天然指示了j指针下一步应该去到哪里。

我们都知道在编程语言中数组的索引是从0开始的,如果我们将“长度”数值当作“索引”去用,那就相当于定位到数组中第“长度 + 1”个元素的位置了。

还是上文举的这个例子,next[8] == 3,j指针直接跳到了索引为3的位置,相当于跳到了字符串的第 3 + 1 个元素的位置。而前3个字符我们在上文已经说明了是与str1匹配了的,所以我们理所应当地应该从第4个字符开始继续匹配。

回答完3个大家可能提出的疑问之后,我们回到正题,继续“运行程序”

现在匹配的情形是这样的:

在这里插入图片描述

图4.9 KMP举例 - str2“右推”之后的情形

说明一下,图示中的index索引是针对于str2来说的,这里并不是i指针回退到index == 3的位置

此时,str1[i] == str2[j]仍然不成立,按照上文所说,此时继续执行 j = next[j]

在这里插入图片描述

图4.10 KMP举例 - str2第二次“右推”

再次比较,发现str1[i] == str2[j]仍然不成立

但是

我们可以看到指针j已经退到了0位置,再执行 j = next[j]可就要出索引越界的bug了,所以我们的决策是:让i指针往右移一位,j指针不动,继续匹配……

之后的过程我就不说啦,来总结一下:

字符串匹配的时候会遇到三种情况

  1. i j指针所指向的字符相同 ——> i++, j++(同时右移)

  2. i j 指针所指向的字符不相同

    • j 当前不处于0位置 ——> j = next[j],i不变
    • j 当前处于0位置 ——> i++, j不变

算法终止的条件有两个

  1. i指针越界 ——> 字符串匹配失败
  2. j指针越界 ——> str2每个字符都与str1的某个子串匹配上了

KMP算法的执行过程到这里就讲清楚了,我们还剩下一个问题,next数组如何求?

计算next数组

在上文中我们只了解了next数组的含义,以及如何通过观察的方式得到next数组

那么在程序中如何得到一个字符串对应的next数组呢?

我们可以通过递推来计算next数组

这里使用字符串"abcabcacc"为例来介绍求解next数组的方法

在这里插入图片描述

图5.1 next举例 - 初始状态

在介绍next数组的时候提到过,对于任何字符串,next[0] 必为 -1next[1]必为 0

这里就不再过多说明了,我们直接从index == 2 开始

上文中说到,next[i]的数值不仅说明了区间[0, i-1]的最长前后缀匹配长度,同时也是最长前缀的下一个字符的索引。

思考一下在next[i]已经得出的情况下,如何通过next[i]给出的信息推出next[i+1]的值

假设next[i] == 2,表示的是区间[0, 1][i-2, i-1]的字符串是相同的

想要推出next[i+1]的数值,就要知道str[next[i]]与str[i]之间的关系:

  • str[next[i]] == str[i]:这种情况最好推,前后缀最长匹配区间在next[i]的基础上加了1,变成[0, 2][i-2, i],故next[i+1] == next[i] + 1 = 3

在这里插入图片描述

图5.2 next举例 - 递推1

  • str[next[i]] != str[i],记next[i] == ch, 我们要做的是将前缀继续缩小,即ch = next[ch]

    为了能够更清楚地说明这种情况,我再举一个更长一点的例子:

在这里插入图片描述

图5.3 next举例 - 递推2

图中给出了字符串str几乎所有位置对应的next数组数值,现在剩下index == 16的next数值还未求出。现在我们尝试使用递推法来求解

首先,根据上文所说,我们要知道index == 16的信息,就要依靠index == 15的信息

ch = next[15] = 7,需要判断str[7] == str[15]是否成立,将这两个位置标记出来:

在这里插入图片描述

图5.4 next举例 - 递推3

从图中可以看出,str[7] == 'c'str[15] == 'b',两者并不相等,符合我们现在讨论的这种情况

next[15]还能给出的信息是,str在区间[0, 14]的最长前后缀匹配长度为7,将前后缀分别标记出来:

在这里插入图片描述

图5.5 next举例 - 递推4

我们求出next[16]的值,就是要在区间[0, 15]中找到尽可能长的前后缀子串来,并且这个后缀必须包含str[15](既是重点又是废话)。

由于str[7] != str[15],所以图中的绿色区域无法继续扩大,所以我们就要将绿色区域进行缩小,直到在区间[0, 14]中找到一个最长前后缀(绿色区域),并且绿色区域能扩大到index == 15的位置。

那么要怎样缩小这个绿色区域呢?

首先要明确一下两个绿色区域的缩小方向,左边的区域必须保持左边界不变,右边界往左边收缩;右边的区域必须保持右边界不变,左边界往右边收缩。即左边的绿色区域必须包含index == 0位置,右边的绿色区域必须包含index == 14位置。

我们先给绿色区域做个简单的标记

在这里插入图片描述

图5.6 next举例 - 递推5

现在要做的事情就是找出一对前后缀,它们肯定是在当前的绿色区域的子区间中

而前缀必须包含左1,后缀必须包含右2,也就是说前缀在第一片区域,后缀在第二片区域

那么再两片绿色区域分别找前后缀这件事情能否缩小到在一片绿色区域中寻找呢?

不要忘了,两片绿色区域是相同的!右1部分的字符串与右2部分的字符串相同!所以只要找出包含右1最长后缀与包含左1最长前缀进行匹配即可

那么问题继续缩小为在第一片绿色区域中找到最长前后缀匹配

在这里插入图片描述

图5.7 next举例 - 递推6

等等,现在这个问题…我们不是已经解决了吗?答案就藏在next[7]中了呀,绿色区域的最长前后缀匹配长度正是next[7]: 2,即子串"ab"

在这里插入图片描述

图5.8 next举例 - 递推7

并且由于之前两块大的绿色区域(图5.6)是相同的,所以区间[13, 14]的子串必定也等于"ab"

"ab"就是我们要找的绿色区域的子区域最长前缀,那我们现在要做的事情就还是跟刚才一样,试图将绿色区域扩大到包含str[15]字符

我们只需要让ch = next[7] = 2,就可以立刻定位到左边绿色区域的下一个字符(在介绍KMP执行过程的时候有解释),再让str[2]与str[15]进行比较

str[2] == str[15]成立!至此我们找到了index == 16的最长前后缀匹配长度,即next[16] = ch + 1 = 3

在这里插入图片描述

图5.9 next举例 - 递推8

相信大家到这里已经清楚next数组的递推求解过程,我这里再来总结一下求解next[i]的流程:

  1. 设置变量ch = next[i-1],并判断str[ch] == str[i-1]是否成立

    • 成立

      next[i] = ch + 1

    • 不成立

      转至步骤2↓

  2. 判断ch == 0是否成立

    • 成立

      ch无法再通过next[ch]往前跳了,next[i] = 0

    • 不成立

      ch = next[ch]

KMP算法时间复杂度

求解next数组:O(M)

字符串匹配:O(N)

整体复杂度:O(M + N)

至于如何计算时间复杂度,我也不是很清楚,这里只给出了结论。

如果想要了解如何计算,或者更直观地学习KMP算法,推荐观看左程云老师的手把手教学视频

代码实现

这里我给出Golang和Java的KMP算法实现

Golang

计算next数组:

// NextArray 输入一个字符串str
// 返回与str等长度的next数组
func NextArray(str string) []int {
	if len(str) == 0 {
		return []int{}
	} else if len(str) == 1 {
		return []int{-1}
	} else if len(str) == 2 {
		return []int{-1, 0}
	}
	next := make([]int, len(str))
	next[0], next[1] = -1, 0
	ch, i := next[1], 2
	// 递推求解next数组
	for i < len(str) {
		if str[ch] == str[i-1] {
			ch++
			next[i] = ch
			i++
		} else if ch == 0 {
			// next[i] = 0, 切片中默认的数值就是0,无需重复设置
			i++
		} else {
			// 不断往前找
			ch = next[ch]
		}
	}
	return next
}

KMP匹配字符串,返回成功匹配的子串的第一个字符索引,匹配失败返回-1

// IndexOf 在s中匹配sub,返回第一个匹配的子串首字符索引
// 如果匹配失败,返回-1
func IndexOf(s, sub string, c chan int) {
	if len(s) <= len(sub) {
		if s == sub {
			// 字符串长度相等,问题转化为两个字符串是否相等
			c <- 0
			return
		}
		// substring's length too long
		c <- -1
		return
	}
	// 获取字符串sub的next数组
	next := NextArray(sub)
	// 准备i, j指针,分别位于s和sub的开头
	i, j := 0, 0
	for i < len(s) && j < len(sub) {
		if s[i] == sub[j] {
			i++
			j++
		} else if j == 0 {
			i++
		} else {
			j = next[j]
		}
	}
	if j == len(sub) {
		// j指针越界,此时i指针位于匹配成功的子串末尾的下一个字符
		// 举个例子:s = "abcd", sub = "abc"
		// 此时i == 3, j == 3(越界),那么匹配成功的子串的首个字符索引为 i - j = 0
		c <- i - j
		return
	}
	// 匹配失败
	c <- -1
}

对数器,使用标准库strings的Index方法验证KMP算法的正确性:

使用对数器可以帮助我们在没有OJ的情况下验证代码写的是否准确

// Comparator 对数器
// 验证KMP算法的正确性
func Comparator(s, sub string, c chan int) {
	if len(s) < len(sub) {
		// substring's length too long
		c <- -1
		return
	}
	// 调用标准库的函数
	idx := strings.Index(s, sub)
	c <- idx
}

主函数及辅助函数:

// GenerateRandomString 生成一个指定长度的字符串
func GenerateRandomString(n int) string {
	if n <= 0 {
		return ""
	}
	str := []byte{}
	rand.Seed(time.Now().Unix())
	for i := 0; i < n; i++ {
		// 随机生成一个小写字母a - z
		c := byte(rand.Intn(26) + 'a')
		str = append(str, c)
	}
	return string(str)
}

func main() {
	// 使用对数器,验证KMP算法的正确性
	// 最大样本量,s串最大长度,sub串最大长度
	var maxN, maxS, maxSub int
	maxN, maxS, maxSub = 1e7, 1e4, 1e4
	rand.Seed(time.Now().Unix())
	// 生成一个样本量
	n := rand.Intn(maxN)
	for i := 0; i < n; i++ {
		// 生成s和sub的长度
		sLen, subLen := rand.Intn(maxS), rand.Intn(maxSub)
		// 生成s和sub
		s, sub := GenerateRandomString(sLen), GenerateRandomString(subLen)
		// 分别调用kmp算法和对数器得到结果进行比较
		// 使用两个协程并行运算
		c1, c2 := make(chan int), make(chan int)
		go IndexOf(s, sub, c1)
		go Comparator(s, sub, c2)
		ans1, ans2 := <-c1, <-c2
		if ans1 != ans2 {
			log.Printf("解答错误\n输入: s[%s], sub[%s]\n输出: IndexOf[%d], Comparator[%d]", s, sub, ans1, ans2)
			return
		}
	}
	log.Println("算法正确!")
}

运行结果:

在这里插入图片描述

Java

Java使用线程池创建线程,线程接口使用的是Callable,便于返回答案到主线程中

/**
 * KMP字符串匹配算法
 * @author Ambitious
 *
 */
public class KMP {
	
	/**
	 * 使用KMP求出s中第一个匹配的sub子串,并返回子串的第一个字符索引
	 * 匹配失败则返回-1
	 */
	public static int indexOf(String s, String sub) {
		if (s.length() <= sub.length()) {
			if (s.equals(sub)) {
				return 0;
			}
			// 两个字符串的长度不满足条件
			return -1;
		}
		// 获取next数组
		int[] next = getNextArray(sub);
		char[] ss = s.toCharArray();
		char[] subs = sub.toCharArray();
		int i = 0, j = 0;
		while (i < ss.length && j < subs.length) {
			if (ss[i] == subs[j]) {
				i++;
				j++;
			} else if (j == 0) {
				i++;
			} else {
				j = next[j];
			}
		}
		// 匹配成功的标志:j指针到达末尾
		return j == subs.length ? i - j : -1;
	}
	
	/**
	 * 求出与字符串长度相同的next数组
	 */
	public static int[] getNextArray(String str) {
		if (str.length() == 0) {
			return new int[0];
		} else if (str.length() == 1) {
			return new int[] {-1};
		} else if (str.length() == 2) {
			return new int[] {-1, 0};
		}
		char[] strs = str.toCharArray();
		int[] next = new int[strs.length];
		next[0] = -1;
		next[1] = 0;
		int i = 2;
		int ch = next[1];
		while (i < strs.length) {
			if (strs[i-1] == strs[ch]) {
				next[i++] = ++ch;
			} else if (ch == 0) {
				i++;
			} else {
				ch = next[ch];
			}
		}
		return next;
	}
	
	/**
	 * 对数器
	 */
	public static int comparator(String s, String sub) {
		if (s.length() < sub.length()) {
			return -1;
		}
		return s.indexOf(sub);
	}
	
	/**
	 * 生成指定长度的字符串
	 */
	public static String generateRandomString(int n) {
		if (n <= 0) {
			return "";
		}
		StringBuilder builder = new StringBuilder();
		while (n-- > 0) {
			char cur = (char) ((Math.random() * 26) + 'a');
			builder.append(cur);
		}
		return builder.toString();
	}
	
	static class KMPThread implements Callable<Integer> {
		
		private String s;
		private String sub;
		
		public KMPThread(String s, String sub) {
			this.s = s;
			this.sub = sub;
		}
		
		@Override
		public Integer call() throws Exception {
			return indexOf(s, sub);
		}
	}
	
	static class ComparatorThread implements Callable<Integer> {
		
		private String s;
		private String sub;
		
		public ComparatorThread(String s, String sub) {
			this.s = s;
			this.sub = sub;
		}
		
		@Override
		public Integer call() throws Exception {
			return comparator(s, sub);
		}
	}
	
	public static void main(String[] args) throws InterruptedException, ExecutionException {
		// 最大样本量,s最大长度,sub最大长度
		int maxN = 10000000, maxS = 10000, maxSub = 10000;
		// 生成一个样本量
		int n = (int) (Math.random() * maxN);
		ExecutorService service = Executors.newFixedThreadPool(2);
		while (n-- > 0) {
			// 生成s,sub的长度
			int sLen = (int) (Math.random() * maxS);
			int subLen = (int) (Math.random() * maxSub);
			// 生成s,sub
			String s = generateRandomString(sLen);
			String sub = generateRandomString(subLen);
			// 两个线程并行分别运算KMP和对数器
			Future<Integer> task1 = service.submit(new KMPThread(s, sub));
			Future<Integer> task2 = service.submit(new ComparatorThread(s, sub));
			int ans1 = task1.get();
			int ans2 = task2.get();
			if (ans1 != ans2) {
				System.out.printf("解答错误\n输入: s[%s], sub[%s]\n输出: IndexOf[%d], Comparator[%d]", s, sub, ans1, ans2);
				return;
			}
		}
		System.out.println("算法正确!");
	}
}

运行结果:

在这里插入图片描述

我的 个人博客 上线啦,欢迎到访~

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AmbitiousJun

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值