关于常见字符串匹配算法——KMP算法的个人理解与解释

本文是对LeetCode上官方对KMP算法的解释的个人理解与消化,对应链接:
https://leetcode-cn.com/problems/implement-strstr/

.前言
我愿称LeetCode.0028为算法题里的扫地憎

题目描述:
实现 strStr() 函数。

给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串出现的第一个位置(下标从 0 开始)。如果不存在,则返回 -1 。

当 needle 是空字符串时,我们应当返回什么值呢?这是一个在面试中很好的问题。
对于本题而言,当 needle 是空字符串时我们应当返回 0 。这与 C 语言的 strstr() 以及 Java 的 indexOf() 定义相符。

示例:

示例 1:

输入:haystack = "hello", needle = "ll"
输出:2
示例 2:

输入:haystack = "aaaaa", needle = "bba"
输出:-1
示例 3:

输入:haystack = "", needle = ""
输出:0

常见的字符串匹配算法包括暴力匹配、Knuth-Morris-Pratt 算法、Boyer-Moore 算法、Sunday 算法等,本文将讲解Knuth-Morris-Pratt 算法,其他以后找机会补充。

.KMP算法(Knuth-Morris-Pratt)

一、算法核心:前缀函数π(i)

在长度为m的字符串中,设有前缀函数π(i),i满足:0≤i<m表示子字符串S[0 :i]的最长的相等的真前缀与真后缀的长度。特别地,如果不存在符合条件的前后缀,那么 π(i)=0。其中真前缀与真后缀的定义为不等于自身的的前缀与后缀。

举例:
对于字符串 aabaaab,其对应的前缀函数值为:0,1,0,1,2,2,3。

解释:

π(0)=0,因为 a 没有真前缀和真后缀,根据规定为 0(可以发现对于任意字符串 \pi(0)=0π(0)=0 必定成立);

π(1)=1,因为 aa 最长的一对相等的真前后缀为 a,长度为 1;

π(2)=0,因为 aab 没有对应真前缀和真后缀,根据规定为 0;

π(3)=1,因为 aaba 最长的一对相等的真前后缀为 a,长度为 1;

π(4)=2,因为 aabaa 最长的一对相等的真前后缀为 aa,长度为 2;

π(5)=2,因为 aabaaa 最长的一对相等的真前后缀为 aa,长度为 2;

π(6)=3,因为 aabaaab 最长的一对相等的真前后缀为 aab,长度为 3

有了前缀函数,我们就可以快速地计算出模式串在主串中的每一次出现。

二、求解前缀函数

长度为 m 的字符串 s 的所有前缀函数的求解算法的总时间复杂度是严格 O(m) 的,且该求解算法是增量算法,即我们可以一边读入字符串,一边求解当前读入位的前缀函数。

名词表:
为了方便解读,个人先列出几个后面内容比较常用的短式:

S[0 : π(i)-1]		子字符串S[0 : i]的最大长度前缀
S[i-π(i)+1 : i]		子字符串S[0 : i]的最大长度后缀
S[π(i)-1]			子字符串S[0 : i]的最大长度前缀的最后一个字符
S[π(i)]				子字符串S[0 : i]的最大长度前缀的最后一个字符的再后一位字符

关于前缀函数的几个性质:
性质(1):π(i)≤π(i−1)+1。
解析:

	根据前缀函数的定义,π(i)为字符串最长的相等的真前缀与真后缀的长度,即:
	S[0 : π(i)-1] = S[i-π(i)+1 : i]

	当真前缀与真后缀的右端端点左移一位时,理所当然的有:
	S[0 : π(i)-2] = S[i-π(i)+1 : i-1]
	
	依据π(i−1) 定义得:π(i−1)≥π(i)1,即 π(i)≤π(i−1)+1
	当且仅当S[π(i−1)]即S[π(i)-1]等于S[i]可取等号。

反证法:
对于字符串S[0 : i],设:
原最大长度前缀的右端左移一位S[0 : π(i)-2]为S1;
原最大长度后缀的右端左移一位S[i-π(i)+1 : i-1]为S2;
原最大长度前缀的最后一个字符S[π(i)-1]为ch1;
字符串最后一个字符S[I]为ch2;

