KMP算法

复制代码
在我们做算法题的时候,会碰到这样的一个问题:有两个字符串txt、tar,问字符串tar是否在字符串
txt中出现过,如果出现了的话,则返回成功匹配时的下标,若没有则返回-1。这就是经典的字符串
匹配问题。而如果深入了解过字符串匹配问题的话,那就一定听说过KMP算法,那么这篇文章就是
介绍了我对于KMP算法的理解,希望可以对想要了解KMP算法的读者提供一点帮助。

前言中的题目是leetcode中的一道题目:28.找出字符串中第一个匹配项的下标

KMP介绍

KMP(Knuth-Morris-Pratt)算法是一种用于字符串匹配的高效算法,由 Donald Knuth、Vaughan Pratt 和 James H. Morris 在 1977 年共同开发。该算法主要用于在一个主字符串(文本串)中查找一个模式字符串的出现位置,相较于传统的暴力匹配算法,它在时间复杂度上有显著的优化。

传统的暴力算法无非就是遍历txt字符串,当遍历到某一个位置时,我们就从当前位置开始跟tar字符串匹配,如果匹配成功则返回,不成功,则继续向后遍历直到末尾,时间复杂度O(M * N)M为字符串txt的长度,N为字符串tar的长度。

而KMP算法通过记录一些信息从而让字符串匹配过程中的效率大大增加,时间复杂度可以做到O(M + N)。

KMP算法的使用

上面介绍说,KMP算法通过记录一些信息,可以使得匹配过程中效率大大增加,而这个信息就是字符串tar中每个位置之前的子串中的最大相等前后缀长度,这句话对于初次遇到的人来说可能有点拗口。不要着急,我们慢慢来理解上面的含义。

前后缀

上面那句话中我们提到了前缀和后缀,我们先要清楚的认识前缀和后缀是什么:

复制代码
前缀:包含首字符并且不包含尾字符的子串
后缀:包含尾字符并且不包含首字符的子串

下面让我来举一个例子,比如tar = "abcabdabc";

那么tar的前缀就有:

复制代码
a
ab
abc
abca
abcab
abcabd
abcabda
abcabdab

后缀则是:

复制代码
c
bc
abc
dabc
bdabc
abdabc
cabdabc
bcabdabc

很多人在学习KMP算法的时候误把找后缀的顺序认定为从结尾开始向左的方向进行,这与与后缀的方向正好相反,要能够注意到这一点细节。

而我们也不难发现上述字符串tar的最大相等前后缀的长度就是3(前缀中的abc与后缀中的abc相等)。这就是前后缀与最大相等前后缀的长度。

字符串tar中每个位置之前的子串中的最大相等前后缀长度

现在让我们来理解这句话,这里我依然举一个例子,比如字符串tar = "issip";

复制代码
它的每个位置的子串意思就是包含首字符与当前位置字符的子串,那么tar的子串有:
i
is
iss
issi
issip
而每个字串的最大相等前后缀我们也可以得到:
i : 0(单个字符既没有前缀也没有后缀,所以是0)
is: 0
iss: 0
issi: 1
issip 0

这样的话我们就得到了tar中每个位置之前的子串中的最大相等前后缀长度了,在编程语言中我们可以用数组把他们存起来,得到一张表,这张表就叫做部分匹配表。

那么tar的部分匹配表就是:{0, 0, 0, 1, 0}。下面我们就来使用这一张表来模拟KMP算法的过程,在这里我们假设字符串txt = "mississippi"。

模拟KMP算法

上述过程中我们明确了txt = "mississippi",tar = "issip"。并且获得了tar的部分匹配表{0, 0, 0, 1, 0},接下来我们就来模拟KMP算法。

总体的流程我们依然是遍历txt与tar两个字符串,只是在字符匹配的过程中有了差异。

接下来我们有一个下标指向txt的某一个字符,j指向tar的某一个字符,两者初始值都为0,然后向后遍历比较:

j = prefix[j - 1]的原因

这张图里我们其中一个过程用到了部分匹配表,让我来解释一下为什么当在匹配过程中txt[i] != tar[j] && j > 0时,就可以让j = prefix[j - 1]呢?

