数据结构与算法分析(十六)--- 如何设计更高效的字符串匹配算法?(BF + RK + KMP + BMH)
文章目录
- 一、Brute Force 匹配算法
- 二、Rabin–Karp 匹配算法
- 三、Knuth–Morris–Pratt 匹配算法
- 四、Boyer-Moore-Horspool 匹配算法
- 五、字符串匹配算法效率对比
- 更多文章:
字符处理操作作为计算机的核心任务之一,各种编程语言都为其提供了丰富的字符处理函数库,比如前两篇博文介绍的C语言字符处理和C++ string 与regex class 操作。字符处理操作中最常用的应该算是字符串查找匹配操作了,比如C 语言提供的strstr()函数和C++ 提供的find()函数、search()函数、regex_match()函数、regex_search()函数等,这些字符串查找匹配函数都使用什么字符串匹配算法呢?这些字符串匹配算法是如何对基础的匹配算法进行优化的?是否能像排序算法那样,欣赏到类似归并、快速等高级排序算法对插入、选择等基础排序算法的巧妙优化,让我们大开眼界呢?
一、Brute Force 匹配算法
Brute Force 算法中文翻译过来叫暴力匹配算法或者朴素匹配算法,从字面意思看就是没使用什么特别的优化技巧,逐字符比对,像下面图示这样:
BF 匹配算法从主字符串txt 和模式串pat 的首字符开始对比,根据匹配结果可分两种情况分析:
- 如果主串和模式串字符匹配,指向两个字符的游标 i 和 j 都后移一位对比下一个字符(也即 i++, j++);
- 如果主串与模式串字符不匹配,指向两个字符的游标 i 和 j 都回到模式串首字符位置(也即 i = i - j, j =0),主串当前字符已验证不匹配,因此再后移一个字符继续比较(也即 i = i - j + 1);
将上面的思路翻译为实现代码如下:
char* BF_match(const char* str, const char* pat)
{if(str == NULL || pat == NULL)return NULL;size_t i = 0, j = 0;while (str[i] != '\0' && pat[j] != '\0') {if (str[i] == pat[j]) {// 如果当前字符匹配成功,则主串与模式串游标都后移一位,对比下一个字符i++;j++;} else {// 如果当前字符不匹配,则将模式串游标移到首字符处,主串游标移到与模式串首字符对应的下一个位置i = i - j + 1;j = 0;}}// 匹配成功,返回模式串pat 在主串str 中的位置,否则返回 -1if (pat[j] == '\0')return (char *)(str + i - j);elsereturn NULL;
}
从上面的算法思想可以看出,在极端情况下(比如长度为n 的主串”aaa…aaa“,长度为m 的模式串”aa…b“),每次都比对m 个字符,要比对n - m + 1 次,BF 算法的最坏情况时间复杂度为 O(n * m)。
从BF 算法的时间复杂度可知,如果模式串的长度m 比较小,BF 算法的效率还是不错的。在实际开发中,大部分情况下模式串确实都比较短,而且该算法代码实现比较简单,空间复杂度为O(1),所以还是挺常用的。比如早期C 标准库提供的strstr() 函数就采用了BF 匹配算法,实现代码如下(基于GCC-4.8.0 实现,为区别C标准库函数修改了函数名):
#include <string.h>char* STR_match(const char *str, const char *pat)
{if(str == NULL || pat == NULL)return NULL;const size_t pLen = strlen(pat);const char *pos = str;// 先找到主串与模式串首字符匹配的位置,找不到则返回空指针while ((pos = strchr(pos, pat[0])) != NULL) {// 主串与模式串首字符匹配,则继续比较后面的pLen - 1 个字符if(strncmp(pos, pat, pLen) == 0)return (char *) pos; // 如果匹配成功,则返回主串匹配首字符指针++pos; // 若匹配失败,则主串游标后移一位,继续查找与模式串首字符匹配的位置}return NULL;
}
二、Rabin–Karp 匹配算法
如果模式串比较长,我们不满足于O(n * m) 的时间复杂度,该如何优化呢?算法优化有一个核心原则:从信息论角度分析,尽可能高效的挖掘使用更多信息,让计算机少做事情,是算法优化的切入点。
从这个角度思考分析,BF 算法有两个优化方向:
- BF 算法逐个字符比较效率较低,能否省去逐字符比较过程,直接对比模式串和主串中等长的字符序列呢?
- BF 算法遇到匹配失败的字符总是往前回到初始位置开始比较,主串游标每次只后移一位,能否充分利用已经对比过的字符信息跳过一些不可能匹配的情况,让主串游标每次可以后移多位呢?
先分析第一种优化方向,能否在O(1) 时间复杂度内比较两个长度为m 的字符串呢?一般情况下,两个定长数值可以在常数时间获得比较结果,能否将变长字符串转换为定长数值呢?我们很容易想到哈希散列函数(可参阅博文:哈希算法能用来干啥?),比如著名的消息摘要算法MD5 可以将一个文件转换为一个128 位的散列值,自然也可以将一个字符串转换为定长的hash value,然后在常数时间内比较是否相等,达到省去逐字符比较的优化目的。
当然MD5 哈希运算比较耗时,如果比较的主串和模式串字符种类比较少,可以设计更简单的哈希算法,比如只包含26 个字母可以将字符串转换为26 进制数值,甚至可以直接将各字符的编码值相加获得该字符串的哈希值。
著名的Rabin-Karp 字符串匹配算法就是采用这种思路对BF 算法进行优化的,而且充分利用主串中两个相邻子串哈希值计算的关系(也即使用滚动哈希算法),进一步提高哈希计算效率,将主串中所有与模式串等长子串的哈希值计算时间复杂度提高到O(n)。模式串与每个子串哈希值比较的时间复杂度为O(1),最多需要比较 (n - m + 1) 个子串,因此RK 字符串匹配算法的时间复杂度为O(2n - m + 1),简写为O(n)。
使用两个字符串的哈希值比对,还需要注意哈希冲突的问题,如果存在大量的哈希冲突,RK 匹配算法的时间复杂度也会退化。如果两个字符串的哈希值不一致则两个字符串肯定不匹配,如果两个字符串的哈希值一致,不能保证两个字符串能够匹配,为了防止是哈希冲突的情况,还需要再次逐个字符比对两个字符串,才能确保两个字符串是否匹配。
本文主要介绍RK 算法的实现思路,哈希算法就采用最简单的形式,将字符串中各字符的ASCII 值相加便得到其哈希值,为防止字符串过长而超出类型极限,对其进行取余计算。计算出第一个哈希值hash[0] 后,使用滚动哈希算法计算后续的哈希值:
hash[i + 1] = hash[i] - str[i] + str[i + strlen(pat)];
计算完模式串及主串中各子串的哈希值并保存到hash 数组中,后续只需要逐个比对哈希值即可,若哈希值相等再使用C 标准库中的strncmp 函数比较模式串与哈希值匹配的子串,若二者匹配则直接返回,RK 算法的实现代码如下:
#include<stdlib.h>
#include<string.h>
#include<stdint.h>
#include<limits.h>size_t* Roll_hash(const char* str, const char* pat, const size_t pLen, const size_t sNum)
{// 为各子串及其模式串哈希值分配存储空间,hash[sNum]存储模式串哈希值,故分配sNum + 1 个元素size_t* hash = malloc((sNum + 1) * sizeof(size_t));if(hash == NULL) return NULL;size_t sHash = 0, pHash = 0;// 哈希函数使用最简单的将各字符ASCII 值相加for (size_t i = 0; i < pLen; i++) {sHash += (size_t)str[i];pHash += (size_t)pat[i];}hash[0] = sHash % (SIZE_MAX - CHAR_MAX); // 存储主串中第一个与模式串等长的子串哈希值,并取余以防超出类型极限hash[sNum] = pHash % (SIZE_MAX - CHAR_MAX); // 存储模式串哈希值,对(SIZE_MAX - CHAR_MAX)取余,后面滚动计算哈希值时可保证不超限// 采用滚动哈希算法,主串中各子串哈希值都根据前一个哈希值计算得出,因hash[0] < (SIZE_MAX - CHAR_MAX),后续哈希值不用取余也可保证不超限for (size_t j = 1; j < sNum; j++)hash[j] = hash[j - 1] - (size_t)str[j - 1] + (size_t)str[j - 1 + pLen];return hash;
}char* RK_match(const char* str, const char* pat)
{if(str == NULL || pat == NULL)return NULL;// 计算模式串长度,以及主串中与模式串等长的子串数量size_t pLen = strlen(pat);size_t sLen = strlen(str);size_t sNum = sLen - pLen + 1;if(sNum < 1) return NULL;// 调用滚动哈希计算函数,计算模式串以及主串中与模式串等长的各子串哈希值size_t *hash = Roll_hash(str, pat, pLen, sNum);if(hash == NULL)return NULL;// 获取模式串哈希值size_t hPat = hash[sNum];// 主串中各子串哈希值与模式串哈希值相比较for (size_t i = 0; i < sNum; i++) {// 如果某子串哈希值与模式串哈希值相等,则继续逐字符比较if(hPat == hash[i]) {// 如果模式串与某主串相匹配,则返回主串中匹配首字符的指针if(strncmp(str + i, pat, pLen) == 0){free(hash); // 释放为hash 数组分配的空间return (char *) (str + i);}}}// 释放为hash 数组分配的空间free(hash);return NULL;
}
从上面的实现代码可以看出,RK 字符串匹配算法是比较占用空间的,空间复杂度为O(n - m + 1),简写为O(n),如果主串很长而模式串很短,不光要占用更多的内存空间,而且这么多连续内存空间的分配也比较耗时,此时RK 匹配算法可能因哈希表内存分配和哈希计算等初始工作量较大而导致实际效率不如BF 匹配算法。所以,RK 算法在模式串很长时比较有优势。
三、Knuth–Morris–Pratt 匹配算法
前文提出了两个BF 匹配算法的优化方向,RK 匹配算法是从第一个优化方向,也即使用模式串与主串中各子串的哈希值比较来替代BF 算法中逐字符的比较,模式串越长且哈希冲突越少,RK 算法相比BF 算法的优势越明显。第二个优化方向,能否充分利用已经对比过的字符信息跳过一些不可能匹配的情况,让主串游标每次可以后移多位呢?
充分利用前面已经比较过的字符信息,可以让主串游标不走回头路,继续从匹配失败的字符处开始比较,这样就可以把字符串匹配效率提升到O(n) 时间复杂度,就像下面的图示:
著名的Knuth–Morris–Pratt 匹配算法就是采用上述优化思路,充分利用已经比较过的字符信息,保证主串游标不走回头路,来提高字符处匹配效率的。遇到字符匹配失败的位置,主串游标不回退,但也不能直接继续比较,模式串游标需要回退到合适的位置,才能继续与主串游标指向的字符比较。所以,问题的重点是,当字符匹配失败时模式串游标回退到什么位置?
为了方便说明,我们把模式串与主串匹配失败的字符称为坏字符,把前面已经匹配的字符序列称为好前缀,如下图所示:
当遇到坏字符的时候,我们需要把模式串往后滑动,当出现模式串的前缀子串与主串好前缀的后缀子串再次重合时停止滑动,中间不重合的情况肯定是无法匹配的。重合部分的模式串新前缀子串相当于已经比较过了,这是利用了先前已经比较过的好前缀匹配信息,尽可能减少比较次数以提高效率。模式串向后滑动至再次重合的过程图示如下:
好前缀的后缀子串与模式串的前缀子串再次重合时,实际上就是好前缀的后缀子串与其前缀子串相同的情况。当遇到字符匹配失败时,模式串游标回退到什么位置的问题,就转变为求好前缀中前缀子串与后缀子串的最大可匹配长度问题,怎么求好前缀中前缀子串与后缀子串的最大可匹配长度呢?比如对于给定的一个好前缀“ababa”,我们先尝试最长的前缀子串“abab”是否能与最长的后缀子串“baba”匹配,二者不匹配我们再尝试次长的前缀子串“aba”是否能和次长的后缀子串“aba”匹配,直到二者匹配则其最长可匹配前缀子串为“aba”,也即最大可匹配长度为3。
好前缀是模式串的一部分,所以求好前缀中前缀子串与后缀子串的最大可匹配长度问题只跟模式串有关。由于不匹配的坏字符可能出现在任何位置,所以我们需要求出模式串的每个前缀子串(构成前缀集合)的最大可匹配长度,如下表所示:
模式串各前缀子串 | 前缀子串尾字符下标 | 前缀子串的后缀集合 | 前缀子串的最长可匹配后缀子串 | 前缀子串的最大可匹配长度 |
---|---|---|---|---|
a | 0 | 不存在 | 不存在 | 0 |
ab | 1 | {“b”} | 不匹配 | 0 |
aba | 2 | {“ba”, “a”} | a | 1 |
abab | 3 | {“bab”, “ab”, “b”} | ab | 2 |
ababa | 4 | {“baba”, “aba”, “ba”, “a”} | aba | 3 |
ababac | 5 | {“babac”, “abac”, “bac”, “ac”, “c”} | 不匹配 | 0 |
很多书籍把模式串的所有前缀子串对应的最大可匹配长度值组成的数组称为PMT(Partial Match Table),也即上表中的最后一列,下文为了描述方便,也使用PMT 数组来表示模式串所有前缀子串对应的最大可匹配长度值集合。
由于在匹配失败的坏字符 j 处影响游标回退的实际上是其好前缀的最大可匹配长度,也即前一位的PMT 值PMT[j - 1],为了编程方便,我们将PMT 数组元素都向后移动一位,我们把新得到的数组称为next 数组(next[j] = PMT[j - 1])。由于首字符失配与非首字符失配的情况不同(非首字符失配主串游标不动,模式串游标回退;首字符失配,模式串游标退无可退,要保证主串游标继续前进),为了编程方便,我们将next 数组的首元素设置为 -1,方便识别并区分处理首字符失配的情况。PMT 数组元素后移一位得到next 数组的过程如下表示:
模式串 | a | b | a | b | a | c | d |
---|---|---|---|---|---|---|---|
前缀子串结尾字符下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
PMT 数组元素值 | 0 | 0 | 1 | 2 | 3 | 0 | - |
next 数组元素值 | -1 | 0 | 0 | 1 | 2 | 3 | 0 |
有了next 数组,我们就可以获知模式串与主串匹配失败时,模式串游标 j 需要回退到什么位置了。模式串游标 j 回退后,其前缀子串实际上就是回退前其好前缀的最长可匹配前缀子串,长度为next[j],模式串游标 j 回退后的位置实际上就是回退前其好前缀的最长可匹配前缀子串的下一个字符位置,也即下标为next[j] 的位置,坏字符处的游标j 回退过程为 j = next[j]。next 数组的数学表示如下:
next[j]={−1,j=0max{p[0]...p[k−1]=p[j−k]...p[j−1],1≤k≤j},最大可匹配长度0,不匹配的情况next[j] = \begin{cases} -1 &\text{,} j = 0 \\ max\{p[0]...p[k−1]=p[j−k]...p[j−1], 1 \le k \le j \} &\text{,} 最大可匹配长度 \\ 0 &\text{,} 不匹配的情况 \end{cases} next[j]=⎩⎪⎨⎪⎧−1max{p[0]...p[k−1]=p[j−k]...p[j−1],1≤k≤j}0,j=0,最大可匹配长度,不匹配的情况
按照上述思路,编写KMP 字符串匹配算法的实现代码如下(求next 数组采用上图中模式串逐位后移,其前缀子串与好前缀后缀子串匹配的过程,假设最大可匹配长度为k,要比较的前缀子串为pat[0]…pat[k-1],后缀子串为pat[j - k]…pat[j-1],k 从最大值 j-1 开始尝试,直到其前缀子串与后缀子串相等):
#include<stdlib.h>
#include<string.h>int* GetNext(const char* pat, const size_t pLen)
{// 为next 数组分配空间,并初始化为 0int* next = calloc(pLen, sizeof(int));if(next == NULL) return NULL;next[0] = -1; // next 数组首元素设为 -1,方便区分首字符不匹配的情况// j 为模式串各前缀子串的后一个字符位置,k 为其前缀子串的最大可匹配长度for (int j = 2; j < pLen; j++) {// 从最大长度开始尝试匹配前缀子串和后缀子串for(int k = j - 1; k > 0; k--) {// 若匹配成功则将最大长度k 记入next 数组,若匹配失败k-1 继续尝试if(strncmp(pat, pat + (j - k), k) == 0) {next[j] = k;break;}}}return next;
}char* KMP_match(const char* str, const char* pat)
{if(str == NULL || pat == NULL)return NULL;long long pLen = strlen(pat);long long sLen = strlen(str);// 为next 数组分配空间,并计算next 数组元素值int *next = GetNext(pat, pLen);if(next == NULL) return NULL;// 比较主串与模式串,主串游标 i 不回退,模式串游标 j 按next 数组值回退long long i = 0, j = 0;while (j < pLen && i < sLen) {if (str[i] == pat[j]) {// 如果当前字符匹配成功,则主串与模式串游标都后移一位,比较下一个字符i++;j++;} else {// 如果当前字符不匹配,则主串游标 i 不变,模式串游标 j 回退到next[j] 处j = next[j];// 如果j = -1,说明主串与模式串的首字符不匹配,主串与模式串的游标都后移一位,主串下一个字符与模式串首字符比较if(j == -1){i++;j++;}}}// 释放为next 数组分配的空间free(next);//匹配成功,返回模式串pat 在文本串str 中的位置,否则返回-1if (j == pLen)return (char *)(str + i - j);elsereturn NULL;
}
上面实现的KMP 算法,空间复杂度比较好分析,next 数组占用空间大小跟模式串等长,所以KMP 算法的空间复杂度为O(m),时间复杂度如何分析呢?KMP 算法的时间复杂度分析主要包含两方面:一方面是主串与模式串匹配过程;另一方面是next 数组计算过程。
先分析KMP 算法主串与模式串匹配过程的时间复杂度。主串游标 i 虽然不回退,但也可能停滞不前(只有在字符匹配或者模式串首字符失配时才前进,模式串其余字符失配时停滞不前)。最好情况是,主串游标 i 每次都前进,也即每次都是模式串首字符失配,此时的时间复杂度为O(n)。最坏情况是,主串游标 i 尽可能多的停滞不前,也就是模式串游标 j 回退最多次数到首字符,比如主串“aaaabaaaab…aaaab",模式串“aaaaa”,主串每个周期的前m-1 个字符与模式串一致,只有最后一个字符不匹配,模式串每个字符都相同,遇到失配字符需要回退 m 次,所以每个周期的比较次数为 m - 1 + m 次,周期个数为 n / m 个,比较总次数为 (2 * m - 1) * n / m < 2 * n,此时的时间复杂度为O(2*n),简写为O(n)。
接下来分析next 数组计算的时间复杂度。先看最内层字符比较次数为k,再看中间层 k 从j-1 递减到 1, 所以内两层的比较次数为 1 + 2 + 3 + … + j - 1 = j * (j-1) / 2,最外层 j 从2 递增到 m - 1,整个过程比较总次数为((22 + 32 + … + (m-1)2) - (2 + 3 + … + m - 1)) / 2 = m * (m-1) * (m-2) / 6,因此上述next 数组计算的时间复杂度为O((m3 - 3 * m2 - 2 * m) / 6),简写为O(m3)。上面实现的KMP 匹配算法,字符匹配和next 数组计算两部分总的时间复杂度为O(n + m3)。
上面实现的KMP 算法计算next 数组的时间复杂度太高了,如果模式串比较长,对整体效率影响比较大,如果模式串比较短,BF 算法效率也不差,KMP 算法设计目的应该是服务于模式串比较长的场景,因此我们有必要对next 数组的计算过程进行优化。
对next 数组计算过程的优化,依然遵循前面介绍的原则,充分利用已经计算过的信息,让计算机少做事。我们已经知道next[0] = -1 这个初始条件,假设前面next[1]…next[j] 已经计算出来了,我们能否利用已经计算出的值快速推导出next[j+1] 的值呢?
假设next[j] = k,也即pat[0, k-1] = pat[j-k, j-1],其中pat[0, k-1] 是模式串pat[0, j-1] 的最大可匹配前缀子串,pat[j-k, j-1] 为其最大可匹配后缀子串,要推导出next[j+1] 的值需要知道pat[k] 是否等于pat[j]?这个比较过程相当于拿模式串的最大可匹配前缀子串pat[0, k] 作为新的模式串,原来的模式串pat[0, j] 作为新的主串,然后二者进行匹配的过程。
使用KMP 匹配算法思想,如果pat[j] 与pat[k] 匹配,那么新主串游标j 和新模式串游标k 均加一,且pat[0, j] 的最大可匹配前缀子串延长一位到pat[0, k],也即next[j+1] = next[j] + 1 = k + 1。如果pat[j] 与pat[k] 不匹配,那么新主串游标j 不动,新的模式串游标k 需要回退到next[k] ,也即k = next[k](next[k] 是前面已经计算出结果的,因为k < j,前面假设next[0, j] 都已经计算出来了),这样就实现了利用前面已经计算出的next[0, j]快速推导下一个next[j+1] 的目的,新主串与新模式串匹配过程图示如下:
按照上面的分析思路,编写优化后的next 数组计算函数,实现代码如下(与KMP 匹配算法类似,需要特别处理新模式串首字符与新主串不匹配的情况,也即新模式串游标k = -1 时,新模式串游标k 和新主串游标j 均后移一位,同时next[j+1] = 0,处理操作跟字符pat[j] 和pat[k] 匹配时一样):
#include<stdlib.h>int* GetNext(const char* pat, const size_t pLen)
{// 为next 数组分配空间,并初始化为 0int* next = calloc(pLen, sizeof(int));if(next == NULL) return NULL;next[0] = -1; // next 数组首元素为-1,用于区别其它位置匹配失败的情况// k 为模式串最大可匹配前缀子串首字符游标,初始值 -1 表示不存在,j 为模式串首字符游标int k = -1, j = 0;while (j < pLen - 1) {// 如果是新模式串首字符不匹配,或者新模式串字符pat[k]与新主串字符pat[j]匹配成功,则next[j+1] = k+1if (k == -1 || pat[j] == pat[k]) {next[++j] = ++k;} else {// 如果新模式串非首字符pat[k]与新主串字符pat[j]匹配失败,则新模式串游标k 回退到next[k] 处k = next[k];}}return next;
}char* KMP_match(const char* str, const char* pat)
{if(str == NULL || pat == NULL)return NULL;long long pLen = strlen(pat);long long sLen = strlen(str);// 为next 数组分配空间,并计算next 数组元素值int *next = GetNext(pat, pLen);if(next == NULL) return NULL;// 比较主串与模式串,主串游标 i 不回退,模式串游标 j 按next 数组值回退long long i = 0, j = 0;while (j < pLen && i < sLen) { // 如果模式串首字符与主串不匹配,或者模式串字符pat[j]与主串字符str[i]匹配,则主串与模式串的游标都后移一位,继续比较下一个字符if (j == -1 || str[i] == pat[j]) {i++;j++;} else {// 如果模式串非首字符pat[j]与主串字符str[i]不匹配,则主串游标i 不变,模式串游标j 回退到next[j] 处j = next[j];}}// 释放为next 数组分配的空间free(next);//匹配成功,返回模式串pat 在文本串str 中的位置,否则返回-1if (j == pLen)return (char *)(str + i - j);elsereturn NULL;
}
再次分析优化后的next 数组计算时间复杂度,由于next 数组计算过程跟KMP 主串和模式串匹配过程类似,next 数组是拿模式串和其最大可匹配前缀子串进行匹配,前面已经分析了KMP 字符匹配过程的时间复杂度为O(n),这里next 数组计算过程的时间复杂度则为O(m),优化后的KMP 算法总的时间复杂度为O(n + m),简写为O(n)。
如果模式串比较长,KMP 匹配算法的效率要高于BF 匹配算法。一般来说主串都比模式串长得多,KMP 匹配算法的空间复杂度O(m) 要好于RK 匹配算法的空间复杂度O(n)。
四、Boyer-Moore-Horspool 匹配算法
前面RK 匹配算法和KMP 匹配算法分别从两个方向优化了BF 算法的效率,还有没有其它的优化思路呢?我们继续从第二个优化方向出发,看能否让主串游标每次尽可能后移多位,跳过一些不可能匹配的情况。主串与模式串比对到坏字符处时,KMP 算法充分利用了前面已经比较过的信息,让主串游标不回退,来提高匹配效率。我们能够利用字符串自身的匹配信息跳过一些不可能匹配的情况吗?
前面分析KMP 匹配算法时间复杂度时,举了一个最坏情况的例子,比如主串“aaaabaaaab…aaaab",模式串“aaaaa”,使用KMP 匹配算法从前往后匹配到主串的字符’b’时,模式串需要回退m 次,也就是匹配主串中的一个周期"aaaab" 需要比较2 * m-1 次,如果我们从模式串末尾往前比较,主串中的字符‘b’并没有出现在模式串中,就可以直接往后滑动m 位,因为主串中的字符’b’ 不可能与模式串中的任何一个字符匹配,这就达到一次比较后移m 位的效果,明显提升了匹配效率,就像下面的图示:
著名的Boyer-Moore-Horspool 匹配算法就是采用上述优化思路,充分利用字符匹配时主串坏字符与模式串各字符的关系,将模式串中不可能与主串坏字符匹配的情况全部跳过去,从而达到提高字符串匹配效率的目的。BMH 匹配算法的重点是,从后往前匹配,当遇到坏字符时模式串应该向后滑动多少位?
我们很容易想到,模式串从后往前匹配遇到坏字符时,模式串向后滑动过程中,只要不与主串坏字符匹配,就是不可能匹配的情况,我们需要找到主串中的坏字符在模式串中最后出现的位置,比如上图中主串坏字符’c’ 没在模式串中出现,我们直接将模式串首字符后移到坏字符’c‘ 后面。如果主串坏字符在模式串中存在,比如坏字符在模式串的下标为si,主串坏字符在模式串中最后出现位置下标为xi,模式串可以直接向后滑动si - xi 位,模式串pat[xi+1, si] 的字符不可能与主串坏字符匹配,比如下面的图示:
当遇到坏字符时模式串应该向后滑动多少位的问题就转换为如何快速获得主串中坏字符在模式串中最后出现的位置。我们可以使用一个数组(由于该数组是查询坏字符最后出现位置的,暂且称为bad char 数组吧,简称bc 数组)保存模式串中各字符的下标,这里也用到了哈希映射的思想,可以将字符编码作为下标索引,数组内的元素值存储该字符在模式串中最后出现的位置下标,这样可以在O(1) 时间内获得模式串中某字符出现的最后位置。
怎么获得模式串中某字符出现的最后位置下标呢?这个很简单,将模式串中的字符及其对应的下标依次存入bc 数组,靠后的相同字符会用靠后的下标覆盖靠前的下标,处理完模式串中的所有字符,bc 数组中存储的就是模式串每个字符出现的最后位置下标。还有个特例需要考虑,假如主串坏字符并没有出现在模式串中,可以将其出现位置下标设置为 -1,就可以与其它情况同等处理了。按照上述思路,编写BMH 匹配算法的实现代码如下:
#include<stdlib.h>
// CHAR_SIZE 为设置的最大字符集数量,这里假设仅使用ASCII 的128 个字符
#define CHAR_SIZE 128int* GenerateBC(const char* pat)
{// 为bc 数组分配空间,并初始化为 -1int* bc = malloc(CHAR_SIZE * sizeof(int));if(bc == NULL) return NULL;memset(bc, -1, CHAR_SIZE * sizeof(int));// bc 数组采用哈希映射的思路,存储模式串中各字符的下标,靠后的字符下标会覆盖先前相同的字符下标for(int i = 0; pat[i] != '\0'; ++i) {int ascii = (int)pat[i];bc[ascii] = i;}return bc;
}char* BMH_match(const char* str, const char* pat)
{if(str == NULL || pat == NULL)return NULL;long long pLen = strlen(pat);long long sLen = strlen(str);int *bc = GenerateBC(pat);if(bc == NULL)return NULL;long long i = 0, j = pLen - 1;while (i <= sLen - pLen) {// 从模式串pat 末字符往前逐个比较,直到发现匹配失败的字符或者模式串匹配完for(j = pLen - 1; j >= 0; --j){if(str[i + j] != pat[j])break;}// 如果模式串匹配完了,说明模式串匹配成功,返回主串匹配首字符指针if(j < 0) {free(bc); // 释放为bc 数组分配的空间return (char *)(str + i);}// BMH 与BM 不同之处,BM 从匹配失败字符确定移动距离,BMH 从模式串末字符确定移动距离,故将游标j 重新移回模式串末字符位置j = pLen - 1;// 模式串匹配失败,确定主串游标i 的移动距离,i 的移动距离至少 1 位if(bc[(int)str[i + j]] < j)i += j - bc[(int)str[i + j]];elsei++;}// 释放为bc 数组分配的空间free(bc);return NULL;
}
BMH 匹配算法,每次遇到匹配失败的坏字符,总是从模式串末尾字符确定向后滑动距离,也即查询主串中与模式串末尾字符对应的字符在模式串中最后或次后出现的位置下标(如果模式串末尾字符与主串对应字符匹配,就找主串中对应字符在模式串中次后出现的位置)。
我们分析下BMH 算法的复杂度,空间复杂度比较简单,就是字符集的大小CHAR_SIZE,时间复杂度分析比较复杂点。首先分析最好情况时间复杂度,假设每次模式串末字符与主串不匹配,且主串坏字符在模式串中不存在,就比如前面举的例子主串“aaaabaaaab…aaaab",模式串“aaaaa”,每个周期比较一次,模式串向后滑动m 位,主串共有n / m 个周期,BMH 匹配算法最好情况时间复杂度为O(n / m)。
接下来分析最坏情况时间复杂度,假设每次匹配到模式串首字符时失配,且模式串仅向后滑动一位,比如主串“baaaabaaaa…baaaa",模式串“aaaaa”,每个周期比较 m - 1 次,模式串向后滑动一位,主串共有n / m 个周期,BMH 匹配算法最坏情况时间复杂度为O(n * m)。
在字符集比较小的情况下(比如仅使用ASCII 字符),BMH 匹配算法空间复杂度很低,一般情况下匹配效率要好于BF 算法,根据实现统计,BMH 算法的匹配效率要比KMP 算法高几倍。当字符集比较大时,比如中文字符集,BMH 匹配算法需要占用更多的内存空间。
为了避免BMH 算法出现最坏情况,可以和其它算法配合使用,比如Two-way 双向字符串匹配算法就可以看作是从前往后匹配的KMP 算法和从后往前匹配的BMH 算法的组合,兼具KMP 和BMH 算法的优点,可以获得比单一算法更高的匹配效率,而且最坏情况时间复杂度为O(n),Glibc 库提高的字符串匹配函数strstr 就使用Two-way 双向字符串匹配算法,保证获得尽可能高的匹配效率。
五、字符串匹配算法效率对比
上面介绍了四种字符串匹配算法,外加GCC 早期提供的优化后的BF 匹配算法和后来提供的Two-way 匹配算法,一共六种字符串匹配算法。我们分别在windows 10 和Ubuntu 20.04 系统上简单对比下其执行效率,同时验证上面的算法实现代码是否有bug。
作为对比的文本串和模式串使用随机生成方式,仅使用ASCII 字符集中的可显示字符(也即字符编码为0x20 到0x7E 的95 个字符),随机生成文本串和模式串的代码如下:
#include<stdlib.h>
#include<time.h>char* str_init(char* str, size_t n)
{str = malloc((n + 1) * sizeof(char));// 根据时间设置随机数种子,让每次生成的随机数不一样srand(time(NULL));// 随机生成包含26 各小写字母且长度为n 的字符串for(size_t i = 0; i < n; i++)// ASCII 字符集可显示字符为0x20 到0x7E 共95 个字符str[i] = 0x20 + rand() % 95;str[n] = '\0';return str;
}
用于验证匹配结果的函数代码如下:
#include<stdio.h>
#include<stdbool.h>
#include<string.h>bool validate_match(const char* pos, const char* pat)
{bool res = false;if(pos != NULL && pat != NULL){int pLen = strlen(pat);if(strncmp(pos, pat, pLen) == 0)res = true;}if(res == true)printf("The string match was successful.\n\n");elseprintf("The string match failed.\n\n");return res;
}
使用C 库提供的时间统计函数,分别统计不同字符串匹配函数的执行时间,假设主串字符个数为五亿个,模式串字符个数为五万个,main 函数调用上述六种不同字符串匹配函数的代码如下:
...
#define STR_LEN 500000000
#define PAT_LEN 50000
#define CHAR_SIZE 128
...
int main(void)
{int method;printf("BF match: 1\n");printf("STR match: 2\n");printf("RK match: 3\n");printf("KMP match: 4\n");printf("BMH match: 5\n");printf("strstr match: 6\n");printf("Select method: ");scanf("%d", &method);char *str, *pat, *pos;long long index;str = str_init(str, STR_LEN);// 使用随机数生成主串和模式串之间等待2 秒,由于使用当前时间作为随机数种子,有时间差可以让模式串与主串前面的随机字符不一致
#if defined (__linux__) // any linux distributionsystem("sleep 2");
#elif defined (_WIN32) // any windows systemsystem("ping -n 2 127.0.0.1 > nul");
#endifpat = str_init(pat, PAT_LEN);printf("The length of the main string : %lu\n", (size_t)STR_LEN);printf("The length of the pattern string: %lu\n\n", (size_t)PAT_LEN);clock_t start, end;double time;start = clock();switch (method){case 1:pos = BF_match(str, pat);break;case 2:pos = STR_match(str, pat);break;case 3:pos = RK_match(str, pat);break;case 4:pos = KMP_match(str, pat);break;case 5:pos = BMH_match(str, pat);break;case 6:pos = strstr(str, pat);break;default:break;}end = clock();time = (double)(end - start) / (CLOCKS_PER_SEC / 1000);validate_match(pos, pat);index = (pos == NULL) ? -1 : (pos - str);printf("Match location: %lld\n\n", index);printf("Execution time: %.3lf ms.\n\n", time);free(str);return 0;
}
由于是随机产生的字符串,可能匹配成功的位置不一样,不同字符串匹配算法只有在匹配到相同位置时才有比较意义,因此下面统计匹配失败的情况,也即字符串全部匹配完需要的执行时间,测试结果如下:
文本串str: 5e8 个字符 模式串pat: 5e4 个字符 |
windows 10 | ubuntu 20 |
---|---|---|
BF_match | 945 ms | 944 ms |
STR_match | 440 ms | 113 ms |
RK_match | 2509 ms | 2616 ms |
KMP_match | 1640 ms | 1558 ms |
BMH_match | 99 ms | 85 ms |
C library: strstr | 432 ms | 41 ms |
从上面六种字符串匹配算法的执行结果,我们可以做一些合理的推测:
- windows 10 系统C 标准库提供的strstr() 函数执行时间跟STR_match 函数接近,推测其采用的是优化后的Brute Force 匹配算法,跟GCC-4.8.0 提供的strstr() 函数实现类似;
- Ubuntu 20系统C 标准库提供的strstr() 函数执行时间最短,推测其采用的是Two-way 双向字符串匹配算法,执行效率比KMP 算法和BMH 算法都高;
- Ubuntu 20 系统C 标准库提供的strstr() 函数和调用了strchr() 与strncmp() 函数的STR_match 函数执行时间都要少于windows 10 系统,推测Linux 系统C 标准库对这几个函数的实现效率优化比windows 系统做的更好;
- 在两个系统中RK_match 算法执行时间最长,推测主要是为哈希表分配太大的连续内存空间比较耗时;KMP_match 算法执行时间也比BF_match 算法更长,可能也跟其需要为next 数组分配大量空间有关;BMH_match 算法执行时间很短,说明其遇到最坏情况的概率很低,也跟测试使用的字符集较小有关;
- 在实际工程开发中,C 库函数提供的strstr() 函数具有较高的效率,我们没有必要再自己实现一个KMP 或BMH 匹配算法,如果嫌windows 系统提供的strstr() 函数效率略有不足,可以考虑直接移植linux 系统C 标准库实现glibc 中提供的strstr() 函数代码。
本文字符串匹配算法实现代码及其测试代码下载链接:https://github.com/StreamAI/ADT-and-Algorithm-in-C/blob/master/string/str_match.c
更多文章:
- 《String-searching_algorithm》
- 《数据结构与算法分析(十七)— 怎么用回溯剪枝高效穷举所有可能的解?》
- 《数据结构与算法分析(十五)— String 和Regex 支持的字符处理操作(C++11)》
- 《数据结构与算法分析(十四)— 字符串和字符处理函数库(C11)》
- 《数据结构与算法分析(七)— 排序算法分析 + 排序优化》
数据结构与算法分析(十六)--- 如何设计更高效的字符串匹配算法?(BF + RK + KMP + BMH)相关推荐
- 比KMP算法更简单更快的字符串匹配算法
我想说一句"我日,我讨厌KMP!". KMP虽然经典,但是理解起来极其复杂,好不容易理解好了,便起码来巨麻烦! 老子就是今天图书馆在写了几个小时才勉强写了一个有bug的.效率不高的 ...
- 数据结构笔记(十六)-- 数组实现
一.数组的结构定义与实现 typedef int ElemType;//定义数组存储数据类型 typedef struct Array {ElemType *base; // 数组元素基址,由Init ...
- 数据结构与算法分析(六)队列总结
一.什么是队列? 1.先进者先出,这就是典型的"队列"结构. 2.支持两个操作:入队enqueue(),放一个数据到队尾:出队dequeue(),从队头取一个元素. 3.所以,和栈 ...
- 数据结构与算法(十六)冒泡排序和鸡尾酒排序
冒泡排序(Bubble Sort)是一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,以将当前序列的最小值交换到当前序列最前端为一轮结束,需要(length-1)轮,感觉数据是一 ...
- 数据结构与算法分析(六)——C++实现二叉查找树
二叉树的基本概念 1.定义 二叉树(binary tree)是一颗树,其中每个节点都不能有多于两个的儿子. 2.实现 每一个节点用一个结构体表示,包含一个关键字和两个指向左儿子和右儿子的指针. 3.遍 ...
- 八叉树和十六叉树结构
(1)三维和四维数据结构的提出.前面介绍的数据结构都是二维的,然而在有些信息系统中,需要有真三维的空间数据结构.例如矿山开采中的地下资源埋藏和采矿巷道的空间分布,如果用二维的坐标体系就根本无法很好表达 ...
- java数据结构与算法之双链表设计与实现
转载请注明出处(万分感谢!): http://blog.csdn.net/javazejian/article/details/53047590 出自[zejian的博客] 关联文章: java数据结 ...
- 面试mysql中怎么创建索引_阿里面试:MySQL如何设计索引更高效?
有情怀,有干货,微信搜索[三太子敖丙]关注这个不一样的程序员. 本文 GitHub https://github.com/JavaFamily 已收录,有一线大厂面试完整考点.资料以及我的系列文章. ...
- 的列数 获取mysql_阿里面试:MySQL如何设计索引更高效?
有情怀,有干货,微信搜索[三太子敖丙]关注这个不一样的程序员. 本文 GitHub https://github.com/JavaFamily 已收录,有一线大厂面试完整考点.资料以及我的系列文章. ...
最新文章
- django部署iiswin10_基于Windows平台的Django在本地部署和腾讯云服务器上部署的方法教程(一)...
- 深入浅出话命令(Command)-笔记(-)
- android 画布旋转,Android-在安卓开发中,如何实现一个简单的图片旋转
- 在 JavaFX 中,如何计算文本所占像素的宽度
- 微信小程序 App()方法与getApp()方法
- pyqt打包成linux可执行程序,PyQtopencv图像处理(5):python程序打包成可执行文件...
- android读写缓存文件路径,Android app-cache-Path的 缓存图片、缓存文件的路径包名路径 和外路径比较...
- 封装连接mysql数据库_封装连接mysql数据库
- java oracle 触发器_Oracle 触发器
- php限制密码输入错误次数,js密码输入错误指定次数禁止输入
- WPF中,输入完密码回车提交 ,回车触发按钮点击事件
- java 不以科学记数法输出double
- 谷歌研发开源协议,助听器有望原生支安卓系统
- 斐讯k3 搭建php环境,斐讯K3刷机教程官改V2.1D或者其它版本教程
- 【STM32】标准库 菜鸟入门教程(1)初识最小系统
- Summernote 上传图片至 SMMS 图床 Api
- 1u服务器系统风扇,1U工控服务器机箱介绍
- Dire Wolf(区间DP)
- 很多人都在说Java已经饱和了,未来的就业前景究竟怎么样?
- 机器人操作系统(ROS)入门