数据结构之KMP算法浅析

首先,祝屏幕前的你新年快乐~~~🎆

之前写过一篇数据结构树状数组的文章,其目的一来是加深自己对树状数组的印象,二来是防止自己太久不用可能会忘记,以备不时之需。今天,和树状数组一样,总结一下数据结构中的另一个算法,KMP算法。

说实话,距第一次了解到KMP算法已经好几年了,这些年自己平时在刷题过程中遇到需要使用KMP算法的地方其实并不是很多。每次看完KMP,当时自以为是理解了,但往往长时间不用之后就会忘记,所以这里也对KMP算法做一个总结,以便复习使用,常看常新。

本文全文参考《算法笔记》,并结合自己的理解进行介绍,示例代码均使用C++。s[0, k]表示字符串s从索引0开始、以索引k结束的子串;s的前缀子串表示以s[0]为第一个字符的子串,s的后缀子串表示以s的最后一个字符为末尾字符的子串。前后缀子串的长度要求均小于s

问题

KMP算法于1976年由Knuth、Morris 和 Pratt三位提出,目的是解决字符串匹配的问题。字符串匹配问题并不少见,简单说就是给定两个字符串,判断一个字符串是否包含或被包含于另一个字符串。也就是说,给定两个字符串textpattern,判断pattern是否为text的字串。这里我们只考虑patterntext长度更小或相等的情况。(pattern长度更大的情况不可能为子串,实际代码中加个判断即可)

显然,最简单的方法就是双重循环,按照每个字符进行匹配,很容易可以写出下列代码:

bool isSubStr(string text, string pattern) {
	for (int i = 0; i <= text.length() - pattern.length(); i++) {
		for (int j = 0; j < pattern.length(); j++) {
			if (text[i + j] != pattern[j]) {
				break;
			}
			if (j == pattern.length() - 1) {
				return true;
			}
		}
	}
	return false;
}

假设text的长度为mpattern的长度为n,那么以上算法的时间复杂度即为O(mn)。当mn的数量级很大时,例如10^5,显然无法接受。

该算法复杂度过大的原因在于每次匹配失败后,都会从头开始匹配pattern。但实际上,有时当某次匹配失败时,我们不需要从头匹配pattern。我们以text = "abababaabc"pattern = "ababaab"为例看一下中间的某一个过程。
图1

图1

如图1所示,按照上面代码的逻辑,i表示text中将要匹配的字符索引,此时i = 5j表示pattern中已匹配的最后一个字符索引,此时j = 4。可以看到,textpattern中的第0至4位已成功匹配,接下来要匹配的是text的第五位和pattern的第五位。显然text[5] != pattern[5],即text[i] != pattern[j + 1]。因此,按照上面的算法,接下来我们会回退j至0,并让pattern向右移动一个位置,以重新从头匹配text

但实际上,观察上图我们可以发现,每次匹配失败时,并不需要重新从头匹配,即j并不需要回退至0: 我们可以将pattern向右移动k个位置,i不变,j变为j-k,使得text[i] == pattern[j - k + 1],且pattern[j - k + 1]前的子串与text[i]前的对应长度子串相等。这样,保证j以及j之前的pattern子串与text中的相应子串已匹配,当k越小时,需要重新匹配的字符越少。显然,这样的k最小等于2,如下图2所示。
在这里插入图片描述

图2

图中,我们向右移动两个位置后,j =2,相应的pattern子串pattern[0, 2] = "aba"text中对应的子串text[2, 4] = "aba"相等。因此,这部分子串不要重新再匹配,减小了时间代价。

如果我们每次匹配失败时都能找到这样的一个k,使得j回退的值最小,且pattern[0, j]不需要重新再匹配,就能大大减小算法的复杂度。那么,我们如何找到这样的k呢?

从上文可以看到,在匹配失败时,textpattern中已匹配的子串为s = ababa,回退需满足的条件为

  1. text[i] == pattern[j - k + 1],且
  2. pattern[j - k + 1]前的子串与text[i]前的对应长度子串相等,即pattern[0, j - k] == text[i - (j - k + 1), i - 1]