我们来再次分析一下部分匹配表中的含义,部分匹配表中存储的当前下标的最大相等前后缀长度,那这就意味着,在某一个下标i构成的子串处,k = prefix[i],那么一定有该子串的前k个子字符串和后k个子字符串是相等的,那么当我们在匹配过程中出现了字符不相等的情况,在暴力匹配中,我们应该另j = 0,i等于开始当前开始匹配时的下一个字符。

而在上述KMP匹配过程中我们碰到了字符's' 和字符 'p'不匹配的情况,这个时候我们要知道下标j之前的字符我们是匹配成功的,也就是tar字符串从下标0到j - 1我们都是匹配成功的,而我们又知道下标j - 1的最大相等前后缀长度,这就意味着其实我们不需要让j再回到起始位置。

因为字符串tar从下标0到下标prefix[j - 1] - 1构成的子串 "i" 和从下标j - prefix[j - 1]到下标j - 1构成的子串是相等的

并且我们也知道字符串txt从下标i - prefix[j - 1]到下标i - 1构成的子串 "i" 与tar从下标j - prefix[j - 1]到下标j - 1构成的子串 "i" 也一定是相等的。

所以对于tar前prefix[j - 1]个字符我们就不需要重复匹配了,而是直接从tar的下标prefix[j - 1]继续向后匹配就可以了。

至此tar不需要回退的原因我们已经找到了那就是由于部分匹配表的存在

i不需要回退的原因

但是或许有人就有疑问了,那为什么txt的下标不需要回退呢?在暴力匹配过程中,假如我们选定一个下标start开始匹配,当字符串出现不匹配的情况时,我们需要从start + 1开始继续匹配,但是在KMP算法中,似乎没有这一步,它为什么能够排除这种情况呢:假如我们依旧开始从start开始匹配,匹配到i(i > start)位置时出现不匹配的情况,他凭什么不把start回退到start + 1(又或者是start + x(start + x < i))开始匹配,而是可以直接从i开始继续匹配呢。

其实这一件事情部分匹配表也帮我们做了,当我们按照上述过程到了i位置出现不匹配,假定此时tar下标为j,那就意味着txt下标从start开始到i - 1构成的子字符串与tar从下标0开始到j - 1构成的子字符串是相等的,tar从下标0开始到j - 1构成的子字符串的最大相等前后缀长度与txt下标从start开始到i - 1构成的子字符串是一致的。

现在我们按照上面的设想继续从start + 1开始匹配,我们发现这其实就是tar从下标0开始到j - 2构成的子字符串与tar从下标1开始到下标j - 1判定是否相等吗,换句话说这不就是判定tar从下标0开始到j - 1构成的子字符串的最长的前缀和后缀是否相等吗?到了这里我想一切都豁然开朗了,这两个子串相不相等部分匹配表不就已经记录了啊,如果相等的话部分匹配表prefix[j - 1]会记录该最大相等前后缀的长度(也就是上述最长前缀的长度),那我们本来就不用回退让start为start + 1,因为部分匹配表告诉我们这两个不相等我们直接让txt的下标i和tar的下标prefix[j - 1]继续向后匹配就可以了,如果不相等的话那就意味着部分匹配表prefix[j - 1]的值小于上述最长前缀(或后缀)的长度,那我们更不用匹配了,因为我们已经知道他俩不相等了。那么以此类推为什么不把start回退到start + x(start + x < i)开始匹配,而是可以直接从i开始继续匹配的子问题我们也都是类似的解决方案,一切都在部分匹配表中。

KMP算法

接下来我附上KMP算法的C++代码:

cpp 复制代码
int strStr(vector<int>& prefix, string& txt, string &tar)
{
        int l = 0, r = 0;
        while(l < txt.size())
        {
            if(r == tar.size())
                break;
            else if(txt[l] == tar[r])
            {
                l++;
                r++;
            }
            else
            {
                if(r == 0)
                    l++;
                else r = prefix[r - 1];
            }
        }

        return r == tar.size() ? l - tar.size() : -1;
}

