KMP 字符串匹配原理 算法分析 C++代码详细分析

kmp算法理解

核心逻辑是避免字符串匹配时的重复运算,比如{“ABABABCABAB”}中查找{“ABABC”},当字符段ABABA和ABABC不匹配时,移动要查找字符串到A处(加粗)处进行下一次匹配,即
ABABABCABAB
     ABABC
而不用在下一个字母B处进行匹配,其实就是利用了字符串ABABA中重复的AB项。

前缀和

可以用前缀和来表示这个头和尾重复的字符串长度,字符串必须包含首部和尾部:
例如ABA对应1,1表示首尾有相同的A。

A:0
AB:0
ABA:1
ABAB:2
ABABC:0

即对应:
ABABC
0 01 2 0
类似的,有ABABA对应00123.

建立一个数组prefix记录这个对应表,并且右移一位,首位置为-1,方便匹配。prefix=(-1,0,0,1,2),对应关系:
ABABC
-10012
因此ABABC中的B(加粗)对应1表示前面的ABA前缀和为1.

匹配逻辑

可以利用这个特性来巧妙的进行字符串的匹配,当ABABABCABAB(text表示)与ABABC(pattern)在第一次匹配到text中的A(text[4])处失败时,用暴力搜索的话会回到B处进行匹配:
ABABABCABAB
   ABABC

如果运用前缀表,在pattern第5位匹配失败,此时prefix[4]=2,这个2在此处有两个含义:
1.值大于0说明首尾必有相同的字符串,且长度为2;(此例中pattern头尾各有一个AB)
2.重新回溯的地址位置,也可以理解为pattern[4] C映射位置为pattern[2] A;(结合代码理解为什么prefix要右移一位)

可以发现匹配失败的A(text[4])与C(pattern[4])的前面有公共字符串,因为只要匹配到这一位且prefix值=2(>0),那么在A(text[4])前面必有AB(其他情况不一定是AB,但总是一个和头部对应长度为2的字符串)和pattern的头AB对应,即:

----------ABC 尾部的AB
ABA---------- 头部的AB

此时回溯地址是2,pattern的指针移向2,因为数组从0开始且相同字符串长度为2,那么此时匹配失败的A(text[4])重新与pattern[2](AB后一位A)来比较,如果text[4]=pattern[2],那么说明 局部匹配上了,即AB+text[4]成了新的头ABA,即

ABABABCABAB
     ABABC

然后text和pattern(此时指针更新为2)两项指针指向同时后移,那么下一步的匹配就变成了text[5]和pattern[3]的匹配判断:

ABABABCABAB
     ABABC

显而易见到text[6]匹配成功,kmp算法跳过了很多重复部分,提高了效率。

如何获取前缀表

用双指针判断:i代表前缀和,j代表pattern位数
PS:i不仅代表前缀和也代表回溯时的位置,因为数组从0计数。
(1)

在这里插入图片描述

prefix[0]默认为0;j后移一位,i不动:

(2)
在这里插入图片描述

此时判断pattern[j]是否等于prefix[i],此时prefix[0]=A是pattern的头,如果后面的pattern[j]的值也等于A,那么说明头尾有相同的字符,i的值加1,不相等的话prefix[j]=0说明没有前缀和,继续移动j的位置:

(3)

在这里插入图片描述

pattern[2]=prefix[0]成立,此时头尾有相同的A了,i++,j++,prefix[j]=i+1=1,意味着下一位pattern[j]要比较的值不是A了,而是prefix[1]=B,因为此时的pattern[j](B)位置前必有和pattern头部相同的一个数(此处是A)。

(4)
在这里插入图片描述

此时pattern[j]=pattern[i]=B,说明头尾有相同的字符串AB,i++,j++,prefix[j]=i.同理类推,下一个数pattern[j]和pattern头部AB的下一个数A做比较。

(5)

在这里插入图片描述

pattern[j]!=pattern[i](C!=A),i=0,prefix[j]=0,j=pattern.size()所以结束匹配。得到:
ABABC
00 1 2 0

但是这样的思考逻辑会有漏洞!如:
ABCABA
00 0 12

当匹配到最后一位pattern[5]时,对应的回溯地址i是2,不等于pattern[2]=C,但是此时prefix[5]不能置为0,因为此时头尾都有相同的A,所以正确的值是prefix[j]=1,那么怎么判断?
可以加简单的pattern[j]==pattern[0]判断?

再看这个栗子:
A B C A A B C AB
0 0  0 1  1 2  3  4

j=8时,pattern[8]=B,此时i=4,判断pattern[8]与pattern[4]是否相等,不等,然后判断pattern[8]与pattern[0],不等,但是我们发现头和尾都有相同的字符串AB,正确的计算是prefix[8]=2。
发现不能简单用头尾是否相等来判断(pattern[j]==pattern[0])

那么这种情况怎么处理?
方便理解,此处可以理解为一种尾部到头部的映射关系,B相当于挪到了pattern[4](A)处比较。
ABCAA---------------ABCAB

