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;
}
};