到这里就完了吗?如果你去认真了解过KMP算法的话,就会知道学会KMP算法本身其实是不难理解的,在上面的讲述过程中,我们一直都在说KMP算法本身,但是一直没有详细说部分匹配表,但是部分匹配表出问题的人又不会给你,需要我们自己解决,而KMP算法中,求得部分匹配表才是比较难理解的部分。

部分匹配表的获取

如果我们使用最简单无脑的方式,那就是依旧是遍历tar字符串,当遍历到某一个下标时,找出它所有的前后缀,然后对应长度前后缀逐一比对找到当前下标的最大相等前后缀,这一套操作下来,时间复杂度直接打到O(N ^ 3)。这样一来不就本末倒置了,整体时间复杂度大致还不如暴力匹配呢。

所以我们一定有着更有的解法,那就是使用动态规划,但是这其中不仅仅是简单的动态规划,他还融入了KMP算法,这正是部分匹配表难理解的原因。

至于为什么能够想到使用动态规划,其实我也不知道(我也是个小菜鸟),但是我能够知道它为什么可以使用动态规划。

为什么可以使用动态规划

现在我们依旧是在遍历tar字符串,然后寻找每个下标构成的子串的最大相等前后缀长度。既然是这样的方式,那也就意味着,我们在寻找下标为i构成的子串的最大相等前后缀长度时,我们就已经获取了下标0到i - 1的构成的子串的最大相等前后缀长度。

根据上面的认识,当我们遍历到下标i的时候,我们可以发现这一点:那就是当前下标的最大相等前后缀的长度绝对不会大于下标i - 1的最大相等前后缀的长度加一。

这一点我们是可以证明的(上面提到的下标i是大于0的,因为如果下标等于0的话,长度为一的子串的最大相等前后缀永远是0)。

假设我们当前遍历到下标i,并且下标i - 1的最大相等前后缀长度是k,那就意味着下标从0开始到i - 1构成的子串中,前k个字符构成的子串和后k个字符构成的子串是相等的:

这里的k其实就是prefix[i - 1]。

既然上上图中k给定数值2,那我们就可以断定当前下标i的最大相等前后缀长度不会超过3。

我们可以想一下,假如当前下标的最大相等前后缀长度大于3,假如是4,那就对应下图中的情况:

如果是这样的情况,那么prefix[i - 1]一定是3:

显然这是不可能的,所以我上述的结论是正确的。

而有了这一点,那当我们在判断当前下标i的最大相等前后缀长度时,只需要从长度prefix[i - 1] + 1的前后缀开始判断就可以了,所以我们使用动态规划,可以进行简单的优化。

但是这样的优化够吗?我们发现,就算我们能排除一部分遍历的情况,但是当面对prefix[i - 1]很大的情况下,在我们寻找当前最大相等前后缀长度时,最坏的情况可以遍历prefix[i - 1] + 1次,我们仍然可以进行优化,那就是使用KMP算法。

在寻找当前最大相等前后缀长度时应用KMP算法

刚刚提到,当面对prefix[i - 1]很大的情况下,在我们寻找当前最大相等前后缀长度时,最坏的情况可以遍历prefix[i - 1] + 1次,那就是当前遍历到的下标字符,不存在于由下标0到下标prefix[i - 1] + 1构成的子串中:

这才会遍历那么多次。但是我们实际上不需要遍历那么多次,在最开始介绍获取部分匹配表的时候,我就说过,当我们遍历到i时,0到i - 1的部分匹配表就已经获取好了,我们现在再来从一开始的匹配来看上面这张图:

按照我们以前的思路,如果我们匹配成功,那不用继续往下找了,这就是最大的,是4,但是如果不匹配我们需要继续缩短前后缀,直到前后缀的末尾字符匹配成功。

但是其实失败之后我们是不需要一个一个进行遍历的。我们其实可以将这一个过程想象成KMP算法的过程,即下标从0开始到下标prefix[i - 1]构成的子串在tar字符串中找第二个匹配的过程,而我们刚好在匹配过程中碰到了下标i与即下标从0开始到下标prefix[i - 1]构成的子串的下标j不匹配,那此时我们在KMP算法中该如何进行呢?不就是查部分前缀表prefix[j - 1]吗?

