字符串
- 1 KMP算法
- 状态机概述
- 构建状态转移
1 KMP算法
原文链接:https://zhuanlan.zhihu.com/p/83334559
先约定,本文用pat表示模式串,长度为M,txt表示文本串,长度为N,KMP算法是在txt中查找子串pat,如果找到,返回这个子串的起始索引,否则返回-1.
用一张图演示一下KMP算法:
KMP算法永不回退txt中的索引i,不走回头路。而是借助一个数组dp,dp数组中存储的信息把pat移到正确的位置继续匹配。
KMP的难点在于计算dp数组,计算这个dp数组,之和pat有关。意思是说,只要给我个pat,我就能通过这个模式串计算出dp数组,与txt无关系。
dp 数组只和 pat 有关,那么我们这样设计 KMP 算法就会比较漂亮:
int KMP(char *pat,char *txt)
{/*第一步获取dp数组*/dp = getDP(char *pat);/* 第二步搜索*/return_value = search(char *txt,char *pat);/* 第三步 */return return_value;
}
状态机概述
为什么说KMP算法与状态机有关呢?是这样的,我们可以认为pat的匹配就是状态的转移,比如pat=“ABABC”:
如上图,圆圈内的数字就是状态,状态0是起始状态,状态5是终止状态。开始匹配时pat处于起始状态,一旦转移到终止状态,就说明在txt中找到了pat。比如说当前处于状态2,说明字符AB被匹配。
另外,当处于一个状态时,接下来匹配的字符不同,pat 状态转移的行为也不同。比如说假设现在匹配到了状态 4,如果遇到字符 A 就应该转移到状态 3,遇到字符 C 就应该转移到状态 5,如果遇到字符 B 就应该转移到状态 0:
KMP算法最关键的步骤就是构造这个状态转移,要确定状态转移的行为,得确定两个变量,一个是当前的匹配状态,另一个是遇到的字符。确定了这两个变量后,就可以知道这个情况下应该转移到那个状态。
为了描述状态转移图,我们定义一个二维 dp 数组,它的含义如下:
dp[j][c] = next
0 <= j < M,代表当前的状态
0 <= c < 256,代表遇到的字符(ASCII 码)
0 <= next <= M,代表下一个状态dp[4]['A'] = 3 表示:
当前是状态 4,如果遇到字符 A,
pat 应该转移到状态 3
因为状态3之前的字符和状态4之前的字符相同dp[1]['B'] = 2 表示:
当前是状态 1,如果遇到字符 B,
pat 应该转移到状态 2
根据我们这个dp数组和刚才状态转移的过程,我们可以先写出KMP算法的search函数:
int search(char *txt,char *pat)
{int M = strlen(pat);int N = strlen(txt);//pat的初始态为0int j = 0;for(int i=0;i<N;i++){/* 当前状态是j,遇到字符txt[i],pat应该转移到那个状态? */j = dp[j][txt[i]];/* 如果到达终止态,返回匹配开头的索引 */if(j==M) return i-M+1;}/* 没有到达终止态,返回-1表示匹配失败 */return -1;
}
构建状态转移
回想刚才说的:要确定状态转移的行为,必须明确两个变量,一个是当前的匹配状态,另一个是遇到的字符,而且我们已经根据这个逻辑确定了 dp 数组的含义,那么构造 dp 数组的框架就是这样:
for 0 <= j < M: # 状态for 0 <= c < 256: # 字符dp[j][c] = next
这个next状态应该怎么求呢?显然,如果遇到的字符c和pat[j]匹配,状态就应该向前推进一个,也就是说next=j+1
,我们不妨称这种情况为状态推进:
如果字符c和pat[j]不匹配,状态就要回退(或者原地不动),我们不妨称这种情况为状态重启:
那么,如何得知在哪个状态重启呢?解答这个问题之前,我们再定义一个名字:影子状态(我编的名字),用变量 X 表示。所谓影子状态,就是和当前状态具有相同的前缀。比如下面这种情况:
当前状态j=4,其影子状态为X=2,它们都有相同的前缀AB,因为状态X和状态j存在相同的前缀,所以当状态j准备进行状态重启(遇到的字符c和pat[j]不匹配)的时候,可以通过X的状态转移来获得最近的重启位置。
比如说刚才的情况,如果状态 j 遇到一个字符 “A”,应该转移到哪里呢?首先只有遇到 “C” 才能推进状态,遇到 “A” 显然只能进行状态重启。状态 j 会把这个字符委托给状态 X 处理,也就是 dp[j]['A'] = dp[X]['A']
为什么这样可以呢?因为:既然 j 这边已经确定字符 “A” 无法推进状态,只能回退,而且 KMP 就是要尽可能少的回退,以免多余的计算。那么 j 就可以去问问和自己具有相同前缀的 X,如果 X 遇见 “A” 可以进行「状态推进」,那就转移过去,因为这样回退最少。
所以计算bp数组的伪代码就有了:
int X # 影子状态
for 0 <= j < M:for 0 <= c < 256:if c == pat[j]:# 状态推进dp[j][c] = j + 1else: # 状态重启# 委托 X 计算重启位置dp[j][c] = dp[X][c] # 更新 XX = dp[X][c]
为什么更新X使用的是: X = dp[X][c]
,我们首先要理解dp的含义。看下图:
dp[4]['A'] = 3 表示:
当前是状态 4,如果遇到字符 A,
pat 应该转移到状态 3
因为状态3之前的字符和状态4之前的字符相同
状态3前面的字符是ABA,状态四前面有ABA(最后一个A是要匹配的),所以dp[4]['A'] = 3
,dp[状态n][匹配的字符y]
的值实际代表的含义是在转态n下pat字符串前面和后面能匹配的字符数(也就是前面说的影子)。
X = dp[X][c]
表示在原来状态下匹配字符c,如果相同的话,影子状态就是增加1,如上图,假设现在在状态3匹配,则X=1,接下来就会计算dp[4]状态下的转移,在计算状态4转移前,首先要计算出状态4对应的影子状态,dp[X][c]
就是比较X=1对应的字符B和状态3对应的字符B是否相同,如果相同就是要增加影子长度,不相同就要转移,计算对应的影子。
所以,getDP函数实现如下:
void getDP(char *pat,int M)
{/* dp[状态][字符] = 下个状态 */dp[0][pat[0]]= 1;int X=0;for(int j=1;j<M;j++){for(int c=0;c<256;c++){/* 和字符c匹配了 */if(pat[j]==c)dp[j][c]=j+1;else /* 和字符c不匹配 */dp[j][c]=dp[X][c];}/* 更新 X*/X = dp[X][pat[j]];}
}
我们整合并化简两个程序,形成一个KMP算法。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>int dp[256][256];void getDP(char *pat,int M)
{/* dp[状态][字符] = 下个状态 */dp[0][pat[0]]= 1;int X=0;for(int j=1;j<M;j++){for(int c=0;c<256;c++){/* 和字符c匹配了 */if(pat[j]==c)dp[j][c]=j+1;else /* 和字符c不匹配 */dp[j][c]=dp[X][c];}/* 更新 X*/X = dp[X][pat[j]];}
}int search(char *txt,char *pat)
{int M = strlen(pat);int N = strlen(txt);//pat的初始态为0int j = 0;for(int i=0;i<N;i++){/* 当前状态是j,遇到字符txt[i],pat应该转移到那个状态? */j = dp[j][txt[i]];/* 如果到达终止态,返回匹配开头的索引 */if(j==M) return i-M+1;}/* 没有到达终止态,返回-1表示匹配失败 */return -1;
}int main(void)
{char *txt = "BCDABABC";char *pat = "ABABC";getDP(pat,strlen(pat));int count = search(txt,pat);printf("count = %d\n",count);return 0;
}