此处着重分析,很明显,因为B!=A,此时的prefix[j]不可能为5,那么看前一位prefix[4](A)=4,说明尾部B前面必有长度为4的字符串(此处是ABCA)与头部相同。

分析ABCA----B,我们发现,如果挨着B最近的A有与头部映射关系的话,也就是prefix[i-1]=prefix[3]=1>0),此时B前面必有长度为1的字符串与头部对应:
AB--------------AB

那么B要比较的新数也就是pattern[1]==B?,条件成立 , i++,prefix[8]=2.

注意,不能只考虑挨着B的前一个数,而要考虑B前面与头部映射字符串的的长度,举个栗子AABAADAABAAB , 要考虑B前面两个A

栗子2:ABABCABABA

思路总结:
比较尾部时,先判断是否与pattern[i]相等,不相等的话就更新回溯地址,i=prefix[i-1],再继续比较,如果回溯到0,就与首位比较,结束回溯。

代码:

void prefix_table(const string& pattern,int* prefix)//prefix代表前缀表
{
    int n = pattern.size();
    int i = 0,j=1;          //i表示前缀和以及回溯地址,j表示pattern的位数
    prefix[0] = 0;
    while(j<n)
    {
        if (pattern[j] == pattern[i])
        {
            i++;        //相等的话前缀和长度加1
            prefix[j] = i;
            j++;
        }
        else
        {
            if (i > 0)
            //i大于0说明有相等的两个前缀和,新加的尾部有机会可以组成新的前缀和,i,j不做++操作
            //每次更新回溯地址进行比较if (pattern[j] == pattern[i])
            {
                i = prefix[i - 1];
            }
            else
            {
                prefix[j] = i;//和首位比较不相等,此处表值为0
                j++;
            }
        }
    }
    for (int i = n - 1; i > 0; --i)//后移一位,方便匹配
    {
        prefix[i] = prefix[i - 1];
    }
    prefix[0] = -1;
}

匹配分析

在最开始已经做了例子分析,这里补充一下更新回溯地址的逻辑:
新栗子:
-----------ABCABDAB+匹配位ch---------------(text)
ABCABDABE (pattern)

到匹配位ch时i=8,前缀和i可以理解为匹配位ch要捆绑前i个数来和pattern头部进行匹配。(此处可以和前缀和的回溯地址更新逻辑互相参考,我这里换了种理解方式,但因为前缀表右移一位,所以有些许区别,而且此处i=8不代表前缀和长度。)

以下是ch值得三种假设:

(1)ch=E-> text[j] = = pattern[i],前缀和i++;

(2)ch=C-> text[j] ! = pattern[i],更新i回溯地址:

此时prefix[i]的值表示pattern[i](E)前面字符串ABCABDAB的前缀和长度,更新回溯地址i=prefix[i]=2, 头尾各有一个AB,此时ch就捆绑前2位(AB)和头部字符串进行比较,因为捆绑了2位,所以ch与头部第三位patter[2](C)进行比较,C=pattern[2],局部匹配成功

-----------ABCABDAB+匹配位ch---------------
-----------------------ABCABDABE

所以ch的下一位与pattern第四位比较,i++,j++.

(3)ch=A->text[j] ! = pattern[i],更新i回溯地址,

-----------ABCABDAB+匹配位A---------------(text)
ABCABDABE (pattern)

pattern的下标i=8,回溯位置为prefix[8]=2 (复习前面的点,注意这里是偏移后的前缀表,prefix[8]=2意味着pattern中E前面有长度为2的字符串AB与头部相等,与此处的E做比较等价于与头部AB后一位pattern[2] C作比较,回溯位置=prefix[8] )

-----------ABCABDAB+A匹配位---------------(text)
-----------------------ABCABDABE

跟上一步一样,但A!=pattern[2], 继续更新回溯地址,此时i=prefix[2]=0,说明pattern第3位之前没有前缀和了,头尾不存在重复字符串,判断ch==pattern[0]?相等的话继续匹配text[j]下一位,不等的话i=prefix[i]=0,j++,重新在text[j+1]处进行全新一轮匹配。

-----------ABCABDAB+A匹配位---------------(text)
-----------------------------ABCABDABE

此处A=pattern[0],继续匹配下一位,i++,j++;

代码:

void kmp_search(const string& text,const string& pattern)
{
    int* prefix = new int[pattern.size()];
    prefix_table(pattern, prefix);         //获取前缀表
    int m = text.size(),n=pattern.size();
    int i = 0,j=0;          //i是回溯地址,j是text的位数
    bool find_text=0;
    while(j<m)
    {
        if (text[j] == pattern[i])
        {
            if (i == n - 1)
            {
                printf("Find Patter at %d \n", j - n + 1);
                find_text = true;
                if (i == 0)  //考虑只匹配一个字符
				{
					i = 0;
					j++;
					continue;
				}
				//在text中寻找多个pattern需要考虑重复部分
				//比如在text=ABCABCAB,中找所有pattern=ABCAB,
				int k = i;
				i = prefix[i];
				while (pattern[k] != pattern[i])
				{
					i = prefix[i];
					if (i == -1)
						break;
				}
				if (i == -1)  //没有重复部分,比如ABCD直接从头开始比较 
				{
					i = 0;
					j++;
				}
				else   //有重复部分,比如ABCA直接从B开始比较 
				{
					i++;
					j++;
				}
				continue;              
            }
            j++;
            i++;
        }
        else
        {
            i = prefix[i];     //更新回溯地址
            if (i == -1)
            {
                i = 0;
                j++;
            }
        }
    }
    if (!find_text)
        cout << "No Find!" << endl;
}