在让j = prefix[j - 1]的过程中,一旦出现str[j] == tar[i],那就说明我们找到最大相等前后缀长度了,那就是j + 1。而这个部分匹配表我们刚好有,所以这个算法是成立的。现在我们的获取tar字符串的部分匹配表的算法就完成了,附上代码:

需要注意: 这里的函数中的prefix的长度已经是预先初始化为跟字符串s同等大小了

cpp 复制代码
void getPrefix(vector<int> & prefix, string &s)
    {
        if(prefix.size() == 0)
            return;
		
		// 长度为1的字符串最大相等前后缀长度始终为0
        prefix[0] = 0;
        // 记录上一个位置的最大相等前后缀长度
        int j = 0;

        for(int i = 1; i < s.size(); i++)
        {
            while(j > 0 && s[i] != s[j])
            {
                j = prefix[j - 1];
            }
            if(s[i] == s[j]) ++j;

            prefix[i] = j;
        }
    }

KMP算法完全版

其实KMP算法的使用过程也可以跟上面获取部分匹配表的代码相同,这是最开始的我的代码:

cpp 复制代码
int strStr(vector<int>& prefix, string& txt, string &tar)
{
        int l = 0, r = 0;
        while(l < txt.size())
        {
            if(r == tar.size())
                break;
            else if(txt[l] == tar[r])
            {
                l++;
                r++;
            }
            else
            {
                if(r == 0)
                    l++;
                else r = prefix[r - 1];
            }
        }

        return r == tar.size() ? l - tar.size() : -1;
}

最终的KMP算法代码:

cpp 复制代码
void getPrefix(vector<int> & prefix, string &s)
{
    if(prefix.size() == 0)
        return;

	// 长度为1的字符串最大相等前后缀长度始终为0
    prefix[0] = 0;
    // 记录上一个位置的最大相等前后缀长度
    int j = 0;

    for(int i = 1; i < s.size(); i++)
    {
        while(j > 0 && s[i] != s[j])
        {
            j = prefix[j - 1];
        }
        if(s[i] == s[j]) ++j;

        prefix[i] = j;
    }
}

int strStr(string& txt, string &tar)
{
	if(tar.size() > txt.size())
		return -1;

	vector<int> prefix(tar.size());
	getPrefix(prefix, tar);

	for(int i = 0, j = 0; i < txt.size(); i++)
	{
        while (j > 0 && txt[i] != tar[j]) 
        {
            j = pi[j - 1];
        }
        
        if (txt[i] == tar[j]) 
        {
            j++;
        }
        
        if (j == tar.size()) 
        {
            return i - tar.size() + 1;
        }
	}

    return -1;
}

这就是我对KMP算法的全部理解,如果有不对的地方,请指正。

相关推荐
只有月亮知道10 分钟前
C++list常用接口和模拟实现
开发语言·c++
勘察加熊人22 分钟前
c#和c++脚本解释器科学运算
开发语言·c++·c#
明月看潮生1 小时前
青少年编程与数学 02-016 Python数据结构与算法 03课题、数组与链表
数据结构·python·青少年编程·编程与数学
zyx没烦恼1 小时前
Linux 下 日志系统搭建全攻略
linux·服务器·开发语言·c++
苏卫苏卫苏卫1 小时前
【Python】数据结构练习
开发语言·数据结构·笔记·python·numpy·pandas
weisian1512 小时前
力扣经典算法篇-9-跳跃游戏(贪心算法,反向递推)
算法·leetcode·游戏
MCYH02062 小时前
C++抽卡模拟器
java·c++·算法·概率·原神
pystraf2 小时前
P10587 「ALFR Round 2」C 小 Y 的数 Solution
数据结构·c++·算法·线段树·洛谷
ゞ 正在缓冲99%…3 小时前
leetcode221.最大正方形
java·算法·动态规划
_x_w3 小时前
【8】数据结构的栈与队列练习篇章
开发语言·数据结构·笔记·python·链表