Boyer-Moore 算法是一种非常高效的字符串搜索算法,被广泛的应用于多种字符串搜索场景:
-
文本搜索(尤其是大篇幅的文本搜索)
-
文档编辑器以及 IDE 工具中的字符串搜索/替换
-
编译器中搜索源代码中的关键字/符号
-
文件系统中搜索给定的文件名
通常,在字符串搜索过程中,我们期望尽快得到结果(提高算法运行速度,降低时间复杂度),为此我们需要对字符串(文本以及搜索的子串)进行一些预处理。对于大文本,该预处理过程会消耗可观的内存空间,而如果在搜索过程中该预处理过程需要反复进行,则又会消耗相当的 CPU 资源。
Boyer-Moore 算法只需要对被搜索的子串进行一次预处理,通过在本次预处理过程中收集到的信息来提升算法的运行效率,使得算法的时间复杂度尽量趋近于 O(n)。Boyer-Moore 算法的一个显著特征是匹配过程从子串的末尾开始向前,如果遇到不匹配的字符,则根据在预处理过程中收集到的信息进行跳跃/移动,避免逐个字符进行比较。而具体的跳跃/移动规则,则同时使用两种策略实现:
-
坏字符启发
-
好后缀启发
⒈ 坏字符启发
在将子串中的字符与文本中的字符进行比较时,如果文本中的字符与子串中的字符不匹配,我们称文本中的当前字符为坏字符。对于坏字符,通常有两种处理方式:
⓵ 坏字符在子串中的其他位置存在
当坏字符在子串中存在时,如果坏字符在子串中的位置位于当前索引位置之前,则将子串中的坏字符与文本中的坏字符对齐,然后重新从子串的最后开始向前与文本中的字符进行匹配;如果坏字符在子串中的位置位于当前索引位置之后,则将子串向后移动一个字符的位置,然后重新开始从子串的最后开始向前与文本中的字符进行匹配。
如上图所示,从后往前将子串中的字符与文本中的字符进行比较,子串中的字符 C 与文本中的 R 不匹配。但文本中的字符 R 在子串中的其他位置存在,此时将子串中的 R 与文本中的 R 对齐,然后重新从后往前进行匹配。
在对子串进行预处理时,由于子串中 R 出现了两次,所以实际预处理完成之后收集到的信息中记录的是位于 C 之后的 R 的位置信息。所以,实际在代码中,遇到这种情况,子串只能向后移动一个字符的位置。
⓶ 坏字符在子串中不存在
如果坏字符在子串中不存在,那么移动子串,将子串的第一个字符与文本中坏字符之后的字符对齐,然后重新从后往前将子串中的字符与文本中的字符进行匹配。
如上图所示,坏字符 G 与子串中的字符 Q 不匹配,并且坏字符 G 在子串中并不存在,此时将子串移动到与坏字符 G 后的字符对齐,然后重新从后往前开始匹配。
package main
import (
"fmt"
)
func main() {
text := "AYRRQMGRPCRQ"
subStr := "RPCRQ"
fmt.Printf("text = %+v\n", text)
fmt.Printf("subStr = %+v\n", subStr)
// 构建子字符串中各个字符及相应的索引的映射
m := make(map[byte]int, len(subStr))
for i := 0; i < len(subStr); i ++ {
m[subStr[i]] = i
}
fmt.Printf("m = %+v\n", m)
shiftLength := 0
subIndex := len(subStr) - 1
for shiftLength <= len(text) - len(subStr) {
// 每次比较都从子字符串的末尾开始向前,逐个字符进行比较
for subIndex >= 0 && text[shiftLength + subIndex] == subStr[subIndex] {
subIndex --
}
if subIndex == -1 {
// 子字符串在文本中出现,跳过文本中的子字符串继续向后查找
fmt.Printf("subStr found in text at index %+v\n", shiftLength)
shiftLength += len(subStr)
} else if v, ok := m[text[shiftLength + subIndex]]; ok {
// 文本中的字符与子字符串中相应位置的字符不匹配,但该字符在子字符串中存在
if subIndex > v {
// 如果该字符在子字符串中的位置在当前索引位置之前,则将二者位置对齐,然后重新查找
shiftLength += subIndex - v
} else {
// 文本中的索引位置向前移动一个字符(考虑子字符串中同一个字符重复出现的情况)
shiftLength += 1
}
} else {
// 文本中的字符在子字符串中不存在
shiftLength += subIndex
}
subIndex = len(subStr) - 1
}
}
⒉ 好后缀启发
在将子串按照从后往前的顺序与文本中的字符进行比较时,遇到不匹配的字符时,子串末尾已经匹配得字符即为好后缀。此时根据好后缀在子串中其他位置是否存在,重新确定子串在文本中开始匹配的位置。
⓵ 好后缀或好后缀的后缀在子串中的其他位置存在
如上图所示,从后往前将子串中的字符与文本进行匹配,文本中字符 Y 与子串中相应位置的字符 Q 不匹配,此时出现好后缀 CRQ。虽然好后缀 CRQ 在子串中只出现了一次,但好后缀的后缀 RQ 却在子串的头部再次出现,此时将子串头部的 RQ 与文本中的 RQ 对齐,然后重新从后往前匹配。
⓶ 好后缀在子串中不存在
如上图所示,子串中不存在好后缀,此时只要将文本中的起始匹配位置向后移动一个字符,然后重新从后往前匹配。
起始匹配位置移动之后,子串中出现了好后缀 RQ,并且 RQ 在子串的头部也存在。此时,将子串头部的 RQ 与文本中的 RQ 对齐,确定新的匹配开始位置,重新匹配。
好后缀启发的关键在于对子串的预处理。
在对子串进行预处理的过程中,需要确定子串中单个字符以及多个连续字符出现的频次以及相应的位置。当匹配过程中出现好后缀时,会根据预处理过程中收集到的信息确定文本中新的开始匹配的位置。
package main
import (
"fmt"
)
func main() {
text := "AYCRQMGRQCRQ"
subStr := "RQCRQ"
fmt.Printf("text = %+v\n", text)
fmt.Printf("subStr = %+v\n", subStr)
shifts := make([]int, len(subStr) + 1)
borderPosition := make([]int, len(subStr) + 1)
// 确定搜索字符串中各个子串的边界
i, j := len(subStr), len(subStr) + 1
borderPosition[i] = j
for i > 0 {
for j >= 0 && j <= len(subStr) && subStr[i - 1] != subStr[j - 1] {
if shifts[j] == 0 {
shifts[j] = j - i
}
j = borderPosition[j]
}
i --
j --
borderPosition[i] = j
}
fmt.Printf("shifts = %+v\n", shifts)
fmt.Printf("borderPosition = %+v\n", borderPosition)
// 确定搜索字符串中各个字符与文本中的字符不匹配时应该移动的距离
j = borderPosition[0]
for i := 0; i <= len(subStr); i ++ {
if shifts[i] == 0 {
shifts[i] = j
}
if i == j {
j = borderPosition[j]
}
}
fmt.Printf("shifts = %+v\n", shifts)
fmt.Printf("borderPosition = %+v\n", borderPosition)
// 在文本中搜索字符串
shiftLength := 0
for shiftLength <= len(text) - len(subStr) {
j = len(subStr) - 1
for j >= 0 && subStr[j] == text[shiftLength + j] {
j --
}
if j == -1 {
fmt.Printf("subStr find in text at position %+v\n", shiftLength)
shiftLength += shifts[0]
} else {
shiftLength += shifts[j + 1]
}
}
}