【模版】字符串--KMP算法
字符串匹配是计算机中的一个基本问题。字符串匹配应用很广泛,比如你想在一篇文章中找到某个关键字所在的位置,或者是你想在一份名单中找到某个名字是否出现等等。
抽象描述起来,就是我们现在有一个长度为 n 的字符串S,称为主串,有一个长度为 m 的字符串P,称为模式串。我们如何找到模式串在主串中存在的位置呢?模式串是否在主串中存在呢?
Brute-Force算法(暴力匹配算法)
暴力匹配算法就像名字一样暴力,对于模式串P和主串S,我们分别先进行如下两个操作:
设定两个指针,假设主串匹配到i位置,模式串匹配到j位置。
- 如果字符串匹配(S[i] == P[j]),那么让两个指针移动,即i++,j++,继续匹配下一个字符
- 如果字符串不匹配(S[i] != P[j]),那么两个指针都回溯,i回溯到 i - (j - 1) 的位置,j回溯到字符串开始的位置
代码描述:
int BruteForce (string &s,string &p) {
int n = s.size();
int m = p.size();
int i=0,j=0;
while (i<n && j<m) {
if (s[i] == p[j]) {
i++;
j++;
}
else
{
i = i - j + 1;
j = 0;
}
}
if (j == m) //如果匹配成功
return i - j;
else
return -1;
}
不难分析,暴力算法的时间复杂度是O(m*n),因为每一次匹配失败,算法会直接从头回溯 i,j 指针,做了很多无用功,比如对于如下极端情况:
主串S:AAAAAAAAAAAAAAC
模式串P:AAC
当匹配失败后,我们会一直回溯 i 和 j 指针,直到最后匹配成功,可以看到,这个算法做了很多无用功,并且我们的不匹配的信息只有C一个,那么我们前面匹配成功的AA完全没有用
那么我们有没有一种算法,让 i 不往回退,只需要移动 j 即可呢?
答案是肯定的。这种算法就是本文的主旨KMP算法,它利用之前已经部分匹配这个有效信息,保持 i 不回溯,通过修改 j 的位置,让模式串尽量地移动到有效的位置。
KMP算法:
1.KMP算法简介:
Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP算法”,常用于在一个文本串S内查找一个模式串P 的出现位置,这个算法由Donald Knuth、Vaughan Pratt、James H. Morris三人于1977年联合发表,故取这3人的姓氏命名此算法。
如何通过前面匹配成功的信息使得 i 指针不回溯,只回溯 j 指针,将算法优化成线性复杂度呢?
- 假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置
- 如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),令i++;j++,继续匹配下一个字符;
- 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。
- 换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值
j
被回溯到一个合适的位置,我们引入了PMT(Partial Match Table,部分匹配表),next数组也和PMT相关。int n = s.size();
int m = p.size();
int ne[SIZE]; ne[0]=-1;
for (int i = 0, j = -1; i < n; i ++ )
{
while (j != -1 && s[i] != p[j + 1]) j = ne[j]; //不匹配后,只回溯 j 指针
if (s[i] == p[j + 1]) j ++ ;
if (j == m - 1) //因为每次比较都是比较p[j+1]
{
cout << i - j << ' ';
j = ne[j]; //继续匹配
}
}
很容易分析出来,KMP的时间复杂度整体是O(n)的,最大的问题是,我们如何构造出next数组呢?
2.next数组解析
next数组和模式串形成映射数组,存的数据next[i]就是模式串P从P[0]到P[i-1]的最长公共前后缀长度
- ①寻找前缀后缀最长公共元素长度
- 对于P = p0 p1 ...pj-1 pj,寻找模式串P中长度最大且相等的前缀和后缀。如果存在p0 p1 ...pk-1 pk = pj- k pj-k+1...pj-1 pj,那么在包含pj的模式串中有最大长度为k+1的相同前缀后缀。举个例子,如果给定的模式串为“abab”,那么它的各个子串的前缀后缀的公共元素的最大长度如下表格所示:
- ②求next数组
- next 数组考虑的是除当前字符外的最长相同前缀后缀,所以通过第①步骤求得各个前缀后缀的公共元素的最大长度后,只要稍作变形即可:将第①步骤中求得的值整体右移一位,然后初值赋为-1,如下表格所示:
- ③根据next数组进行匹配
- 匹配失配,j = next [j],模式串向右移动的位数为:j - next[j]。换言之,当模式串的后缀pj-k pj-k+1, ..., pj-1 跟文本串si-k si-k+1, ..., si-1匹配成功,但pj 跟si匹配失败时,因为next[j] = k,相当于在不包含pj的模式串中有最大长度为k 的相同前缀后缀,即p0 p1 ...pk-1 = pj-k pj-k+1...pj-1,故令j = next[j],从而让模式串右移j - next[j] 位,使得模式串的前缀p0 p1, ..., pk-1对应着文本串 si-k si-k+1, ..., si-1,而后让pk 跟si 继续匹配。如下图所示:
3.如何求出next数组?
我们先介绍一个概念:前缀函数
给定长为n的字符串s,其前缀函数定义为一个长为n的数组π。其中π[i]为s的第i个最长公共前后缀的长度。 一般规定π[0] = 0
同时,不难由定义看出,我们的next数组和前缀函数数组息息相关(有一些KMP的写法不一样,next数组不完全是前缀函数数组)
数学描述如下:
由定义,我们显然能得到如下结论:
如果π[i]=j,那么有s[0~j-1] = s[i-j+1~i]
由此,我们可以由此结论得出求前缀函数的朴素算法
vector<int> prefix_function(string s) {
int n = (int)s.length();
vector<int> pi(n);
for (int i = 1; i < n; i++)
for (int j = i; j >= 0; j--)
if (s.substr(0, j) == s.substr(i - j + 1, j)) {
pi[i] = j;
break;
}
return pi;
}
//pi[0]初始化为0;
//string substr (size_t pos = 0, size_t len = npos) const;
显见该算法的时间复杂度为 O(n^3),具有很大的改进空间。 (两层循环+字符串截取)
优化方法1:
第一个重要的观察是 相邻的前缀函数值至多增加 1。
参照下图所示,只需如此考虑:当取一个尽可能大的π[i+1]时,必然要求新增的s[i+1]也与之对应的字符匹配
即 s[i+1] = s[π[i]] 时,此时π[i+1] = π[i] + 1。
所以当移动到下一个位置时,前缀函数的值要么增加一,要么维持不变,要么减少。
严格证明:π[i+1] - π[i] <= 1
利用反证法:
如果π[i] = j, π[i+1] = j+2, 那么s[0~j-1] = s[i-j+1~i], 并且s[0~j+2] = s[i-j,i+1]相同,显然此时此时显然s[0,j+1]与相等,故π[i]应该是j+1,显然矛盾!
图解如下:
故我们的代码可以做如下改进:
vector<int> prefix_function(string s) {
int n = (int)s.length();
vector<int> pi(n);
for (int i = 1; i < n; i++)
for (int j = pi[i - 1] + 1; j >= 0; j--) // improved: j=i => j=pi[i-1]+1
if (s.substr(0, j) == s.substr(i - j + 1, j)) {
pi[i] = j;
break;
}
return pi;
}
显然,pi的值最多增加n,也就最多减少n,意味着仅需要n次字符串比较就可以得到所有pi的值,所以此时求前缀函数的复杂度为O(n^2)
优化方法2:
第一个优化方法中,我们讨论了计算π[i+1]时的最好情况:s[i+1] = s[π[i]],此时π[i+1] = π[i] + 1。现在我们考虑得更远一些,如果s[i+1] != s[π[i]]那怎么办呢?
失配时,我们希望在子串s[0~i]中,找到仅次于π[i]的第二长度 j ,使得在位置i的前缀性质依旧保持,即s[0~j-1] = s[i-j+1~i];
如果我们找到这样的长度 j ,那么只需要再次比较s[i+1]和s[j]。如果s[i+1]=s[j],那么就有 π[i+1] = j+1 ,否则,我们就继续找到仅次于 j 的第二长度 j(2)使得前缀性质继续得到保持,如此反复直到 j = 0。如果s[i+1] != s[0],则π[i+1] = 0。
观察上图发现,对于子串s[0~i]的第二长度 j 有如下性质
s[0~j-1] = s[i-j+1~i] = s[π[i]-j~π[i]-1]
也就是说,j 等价于子串 s[π[i]-1] 的前缀函数值,即 j = π[π[i]-1] 。同理可得,次于 j 的第二长度 j(2)为 s[j-1] 的前缀函数值,即 j(2)= π[j-1]。
显然,我们可以得到一个关于 j 的状态转移方程:
j(n)= π [j(n-1)-1] ,( j(n-1)> 0 )
伪代码实现如下:
字符串下标从0开始:
string p; //需要自匹配的模式串
cin >> p;
int next[SIZE]
next[0] = 0;
int n = p.size();
for (int i=1;i<n;i++) {
int j = next[i-1];
while (j > 0 && s[i] != s[j]) j = next[j-1];
if (s[i] = s[j]) j++;
next[i] = j;
}
字符串下标从1开始:
char s[MAXSIZE]; //需要自匹配的模式串
cin >> p+1;
int next[SIZE]
next[1] = 0;
int n = p.size();
for (int i=2;i<=n;i++) {
int j = next[i-1];
while (j > 0 && s[i] != s[j+1]) j = next[j];
if (s[i] == s[j+1]) j++;
next[i] = j;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 周边上新:园子的第一款马克杯温暖上架
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
· 使用C#创建一个MCP客户端