文章目录
- 1. 算法背景
- 2. BM(Boyer-Moore)算法
- 2.1 坏字符规则(bad character rule)
- 2.2 好后缀规则(good suffix shift)
- 2.3 复杂度及完整代码
- 3. KMP(Knuth Morris Pratt)算法
- 3.1 好前缀 和 坏字符规则
- 3.2 高效构建 失效函数
- 3.3 复杂度及完整代码
- 4. 总结
1. 算法背景
文本编辑器/搜索引擎 随着处理的数据量的不断增加,对字符串匹配的效率要求越来越高,字符串匹配的效率也在不断演进。
传统的字符串匹配算法:BF(Brute Force) 和 RK(Rabin Karp) 算法 都消耗大量的时间。其中BF 比较粗暴,会将主串中的每一个字符都和模式串进行比较。RK在其基础上做了优化,虽然不再一个一个字符比较,而是从主串中构建模式串长度的子串hash,从而达到高效的hash比较,但是构建每个子串的hash值的过程同样需要取m-n+1个子串,并且对其计算hash,代价同样巨大。
以上两种字符串匹配算法满足不了我们在大数据量下文本编辑器的查找性能(vim / less / more 的内容查找),对字符串匹配高效性能的需求 促进了BM/KMP这样的高效匹配算法的出现。核心匹实现 还是说 每一次发现主串和模式串不匹配的字符之后,尽可能在主串中移动多个一定不会匹配的字符。
当然,目前BF,RK,BM,KMP 这样的算法能够在单模式串之间完成匹配,也就一个主串,一个模式串之间的匹配。对于 文本编辑器,像VIM这样的 还需要有多模式串之间的高性能匹配需求,也就是一个主串多个模式串之间的匹配。所以,Trie树这样的数据结构 以及 AC 自动机这样的高效算法 应运而生,并且他们被广泛应用在了搜索引擎之中。
本篇将带你欣赏 高效的单模式串匹配算法 BM和KMP。
2. BM(Boyer-Moore)算法
像背景中介绍的BF/RK 算法,核心还是对主串中的一个一个字符进行匹配,如下图,如果第一次匹配到
c
发现模式串对应下标为
d
不匹配了,则会回到主串的第二个字符作为重新开始匹配的起始字符。
更加高效的匹配方式应该是跳过一定不会匹配的字符,比如上图中主串的
b
和
c
,直接将主串的起始匹配字符向后移动到
a
开始进行匹配,这样能够有效减少匹配次数。
所以BM算法是为了找到能够保证不会漏过匹配可能性的情况下 高效移动的规律。
主要就是两种规则:
- 坏字符
- 好后缀
2.1 坏字符规则(bad character rule)
一般情况下,我们完成字符串匹配的过程是从左向右进行匹配的,比较符合我们的思维习惯,而坏字符规则 是反向 从右向左进行匹配。
先匹配最右边的字符,发现主串
c
和模式串
d
不匹配,则认为c是坏字符,接下来模式串的移动将向后滑动三位,移动到坏字符之后。仍然从右向左进行匹配,发现主串中的
a
和模式串的
d
不匹配。
接下来的移动则不能直接移动到坏字符之后,因为模式串中也有一个
a
,如果我们直接移动到主串坏字符之后,就错过了匹配, 应该将模式串向后滑动两位,如下:
所以坏字符规则下的算法移动方式如下:
- 从右向左匹配模式串和主串,发现坏字符
时记录 此时对应模式串的下标 ch
Si
- 检测模式串中是否有ch,有的话将其下标记录
,否则记录Xi
Xi = -1
- 移动时 直接移动
的长度Si - Xi
比如上图 的算法移动方式如下:
- 第一次发现坏字符时
,则直接将模式串滑动Si=2;Xi=-1
Si-Xi=3
- 第二次发现坏字符时
,因为模式串存在字符Si=2;
,所以a
,最后移动Xi=0
次Si-Xi=2
- 第三次 Match
实现代码:
-
函数会预先记录模式串中每一个字符的index,如果有两个一样的字符,将后一个字符的下标作为index(防止匹配漏掉)generateBC
-
函数中先从右向左,发现不匹配的字符,记录该字符对应的模式串下标; 从mp中取坏字符在模式串中存在的index,相减即可。bm_alg
// storage the des's position
//
// @params:
// des: destination string
// mp: update the relationship between char in
// des with it's index in des
//
// examples:
// s t r _ e x a m p l e
// 0 1 2 3 4 5 6 7 8 9 10
//
// mp['s'] = 0
// ...
// mp['e'] = 10
// ....
void generateBC(string des, map<char ,int> &mp) {
int i;
for(i = 0;i < des.size(); i ++) {
// and the map relationship between des's
// element with it's position
mp[des[i]] = i;
}
}
int bm_alg(string src, string des) {
map<char ,int> last_pos;
generateBC(des, last_pos);
int i=0;
int src_len = src.size();
int des_len = des.size();
while (i <= (src_len - des_len)) {
int j;
// find the bad char
for (j = des_len - 1; j >= 0; j--) {
if (src[i+j] != des[j]) {
break;
}
}
if (j < 0) {
return i;
}
// bad char's move position
int x = j - last_pos[src[i+j]];
// move the max of position between badchar
i = i + x;
}
return -1;
}
坏字符实现的最好时间复杂度能够达到O(n/m),尤其是主串和模式串有类似特点的场景:
aaabaaabaaabaaab
和
aaaa
这种情况下的匹配时十分高效的。
但是坏字符并不能处理所有的匹配情况,比如
aaaaaaaaaaaaa
和
baaa
这种情况,使用坏字符规则 下的滑动位置会变成负数(Si=0,Xi=3,滑动-3,显然不合理),所以BM算法还需要好后缀规则。
2.2 好后缀规则(good suffix shift)
当我们从右往左遍历模式串时找到坏字符时,前面已经相同的部分表示好后缀。
通过好后缀方式移动的算法有两种:
- 如果好后缀在模式子串v 中的另一个位置还存在v’,则将模式子串的v’ 和主串的好后缀对齐。
- 如果好后缀不存在于模式串中的其他位置,则判断好后缀的后缀子串 是否包含在模式串的前缀子串中;包含,则移动模式串 使后缀子串和匹配的前缀子串对齐;否则,按照坏字符规则移动。
对于第一种算法,移动方式如下:
找到好后缀在模式串的起始下标Si,以及另一个在模式串中匹配的好后缀的起始下标Xi,最终移动Si - Xi。
为什么会有第二种判断好后缀的后缀子串的过程呢?如下匹配:
但实际上,这种情况已经过度滑动了,按照第一种移动方式会错过正确的结果。
第二种办法是取好后缀的后缀子串,判断其是否能够和模式串的前缀子串匹配,匹配则移动到两个子串相匹配的位置。
后缀子串表示最后一个字符相同,前缀子串表示第一个字符相同;比如:abcd的后缀子串为 d,cd,bcd;对应的前缀子串为 a,ab,abc。这两个指标是固定的,且能够在开始匹配前快速初始化模式串的所有后缀子串和前缀子串 ,已经其是否匹配。
构建过程如下:
- suffix 数组下标表示 后缀子串的长度,值表示后缀子串在模式串中匹配的另一个子串的起始位置。比如
,后缀子串b长度为1,模式串中还有另一个b,则2表示这个b的其实下标。suffix[1]=2
- Prefix 数组 的bool值表示这个后缀子串是否和前缀子串匹配。
构建两个数组的代码如下:
// generate suffix and prefix arr
//
// @params:
// des: destination str
// suffix: the first position of the suffix str in des
// prefix: if the suffix exist in prefix of des, update
// prefix arr vector<bool> to true
//
// examples:
// des: b a b c a b
// pos: 0 1 2 3 4 5
// suffix: b, ab, cab, bcab, abcab
// suffix[1] = 0
// suffix[2] = 1
// suffix[3] = -1
// suffix[4] = -1
// suffix[5] = -1
//
// prefix[1] = true
// prefix[2] = true
// prefix[3] = false
// ...
void generateGS(string des, vector<int> &suffix,
vector<bool> &prefix) {
int i;
int des_len = des.size();
for (i = 0;i < des_len; i++) {
suffix.push_back(-1);
prefix.push_back(false);
}
// traverse the prefix str , update the suffix vector
// and prefix vector
for (i =0; i< des_len - 1; i++) {
int j = i; // prefix start index
int k = 0;
while(j >= 0 && des[j] == des[des_len - 1 -k]) {
--j;
++k;
suffix[k] = j + 1;
}
if (j == -1) {
prefix[k] = true;
}
}
}
完整好后缀的匹配规则 整体以尽可能短得方式移动:
- 判断好后缀u是否包含在模式串中的其他位置u’, 移动到u’的起始位置,结束。
- 判断好后缀的后缀子串v 是否包含在模式串的其他位置v’,并且和前缀子串匹配,则移动到前缀子串的起始位置,结束。
- 移动模式串长度 m 个位置,结束。
移动代码如下:
// move the des str to a position
//
// @params:
// j: bad char's index
// des: destination string
// suffix: suffix in des's start index
// prefix: wether the suffix math with prefix
//
// case:
// src: a b c a c a b c b c b a c a b c
// des: c b a c a b c
int movePosition(int j, string des, vector<int> suffix,
vector<bool> prefix) {
// case1: move to the longest len of sufix
// 'k' is the good suffix's start index
// 'j' is the bad char's index
int k = des.size() - 1 - j;
if (suffix[k] != -1) {
return j - suffix[k] + 1;
}
// case2: move to other suffix position
//
// longest suffix's range [j+1, len(des)-1]
// other suffix's range [j+2, len(des)-1]
for (int r = j + 2; r <= des.size() - 1; r++) {
if (prefix[des.size() - r] == true) {
return r;
}
}
// case3: move the len of des
return des.size();
}
在好后缀的移动规则和坏字符的移动规则之间取最大的移动位置来移动即可(好后缀的移动是普遍大于坏字符的移动方式),也就是好后缀的规则可以单独使用。
2.3 复杂度及完整代码
- 空间复杂度:使用额外的两个vector和一个map来保存中间数据。其大小与模式串的长度m有关。
- 时间复杂度:
- A new proof of the linearity of the Boyer-Moore string searching algorithm 证明最坏场景上限为O(5n)
- Tight bounds on the complexity of the Boyer-Moore string matching algorithm 证明最坏场景上限为O(3n)
source_code: https://github.com/BaronStack/DATA_STRUCTURE/blob/master/string/bm_alg.cc
完整代码实现如下:
#include <iostream>
#include <map>
#include <string>
#include <vector>
#define MAP_SIZE 256
using namespace std;
// storage the des's position
//
// @params:
// des: destination string
// mp: update the relationship between char in
// des with it's index in des
//
// examples:
// s t r _ e x a m p l e
// 0 1 2 3 4 5 6 7 8 9 10
//
// mp['s'] = 0
// ...
// mp['e'] = 10
// ....
void generateBC(string des, map<char ,int> &mp) {
int i;
for(i = 0;i < des.size(); i ++) {
// and the map relationship between des's
// element with it's position
mp[des[i]] = i;
}
}
// generate suffix and prefix arr
//
// @params:
// des: destination str
// suffix: the first position of the suffix str in des
// prefix: if the suffix exist in prefix of des, update
// prefix arr vector<bool> to true
//
// examples:
// des: b a b c a b
// pos: 0 1 2 3 4 5
// suffix: b, ab, cab, bcab, abcab
// suffix[1] = 0
// suffix[2] = 1
// suffix[3] = -1
// suffix[4] = -1
// suffix[5] = -1
//
// prefix[1] = true
// prefix[2] = true
// prefix[3] = false
// ...
void generateGS(string des, vector<int> &suffix,
vector<bool> &prefix) {
int i;
int des_len = des.size();
for (i = 0;i < des_len; i++) {
suffix.push_back(-1);
prefix.push_back(false);
}
// traverse the prefix str , update the suffix vector
// and prefix vector
for (i =0; i< des_len - 1; i++) {
int j = i; // prefix start index
int k = 0;
while(j >= 0 && des[j] == des[des_len - 1 -k]) {
--j;
++k;
suffix[k] = j + 1;
}
if (j == -1) {
prefix[k] = true;
}
}
}
// move the des str to a position
//
// @params:
// j: bad char's index
// des: destination string
// suffix: suffix in des's start index
// prefix: wether the suffix math with prefix
//
// case:
// src: a b c a c a b c b c b a c a b c
// des: c b a c a b c
int movePosition(int j, string des, vector<int> suffix,
vector<bool> prefix) {
// case1: move to the longest len of sufix
int k = des.size() - 1 - j;
if (suffix[k] != -1) {
return j - suffix[k] + 1;
}
// case2: move to other suffix position
//
// longest suffix's range [j+1, len(des)-1]
// other suffix's range [j+2, len(des)-1]
for (int r = j + 2; r <= des.size() - 1; r++) {
if (prefix[des.size() - r] == true) {
return r;
}
}
// case3: move the len of des
return des.size();
}
// boyer-moore algorithm
//
// two rules:
// 1. bad char
// 2. good suffix
int bm_alg(string src, string des) {
if (src.size() == 0 || des.size() == 0) {
return -1;
}
map<char ,int> last_pos;
vector<int> suffix;
vector<bool> prefix;
generateBC(des, last_pos);
generateGS(des, suffix, prefix);
int i=0;
int src_len = src.size();
int des_len = des.size();
while (i <= (src_len - des_len)) {
int j;
// find the bad char
for (j = des_len - 1; j >= 0; j--) {
if (src[i+j] != des[j]) {
break;
}
}
if (j < 0) {
return i;
}
// bad char's move position
int x = j - last_pos[src[i+j]];
int y = 0;
// good suffix's move position
if (j < des_len - 1) {
y = movePosition(j, des, suffix, prefix);
}
// move the max of position between badchar and
// good suffix
i = i + std::max(x,y);
}
return -1;
}
int main() {
string src;
string des;
cout << "input src ";
cin >> src;
cout << "input des ";
cin >> des;
if (bm_alg(src, des) != -1) {
printf("%s is the substr of %s with index : %d\n",
des.c_str(), src.c_str(), bm_alg(src,des));
} else {
printf("not match \n");
}
return 0;
}
3. KMP(Knuth Morris Pratt)算法
有了之前对BM算法的理解,接下来再看KMP算法就事半功倍了。
BM算法中通过 坏字符 和 好后缀 规则 来约束模式串的滑动,两个规则的结合能够高效得检索模式串是否和主串匹配。KMP算法相比于BM算法 都希望加速模式串的滑动,提升匹配效率。不同的是KMP将好后缀规则变成了好前缀规则。
如上图,将模式串和主串中已经匹配的字符串称为好前缀,不同的字符同样是坏字符,就像BM算法的好后缀规则下,监测到好后缀的后缀子串在模式串中的其他位置,则向后滑动到这个匹配后缀子串的起始位置即可。同样,KMP的好前缀中也希望滑动到匹配的后缀子串的位置(为了避免滑动过多,跳过匹配的情况,这里选择最大匹配的后缀子串)。
3.1 好前缀 和 坏字符规则
如下图,取好前缀的最大匹配后缀子串,其长度为
k
,将模式串匹配的前缀子串的结尾index移动 j-k个位置 即能够和最大匹配后缀子串对齐,此事坏字符的位置i不用发生变化。
由上图可知,KMP的滑动除了需要依赖主串找到坏字符之外, 其他的最大匹配前缀子串的计算并不需要主串的参与,仅仅通过模式串就能够预先完成所有情况的最大匹配前缀子串的结尾下标计算。
也就是我们在KMP的算法规则中 只需要知道当出现坏字符时,模式串的最大可匹配前缀的下标即可,这样就能够完成模式串的移动了。这个最大可匹配下标的计算也就是KMP 常说的失效函数的计算,属于KMP的核心计算过程了,我们将失效函数用next数组来表示。
基本的kmp代码实现如下:
int kmp_alg(string src, string des) {
if (src.size() == 0 || des.size() <= 0) {
return -1;
}
int src_len = src.size();
int des_len = des.size();
vector<int> next;
int i, j;
next.resize(des_len);
// Get the next vector
getNext(des,next);
for (i = 0;i < src_len; i++) {
// 1. Find the bad char
// 2. Next[j-1] is the longest match prefix's tail index
// move the j to the destinations.
//
// Example:
// i
// 0 1 2 3 4 5 6 7 8 9
// src: a b a b a e a b a c
// des: a b a b a c d
// 0 1 2 3 4 5 6
// j
//
// when find bad char: i = 5, j = 5;
// good prefix is : ababa
// longest match prefix : aba
// longest match prefix tail in des: 2
// next[j-1] : next[5-1] = 2
// j: 2 + 1 = 3
//
// after slide ,the src and des is bellow:
// i
// 0 1 2 3 4 5 6 7 8 9
// src: a b a b a e a b a c
// des: a b a b a c d
// 0 1 2 3 4 5 6
// j
//
while(j > 0 && src[i] != des[j]) {
j = next[j-1] + 1;
}
// The good prefix, just add the j
if (src[i] == des[j]) {
j++;
}
// Match the des and return the index in srs
if (j == des_len) {
return (i - des_len + 1);
}
}
return -1;
}
接下来看一下如何构建失效函数这一部分。
3.2 高效构建 失效函数
如下图,左侧的图能够非常直观得看到 当发现了好前缀之后,如何找到其最大可匹配前缀的过程。
通过将好前缀的所有前缀子串和后缀子串列出来,找到其中最长一个能够完成匹配的前缀子串,即是我们的最长可匹配前缀子串。这个寻找过程,我们也可以用来直接构造如右侧图的next数组(失效函数),也就是暴力法。
暴力法就是 **要对模式串中的每一个好前缀(m-1个)都要分别找出其后缀子串和前缀子串,并从中找到能够匹配的最长的子串,**这个代价是极大的。
更优的办法 是使用next[i-1] 来 尝试构建next[i],理论上 next[i] 中肯定包含next[i-1],因为next[i]对应的好前缀是包含next[i-1]对应的好前缀的,这种方式理论上是可行的,需要关注的细节如下:
- case1: 假如next[i-1] = k-1,也就是子串des[0,k-1]是des[0,i-1]的最长可匹配前缀子串。如果des[0,k-1]的下一个字符des[k]和 des[0,i-1]的下一个字符des[i] 相等,则next[i] = next[i-1] + 1= k。
-
case2: 如果des[0,k-1]的下一个字符des[k]和 des[0,i-1]的下一个字符des[i] 不相等,这个时候的处理稍微麻烦一些。
也就是des[0,k-1]中无法满足找到最长可匹配前缀子串,那么des[0,next[k-1]]的下一个字符能否满足等于des[i]的要求呢,即des[next[k-1]]和des[i]是否相等,想等则认为des[0,next[k-1]] 是 des[0,i]的最长可匹配前缀子串,next[i] = next[k-1]+1 。
整体上类似于数学的递推公式:
- 前一个的最长串des[0,k] 的下一个字符des[k+1]不与最后一个字符des[i]相等,则需要找前一个的次长串
- 问题也就变成了求des[0, next[k]]的最长串:如果下一个字符des[next[k] + 1]还不和des[i]相等,那么继续回溯到求des[0,next[next[k]]]的最长串,再判断des[next[next[k]] + 1]和des[i]是否相等,相等则next[i] = next[next[k]] + 1
- 否则继续,依此直到找到 能够和des[i]相等的字符。此时的next[next[…]]+1 就是next[i]的值。
next数组的求值代码如下:
void getNext(string des, vector<int> &next) {
next[0] = -1;
int k = -1;
int i;
// i begin with 1, next[0] always is -1
for (i = 1; i< des.size(); i++) {
// Find the longest match prefix by judge
// two char dex[i] and des[k+1].
// Just like case1 and case2: next[i-1]=k-1
// if des[i] == des[k-1], then next[i] = next[i-1] + 1 = k;
while(k != -1 && des[i] != des[k+1]) {
//let 'k' storage 'next[next[...]]'
k = next[k];
}
// find the match char, let k ++
if (des[i] == des[k+1]) {
k++;
}
next[i] = k;
}
}
关于次长可匹配前缀和最长可匹配前缀之间的关系可以参考如下图理解一下:
y = next[i-1] 其实就是上面代码中的 k = next[k]的逻辑。
3.3 复杂度及完整代码
KMP算法以难理解著称,总体上就是一个回溯的过程,当前next[k]不满足匹配子串[0,k]的下一个字符相等时,则回到next[next[k]]看看。
空间复杂度:维护一个 m(模式串长度)大小的数组。
时间复杂度:
- 在next数组计算过程中,第一层for 循环[1,m-1],第二层的while循环中k=next[k]并不是每次都会执行,总的执行次数可以通过语句k++来判断,并不会超过m次,毕竟next[k]的回溯过程一定会碰到-1的情况。所以next数据计算过程整体的时间复杂度是O(m) – while循环的执行次数小于m,且不稳定,可以当作常量来看。
-
在外部主体进行匹配的过程中,外部循环[0,n-1],n表示主串的长度;因为j本身比i小, 所以while循环中 j=next[j-1]+1不可能循环n次,又因为next[j-1] 肯定小于j,相当于j之前的一个下标,也就是while中语句的执行次数是小于m的,也能够看作一个常量。
即外部匹配过程的时间复杂度是O(n)
所以总体的时间复杂度是O(m+n),当然相比于BM 的复杂度计算还不够严谨,像O(5n)也是O(n)的范畴,这个就比BM算法消耗时间更久了。
source code:
https://github.com/BaronStack/DATA_STRUCTURE/blob/master/string/kmp_alg.cc
完整代码如下:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
void getNext(string des, vector<int> &next) {
next[0] = -1;
int k = -1;
int i;
// i begin with 1, next[0] always is -1
for (i = 1; i< des.size(); i++) {
// Find the longest match prefix by judge
// two char dex[i] and des[k+1].
// Just like case1 and case2: next[i-1]=k-1
// if des[i] == des[k-1], then next[i] = next[i-1] + 1 = k;
while(k != -1 && des[i] != des[k+1]) {
//let 'k' storage 'next[next[...]]'
k = next[k];
}
// find the match char, let k ++
if (des[i] == des[k+1]) {
k++;
}
next[i] = k;
}
}
int kmp_alg(string src, string des) {
if (src.size() == 0 || des.size() <= 0) {
return -1;
}
int src_len = src.size();
int des_len = des.size();
vector<int> next;
int i, j;
next.resize(des_len);
// Get the next vector
getNext(des,next);
j = 0;
for (i = 0;i < src_len; i++) {
// 1. Find the bad char
// 2. Next[j-1] is the longest match prefix's tail index
// move the j to the destinations.
//
// Example:
// i
// 0 1 2 3 4 5 6 7 8 9
// src: a b a b a e a b a c
// des: a b a b a c d
// 0 1 2 3 4 5 6
// j
//
// when find bad char: i = 5, j = 5;
// good prefix is : ababa
// longest match prefix : aba
// longest match prefix tail in des: 2
// next[j-1] : next[5-1] = 2
// j: 2 + 1 = 3
//
// after slide ,the src and des is bellow:
// i
// 0 1 2 3 4 5 6 7 8 9
// src: a b a b a e a b a c
// des: a b a b a c d
// 0 1 2 3 4 5 6
// j
//
while(j > 0 && src[i] != des[j]) {
j = next[j-1] + 1;
}
// The good prefix, just add the j
if (src[i] == des[j]) {
j++;
}
// Match the des and return the index in srs
if (j == des_len) {
return (i - des_len + 1);
}
}
return -1;
}
int main() {
string s1,s2;
cin >> s1;
cin >> s2;
if (kmp_alg(s1,s2) == -1) {
cout << s1 << " with " << s2 << " not match !" << endl;
} else {
cout << s1 << " with " << s2 << " match !" << endl;
}
return 0;
}