Boyer-Moore 算法简介
在字符串匹配的领域中,Boyer-Moore 算法可谓是一颗璀璨的明星,发挥着举足轻重的作用。它由 Robert S. Boyer 和 J Strother Moore 于 1977 年提出,是一种高效的字符串匹配算法,专门用于在一个目标串中查找一个模式串的出现位置 。在实际工程项目里,我们常常会面临文本搜索、替换、过滤等任务,比如在一个庞大的日志文件中查找特定的错误信息,又或是在一篇长篇文章里替换某个特定的词汇,字符串匹配算法就是帮助我们快速高效完成这些任务的得力助手,而 Boyer-Moore 算法更是其中的佼佼者。
与传统的字符串匹配算法,如朴素算法(Brute-Force)相比,Boyer-Moore 算法的效率有了质的飞跃。朴素算法就像是一个勤劳但有点死板的工人,它会从目标串的开头开始,一位一位地与模式串进行比对,一旦发现不匹配,就将模式串向右移动一位,重新开始比对。这种方式虽然简单直接,但在面对较长的目标串和模式串时,效率非常低下,因为它做了很多不必要的比较。而 Boyer-Moore 算法则聪明得多,它的核心思想是通过预处理模式串,巧妙地利用字符比较的不匹配信息来跳过尽可能多的目标字符,从而快速定位可能的匹配位置,大大减少了比较次数,就像一个经验丰富的探险家,能够快速找到宝藏的位置,而不是在茫茫大海中盲目摸索。
算法核心原理
坏字符规则
坏字符规则是 Boyer-Moore 算法中非常重要的一部分,它的作用是在模式串与目标串匹配过程中,当发现不匹配的字符(即坏字符)时,通过巧妙地移动模式串,跳过一些不必要的比较,从而提高匹配效率 。具体来说,坏字符就是在模式串与目标串从后往前匹配时,第一个不匹配的字符。
我们来看一个具体的例子,假设目标串是 “HERE IS A SIMPLE EXAMPLE”,模式串是 “EXAMPLE”。在匹配过程中,从模式串和目标串的末尾开始比较,当比较到 “HERE IS A SIMPLE EXAMPLE” 中的 “S” 和 “EXAMPLE” 中的 “E” 时,发现不匹配,此时 “S” 就是坏字符。接下来,我们需要确定模式串应该移动多少位。根据坏字符规则,移动的位数等于坏字符在目标串中的位置减去坏字符在模式串中最右出现的位置(如果坏字符不在模式串中,最右出现位置记为 - 1) 。在这个例子中,“S” 在目标串中的位置是 6(从 0 开始计数),“S” 不在模式串中,所以最右出现位置记为 - 1,那么模式串需要向右移动 6 - (-1) = 7 位。
再举个例子,假设目标串是 “ABABDABACD”,模式串是 “ABACD”。从后往前比较,当比较到 “ABABDABACD” 中的 “D” 和 “ABACD” 中的 “D” 时,发现不匹配,“D” 就是坏字符。“D” 在目标串中的位置是 4,在模式串中的最右出现位置是 3,所以模式串需要向右移动 4 - 3 = 1 位。
通过这些例子,我们可以清晰地看到坏字符规则的工作原理,它能够根据坏字符的位置信息,快速确定模式串的移动距离,从而减少比较次数,提高匹配效率。在实际实现中,我们通常会创建一个坏字符表,用于存储模式串中每个字符的最右出现位置,这样在匹配过程中就可以快速查询,进一步提高算法的效率。
好后缀规则
好后缀规则是 Boyer-Moore 算法的另一个关键组成部分,它与坏字符规则相互配合,共同提升了算法在字符串匹配中的效率。好后缀,指的是在模式串与目标串从后往前匹配的过程中,遇到坏字符之前,已经成功匹配的那部分后缀子串 。
为了更好地理解好后缀规则,我们来看一个具体的示例。假设目标串是 “ABABABCABABAC”,模式串是 “ABABAC”。从模式串和目标串的末尾开始比较,一开始 “C” 与 “C” 匹配,“A” 与 “A” 匹配,“B” 与 “B” 匹配,“A” 与 “A” 匹配,“B” 与 “B” 匹配,此时 “ABABAC” 中的 “ABAC” 是好后缀。但继续比较时,发现 “ABABABCABABAC” 中的 “C” 与 “ABABAC” 中的 “B” 不匹配,“C” 成为坏字符。此时,我们就需要根据好后缀规则来移动模式串。
好后缀规则的移动方式分为几种情况 。第一种情况,如果在模式串中存在与好后缀相同的子串(这个子串不能是好后缀本身,而是在模式串中其他位置的相同子串),那么就将模式串移动,使得这个相同的子串与目标串中的好后缀对齐。在上述例子中,模式串 “ABABAC” 中除了当前匹配的后缀 “ABAC”,在前面还有 “ABAC”,所以我们将模式串向右移动,让前面的 “ABAC” 与目标串中的好后缀 “ABAC” 对齐。
第二种情况,如果在模式串中找不到与好后缀相同的子串,那就寻找好后缀的后缀子串(即好后缀去掉第一个字符后的子串,再去掉第一个字符后的子串,以此类推),看是否有与模式串的前缀子串相同的。如果有,就将模式串移动,使模式串的前缀子串与好后缀的这个后缀子串对齐 。比如模式串是 “ABCDEF”,目标串是 “XYZABCDE”,好后缀是 “CDE”,在模式串中找不到与 “CDE” 相同的子串,但 “CDE” 的后缀子串 “DE” 与模式串的前缀子串 “DE” 相同,所以将模式串移动,使模式串的 “DE” 与目标串中好后缀 “CDE” 的后缀子串 “DE” 对齐。
第三种情况,如果好后缀的所有后缀子串都与模式串的前缀子串不相同,那么就将模式串直接移动到好后缀的下一个字符处 。例如目标串是 “ABCDFG”,模式串是 “ABCDE”,好后缀是 “CDE”,但模式串中没有与 “CDE” 相同的子串,“CDE” 的后缀子串 “DE”“E” 也与模式串的前缀子串都不相同,此时就将模式串移动到好后缀 “CDE” 的下一个字符 “F” 处。
好后缀规则通过对已匹配的好后缀进行分析,根据不同情况确定模式串的移动距离,进一步减少了匹配过程中的比较次数,与坏字符规则相辅相成,共同构成了 Boyer-Moore 算法高效匹配的基础。在实际的代码实现中,我们需要通过预处理模式串,构建相关的数据结构来快速确定好后缀规则下模式串的移动距离,从而实现高效的字符串匹配。
算法步骤与流程
预处理阶段
在 Boyer-Moore 算法中,预处理阶段是至关重要的,它为后续高效的匹配过程奠定了坚实的基础。这个阶段主要完成两项关键任务:生成坏字符表和好后缀表 。
生成坏字符表时,我们需要遍历模式串,记录每个字符在模式串中最后一次出现的位置。以模式串 “ABABAC” 为例,我们创建一个大小为字符集大小(通常为 256,因为 ASCII 码表有 256 个字符)的数组,初始时将数组中所有元素的值设为 -1,表示这些字符在模式串中尚未出现 。然后从模式串的开头开始遍历,当遇到字符 'A' 时,将数组中索引为 'A' 的元素值更新为当前位置 0;继续遍历,再次遇到 'A' 时,由于要记录最后出现的位置,所以将数组中索引为 'A' 的元素值更新为当前位置 2;以此类推,最终得到坏字符表。在实际代码实现中,C++ 语言可以这样写:
void generateBadCharTable(const string& pattern, int badCharTable[]) {
int m = pattern.length();
for (int i = 0; i < 256; ++i) {
badCharTable[i] = -1;
}
for (int i = 0; i < m; ++i) {
badCharTable[(unsigned char)pattern[i]] = i;
}
}
坏字符表的作用是在匹配过程中,当发现坏字符时,能够快速确定模式串应该移动的距离。例如,在目标串 “ABABABCABABAC” 中匹配模式串 “ABABAC”,当比较到目标串中的 “C” 和模式串中的 “B” 不匹配时,“C” 就是坏字符,通过查询坏字符表,我们可以知道 “C” 在模式串中最后出现的位置(这里是 -1,因为模式串中没有 “C”),从而根据坏字符规则计算出模式串应该移动的距离。
生成好后缀表相对复杂一些,它需要记录模式串中每个后缀子串在模式串中其他位置的匹配信息 。我们可以通过构建一个辅助数组来实现。以模式串 “ABABAC” 为例,从模式串的末尾开始,逐步生成后缀子串,如 “C”“AC”“BAC”“ABAC”“BABAC”“ABABAC”,然后分别查找这些后缀子串在模式串中除当前位置外的其他位置是否存在 。对于后缀子串 “ABAC”,它在模式串中除了末尾位置外,前面还有一处出现,我们就记录下这个信息。在实际代码实现中,C++ 语言可以这样写:
void generateGoodSuffixTable(const string& pattern, int goodSuffixTable[]) {
int m = pattern.length();
int* suffix = new int[m];
computeSuffixes(pattern, suffix);
int j = 0;
for (int i = m; i >= 0; --i) {
if (i == m || suffix[i] == i + 1) {
for (; j < m - i; ++j) {
if (goodSuffixTable[j] == -1) {
goodSuffixTable[j] = m - i;
}
}
}
}
for (int i = 0; i <= m - 2; ++i) {
goodSuffixTable[m - 1 - suffix[m - 1 - i]] = m - 1 - i;
}
delete[] suffix;
}
void computeSuffixes(const string& pattern, int suffix[]) {
int m = pattern.length();
int f = 0, g = m - 1;
suffix[m - 1] = m;
for (int i = m - 2; i >= 0; --i) {
if (i > g && suffix[i + m - 1 - f] < i - g) {
suffix[i] = suffix[i + m - 1 - f];
}
else {
if (i < g) {
g = i;
}
f = i;
while (g >= 0 && pattern[g] == pattern[g + m - 1 - f]) {
g--;
}
suffix[i] = f - g;
}
}
}
好后缀表在匹配过程中,当出现好后缀时,能帮助我们根据好后缀规则快速确定模式串的移动距离,从而跳过一些不必要的比较。
搜索匹配阶段
搜索匹配阶段是 Boyer-Moore 算法实际执行字符串匹配的关键环节,它充分利用预处理阶段生成的坏字符表和好后缀表,在目标串中高效地寻找模式串的匹配位置 。
在这个阶段,我们从目标串的起始位置开始,将模式串与目标串进行对齐,然后从模式串的末尾字符开始,依次与目标串中对应的字符进行比较 。例如,目标串为 “ABABABCABABAC”,模式串为 “ABABAC”,首先将模式串与目标串的前 6 个字符对齐,从模式串的最后一个字符 “C” 开始,与目标串中对应的字符进行比较,“C” 与 “C” 匹配,继续向前比较,“A” 与 “A” 匹配,“B” 与 “B” 匹配,“A” 与 “A” 匹配,“B” 与 “B” 匹配 。
如果在比较过程中,所有字符都匹配成功,那么就找到了一个模式串在目标串中的匹配位置,记录下这个位置,并将模式串向右移动,继续寻找下一个可能的匹配位置 。比如上述例子中,当比较完模式串 “ABABAC” 与目标串 “ABABABCABABAC” 的前 6 个字符都匹配后,就找到了一个匹配位置。
然而,如果在比较过程中发现不匹配的字符,即坏字符,此时就需要根据坏字符规则和好后缀规则来确定模式串的移动距离 。还是以上述例子为例,当继续向前比较,发现目标串中的 “C” 与模式串中的 “B” 不匹配,“C” 就是坏字符。首先根据坏字符规则,查询坏字符表,得到 “C” 在模式串中最后出现的位置(这里是 -1),根据坏字符规则公式(移动距离 = 坏字符在目标串中的位置 - 坏字符在模式串中最后出现的位置),计算出模式串应该向右移动的距离。同时,我们还需要考虑好后缀规则,因为在不匹配之前,已经有部分字符匹配成功,形成了好后缀,这里的好后缀是 “ABAC”。查询好后缀表,根据好后缀规则确定模式串的移动距离 。然后取坏字符规则和好后缀规则计算出的移动距离中的较大值,将模式串向右移动相应的距离,重新开始匹配 。
在实际代码实现中,C++ 语言的搜索匹配阶段代码如下:
vector<int> boyerMooreSearch(const string& text, const string& pattern) {
vector<int> result;
int n = text.length();
int m = pattern.length();
int badCharTable[256];
int goodSuffixTable[m];
generateBadCharTable(pattern, badCharTable);
generateGoodSuffixTable(pattern, goodSuffixTable);
int shift = 0;
while (shift <= n - m) {
int j = m - 1;
while (j >= 0 && pattern[j] == text[shift + j]) {
j--;
}
if (j < 0) {
result.push_back(shift);
shift += (shift + m < n)? m - badCharTable[text[shift + m]] : 1;
}
else {
shift += max(1, j - badCharTable[text[shift + j]], goodSuffixTable[j]);
}
}
return result;
}
这段代码清晰地展示了搜索匹配阶段的流程,通过不断地移动模式串并比较字符,利用坏字符表和好后缀表来确定移动距离,从而高效地在目标串中查找模式串的所有匹配位置。
C++ 代码实现
代码结构概述
实现 Boyer-Moore 算法的 C++ 代码主要包含三个核心部分:坏字符表生成函数、好后缀表生成函数以及搜索函数 。在数据结构的使用上,我们通过数组来存储坏字符表和好后缀表的信息 。坏字符表使用一个大小为 256 的数组,因为 ASCII 码表包含 256 个字符,这样可以方便地通过字符的 ASCII 码值作为索引,快速获取该字符在模式串中最后出现的位置 。好后缀表则使用一个与模式串长度相同的数组,用于记录模式串中每个后缀子串在模式串中其他位置的匹配信息 。
在函数定义方面,坏字符表生成函数generateBadCharTable负责初始化坏字符表,它接收模式串和坏字符表数组作为参数,通过遍历模式串,将每个字符在模式串中的最后出现位置记录到坏字符表数组中 。好后缀表生成函数generateGoodSuffixTable稍微复杂一些,它依赖于一个辅助函数computeSuffixes来计算模式串中每个位置的后缀信息,然后根据这些信息生成好后缀表,该函数同样接收模式串和好后缀表数组作为参数 。搜索函数boyerMooreSearch是整个算法的执行核心,它接收目标串和模式串作为参数,在函数内部调用前面两个函数生成坏字符表和好后缀表,然后通过不断地比较目标串和模式串的字符,利用坏字符规则和好后缀规则来确定模式串的移动距离,从而在目标串中查找模式串的所有匹配位置,并将这些位置存储在一个vector容器中返回 。
具体函数实现
- 坏字符表生成函数
void generateBadCharTable(const string& pattern, int badCharTable[]) {
int m = pattern.length();
for (int i = 0; i < 256; ++i) {
badCharTable[i] = -1;
}
for (int i = 0; i < m; ++i) {
badCharTable[(unsigned char)pattern[i]] = i;
}
}
这段代码首先将坏字符表数组badCharTable的所有元素初始化为 -1,表示这些字符在模式串中尚未出现 。然后通过遍历模式串,对于每个字符,将其在模式串中的位置记录到坏字符表数组中,索引为该字符的 ASCII 码值 。例如,对于模式串 “ABABAC”,当遍历到第一个字符 'A' 时,将badCharTable['A']的值设为 0;当再次遍历到字符 'A' 时,由于要记录最后出现的位置,所以将badCharTable['A']的值更新为 2 。
- 好后缀表生成函数
void generateGoodSuffixTable(const string& pattern, int goodSuffixTable[]) {
int m = pattern.length();
int* suffix = new int[m];
computeSuffixes(pattern, suffix);
int j = 0;
for (int i = m; i >= 0; --i) {
if (i == m || suffix[i] == i + 1) {
for (; j < m - i; ++j) {
if (goodSuffixTable[j] == -1) {
goodSuffixTable[j] = m - i;
}
}
}
}
for (int i = 0; i <= m - 2; ++i) {
goodSuffixTable[m - 1 - suffix[m - 1 - i]] = m - 1 - i;
}
delete[] suffix;
}
void computeSuffixes(const string& pattern, int suffix[]) {
int m = pattern.length();
int f = 0, g = m - 1;
suffix[m - 1] = m;
for (int i = m - 2; i >= 0; --i) {
if (i > g && suffix[i + m - 1 - f] < i - g) {
suffix[i] = suffix[i + m - 1 - f];
}
else {
if (i < g) {
g = i;
}
f = i;
while (g >= 0 && pattern[g] == pattern[g + m - 1 - f]) {
g--;
}
suffix[i] = f - g;
}
}
}
generateGoodSuffixTable函数首先分配一个辅助数组suffix用于存储模式串每个位置的后缀信息 。然后调用computeSuffixes函数来计算这些后缀信息 。在计算好后缀表时,通过两次循环来填充goodSuffixTable数组 。第一次循环从模式串的末尾开始,对于满足特定条件的位置,填充goodSuffixTable数组中相应的位置 。第二次循环则根据辅助数组suffix的信息,进一步完善goodSuffixTable数组 。
computeSuffixes函数通过不断地比较模式串中的字符,计算出每个位置的后缀长度,并将其存储在suffix数组中 。例如,对于模式串 “ABABAC”,在计算过程中,通过比较不同位置的字符,确定每个位置的后缀长度,如对于位置 4(字符 'A'),其后缀长度可能通过比较 “ABAC” 与模式串其他部分的字符得到 。
- 搜索函数
vector<int> boyerMooreSearch(const string& text, const string& pattern) {
vector<int> result;
int n = text.length();
int m = pattern.length();
int badCharTable[256];
int goodSuffixTable[m];
generateBadCharTable(pattern, badCharTable);
generateGoodSuffixTable(pattern, goodSuffixTable);
int shift = 0;
while (shift <= n - m) {
int j = m - 1;
while (j >= 0 && pattern[j] == text[shift + j]) {
j--;
}
if (j < 0) {
result.push_back(shift);
shift += (shift + m < n)? m - badCharTable[text[shift + m]] : 1;
}
else {
shift += max(1, j - badCharTable[text[shift + j]], goodSuffixTable[j]);
}
}
return result;
}
boyerMooreSearch函数是字符串匹配的核心实现 。它首先定义了存储匹配结果的result向量,以及目标串长度n和模式串长度m 。然后调用前面定义的两个函数生成坏字符表和好后缀表 。在匹配过程中,通过一个外层循环不断移动模式串,每次移动的距离由坏字符规则和好后缀规则共同决定 。在内层循环中,从模式串的末尾开始与目标串中对应的字符进行比较,如果发现不匹配(即j >= 0且字符不相等),则根据坏字符规则和好后缀规则计算模式串的移动距离 。如果所有字符都匹配成功(即j < 0),则将当前匹配位置shift添加到结果向量result中,并根据一定规则确定下一次移动的距离 。例如,在目标串 “ABABABCABABAC” 中搜索模式串 “ABABAC”,当第一次匹配成功后,根据规则计算下一次模式串的移动距离,继续寻找下一个可能的匹配位置 。
代码示例与运行结果
示例文本与模式串设定
为了更直观地展示 Boyer-Moore 算法的运行效果,我们设定如下示例文本串和模式串 。假设我们要在一篇文章中查找特定的关键词,文本串text就代表这篇文章的内容,模式串pattern则代表我们要查找的关键词 。例如:
std::string text = "ABABABCABABAC";
std::string pattern = "ABABAC";
这里文本串text模拟了一段包含重复字符的文本内容,模式串pattern是我们期望在文本中找到的子串 。通过这样的设定,我们可以清晰地看到 Boyer-Moore 算法在处理包含重复字符的文本和模式串时的高效性 。
运行结果展示
当我们运行之前实现的 Boyer-Moore 算法代码,对上述设定的文本串和模式串进行匹配时,得到的运行结果如下:
#include <iostream>
#include <vector>
#include "boyer_moore.h"//假设上述代码封装在boyer_moore.h头文件中
int main() {
std::string text = "ABABABCABABAC";
std::string pattern = "ABABAC";
std::vector<int> result = boyerMooreSearch(text, pattern);
if (result.empty()) {
std::cout << "模式串在文本串中未找到。" << std::endl;
}
else {
std::cout << "模式串在文本串中出现的位置为:";
for (size_t i = 0; i < result.size(); ++i) {
std::cout << result[i];
if (i != result.size() - 1) {
std::cout << ", ";
}
}
std::cout << std::endl;
}
return 0;
}
运行上述代码后,输出结果为:
模式串在文本串中出现的位置为:0, 6
这表明模式串 “ABABAC” 在文本串 “ABABABCABABAC” 中出现了两次,分别在位置 0 和位置 6 开始的地方 。通过这个运行结果,我们可以直观地看到 Boyer-Moore 算法能够准确地在文本串中找到模式串的所有匹配位置,验证了算法实现的正确性和有效性 。
算法优化与注意事项
优化思路探讨
在基础的 Boyer-Moore 算法实现中,虽然已经利用坏字符规则和好后缀规则大大提高了匹配效率,但仍有一些优化的空间 。从空间复杂度的角度来看,当前实现中使用了大小为 256 的坏字符表以及与模式串长度相同的好后缀表,在一些特殊情况下,可能会占用较多的内存 。我们可以考虑使用更紧凑的数据结构来存储这些信息,例如对于字符集较小的情况,可以根据实际字符集大小创建更小的坏字符表,从而减少内存占用 。在好后缀表的构建上,也可以进一步优化算法,减少辅助数组的使用,从而降低空间复杂度 。
针对特殊模式串,我们也可以进行有针对性的优化 。如果模式串中存在大量重复字符,例如模式串为 “AAAAA”,传统的坏字符规则和好后缀规则在处理时可能没有充分利用这种重复特性 。我们可以在预处理阶段检测这种重复模式,然后在匹配过程中直接跳过这些重复部分,从而进一步提高匹配效率 。当检测到模式串是由同一个字符重复组成时,可以直接在目标串中查找该字符,然后判断以该字符为起点的子串是否与模式串匹配,这样可以避免一些不必要的字符比较 。
注意事项强调
在实现和应用 Boyer-Moore 算法时,有一些容易出错的地方和需要特别关注的边界条件 。在构建坏字符表时,一定要确保将所有字符的初始值设为 -1,否则在查询坏字符位置时可能会得到错误的结果 。在好后缀表的生成过程中,对于各种情况的处理要非常细致,特别是在查找好后缀的后缀子串与模式串前缀子串匹配的情况时,要注意判断条件的准确性,否则可能会导致模式串移动的距离计算错误,从而错过正确的匹配位置 。
在搜索匹配阶段,当模式串移动时,要注意索引的边界条件 。比如在判断是否匹配成功以及计算下一次移动距离时,要确保索引不会超出目标串或模式串的范围 。在实际应用中,还需要考虑目标串和模式串为空的情况,这是常见的边界条件,需要在代码中进行特殊处理,否则可能会导致程序崩溃或得到错误的结果 。当目标串为空时,无论模式串是什么,都应该返回空的匹配结果;当模式串为空时,也需要根据具体的应用场景来确定合适的返回值,一般来说可以返回空的匹配结果或者抛出异常 。
总结与展望
Boyer-Moore 算法作为字符串匹配领域的经典算法,以其独特的坏字符规则和好后缀规则,在众多字符串匹配场景中展现出了卓越的性能。它通过巧妙地利用字符比较的不匹配信息,大大减少了不必要的比较次数,从而显著提高了匹配效率,尤其在处理长模式串和大型文本串时优势明显 。该算法在文本编辑器的查找功能、文本搜索引擎、编译器中搜索源代码中的关键字等实际应用场景中发挥着重要作用 。
然而,算法的世界永无止境,Boyer-Moore 算法也有进一步优化和改进的空间 。在未来,随着计算机技术的不断发展和实际应用场景的日益复杂多样化,我们可以探索将 Boyer-Moore 算法与其他先进的算法思想或数据结构相结合,以进一步提升其性能 。将其与哈希算法结合,利用哈希算法快速定位的特点,进一步减少匹配过程中的比较次数;或者研究如何在保证算法准确性的前提下,进一步降低其空间复杂度,使其能够在资源受限的环境中更好地运行 。
对于广大读者来说,希望大家能够深入理解 Boyer-Moore 算法的原理和实现,通过不断地实践和探索,将其灵活应用到实际项目中 。也期待大家能够在算法的研究道路上不断前行,尝试对 Boyer-Moore 算法进行改进和创新,为字符串匹配领域的发展贡献自己的智慧和力量 。相信在不断的研究和实践中,我们能发现更多关于 Boyer-Moore 算法的奥秘,使其在更多领域发挥更大的价值 。