小测试:

int main()
{
    string s1 = { "ABCABCABC" };
    string s2 = { "ABCABC" };
    kmp_search(s1, s2);
}

结果:
在这里插入图片描述

前缀表不移位版本

#include <iostream>
using namespace std;

void prefix_table(const string& pattern, int* prefix)//prefix代表前缀表
{
    int n = pattern.size();
    int i = 0, j = 1;          //i表示前缀和以及回溯地址,j表示pattern的位数
    prefix[0] = 0;
    while (j < n)
    {
        if (pattern[j] == pattern[i])
        {
            i++;        //相等的话前缀和长度加1
            prefix[j] = i;
            j++;
        }
        else
        {
            if (i > 0)
                //i大于0说明有相等的两个前缀和,新加的尾部有机会可以组成新的前缀和,i,j不做++操作
                //每次更新回溯地址进行比较if (pattern[j] == pattern[i])
            {
                i = prefix[i - 1];
            }
            else
            {
                prefix[j] = i;//和首位比较不相等,此处表值为0
                j++;
            }
        }
    }
}
void kmp_search(const string& text, const string& pattern)
{
    int* prefix = new int[pattern.size()];
    prefix_table(pattern, prefix);         //获取前缀表
    int m = text.size(), n = pattern.size();
    int i = 0, j = 0;          //i是回溯地址,j是text的位数
    bool find_text = 0;
    while (j < m)
    {
        if (text[j] == pattern[i])
        {
            if (i == n - 1)
            {
                printf("Find Patter at %d \n", j - n + 1);
                i = prefix[i-1];             //也要更新地址,因为可能有重叠部分
                find_text = true;
            }
            j++;
            i++;
        }
        else
        {
            if (i == 0)
                j++;
            else
                i = prefix[i - 1];
        }
    }
    if (!find_text)
        cout << "No Find!" << endl;
}

int main()
{
    string s1 = { "ABCABCABC" };
    string s2 = { "ABC" };
    kmp_search(s1, s2);
}

kmp与动态规划

力扣上看到的,这个弄清楚了能更加理解匹配时回溯地址的跳转。
在这里插入图片描述dp[i][c]中i表示图中0,1,2…5各种状态,c表示遇到的字符,dp[i][c]表示在状态i匹配不同字符时分别应该跳到哪个状态,下面是匹配动画:
在这里插入图片描述

自己改的代码

class Solution {
public:
    int strStr(string haystack, string needle) {
        if(needle.empty())
            return 0;
        if(haystack.empty()||needle.size()>haystack.size())
            return -1;
        int m=haystack.size();
        int n=needle.size();
        int** dp=new int*[m];
        int x=0,t=0;
        for(int i=0;i<m;i++)
        {
            dp[i]=new int[256];
            fill(dp[i],dp[i]+256,0);
        }
        dp[x][needle[0]]=1;
        for(int i=1;i<n;i++) //创建dp数组,类似前缀表
        {
            for(int c=0;c<256;c++)
            {
                dp[i][c]=dp[x][c];
                // X类似我们上面分析的回溯地址,
                //匹配到的c相当于挪到了回溯地址去继续匹配
            }
            dp[i][needle[i]]=i+1;//如果遇到这个状态原本值,进下一状态
            x=dp[x][needle[i]];  //更新回溯地址
        }
        for(int i=0;i<m;i++)//利用dp数组匹配
        {
            t=dp[t][haystack[i]];
            if(t==n)//t能走到needle最后一个状态说明匹配成功
                return i-n+1;
        }
        return -1;
    }
    
};

精简写法

leetcode 28题可作为练习

class Solution {
public:
    int kmp(string s, string T) {
        int ans=0;
        int j=0;
        int n=s.size();
        int next[n];
        //next[j]代表j后一位会映射到的比较位置
        next[0]=0;
        for(int i=1;i<n;i++)
        {
            while(j>0&&s[i]!=s[j])
                j=next[j-1];
            if(s[i]==s[j])
                j++;
            next[i]=j;
        }
        j=0;
        for(int i=0;i<T.size();i++)
        {
            while(j>0&&s[j]!=T[i])
                j=next[j-1];
            if(s[j]==T[i])
                j++;
            if(j==n)
            {
                ans++;
                j=next[j-1];
            }
        }
        return ans;
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值