给定一个主串(以 S 代替)和模式串(以 P 代替),要求找出 P 在 S 中出现的位置,此即串的模式匹配问题。
Knuth-Morris-Pratt 算法(简称 KMP)是解决这一问题的常用算法之一,这个算法是由高德纳(Donald Ervin Knuth)和沃恩·普拉特在 1974 年构思,同年詹姆斯·H·莫里斯也独立地设计出该算法,最终三人于 1977 年联合发表。
在继续下面的内容之前,在这里介绍下两个概念:真前缀 和 真后缀。
由上图所得, “真前缀” 指除了自身以外,一个字符串的全部头部组合;“真后缀” 指除了自身以外,一个字符串的全部尾部组合。(网上很多博客,应该说是几乎所有的博客,都是 “前缀”。严格来说,“真前缀” 和 “前缀” 是不同的)
朴素字符串匹配算法
初遇串的模式匹配问题,我们脑海中的第一反应,就是朴素字符串匹配(即所谓的暴力匹配),代码如下:
/* 字符串下标始于 0 */
int NaiveStringSearch(string S, string P)
{
int i = 0; // S 的下标
int j = 0; // P 的下标
int s_len = S.size();
int p_len = P.size();
while (i < s_len && j < p_len)
{
if (S[i] == P[j]) // 若相等,都前进一步
{
i++;
j++;
}
else // 不相等
{
i = i - j + 1;
j = 0;
}
}
if (j == p_len) // 匹配成功
return i - j;
return -1;
}
暴力匹配的时间复杂度为 O(nm)
,其中 n
为 S
的长度,m
为 P
的长度。很明显,这样的时间复杂度很难满足我们的需求。
接下来进入正题:时间复杂度为 O(n+m)
的 KMP 算法。
KMP字符串匹配算法
算法流程
1)
首先,主串 “BBC ABCDAB ABCDABCDABDE” 的第一个字符与模式串 “ABCDABD” 的第一个字符,进行比较。因为 B 与 A 不匹配,所以模式串后移一位。
2)
因为 B 与 A 又不匹配,模式串再往后移。
3)
就这样,直到主串有一个字符,与模式串的第一个字符相同为止。
4)
接着比较主串和模式串的下一个字符,还是相同。
5)
直到主串有一个字符,与模式串对应的字符不相同为止。
6)
这时,最自然的反应是,将模式串整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把"搜索位置"移到已经比较过的位置,重比一遍。
7)
一个基本事实是,当空格与 D 不匹配时,你其实是已经知道前面六个字符是 “ABCDAB”。KMP 算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,而是继续把它向后移,这样就提高了效率。
8)
怎么做到这一点呢?可以针对模式串,设置一个跳转数组int next[]
,这个数组是怎么计算出来的,后面再介绍,这里只要会用就可以了。
9)
已知空格与 D 不匹配时,前面六个字符 “ABCDAB” 是匹配的。根据跳转数组可知,不匹配处 D 的 next 值为 2,因此接下来从模式串下标为 2 的位置开始匹配。
10)
因为空格与 C 不匹配,C 处的 next 值为 0,因此接下来模式串从下标为 0 处开始匹配。
11)
因为空格与 A 不匹配,此处 next 值为 -1,表示模式串的第一个字符就不匹配,那么直接往后移一位。
12)
逐位比较,直到发现 C 与 D 不匹配。于是,下一步从下标为 2 的地方开始匹配。
13)
逐位比较,直到模式串的最后一位,发现完全匹配,于是搜索完成。
next
数组是如何求出的
next
数组的手动求解基于 “真前缀” 和 “真后缀”,即next[i]
等于 P[0]...P[i - 1]
最长的相同真前后缀的长度(请暂时忽视 i 等于 0 时的情况,默认为 -1)。我们依旧以上述的表格为例,为了方便阅读,我复制在下方了。
- i = 0,对于模式串的首字符,我们统一为 next[0] = -1;
- i = 1,前面的字符串为
A
,其最长相同真前后缀长度为 0,即 next[1] = 0; - i = 2,前面的字符串为
AB
,其最长相同真前后缀长度为 0,即 next[2] = 0; - i = 3,前面的字符串为
ABC
,其最长相同真前后缀长度为 0,即 next[3] = 0; - i = 4,前面的字符串为
ABCD
,其最长相同真前后缀长度为 0,即 next[4] = 0; - i = 5,前面的字符串为
ABCDA
,其最长相同真前后缀为 A,即 next[5] = 1; - i = 6,前面的字符串为
ABCDAB
,其最长相同真前后缀为 AB,即 next[6] = 2; - i = 7,前面的字符串为
ABCDABD
,其最长相同真前后缀长度为 0,即 next[7] = 0。
那么,为什么根据最长相同真前后缀的长度就可以实现在不匹配情况下的跳转呢?举个代表性的例子:假如i = 6
时不匹配,此时我们是知道其位置前的字符串为 ABCDAB
,仔细观察这个字符串,首尾都有一个 AB
,既然在 i = 6
处的 D 不匹配,我们为何不直接把i = 2
处的 C 拿过来继续比较呢,因为都有一个AB
啊,(D前面的AB已经适配,意思是刚开始的AB也可以适配,那就直接从第三位开始匹配,就可以少比较两次)而这个 AB
就是 ABCDAB
的最长相同真前后缀,其长度 2 正好是跳转的下标位置。
思路如此简单,接下来就是代码实现了,如下:
void GetNext(string P, int next[])
{
int p_len = P.size();
int i = 0; // P 的下标
int j = -1;
next[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
next[i] = j;
}
else
j = next[j];
}
}
一脸懵逼,是不是。。。上述代码就是用来求解模式串中每个位置的next[]
值。
下面具体分析,我把代码分为两部分来讲:
(1):i
和j
的作用是什么?
i
和j
就像是两个”指针“,一前一后,通过移动它们来找到最长的相同真前后缀。
(2):if...else...
语句里做了什么?
假设i
和j
的位置如上图,由next[i] = j
得,也就是对于位置 i 来说,区段[0, i - 1]
的最长相同真前后缀分别是[0, j - 1]
和 [i - j, i - 1]
,即这两区段内容相同。
按照算法流程,if (P[i] == P[j]),则 i++; j++; next[i] = j;
;若不等,则 j = next[j]
,见下图:
next[j]
代表[0, j - 1]
区段中最长相同真前后缀的长度。如图,用左侧两个椭圆来表示这个最长相同真前后缀,即这两个椭圆代表的区段内容相同;同理,右侧也有相同的两个椭圆。所以 else
语句就是利用第一个椭圆和第四个椭圆内容相同来加快得到[0, i - 1]
区段的相同真前后缀的长度。
细心的朋友会问if
语句中 j == -1
存在的意义是何?第一,程序刚运行时,j
是被初始为 -1
,直接进行 P[i] == P[j]
判断无疑会边界溢出;第二,else
语句中j = next[j]
,j
是不断后退的,若 j
在后退中被赋值为 -1
(也就是j = next[0]
),在 P[i] == P[j]
判断也会边界溢出。综上两点,其意义就是为了特殊边界判断。
#include <iostream>
#include <string>
using namespace std;
/* P 为模式串,下标从 0 开始 */
void GetNext(string P, int next[])
{
int p_len = P.size();
int i = 0; // P 的下标
int j = -1;
next[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
next[i] = j;
}
else
j = next[j];
}
}
/* 在 S 中找到 P 第一次出现的位置 */
int KMP(string S, string P, int next[])
{
GetNext(P, next);
int i = 0; // S 的下标
int j = 0; // P 的下标
int s_len = S.size();
int p_len = P.size();
while (i < s_len && j < p_len) // 因为末尾 '\0' 的存在,所以不会越界
{
if (j == -1 || S[i] == P[j]) // P 的第一个字符不匹配或 S[i] == P[j]
{
i++;
j++;
}
else
j = next[j]; // 当前字符匹配失败,进行跳转
}
if (j == p_len) // 匹配成功
return i - j;
return -1;
}
int main()
{
int next[100] = { 0 };
cout << KMP("bbc abcdab abcdabcdabde", "abcdabd", next) << endl; // 15
return 0;
}
优化的KMP 算法,后续再写。。。