LeNotFound
2022-10-17 16:20:23
在涉及字符串的众多实际应用中,模式匹配是最常用的一个操作。
根据具体应用的要求不同,串匹配问题可以多种形式呈现。
我们只关心是否存在匹配而不关心具体的匹配位置,比如垃圾邮件的检测。
若经判断的确存在匹配,则还需确定具体的匹配位置,比如带毒程序的鉴别与修复。
若有多处匹配,则统计出匹配子串的总数,比如网络热门词汇排行榜的更新。
在有多处匹配时,报告出所有匹配的具体位置,比如网络搜索引擎。
形式化的,将其描述为:
如何在字符串数据中,检测和提取以字符串形式给出的某一局部特征。
考虑如下需求:
给定一个文本串
S 和一个模式串P ,在S 中找出P 第一次出现的位置。
这是最经典的考察字符串匹配算法的问题,对于这种需求,我们可以考虑使用暴力匹配算法,这是最直接的算法。
即从
对此,我们可以给出两个版本的代码实现:
int match(string s, string p) // 串匹配算法
{
int lens = s.length(); // 文本串长度
int lenp = p.length(); // 模式串长度
int i = 0, j = 0; // i指向文本串,j指向模式串,代表当前比对字符的位置
while (i < lens && j < lenp)
{
if (s[i] == p[j]) // 若匹配
{
i++;
j++; // 同时后移,跳转至下一个字符
}
else // 若不匹配
{
i -= j - 1; // 文本串回退
j = 0; // 模式串复位
}
}
return i - j; // 返回匹配位置
}
以上代码借助整数
int match(string s, string p)
{
int lens = s.length(); // 文本串长度
int lenp = p.length(); // 模式串长度
int i = 0; // 与模式串首字符的对其位置
int j; // 当前接受比对的字符的位置
for (i = 0; i < lens - lenp + 1; i++)
{ // 文本串从第 i 个字符开始,与模式串中对应的字符逐个比对
for (j = 0; j < lenp; j++)
{
if (p[i + j] != s[j])
{
break; // 若失配,模式串整体右移一个字符,再做一轮比对
}
}
if (j >= lenp)
{
break; // 找到匹配子串
}
}
return i;
}
如上代码借助整数
为了方便分析说明,我们令
从理论上讲,暴力算法最多迭代
我们不妨来构造一种极端情况,考虑如下的
S: 000000000……0000001
P: 0001
考虑如上情况,无论采用上述哪个版本的暴力算法,都需要做
上一节的分析表明,暴力算法在最坏的情况下所需时间,为文本串长度与模式串长度的乘积,很有必要改进。为此,我们不妨从分析以上最坏情况入手。
稍加观察不难发现,问题主要在于这里存在着大量的局部匹配:每一轮的
简单模拟一下暴力算法的匹配过程,统计文本串中各个字符被比对的次数。不难发现,原因就在于
实际上,重复的比对操作没有必要。既然我们已经掌握了
还是回到刚才那个例子,考察在一次迭代中失败的那次比对,尽管这次比对是失败的,但却意味着在此前我们已经获得足够多次成功的匹配,在这个例子中也就是之前的
…… |0|0 0 0 0 0|0 0 0……
|0|0 0 0 0 0|1
| |0 0 0 0 0|0 1
对于如上情况,我们可以发现,即便下一轮迭代只能将模式串整体右移一个字符,但相较于暴力算法,中间那五个连续的
如此一来,
我们再举出一个更为复杂的情况,考察如下的文本串和模式串:
这里的一轮迭代,首次失配于
如果一个位置值得对其,那么它的首字符必定是
一般地,如下图所示,假设前一轮比对终止于
由图可见,经过此前一轮的比对,已经可以确定匹配的范围应为:
于是,若模式串
亦即,在
N(P,j) = { 0 \leq t < j | P[0, t) = P[j - t, j) }
一般地,该集合中可能包含多个这样的
从图中还可以看出,若下一轮比对将从
next[j] = \max( N(P, j) )
则一旦发现
既然集合
将上述思想整理为算法,即可得到 KMP 算法。
这里假定可以通过 buildNext()
函数构造出模式串
int match(string s, string p) // KMP 算法
{
int *next = buildNext(p); // 构造 next 表
int lens = s.length(); // 文本串长度
int lenp = p.length(); // 模式串长度
int i = 0, j = 0; // i 指向文本串,j 指向模式串
while (j < lenp && i < lens) // 自左向右逐个比对字符
{
if (0 > j || s[i] == p[j]) // 若匹配,或 p 已移出最左侧(两个判断的次序不可交换)
{
i++; // 则转到下一字符
j++;
}
else // 否则
{
j = next[j]; // p 右移(注意:文本串不用回退)
}
}
delete[] next; // 释放 next 表空间
return i - j;
}
提示:若使用万能头且声明形如 vector<int> next
的数组,则数组名称不能使用 next
,会与 stl_iterator_base_funcs.h
中的保留关键字 next
冲突.
不难看出,只要
形式化的:
只要
但若
此种情况下,又该如何定义
下标 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|---|
P [ ] | * | C | H | I | N | C | H | I | L | L | A |
next [ ] | N/A | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 3 | 0 | 0 |
考虑上述的实例,假象地加一个通配符
因此如上表所示,不妨假象地在
对于
那么,已知
若
以上图为例,我们来证明一下那个结论。
以左边的红线为界,可以发现,下面的
为了进而证明仅当,我们只需考虑
那么一般地,若
此种情况下,由 next 表的功能定义,
因此,只需反复用
按照以上思路,可以实现next表构造算法如下代码所示。
int *buildNext(string p) // 构造模式串 P 的 next 表
{
int lenp = p.length();
int j = 0; // “主”串指针
int *N = new int[lenp]; // next 表
int t = N[0] = -1; // 模式串指针
while (j < lenp - 1)
{
if (0 > t || p[j] == p[t]) // 匹配
{
j++;
t++;
N[j] = t;
}
else // 失配
{
t = N[t];
}
}
return N;
}
我们会发现上述构造算法中,匹配的部分与 KMP 主算法几乎完全一致,实际上按照上述的分析,这一构造过程完全等效于模式串进行自我匹配,所以两个算法在形式上近似不足为怪。
我们以 洛谷 P3375 【模板】KMP字符串匹配 为例,来演示一下 KMP 算法的使用。
给出两个字符串
现在请你求出
定义一个字符串
对于
ABABABC
ABA
1
3
0 0 1
。
对于 ABA
,字符串 A
既是其后缀也是其前缀,且是最长的,因此最长 border 长度为
这一题和文章开头给出的需求还是有一定出入的,它有可能多次匹配,所以前文给出的代码不能满足需求,需要进行调整,同时题目还要求输出 border 数组。
那么就要微调一下算法的模板,同时要注意 next 数组并不等同于 border 数组。
下面给出代码实现。
#include <bits/stdc++.h>
using namespace std;
vector<int> Next; // 这里要注意命名不可以为 next,会与库函数冲突
void buildNext(string p) // 构造模式串 P 的 next 表
{
Next.resize(p.length() + 1); // 注意这里要多分配一个空间
Next[0] = -1; // 因为首项为 -1
int i = 0, j = -1; // 同时又要保证求出 border 数组
while (i < p.length())
{
if (0 > j || p[i] == p[j])
{
i++;
j++;
Next[i] = j;
}
else
{
j = Next[j];
}
}
}
vector<int> ans; // 保存匹配位置
void match(string s, string p)
{
buildNext(p);
int lens = s.length();
int lenp = p.length();
int i = 0, j = 0;
while (i < lens && j < lenp)
{
if (0 > j || s[i] == p[j])
{
i++;
j++;
if (j == lenp)
{ // 匹配成功,记录位置 同时 j = Next[j] 以便继续匹配
ans.push_back(i - j + 1);
j = Next[j];
}
}
else
{
j = Next[j];
}
}
}
int main()
{
string s, p;
cin >> s >> p;
match(s, p);
for (int i = 0; i < ans.size(); i++) // 输出匹配位置
{
cout << ans[i] << endl;
}
for (int i = 1; i < Next.size(); i++) // 输出 border 数组
{
cout << Next[i] << " ";
}
return 0;
}
实际上,本篇文章目前的 KMP 算法版本还不是最优的,在某一方面还是存在一些细微的瑕疵,可以针对这个缺陷对它再做改进,那么具体的复杂度分析和改进将在下一篇文章中进行介绍,敬请期待。
在此也感谢邓俊辉老师公开的精美课件,为本文的创作提供了很大的便利。
如果发现本文有缺陷或者错误,欢迎在评论区指出,我会及时修改。