先不用纠结条件2中的各种i、j、k,只需要知道两个子串相等即可,就像图2中的绿色方框部分一样。

如图3所示,条件1很好理解。对于条件2,我们要找到的最小k,需要保证pattern[j - k + 1]前的子串与text[i]相同长度的子串相等,即图中红色部分子串与蓝色部分子串同长度的后缀子串相等(红色匹配蓝色后缀,注意图中红色部分的长度不确定,图中的“三个”只是举例)。而蓝色子串是回退前已匹配的子串s = ababa,红色子串为s的前缀子串。要使得k最小,等价于使得以上两个相等字符串的长度最大。因此,以上条件2即为寻找一个最大的j' = j - k,使得字符串s的前缀s[0,j']与s相同长度的后缀子串s[j - j', j]相等。

在这里插入图片描述

图3

综上,如果我们对于每一个位置,都能求得这样一个对应的j',那么我们就可以以最小的代价回退j。这个j'的求法,就是我们接下来要介绍的next数组。

Next数组

给定一个字符串s,长度为n。对于s的子串s[0,i]来说,其前缀子串表示为s[0, k],后缀子串表示为s[i-k, i]。则数组next[i]表示对于子串s[0,i]而言,使得前缀子串s[0, k]与后缀子串s[i-k, i]相等的最大的下标k(即最大长度减一)。注意这里前后缀子串可以重叠,但不能等于s[0,i]即前后缀子串不能为本身。

对于长度为1的字符串,由于前后缀子串不能为本身,所以不存在相等的前后缀子串,此时,我们的next[0]记为-1

我们以s = "ababaa"为例,结合下图理解一下next数组。图中,每个小图的上部分为后缀子串,下部分为前缀子串。
在这里插入图片描述

图4
  1. i = 0时,子串s[i] = "a"。显然,字符串长度为1时,前后缀字符串不相等,next[0] = -1
  2. i = 1时,子串s[i] = "ab"。前缀子串有{"a"},后缀子串有{"b"},无相等子串,next[1] = -1
  3. i = 2时,子串s[i] = "aba"。前缀子串有{"a", "ab"},后缀子串有{"a", "ba"},最长相等子串:"a",故next[2] = 0
  4. i = 3时,子串s[i] = "abab"。前缀子串有{"a", "ab", "aba"},后缀子串有{"b", "ab", "bab"},最长相等子串:"ab",故next[3] = 1
  5. i = 4时,子串s[i] = "ababa"。前缀子串有{"a", "ab", "aba", "abab"},后缀子串有{"a", "ba", "aba", "baba"},最长相等子串:"aba",故next[4] = 2
  6. i = 5时,子串s[i] = "ababaa"。前缀子串有{"a", "ab", "aba", "abab", "ababa"},后缀子串有{"a", "aa", "baa", "abaa", "babaa"},最长相等子串:"a",故next[5] = 0

这里再次强调一下,next数组存的是i结尾子串最长相等前后缀的前缀子串最后一位的下标。结合上面的例举分析与图4,可以多理解几遍,加深印象。

好了,了解了next数组的定义后,下面我们讲一下如何用递推求解next数组。我们令i表示当前待匹配的后缀子串的字符索引,j表示next[i-1],即求next[i-1]中已求得的最长前缀子串的最后一个字符索引。于是,我们在求解next[i]时,主要包含两个过程:

  1. s[i] == s[j + 1],且
  2. s[j + 1]前的子串与s[i]前的对应长度子串相等

读起来可能有点拗口,但其实跟第一节提到的两个条件本质上是一样的。光看上面的过程没理解没关系,我们还是直接用例子来讲解:依旧假设s = "ababaa",我们以求next[4]next[5] 为例看一下递推过程。

在这里插入图片描述

图5

先看next[4],如图5所示,我们已求得next[3] = 1,对应的最长前缀字符串为ab。此时,待匹配的i为4,j = next[3] = 1。显然有s[i] == s[j + 1]s[j + 1]前的子串abs[i]前的对应长度子串相等,故,我们可以直接令next[4] = j + 1,即next[4] = 2

在这里插入图片描述

图6

我们再来看next[5],如图6所示。我们已求得next[4] = 2,对应的最长前缀字符串为aba。此时,待匹配的i为5,j = next[4] = 2。显然有s[i] != s[j + 1],因此,我们需要回退j(将下方的前缀子串向右移动),使得图6中最右侧小图满足s[i] == s[j + 1]且红色部分与aba的后缀子串相等。(注意这里的红色部分长度不固定,图中的一个只是举例,并不表示长度为1。)

考虑到红色部分为aba的前缀子串,且j的回退要最小。这就意味着,对于图6中的最右侧的图,要满足aba的前缀子串(红色)与aba的后缀子串(绿色上部)相等的长度最大——这正好是next数组的定义因此,下一个要回退到的位置即为next[j]

这里从另一个角度解释一下:j要回退到子串s[0, 2] = "aba"的最长前缀子串最后一个字符的位置,即next[2]。而这个下标2即为上一步的next[i-1],也就是我们的j = next[4]。因此,下一个待回退的j即为j = next[j]

所以,我们发现图6中间小图的s[i] != s[j + 1]时,就让j回退,直到满足s[i] == s[j + 1]或者j == -1(此时表示无法再回退)为止。最终,next[5]的求解过程如下:
在这里插入图片描述

图7

综上,我们求next数组的步骤如下:

  1. 初始化next[0] = -1以及j = next[0]
  2. 从索引1开始遍历字符串s,每次遍历时:
    1. 不断令j = next[j],直到s[i] == s[j + 1]j = -1
    2. s[i] == s[j + 1]next[i] = j + 1;否则,next[i] = j

代码如下:

void getNext(const string &pattern, vector<int> &next) {
    int len = pattern.size();
    next.resize(len);
    next[0] = -1;
    int j = -1;
    for (int i = 1; i < len; i++) {
        while (j != -1 && pattern[i] != pattern[j + 1]) {
            j = next[j];
        }
        if (pattern[i] == pattern[j + 1]) {
            j++;
        }
        next[i] = j;
    }
}

了解了next数组以后,就可以很清晰地了解KMP算法。

KMP算法

上一节的next数组是求字符串s自己与自己的匹配,而KMP算法则是在这个的基础上进行textpattern的匹配,主要的思路都是一样的。首先,我们先按照上一节的思路求得patternnext数组,之后用pattern去匹配text的字符。遇到不匹配的情况时,就按照求得的next数组进行回退即可。以text = "ababaa"pattern = "abaa"为例看一下其中一个匹配过程:

在这里插入图片描述

图8

如图8所示,我们依旧让i表示被匹配字符串text的待匹配字符索引,j表示匹配字符串pattern的已匹配子串的最大索引。图8中,i = 2, j = 1,有s[i] == s[j + 1],因此,匹配成功,ij自增1进行下一次匹配。

在这里插入图片描述

图9

如图9所示,此时,i = 3, j = 2的字符。显然,此时有s[i] != s[j + 1],所以,我们需要对j进行回退。由于我们已经求出了textnext数组,next[j] = next[2] = 0。因此,我们令j回退至j = 0,此时s[i] == s[j + 1](图中中间小图),匹配成功,ij自增1进行下一次匹配(图中右小图)。如此循环,若i遍历结束时我们的j == pattern.length() - 1,表示pattern匹配成功;否则,匹配失败。

由此,我们可以得出KMP算法的思路,注意初始化的为patternnext数组(因为j是在pattern字符串上回退的):

  1. 首先初始化patternnext数组,以及j = -1,表示pattern已匹配的末位。
  2. 从0开始遍历text的每个字符text[i]
    1. 不断令j = next[j],直到text[i] == pattern[j + 1]j = -1
    2. text[i] == pattern[j + 1],则j++
  3. 若最终j == pattern.length() - 1,说明patterntext子串,返回true;否则,返回false。

代码如下:

bool KMP(const string &text, const string &pattern) {
	int m = text.length(), n = pattern.length();
	vector<int> next;
	getNext(pattern, next);

	int j = -1;
	for (int i = 0; i < m; i++) {
		while (j != -1 && text[i] != pattern[j + 1]) {
			j = next[j];
		}
		if (text[i] == pattern[j + 1]) {
			j++;
		}
		if (j == n - 1) {
			return true;
		}
	}
	return false;
}

可以看到,next数组的实际含义可以理解为当j + 1位的字符失配时,j可以回退至next[j]的位置,即下一个回退的位置。此外,KMP算法的代码与上一节求next数组的算法非常相似,区别其实仅在于前者是不同字符串之间的匹配,后者为字符串自己的匹配。两者均用到了next数组,前者用的是pattern的数组,后者则用的是自己当前匹配索引前面已求出的数组。

当然,如果要求返回的是textpattern子串的起始索引,那么只需将return true;改为return i - j;即可。

匹配次数

除了解决text中是否含有pattern子串的问题,KMP还可以解决text中是否含有多少个pattern子串的问题。以text = "ababab"pattern = "ababa"为例,如下图所示:
在这里插入图片描述

图10

上图的左侧,已经完成了一次pattern的匹配。此时,i加一变为5,同时我们需要回退j以进行新的匹配。显然,有了上文的了解,此时我们的j依旧回退至next[j]的位置即可。因为此位置保证了pattern的最长相等前后缀,并且使得j回退的位置最小、j之前的子串匹配。最终,我们可以写出以下的代码:

// 统计text中pattern出现的次数
int KMP(const string &text, const string &pattern) {
	int m = text.length(), n = pattern.length();
	vector<int> next;
	getNext(pattern, next);

	int j = -1, ans = 0;
	for (int i = 0; i < m; i++) {
		while (j != -1 && text[i] != pattern[j + 1]) {
			j = next[j];
		}
		if (text[i] == pattern[j + 1]) {
			j++;
		}
		// 主要就是这里不一样
		if (j == n - 1) {
			ans++;
			j = next[j];
		}
	}
	return ans;
}

时间复杂度分析

从代码中看到,每一个for循环中都有一个while循环,似乎相比于开头提到的双重for循环解法没有很大的优势,但其实仔细分析一下,可以得出KMP的时间复杂度为O(m + n)的结论:

首先,for循环的循环次数为O(m)级别。而对于j而言,最多在每次循环中最多只会增加一次,故最多只会增加m次。对于while循环,while循环代表着j的回退,即j的减小。因此,while循环总共最多只会执行m次,因此多于m次的话j就小于-1了。所以,整个for循环的时间复杂度为O(m)

同样的道理,对于next数组的求解其时间复杂度为O(n),故KMP的时间复杂度为O(m + n)

Next数组优化

KMP算法的核心就在于next数组,表示当j+1位匹配失败时j应回退的位置。但实际上,next数组还有优化空间:
在这里插入图片描述
如上图所示,text = "ababacab"pattern = "ababab"。这次,我们不同于上文中的j,我们用i表示pattern中已匹配的子串的末尾。考虑匹配text[5]

对于第一次匹配(粉色),此时i = 4,显然pattern[i + 1] != text[5],因此,我们回退inext[i] = 2
第二次匹配(蓝色),此时i = 2,显然pattern[i + 1] != text[5],我们继续回退inext[i] = 0
第三次匹配(黄色),此时i = 0,显然pattern[i + 1] != text[5],我们继续回退inext[i] = -1
最终,i = -1,无法再回退,后面的逻辑按照上一节的思路继续匹配即可,我们这里分析一下上面这么多次回退的原因。

主要问题在于,上面的每一次回退(粉、蓝、黄),回退后的pattern[i + 1]都是一样的,都是b。也就是说,对于这几次回退,都有pattern[i + 1] = pattern[next[i] + 1],均为字符b,使得回退次数过多(KMP代码中的while循环多次执行)。那么,有没有一种方法,能够让next数组一步到位,使得next[i]“一口气”就能回退到对应的位置;即,上图中蓝色部分的next[4]能够直接等于白色部分的-1

方法是有的,主要就是在getNext的初始化函数里做一个判断:我们对于每一个i,最后对next[i]赋值时,我们判断一下是否有s[i + 1] == s[next[i] + 1]。若是,那么直接让next[i] = next[next[i]];否则,还是按照原来的逻辑对next[i]赋值。

举例来说,对于上图,在i = 2,求next[2]时,原来的逻辑是让next[2]为0。而优化后的逻辑中,s[i + 1] = s[3] = 'b's[next[i] + 1] = s[1] = 'b',两者相等。因此,我们直接令next[2]等于next[0],即next[2] = -1。求next[4]时,同理。

综上,优化后的代码如下,注意j == -1时,不需要修改赋值逻辑。此外,我们用j代替了next[i],因此s[next[i] + 1]就转变为了s[j + 1]

void getNextVal(const string &s, vector<int> &nextVal) {
	int len = s.length();
	nextVal.resize(len);
	nextVal[0] = -1;
	int j = -1;
	for (int i = 1; i < len; i++) {
		while (s[i] != s[j + 1] && j != -1) {
			j = nextVal[j];
		}
		if (s[i] == s[j + 1]) {
			j++;
		}
		// below is the only different 
		if (j == -1 || s[i + 1] != s[j + 1]) {
			nextVal[i] = j;
		} else {
			nextVal[i] = nextVal[j];
		}
	}
}

可以看到,此时的数组不再表示“i结尾子串最长相等前后缀的前缀子串最后一位的下标”这一含义,变为了“当i + 1位字符失配时,应该回退到的最佳位置”这一含义。也因此,上面的代码里将数组名称改为了nextVal。(KMP算法主体的代码不需要任何的改动)

有了这个优化以后,我们无论是在KMP算法的代码里,还是getNextVal的代码里,均可以将while改成if,因为每次只回退一次就可能保证最佳位置。当然,不该对代码运行逻辑也没有影响~

我个人平时还是使用nex数组居多,只有数据量很大时采用nextVal

有限状态机

这里再从另一个角度理解一下KMP算法:有限状态机。所谓有限状态机,了解过体系结构的朋友应该不陌生,简单说就是某个状态,根据不同的输入会转变为不同的状态。如下图,对于字符串s = "ababab"的匹配,起始状态为0,终止状态为6。

对于待匹配的字符串,如果在状态0,输入字符a,就会转为状态1;若在状态1,输入字符b,就会转为状态2,以此类推。若最终能过到达状态6,则说明待匹配字符串中包含有s

若在任何状态遇到其他的字符输入,就会按照图中的回退箭头回退到某些状态:例如状态4若收到除a以外的字符,则转换为状态2。结合前面的内容,其实图中的这些回退箭头,实际上就代表了next数组。(注意不是nextVal

在这里插入图片描述

图片来源: 《算法笔记》图12-10

总结 & 后记

总体而言,KMP算法还是非常地巧妙的。虽然刚上手时理解起来需要一定的时间,但理解之后的代码量其实并不大。核心就是理解next数组的定义、作用,理解了next数组,基本上可以说就理解了KMP 90%的精髓了。

当然,还是那句话,KMP算法虽然非常精妙,但终究只是一种数据结构,一个工具。有时候如果为了用而用,没有对实际需求/问题起到实质性的帮助,反而有点舍本逐末的意思。我们并不应该拿着锤子找钉子,而是时刻备好这把备受偏爱的锤子,当特定钉子出现的时候能够用得上。

同时,KMP算法在看了一遍后短时间内可能能够理解,但过了一段时间后很可能又会有点淡忘。因此,还是需要多多温习,常看常新。

最后,这篇文章花了七个多小时,一万一千字左右。如果能够帮助到你,那就是有价值的。

  • 26
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值