字符串匹配算法有很多种,但是真正在数据结构算法书上的方法无外乎就只有BF暴力搜索和KMP搜索两种。就算是算法导论上面,也只是除了以上两种方法外还有两种RK算法和有限自动机算法。这里对其他比较流行的算法进行一一的介绍
一.BF算法
首先是BF算法。这种算法就是简单的朴素方法,对于主串从第一个字符开始与模式串比较,如果失败,则向后移动一个单位继续比较整个模式串,最后遍历完全部的主串。具体情况如下:(模式串为ababa)
a | b | c | a | a | b | a | b | a | d |
a | b | a | |||||||
a | |||||||||
a | |||||||||
a | b | ||||||||
a | b | a | b | a |
对于这样的一种方法,明显是非常浪费时间的,因为如果模式串有大量的相同的前缀子串和后缀子串的话,那么很多没有比较价值的还是会比较。这样的算法时间复杂度为O(m * n),可以说是非常糟糕。为了解决这个问题,有三位大师就研究了一种现在各类数据结构算法书籍上有的一种晦涩难懂的算法:KMP算法。
二.KMP算法
KMP(Knuth-Morris-Pratt)算法比起BM算法优化在了哪里呢?它的思想就在于利用不匹配的字符的前面那一段字符的最长的相同的真前缀和真后缀子串来移动尽量长的距离,节约时间。具体这么说太抽象,继续来例子。(模式串为abcac)
a | b | a | b | c | a | b | c | a | c | b | a | b |
a | b | c | ||||||||||
a | b | c | a | c | ||||||||
a | b | c | a | c |
单单从比较次数来说,就快了很多,那么为什么这么算呢?
就拿第二步到第三步来说,如果按照朴素算法,还要模式串的第一个字符a分别与主串中第4位的b,第5位的c和第6位的a比较。那么问题来了,这三步有必要再算一遍么?显然不需要!为什么?因为在第二部的时候已经匹配上了bca这三项,也就意味着后面如果比较的话,模式串的第一个字符a与这三个完全不需要比较,只需要直接滑动让模式串的第2个字符与主串中的第7位进行比较就可以。
这么说如果没有道理的话让我们用数学的式子简单推导一下
我们假设有主串s,有模式串p,有主串第i位与模式串第j位不匹配,设此时正有模式串的第k个字符在比较。那么一定需要有从模式串开始到第k-1位那一段与主串中从i - k + 1到i -1 这一段相等才最好。也就是:
"p1p2...pk-1" = "s(i-k+1)s(i-k+2)..s(i-1)"
现在我们得到的已经匹配的式子是什么呢?是从第j - 1位往前k位那一段和从i - k + 1到i - 1这一段,对应为:
"p(j-k+1)p(j-k+2)..p(j-1)"="s(i-k+1)s(i-k+2)..s(i-1)"
上面两个式子联立:
"p1p2...pk-1" = "p(j-k+1)p(j-k+2)..p(j-1)"
我们反过来是不是可以得出,如果有模式串中存在上面推导的式子,那么当第i个主串字符与第j个模式串字符不匹配的时候,就可以将模式串向右移动到模式串中第k个字符和主串第i个字符对其比较,那么前面那些仍然是匹配的,问题转化为了从模式串中第k个字符开始与主串第i个字符进行比较的问题。
由此我们可以推出一个模式串的移动参考数组next:
j | 1 | 2 | 3 | 4 | 5 |
模式串 | a | b | a | b | a |
next[j] | 0 | 1 | 1 | 2 | 3 |
j = 1时 next[j] = 0, j = 2时有 1 < k < j不满足,所以next[j] = 1,j = 3时有p[1] != p[2],所以next[j] = 1,当j = 4时,有p[1] = p[3],真前缀子串与真后缀子串相等长度为1,所以在不匹配的时候,只需要移动模式串到j = 2即可。当j = 5时,有p[1]p[2] = p[3][4],真前缀子串和真后缀子串相等的长度为2,所以在不匹配的时候,只需要移动模式串到j = 3即可。
将上面所说转化成代码不难,但是问题是,如何求next值呢,求出的next值才能更加更加优化呢,首先看按照上面的思路写的代码如下:
void getNext(const char *pattern, int *next){
int i = 0, j = -1;
next[0] = -1;
while(i < strlen(pattern) - 1){
if(j == -1 || pattern[i] == pattern[j])
next[++ i] = ++ j;
else
j = next[j];
}
}
但是这样写虽然优化了一些却还是存在比较大的问题,如下面这个例子:模式串根据上面的规则可以得出这样的一个next数组
j | 1 | 2 | 3 | 4 | 5 |
模式串 | a | a | a | a | b |
next[j] | 0 | 1 | 2 | 3 | 4 |
那么匹配这样主串的时候就会发现会这样匹配
a | a | a | b | a | a | a | a | b |
a | a | a | a | b | ||||
a | a | a | a | b | ||||
a | a | a | a | b | ||||
a | a | a | a | b | ||||
a | a | a | a | b |
怎么样,是不是有一种被骗了的感觉,这明显跟朴素算法一样好不好= =,那么问题就是模式串1~4位的字符都一样,所以不需要和已经不匹配的主串的第4位进行一一的比较。所以我们可以优化next数组。也就是说,如果我们求得的next[j] = k,如果pj = pk的话,就相当于白白比较错一次,所以我们令直接有next[j] = next[k],代码如下:
void getNext_opt(const char *pattern, int *next){
int i = 0, j = -1;
next[0] = -1;
while(i < strlen(pattern) - 1){
if(j == -1 || pattern[i] == pattern[j]){
++ i;
++ j;
if(pattern[i] != pattern[j])
next[i] = j;
else
next[i] = next[j];
}
else j = next[j];
}
}
最后贴出KMP的实现代码:
int Kmp(const char *Str, const char *pattern, const int *next){
int len_s = strlen(Str);
int len_p = strlen(pattern);
for(int i = 0, j = 0; i < len_s; i ++){
while(j > 0 && pattern[j] != Str[i])
j = next[j];
if(pattern[j] == Str[i])
j ++;
if(j == len_p)
cout << "Occure with the pos at : " << i - len_p + 1 << endl;
}
}
KMP算法的时间复杂度是O(m + n),这是非常了不起的,因为经过预处理以后,这个问题变成了可以在线性时间复杂度内解决的问题。
三.RK算法
有没有感觉KMP实在是太难懂,实在是无法驾驭的感觉。确实,对于next的处理确实比较不太容易明白,那么我们现在不妨换换思路吧。我们在想一个字符串的时候都把它确实的看作是一个字符串,字符串这种东西在人看来还是比较舒服,但是对于计算机这种纯数字类的思想的机器来说,到底还是不如数字比较靠谱,所以我们可不可以想个办法把问题转化成为数字的问题呢,显然可以,那就是通过哈希。
那么如何利用哈希呢。我们不妨把我们所接受的字符串想成有进制的数字。而长度是模式串的长度。然后依次比较主串从头到尾以模式串为长度的哈希值。如果不同的话,那说明肯定有字符不相同,这个时候继续比较,如果相同的话,说明其中字母含量是相同的,所以开始依次比较这两者的字符。
那么哈希值如何求解呢,我们想一个十进制的数字5432,如果这是个字符串的话,他的大小其实是(((1×10+5)×10+4)×10+3)×10+2,其中10是进制,类推我们可以得到相似的对字符串的哈希值的求解。 我们这里假设只有小写字母,根据公式:
Hash = P[len_p - 1] + (radix * (P[len_p - 1] + radix(....)))
那么对于如何计算主串的哈希值呢,我们不可能每次都按照那么长的位数计算,所以我们仍然用十进制来举例,如25346现在倘若我们知道2534的值,那么求5346只需要在知道对应十进制的模式串的长度得最大值的情况下有2543 - max_num*2 + 6即可,我们写作公式:
Hash = Hash - Max_num * S[i] +S[i+len_p - 1]
根据这个我们写出代码:
const int radix = 26;
const int prime = 3733799;
bool Match(const char *Str, const char *pattern, const int curs, const int len){
for(int i = curs, j = 0; j != len; i ++, j ++)
if(Str[i] != pattern[j])
return false;
return true;
}
int Rk(const char *Str, const char *pattern){
int len_s = strlen(Str);
int len_p = strlen(pattern);
unsigned long Max_num = 1, Hash_p = 0, Hash_s = 0;
for(int i = 0; i < len_p - 1; i ++)
Max_num = (Max_num * radix) % prime;
for(int i = 0; i < len_p; i ++){
Hash_p = (radix * Hash_p + pattern[i] - 'a') % prime;
Hash_s = (radix * Hash_s + Str[i] - 'a') % prime;
}
for(int i = 0; i < len_s - len_p + 1; i ++){
if(Hash_p == Hash_s)
if(Match(Str, pattern, i, len_p))
return i;
Hash_s = (radix * (Hash_s - Max_num * (Str[i] - 'a')) + Str[i + len_p] - 'a') % prime;
}
return -1;
}
余prime的作用只是为了防止数目太大导致计算过慢,所以我们认为,在余一个极大的素数的情况下,如果两个数的哈希值仍然相等我们认为他们两个的字符是相等的。RK算法的时间复杂度在选择的素数大于模式串长度的情况下,是O(m +n)。
四.Boyer-Moore算法
BM算法是使用范围非常广泛的一种算法。我们平时所常用的Ctrl + F就是这个算法的原理。
朴素算法的思路都是从前往后依次比较,而BM算法是从后往前比较。当然,如果朴素的从后往前比较的话,还是一次移动一步的,并没有改进,但是BM算法针对已经匹配了的后缀信息,移动尽量多的长度,避免不必要的比较。
BM算法的核心是两个规则,也就是GS和BC规则,BC规则的名字为Bad character shift rule,也就是坏后缀移动规则,如下面的情况叫做坏后缀:
a | b | c | a | b | a | a | b | a | d |
a | b | a | b | a |
我们看到,从后往前比的最后一个不匹配,而且后缀不相等,这就是坏后缀。
对于BM规则,有两种情况
第一种情况是上面那张图的情况,此时主串中的不匹配的字符的后一位的字符'a'在模式串中存在,那么移动最靠后模式串中的'a'与之对齐,也就是变成下面的情况:
a | b | c | a | b | a | a | b | a | b |
a | b | a | b | a |
第二种情况是如下的情况:
a | b | c | a | v | a | b | a | b | a |
a | b | a | b | a |
此时我们看到,不匹配的坏后缀的'v'在模式串中是不存在的,那么此时我们就没有必要在对'v'包括它本身的前面的所有字符再比较了。肯定不匹配。所以采取的策略是将跳过该字符,直接将模式串首字符对齐该坏后缀的后面一个字符,如下:
a | b | c | a | v | a | b | a | b | a |
a | b | a | b | a |
这就是BC规则的两种情况,总结一下就是,如果坏后缀的字符在模式串中出现,那么移动模式串中的对应字符与之对应,如果没有在模式串中出现的话,直接将模式串移动至该字符后面一位
如果想借助BC规则来移动,我们首先需要构造一张表,我们称之为BC表,里面存储的是如果是坏后缀的话,需要移动的步数,我们看代码:
<span style="font-size:14px;">void Creat_BC(int *bc, char *pattern, int len){
for(int i = 0; i < 256; i ++)
bc[i] = len; //这里初始化存储的是模式串长,如果后面没有更新就意味着主串如果存在相应的字符的话那肯定是不存在于模式串的,需要移动一个模式串长的距离。
for(int i = 0; i < len - 1; i ++)
bc[pattern[i]] = len - 1 - i; //这里是更新一下数据,从前往后遍历,选取离不匹配位置最近的字符的位置,这样移动以后可以对齐不匹配的字符与模式串中最靠右与之相同的字符
}</span>
好,让我们再讨论一下另外一种规则。
BS规则的名字为Good suffix shift rule,光名字与BC规则比较就能看出些许端倪,suffix的意思是后缀,character意思是字符,也就是说,我们在考虑好后缀的时候,考虑的是一个串而不是单单一个字符。下面的情况就是好后缀:
a | b | c | b | a | a | a | b | a | b |
a | b | a | b | a |
我们看到,从后往前比较的过程中,"ba"是匹配的,那么就称模式串的"ba"为好后缀。
好后缀有三个规则,GS规则有三个,是BM算法的难点和核心。
第一种情况如下:
z | h | q | w | y | e | j | h | g | d | a | c | d | e | x |
z | a | c | d | e | e | x | g | a | c | d | e |
这里将不匹配的字符用红色标记,好后缀用黄底色来表示,如果在模式串中还存在这样的好后缀的话将最靠近后面的好后缀与主串的那一部分对齐。之所以是最靠近后面是因为这样可以保证不漏下可能到情况。做完处理以后会变成这样:(我们暂且假设主串足够长,不然如果模式串的首字符的pos如果加上自身长度大于主串长度的时候就认为是匹配失败了,下同)
z | h | q | w | y | e | j | h | g | d | a | c | d | e | x |
z | a | c | d | e | e |
第二种情况如下:
z | h | q | w | y | e | j | h | g | d | a | c | d | e | x |
c | d | e | x | g | a | c | d | e |
这时候发现虽然有好后缀"acde"但是模式串中已经没有了与好后缀完全匹配的字符串,这时候如果我们细心观察,我们会看到这里为了保证不漏下特殊情况,需要先看到模式串中是否存在最长前缀使得其与好后缀的后缀相匹配,如果存在的话,将两者对齐,如下:
z | h | q | w | y | e | j | h | g | d | a | c | d | e | x |
c | d | e | x |
第三种情况就是上面两种情况都不满足,模式串中不存在最长前缀使得其的匹配好后缀的后缀,这时候只能直接将其移动到好后缀的下一个字符,这里的情况跟BC规则的第二种情况类似,就重复作图了。
对于GS规则,我们同样需要构造表(GS表),但是为了考虑到好后缀的后缀与模式串的前缀相同的情况,所以还需要再进行两个操作:
第一:从已匹配的后缀来判断是否存在一个模式串的前缀可以与之匹配,如果匹配,返回true,否则返回false,代码如下:
bool GS_judge(char *pattern, int len, int pos){
int len_suffix = len - pos;
for(int i = 0; i < len_suffix; i ++){
if(pattern[i] != pattern[pos + i])
return false;
}
return true;
}
上面这个代码比较好理解,下面是第二:需要找到最长的匹配的后缀,也就是好后缀,用一个函数返回其长度:
int suffix_len(char *pattern, int len, int pos){
int i;
for(i = 0; (pattern[pos - i] == pattern[len - 1 - i]) && (i < pos); i ++);
return i;
}
当然这里模式串的后缀不包含其自身。
完成上面两步以后开始建表,代码如下:
void Creat_GS(int *gs, char *pattern, int len){
int last_index = len - 1;
for(int i = len - 1; i >= 0; i --){ //假设i对应的位置是不匹配的。这里判断从i+1开始是否有前缀
if(GS_judge(pattern, len, i + 1))
last_index = i + 1; //存在的话,记录最靠后的一个串的位置
gs[i] = last_index;
}
for(int i = 0; i < len - 1; i ++){ //保存对应好后缀的最靠后的模式串的子串的位置
int slen = suffix_len(pattern, len, i);
if(slen > 0 && pattern[i - slen] != pattern[len - 1 - slen]){ //如果匹配的两个串前面的字符不相同的话保存两个串相对距离,否则不必保存,因为比较以后还是无效的。
gs[len - 1 - slen] = len - 1 - i;
}
}
}
构建完两个表以后,BM算法的最终法则是移动BC表和GS表两者之中可以移动的较大的一个,代码如下:
int boyer_moore(char *Str, int len_s, char *pattern, int len_p){
int i;
int bc[256];
int *gs = (int *)malloc(len_p * sizeof(int));
Creat_BC(bc, pattern, len_p);
Creat_GS(gs, pattern, len_p);
i = len_p - 1;
while(i < len_s){
int j = len_p - 1;
while(j >= 0 && (Str[i] == pattern[j])){
-- i;
-- j;
}
if(j < 0){
return i + 1;
}
i += max(bc[Str[i]], gs[j]);
}
return -1;
}
至此,BM算法全部结束。BM算法虽然从理论上时间复杂度与KMP差不多,但是实际上公认要比KMP快3到5倍,是一种非常好用的字符串匹配算法。
五.Horspool算法
看了BM算法后,是不是对后缀匹配有了新的认识呢。其实后缀匹配用处确实广泛而且效率很高,下面还是介绍一种基于后缀的匹配方法。
这个算法的思想比较好理解,就是从后往前匹配的过程中,如果匹配就一直继续,如果不匹配的话,那就从模式串中找到不匹配字符在其中出现的下一个位置,然后将其与不匹配字符对齐。如果模式串中不存在不匹配的那个字符的话,则直接移动到不匹配的字符的下一个位置,这个与BM的坏后缀有一点类似。
举个简单的例子:
a | b | c | b | c | s | d | x | c | b | c | a | c | x | z | x | x | c |
c | b | c | a | c |
第一次比较,发现从后往前比在第二次比较'a'与'b'时就不匹配了。此时,需要从当前位置继续往前找,恰好发现有'b'的存在,所以将其对齐,如下:
a | b | c | b | c | s | d | x | c | b | c | a | c | x | z | x | x | c |
c | b | c | a | c |
这时发现在第一次匹配'd'与'c'时就已经不匹配了,而经过查找,发现d不存在于模式串中,所以直接移动到下一个位置:
a | b | c | b | c | s | d | x | c | b | c | a | c | x | z | x | x | c |
c | b | c | a | c |
又是第一次匹配不成功,‘a’与'c'匹配不成功,从模式串不匹配处开始找a,找到对齐:
a | b | c | b | c | s | d | x | c | b | c | a | c | x | z | x | x | c |
c | b | c | a | c |
匹配成功
是不是很好实现,下面直接贴出代码:
int Horspool(char *Str, int len_s, char *pattern, int len_p){
if(len_s < len_p){
cout << "ocuure failed!" << endl;
return -1;
}
int next[256];
for(int i = 0;i < 256; i ++)
next[i] = len_p;
for(int i = 0; i < len_p - 1; i ++)
next[pattern[i]] = len_p - i - 1;
int pos = 0;
while(pos < len_s - len_p){
int j = len_p - 1;
while(j >= 0 && Str[pos + j] == pattern[j])
j --;
if(j < 0)
break;
pos = pos + next[Str[pos + len_p - 1]];
}
return pos;
}
Horspool的时间复杂度是O(m * n),但是在平均情况下,只有O(n)效率还是可以的。
好的,最后推出今天想介绍的第六种算法——Sunday算法
六.Sunday算法
Sunday算法,网上有说其实这个比Boyer-Moore算法还快,毕竟是90年后推出的算法,而且当时写出论文的时候直接写道Communivations of the ACM,可见Sunday心气之高。不过确实简便易懂。
Sunday算法的思想是从右向左比较,当出现不匹配的时候,不是去找与不匹配的字符的位置,而是找最后边对齐的后一位的字符在模式串的位置。有两种情况
第一:如下图
a | b | c | a | b | y | a | x | g | h |
y | b | c | x | b |
我们看到从后向左比较的时候,在第二次匹配'a'与'x'比较的时候出现不匹配的情况。这时候看的是模式串比较的主串的最后的一位的后一位的y的值,发现y在模式串中,直接移动匹配之!
a | b | c | a | b | y | a | x | g | h |
y | b | c | x | b |
这种情况下,是不是移动的很多呢。下面看第二种情况:
a | b | c | a | b | z | a | x | g | h | a |
y | b | c | x | b | |
此时发现后一位的'z'在模式串中无从找起,所以索性就直接略掉,直接跳到后两位开始匹配:
a | b | c | a | b | z | a | x | g | h | a |
y | b | v | x | b |
好了这就说完了全部的Sunday算法,这种方法和Horspool和BM算法都是后缀比较的算法,其中时间很大意义上还是根据字符串来说的,BM算法和Sunday算法都要快一些。下面上代码:
void Sunday(char *Str, char *pattern){
int len_p = strlen(pattern);
int len_s = strlen(Str);
for(int i = 0; i < 256; i ++)
next[i] = len_p + 1;
for(int i = 0; i < len_p - 1; i ++)
next[pattern[i]] = len_p - i; //这两个循环就是存要移动的位置,优先存储靠右的字符的位置。
int pos = 0;
while(pos < len_s){
int i = 0;
while(Str[pos + i] == pattern[i]){
i ++;
continue;
}
if(i == len_p){
cout << "occure at the pos of :" << pos << endl;
return ;
}
pos += next[Str[pos + len_p]];
}
cout << "can not occure!" << endl;
}
这里不再对有限自动机进行字符串匹配作介绍,因为他的计算转移函数消耗太大,所以预处理比较耗时,没有KMP好。但是值得一提的是KMP算法的思想是源于有限自动机的。
草草介绍完了6种字符串匹配算法,当时主要的动机是KMP实在是有点晦涩,就想多写写几种其他的算法,对于不同的算法来说,还是那句话,体会其中创作者的思想最重要。