假设:
若ch1 != ch2,且π(i)≥π(i−1)+1,则说明:

对于字符ch1,有后继字符串S3满足:S3 = S2,且S3有后继字符ch3 = ch2;
对于字符串S2,有前序字符ch4满足:ch4 = ch1,前ch4有前序字符串S4满足S4 = S1;
这样,得:
S1 + ch1 + S3 + ch3 = 
S4 + ch4 + S2 + ch2;
得:
S1 + ch1 + S3 = 
S4 + ch4 + S2;
根据π(i)定义,前缀不得取字符串本身,显然,S1 + ch1 + S3的长度要大于S1,与π(i)的定义冲突,
假设不成立,π(i)≤π(i−1)+1正确。

性质(2):如果 s[i]=s[π(i−1)],那么π(i)=π(i−1)+1

	根据前缀函数定义,有:
	S[0 : π(i-1)-1] = S[i-π(i-1)+1 : i-1]

	因为S[π(i-1)] = S[i],则有:
	S[0 : π(i-1)] = S[i-π(i-1) : i]

	根据性质1,:π(i)≤π(i−1)+1,得当且仅当S[π(i−1)]即S[π(i)-1]等于S[i]可取等号,则:
	:π(i)=π(i−1)+1

根据两性质,求解前缀函数π(i)可转换为:设字符串中有字符S[j],找到最大的j,且满足:
1、在字符串S[0 : i-1]中,s[0 : j−1] = s[i−j : i−1]
2、S[j] = S[i];

显然,这里指的是找出S[0 : i-1]的最大前缀π(i−1),且希望前缀与后缀新加入的字符相等S[j] = S[π(i−1)]= S[i],j = π(i−1)。
当S[j] = S[i],根据性质2,则可得知π(i)=π(i−1)+1。

重点:当S[j] != S[i]时。
当S[j] != S[i]时,此时我们需要改变策略,希望在字符串S[0 : i-1]中的前缀S[0 : π(i−1)-1]中找到尽可能大的字符S[j_2],使其满足:
1、在字符串S[0 : π(i−1)-1]中,S[0 : j_2-1] = S[π(i−1)-j_2 :π(i−1)-1],即希望字符串S[0 : i-1]中的前缀也有类似的结构使前缀的前后部分相同;
2、S[j_2]=S[i],即希望满足条件1的前缀的后继字符等同于整个字符串后面加的字符S[I];

解惑:为什么要在字符S[0 : i-1]的前缀找出类似结构的子前缀?
解答前先看看暴力匹配的方法:

我们可以让字符串 needle 与字符串haystack 的所有长度为 m 的子串均匹配一次。

为了减少不必要的匹配,我们每次匹配失败即立刻停止当前子串的匹配,对下一个子串继续匹配。
如果当前子串匹配成功,我们返回当前子串的开始位置即可。如果所有子串都匹配失败,则返回 -1

这个方法的结果是一旦匹配字符串中的某个字符不相等时,整个匹配字符串都需要重新匹配,其结果是造成时间复杂度为O(m*n)

而在前缀函数中,若已经有:
S[0 : π(i−1)-1] = S[i-π(i-1)+1 : i-1],且S[0 : j_2-1] = S[π(i−1)-j_2 :π(i−1)-1]

那么有:S[0 : j_2-1] = S[i-j_2+1 : i-1]
即最大前缀的子前缀依旧满足前后缀相同的要求。
这样,及时S[j] !=s[i],我们也不需要从新匹配,而是找到当前最近的子前缀,并重新判断剩余部分即可。

其中,满足S[0 : j_2-1] = S[i-j_2+1 : i-1]的j_2可规约为j = π(π(i−1)-1)
不难发现这是一个迭代的过程,只需要不断迭代 j_x(令 j_x 变为π(j_x−1))直到 s[i]=s[j_x] 或 j_x=0即可,如果最终匹配成功(找到了 j_x 使得 s[i]=s[j_x]),那么 π(i)=j_x+1,否则 π(i)=0。

