引言
在文本处理、搜索引擎、DNA序列分析等领域,字符串匹配是最基础也最频繁的操作之一。Boyer-Moore(BM)算法是实践中最高效的单模式匹配算法之一,其平均时间复杂度接近O(n/m),其中n为文本长度,m为模式串长度。本文将深入讲解BM算法的两大核心启发式策略——坏字符规则与好后缀规则,并提供完整的Java实现。
核心算法概述
1. 坏字符规则(Bad Character Heuristic)
BM算法从右向左比较模式串与文本。当发生不匹配时,如果文本中的这个”坏字符”在模式串中不存在,则可以将模式串直接右移m位;如果存在,则将其对齐到模式串中最右侧出现的位置。
2. 好后缀规则(Good Suffix Heuristic)
当模式串的后缀部分已经匹配成功(称为”好后缀”),但前面的字符不匹配时,如果在模式串的其他位置也存在相同的后缀,则可以将模式串滑动到该位置对齐。此外,如果好后缀的某个后缀同时也是模式串的前缀,也可以利用这一信息。
Java完整实现
import java.util.*;
/**
* Boyer-Moore字符串匹配算法完整实现
* 包含:坏字符规则、好后缀规则、预处理表构建
*/
public class BoyerMooreAlgorithm {
private final String pattern;
private final int patternLength;
private final int ALPHABET_SIZE = 256; // ASCII字符集大小
// 坏字符表:记录每个字符在模式串中最右出现的位置
private int[] badCharTable;
// 好后缀表:记录好后缀对应的滑动距离
private int[] goodSuffixTable;
public BoyerMooreAlgorithm(String pattern) {
this.pattern = pattern;
this.patternLength = pattern.length();
this.badCharTable = new int[ALPHABET_SIZE];
this.goodSuffixTable = new int[patternLength];
preprocessBadChar();
preprocessGoodSuffix();
}
/**
* 预处理坏字符表
* badCharTable[c] = 字符c在模式串中最右出现的位置索引
* 如果字符不在模式串中,值为-1
* 时间复杂度:O(m),m为模式串长度
*/
private void preprocessBadChar() {
// 初始化为-1,表示字符未在模式串中出现
Arrays.fill(badCharTable, -1);
// 填充每个字符在模式串中最右出现的位置
for (int i = 0; i < patternLength; i++) {
badCharTable[pattern.charAt(i)] = i;
}
}
/**
* 预处理好后缀表
* 好后缀规则是BM算法的精髓,分为两种情况:
* 1. 模式串中存在另一个与好后缀完全匹配的子串
* 2. 好后缀的某个后缀是模式串的前缀
* 时间复杂度:O(m)
*/
private void preprocessGoodSuffix() {
// suffix[i]表示以位置i为结尾的后缀与模式串后缀的最长匹配长度
int[] suffix = new int[patternLength];
Arrays.fill(suffix, -1);
// 计算suffix数组
// 从倒数第二个字符开始,自右向左计算
suffix[patternLength - 1] = patternLength; // 最后一个字符的后缀就是整个模式串
for (int i = patternLength - 2, g = patternLength - 1; i >= 0; i--) {
// g是上一个匹配成功的起始位置的前一个位置
if (i > g && suffix[i + patternLength - 1 - g] < i - g) {
// 如果当前位置在上一个匹配区间内部,且suffix值已知
suffix[i] = suffix[i + patternLength - 1 - g];
} else {
// 重新计算匹配长度
if (i < g) {
g = i;
}
int j = i >= 0 ? i : 0;
// 从位置g向前比较
while (g >= 0 && pattern.charAt(g) == pattern.charAt(g + patternLength - 1 - j)) {
g--;
}
suffix[j] = j - g;
}
}
// 情况1:模式串中存在另一个与好后缀匹配的子串
// 初始化所有位置为patternLength,即最坏情况下滑动m位
Arrays.fill(goodSuffixTable, patternLength);
// 情况2:好后缀的某个后缀也是模式串的前缀
// 从右向左找到第一个suffix[i] == i+1的位置
int j = 0;
for (int i = patternLength - 1; i >= 0; i--) {
if (suffix[i] == i + 1) {
// 从位置0到j都满足前缀条件
for (; j < patternLength - 1 - i; j++) {
if (goodSuffixTable[j] == patternLength) {
goodSuffixTable[j] = patternLength - 1 - i;
}
}
}
}
// 情况1填充:存在匹配的子串
for (int i = 0; i <= patternLength - 2; i++) {
goodSuffixTable[patternLength - 1 - suffix[i]] = patternLength - 1 - i;
}
}
/**
* 使用BM算法在文本中搜索模式串
* 返回所有匹配起始位置的列表
*
* @param text 待搜索的文本
* @return 所有匹配位置的列表
*/
public List<Integer> search(String text) {
List<Integer> matches = new ArrayList<>();
if (text == null || text.length() < patternLength) {
return matches;
}
int textLength = text.length();
int shift = 0; // 当前模式串在文本中的起始位置
while (shift <= textLength - patternLength) {
int j = patternLength - 1; // 从模式串末尾开始比较
// 从右向左匹配
while (j >= 0 && pattern.charAt(j) == text.charAt(shift + j)) {
j--;
}
if (j < 0) {
// 完全匹配成功
matches.add(shift);
// 使用好后缀规则滑动到下一个可能匹配的位置
shift += goodSuffixTable[0];
} else {
// 在位置j发生不匹配
char badChar = text.charAt(shift + j);
// 坏字符规则计算的滑动距离
int badCharShift = j - badCharTable[badChar];
if (badCharShift < 1) {
badCharShift = 1; // 至少滑动1位,避免死循环
}
// 好后缀规则计算的滑动距离
int goodSuffixShift = goodSuffixTable[j];
// 取两者最大值,确保不遗漏任何匹配
shift += Math.max(badCharShift, goodSuffixShift);
}
}
return matches;
}
/**
* 打印预处理表,用于调试和理解算法
*/
public void printPreprocessTables() {
System.out.println("=== 坏字符表(部分) ===");
for (int i = 0; i < ALPHABET_SIZE; i++) {
if (badCharTable[i] != -1) {
System.out.printf(" '%c' -> 最右位置 %d%n", (char)i, badCharTable[i]);
}
}
System.out.println("\n=== 好后缀表 ===");
for (int i = 0; i < patternLength; i++) {
System.out.printf(" 位置 %d -> 滑动 %d%n", i, goodSuffixTable[i]);
}
}
/**
* 基准测试:对比BM算法与朴素算法的性能
*/
public static void benchmark() {
// 构造大规模测试文本
StringBuilder sb = new StringBuilder();
Random random = new Random(42);
for (int i = 0; i < 1000000; i++) {
sb.append((char)('a' + random.nextInt(26)));
}
String text = sb.toString();
String pattern = "algorithm";
// 预热
BoyerMooreAlgorithm bm = new BoyerMooreAlgorithm(pattern);
bm.search(text);
// BM算法计时
long start = System.nanoTime();
List<Integer> bmResult = bm.search(text);
long bmTime = System.nanoTime() - start;
// 朴素算法计时
start = System.nanoTime();
List<Integer> naiveResult = naiveSearch(text, pattern);
long naiveTime = System.nanoTime() - start;
System.out.println("=== 性能对比 ===");
System.out.printf("文本长度: %d, 模式串长度: %d%n", text.length(), pattern.length());
System.out.printf("BM算法: %d matches, time = %.2f ms%n",
bmResult.size(), bmTime / 1_000_000.0);
System.out.printf("朴素算法: %d matches, time = %.2f ms%n",
naiveResult.size(), naiveTime / 1_000_000.0);
System.out.printf("加速比: %.2fx%n", (double)naiveTime / bmTime);
}
/**
* 朴素字符串匹配算法(暴力法)
*/
private static List<Integer> naiveSearch(String text, String pattern) {
List<Integer> matches = new ArrayList<>();
int n = text.length();
int m = pattern.length();
for (int i = 0; i <= n - m; i++) {
int j = 0;
while (j < m && text.charAt(i + j) == pattern.charAt(j)) {
j++;
}
if (j == m) {
matches.add(i);
}
}
return matches;
}
public static void main(String[] args) {
// 示例1:基础演示
String text = "ABAAABCDABCABCDABCDABD";
String pattern = "ABCABCD";
System.out.println("=== BM算法基础演示 ===");
System.out.println("文本: " + text);
System.out.println("模式: " + pattern);
BoyerMooreAlgorithm bm = new BoyerMooreAlgorithm(pattern);
bm.printPreprocessTables();
List<Integer> matches = bm.search(text);
System.out.println("\n匹配位置: " + matches);
// 示例2:大规模性能测试
System.out.println("\n");
benchmark();
// 示例3:DNA序列匹配
System.out.println("\n=== DNA序列匹配示例 ===");
String dna = "ATCGATCGATCGATCGATCGATCGATCGATCG";
String gene = "ATCGATCG";
BoyerMooreAlgorithm dnaBM = new BoyerMooreAlgorithm(gene);
List<Integer> dnaMatches = dnaBM.search(dna);
System.out.println("DNA序列: " + dna);
System.out.println("基因片段: " + gene);
System.out.println("匹配位置: " + dnaMatches);
}
}
/**
* 扩展实现:BM算法在文件搜索中的应用
*/
class FilePatternSearcher {
/**
* 在多行文本中搜索模式,返回匹配的行号和列号
*/
public static List<int[]> searchInLines(String[] lines, String pattern) {
List<int[]> results = new ArrayList<>();
BoyerMooreAlgorithm bm = new BoyerMooreAlgorithm(pattern);
for (int lineNo = 0; lineNo < lines.length; lineNo++) {
List<Integer> matches = bm.search(lines[lineNo]);
for (int pos : matches) {
results.add(new int[]{lineNo + 1, pos + 1}); // 1-based indexing
}
}
return results;
}
public static void main(String[] args) {
String[] codeLines = {
"public class HelloWorld {",
" public static void main(String[] args) {",
" System.out.println(\"Hello, World!\");",
" String pattern = \"Hello\";",
" System.out.println(pattern);",
" }",
"}"
};
List<int[]> results = searchInLines(codeLines, "Hello");
System.out.println("在代码中搜索 'Hello':");
for (int[] pos : results) {
System.out.printf(" 第%d行, 第%d列%n", pos[0], pos[1]);
}
}
}
算法复杂度分析
| 阶段 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 坏字符预处理 | O(m + σ) | O(σ) | σ为字符集大小,通常为256 |
| 好后缀预处理 | O(m) | O(m) | suffix数组与goodSuffix表的构建 |
| 搜索过程(最坏) | O(n × m) | O(1) | 最坏情况如文本为”AAAAA”,模式为”AAAB” |
| 搜索过程(平均) | O(n / m) | O(1) | 实际应用中通常接近最优 |
关键知识点总结
-
从右向左比较:BM算法最大的创新点在于改变了比较方向。当模式串较长且字符集较大时,从右向左比较使得每次不匹配都能获得更大的滑动距离。
-
坏字符规则:通过预处理方式串中每个字符的最右位置,发生不匹配时可以立即计算出安全的滑动距离。该规则对ASCII文本(如英文)特别有效。
-
好后缀规则:通过分析模式串自身的结构,利用已匹配的后缀信息来决定滑动距离。该规则在处理DNA序列(字符集仅4种)时尤为重要。
-
双规则取最大:实际实现中同时应用坏字符和好后缀两个规则,取两者计算出的滑动距离的最大值,确保安全性(不漏匹配)的同时最大化效率。
-
实际应用:BM算法及其变种(如Horspool、Sunday算法)广泛应用于GNU grep、Vim搜索等工具中,是工业界字符串匹配的事实标准之一。