三、复杂度分析:
时间复杂度部分,注意到 π(i)≤π(i−1)+1,即每次当前位的前缀函数至多比前一位增加一,每当我们迭代一次,当前位的前缀函数的最大值都会减少。可以发现前缀函数的总减少次数不会超过总增加次数,而总增加次数不会超过 m次,因此总减少次数也不会超过 m 次,即总迭代次数不会超过 m 次。

空间复杂度部分,我们只用到了长度为 m 的数组保存前缀函数,以及使用了常数的空间保存了若干变量。

.题目解析
记字符串haystack 的长度为 n,字符串needle 的长度为 m。

我们记字符串 str=needle+#+haystack,即将字符串 needle 和 haystack 进行拼接,并用不存在于两串中的特殊字符 # 将两串隔开,然后我们对字符串str 求前缀函数。

因为特殊字符 # 的存在,字符串 str 中 haystack 部分的前缀函数所对应的真前缀必定落在字符串 needle 部分,真后缀必定落在字符串 haystack 部分。当haystack 部分的前缀函数值为 m 时,我们就找到了一次字符串 needle 在字符串haystack 中的出现(因为此时真前缀恰为字符串 needle)。

实现时,我们可以进行一定的优化,包括:
1、我们无需显式地创建字符串 str。

	为了节约空间,我们只需要顺次遍历字符串 needle、特殊字符 # 和字符串 haystack 即可。

2、也无需显式地保存所有前缀函数的结果,而只需要保存字符串 needle 部分的前缀函数即可。

	特殊字符 # 的前缀函数必定为 0,且易知 π(i)≤m(真前缀不可能包含特殊字符 #)。

	这样我们计算 π(i) 时,j=π(π(π()1)1) 的所有的取值中仅有 π(i−1) 的下标可能大于等于 m。
	我们只需要保存前一个位置的前缀函数,其它的 j 的取值将全部为字符串 needle 部分的前缀函数。

3、我们也无需特别处理特殊字符 #,只需要注意处理字符串 haystack 的第一个位置对应的前缀函数时,直接设定 j 的初值为 0 即可。

这样我们可以将代码实现分为两部分:
第一部分是求 needle 部分的前缀函数,我们需要保留这部分的前缀函数值。
第二部分是求 haystack 部分的前缀函数,我们无需保留这部分的前缀函数值,只需要用一个变量记录上一个位置的前缀函数值即可。当某个位置的前缀函数值等于 m 时,说明我们就找到了一次字符串 needle 在字符串 haystack 中的出现(因为此时真前缀恰为字符串 needle,真后缀为以当前位置为结束位置的字符串 haystack 的子串),我们计算出起始位置,将其返回即可。

代码:

int strStr(string haystack, string needle) 
	{
        int n = haystack.size(), m = needle.size();

        if(m==0)
        {
            return 0;
        }

        //匹配字符串的前缀函数
        vector<int> pi(m);

        //理所当然的要一个一个字符读入
        //首字符的前缀函数值为0
        for(int i=1,j=0;i<m;i++)
        {
            //当当前字符不匹配则进行迭代
            //在前缀找子前缀
            while (j>0&&needle[i]!=needle[j])
            {
                j = pi[j - 1];
            }
            //若当前读取的前缀的后继字符匹配字符串当前最后的字符
            //则pi(i)的值等于pi(i-1)+1
	        if(needle[i]==needle[j])
	        {
                j++;
	        }
            pi[i] = j;
        }

        //与需匹配的字符串匹配
        for(int i=0,j=0;i<n;i++)
        {
            //当当前haystack字符不匹配
            //返回至最近可匹配子前缀
	        while (j>0&&needle[j]!=haystack[i])
	        {
                j = pi[j - 1];
	        }

            if(needle[j]==haystack[i])
            {
                j++;
            }

            //当前缀长度对于匹配字符串长度时表示匹配已完成,已经找到拼接字符串str=needle+#+haystack的前后缀
        	//可返回该后缀的开始字符位置
            if(j==m)
            {
                return i - m + 1;
            }
        }
        //如果轮询完整个字符串依旧没有长度为m的后缀则表明没有匹配子字符串
        return -1;
    }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值